@shopify/polaris
Version:
Shopify’s admin product component library
331 lines (328 loc) • 12.7 kB
JavaScript
import React, { useState, useId, useRef, useEffect, useCallback, Children, useMemo } from 'react';
import { debounce } from '../../utilities/debounce.js';
import { useToggle } from '../../utilities/use-toggle.js';
import { Key } from '../../types.js';
import { scrollable } from '../shared.js';
import styles from './Listbox.css.js';
import { useComboboxListbox } from '../../utilities/combobox/hooks.js';
import { scrollOptionIntoView } from '../../utilities/listbox/utilities.js';
import { ListboxContext, WithinListboxContext } from '../../utilities/listbox/context.js';
import { TextOption } from './components/TextOption/TextOption.js';
import { Loading } from './components/Loading/Loading.js';
import { Section } from './components/Section/Section.js';
import { Header } from './components/Header/Header.js';
import { Action } from './components/Action/Action.js';
import { KeypressListener } from '../KeypressListener/KeypressListener.js';
import { Text } from '../Text/Text.js';
import { Option } from './components/Option/Option.js';
let AutoSelection = /*#__PURE__*/function (AutoSelection) {
AutoSelection["FirstSelected"] = "FIRST_SELECTED";
AutoSelection["First"] = "FIRST";
AutoSelection["None"] = "NONE";
return AutoSelection;
}({});
const OPTION_SELECTOR = '[data-listbox-option]';
const OPTION_VALUE_ATTRIBUTE = 'data-listbox-option-value';
const OPTION_ACTION_ATTRIBUTE = 'data-listbox-option-action';
const OPTION_FOCUS_ATTRIBUTE = 'data-focused';
function Listbox({
children,
autoSelection = AutoSelection.FirstSelected,
enableKeyboardControl,
accessibilityLabel,
customListId,
onSelect,
onActiveOptionChange
}) {
const [loading, setLoading] = useState();
const [activeOption, setActiveOption] = useState();
const [lazyLoading, setLazyLoading] = useState(false);
const [currentOptions, setCurrentOptions] = useState([]);
const {
value: keyboardEventsEnabled,
setTrue: enableKeyboardEvents,
setFalse: disableKeyboardEvents
} = useToggle(Boolean(enableKeyboardControl));
const uniqueId = useId();
const listId = customListId || uniqueId;
const scrollableRef = useRef(null);
const listboxRef = useRef(null);
const {
listboxId,
textFieldLabelId,
textFieldFocused,
willLoadMoreOptions,
setActiveOptionId,
setListboxId,
onOptionSelected,
onKeyToBottom
} = useComboboxListbox();
const inCombobox = Boolean(setActiveOptionId);
useEffect(() => {
if (setListboxId && !listboxId) {
setListboxId(listId);
}
}, [setListboxId, listboxId, listId]);
const getNavigableOptions = useCallback(() => {
if (!listboxRef.current) {
return [];
}
return [...new Set(listboxRef.current.querySelectorAll(OPTION_SELECTOR))];
}, []);
const getFirstNavigableOption = useCallback(currentOptions => {
const hasSelectedOptions = currentOptions.some(option => option.getAttribute('aria-selected') === 'true');
let elementIndex = 0;
const element = currentOptions.find((option, index) => {
const isInteractable = option.getAttribute('aria-disabled') !== 'true';
let isFirstNavigableOption;
if (hasSelectedOptions && autoSelection === AutoSelection.FirstSelected) {
const isSelected = option.getAttribute('aria-selected') === 'true';
isFirstNavigableOption = isSelected && isInteractable;
} else {
isFirstNavigableOption = isInteractable;
}
if (isFirstNavigableOption) elementIndex = index;
return isFirstNavigableOption;
});
if (!element) return;
return {
element,
index: elementIndex
};
}, [autoSelection]);
const handleScrollIntoView = useCallback(option => {
const {
current: scrollable
} = scrollableRef;
if (scrollable) {
scrollOptionIntoView(option.element, scrollable);
}
}, []);
const handleScrollIntoViewDebounced = debounce(handleScrollIntoView, 50);
const handleKeyToBottom = useCallback(() => {
if (onKeyToBottom) {
setLazyLoading(true);
return Promise.resolve(onKeyToBottom());
}
}, [onKeyToBottom]);
const handleChangeActiveOption = useCallback(nextOption => {
if (!nextOption) return setActiveOption(undefined);
activeOption?.element.removeAttribute(OPTION_FOCUS_ATTRIBUTE);
nextOption.element.setAttribute(OPTION_FOCUS_ATTRIBUTE, 'true');
handleScrollIntoViewDebounced(nextOption);
setActiveOption(nextOption);
setActiveOptionId?.(nextOption.domId);
onActiveOptionChange?.(nextOption.value, nextOption.domId);
}, [activeOption, setActiveOptionId, onActiveOptionChange, handleScrollIntoViewDebounced]);
const getFormattedOption = useCallback((element, index) => {
return {
element,
index,
domId: element.id,
value: element.getAttribute(OPTION_VALUE_ATTRIBUTE) || '',
disabled: element.getAttribute('aria-disabled') === 'true',
isAction: element.getAttribute(OPTION_ACTION_ATTRIBUTE) === 'true'
};
}, []);
const resetActiveOption = useCallback(() => {
let nextOption;
const nextOptions = getNavigableOptions();
const nextActiveOption = getFirstNavigableOption(nextOptions);
if (nextOptions.length === 0 && currentOptions.length > 0) {
setCurrentOptions(nextOptions);
handleChangeActiveOption();
return;
}
if (nextActiveOption) {
const {
element,
index
} = nextActiveOption;
nextOption = getFormattedOption(element, index);
}
const optionIsAlreadyActive = activeOption !== undefined && nextOption?.domId === activeOption?.domId;
const actionContentHasUpdated = activeOption?.isAction && nextOption?.isAction && nextOption?.value !== activeOption?.value;
const currentValues = currentOptions.map(option => option.getAttribute(OPTION_VALUE_ATTRIBUTE));
const nextValues = nextOptions.map(option => option.getAttribute(OPTION_VALUE_ATTRIBUTE));
const listIsUnchanged = nextValues.length === currentValues.length && nextValues.every((value, index) => {
return currentValues[index] === value;
});
const listIsAppended = currentValues.length !== 0 && nextValues.length > currentValues.length && currentValues.every((value, index) => {
return nextValues[index] === value;
});
if (listIsUnchanged) {
if (optionIsAlreadyActive && actionContentHasUpdated) {
setCurrentOptions(nextOptions);
handleChangeActiveOption(nextOption);
}
return;
}
if (listIsAppended) {
setCurrentOptions(nextOptions);
return;
}
setCurrentOptions(nextOptions);
if (lazyLoading) {
setLazyLoading(false);
return;
}
handleChangeActiveOption(nextOption);
}, [lazyLoading, currentOptions, activeOption, getFirstNavigableOption, getNavigableOptions, getFormattedOption, handleChangeActiveOption]);
useEffect(() => {
if (autoSelection !== AutoSelection.None && !loading && children && Children.count(children) > 0) {
resetActiveOption();
}
}, [children, autoSelection, activeOption, loading, resetActiveOption]);
useEffect(() => {
if (listboxRef.current) {
scrollableRef.current = listboxRef.current.closest(scrollable.selector);
}
}, []);
useEffect(() => {
if (enableKeyboardControl && !keyboardEventsEnabled) {
enableKeyboardEvents();
}
}, [enableKeyboardControl, keyboardEventsEnabled, enableKeyboardEvents]);
const onOptionSelect = useCallback(option => {
handleChangeActiveOption(option);
if (onOptionSelected) onOptionSelected();
if (onSelect) onSelect(option.value);
}, [handleChangeActiveOption, onSelect, onOptionSelected]);
const getNextIndex = useCallback((currentIndex, lastIndex, direction) => {
let nextIndex;
if (direction === 'down') {
if (currentIndex === lastIndex) {
nextIndex = willLoadMoreOptions ? currentIndex + 1 : 0;
} else {
nextIndex = currentIndex + 1;
}
} else {
nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1;
}
return nextIndex;
}, [willLoadMoreOptions]);
const getNextValidOption = useCallback(async key => {
const lastIndex = currentOptions.length - 1;
let currentIndex = activeOption?.index || 0;
let nextIndex = 0;
let element = activeOption?.element;
let totalOptions = -1;
if (!activeOption && autoSelection === AutoSelection.None) {
const nextOptions = getNavigableOptions();
const nextActiveOption = getFirstNavigableOption(nextOptions);
setCurrentOptions(nextOptions);
return {
element: nextActiveOption?.element,
nextIndex: nextActiveOption?.index || 0
};
}
while (totalOptions++ < lastIndex) {
nextIndex = getNextIndex(currentIndex, lastIndex, key);
element = currentOptions[nextIndex];
const triggerLazyLoad = nextIndex >= lastIndex;
const isDisabled = element?.getAttribute('aria-disabled') === 'true';
if (triggerLazyLoad && willLoadMoreOptions) {
await handleKeyToBottom();
}
if (isDisabled) {
currentIndex = nextIndex;
element = undefined;
continue;
}
break;
}
return {
element,
nextIndex
};
}, [autoSelection, currentOptions, activeOption, willLoadMoreOptions, getNextIndex, handleKeyToBottom, getFirstNavigableOption, getNavigableOptions]);
const handleArrow = useCallback(async (type, event) => {
event.preventDefault();
const {
element,
nextIndex
} = await getNextValidOption(type);
if (!element) return;
const nextOption = getFormattedOption(element, nextIndex);
handleChangeActiveOption(nextOption);
}, [getFormattedOption, getNextValidOption, handleChangeActiveOption]);
const handleDownArrow = useCallback(event => {
handleArrow('down', event);
}, [handleArrow]);
const handleUpArrow = useCallback(event => {
handleArrow('up', event);
}, [handleArrow]);
const handleEnter = useCallback(event => {
event.preventDefault();
event.stopPropagation();
if (activeOption) {
onOptionSelect(activeOption);
}
}, [activeOption, onOptionSelect]);
const handleFocus = useCallback(() => {
if (enableKeyboardControl) return;
enableKeyboardEvents();
}, [enableKeyboardControl, enableKeyboardEvents]);
const handleBlur = useCallback(event => {
event.stopPropagation();
if (keyboardEventsEnabled) {
const nextActiveOption = getFirstNavigableOption(currentOptions);
if (nextActiveOption) {
const {
element,
index
} = nextActiveOption;
const nextOption = getFormattedOption(element, index);
handleChangeActiveOption(nextOption);
}
}
if (enableKeyboardControl) return;
disableKeyboardEvents();
}, [enableKeyboardControl, currentOptions, keyboardEventsEnabled, disableKeyboardEvents, getFirstNavigableOption, getFormattedOption, handleChangeActiveOption]);
const listeners = keyboardEventsEnabled || textFieldFocused ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(KeypressListener, {
keyEvent: "keydown",
keyCode: Key.DownArrow,
handler: handleDownArrow
}), /*#__PURE__*/React.createElement(KeypressListener, {
keyEvent: "keydown",
keyCode: Key.UpArrow,
handler: handleUpArrow
}), /*#__PURE__*/React.createElement(KeypressListener, {
keyEvent: "keydown",
keyCode: Key.Enter,
handler: handleEnter
})) : null;
const listboxContext = useMemo(() => ({
onOptionSelect,
setLoading
}), [onOptionSelect]);
return /*#__PURE__*/React.createElement(React.Fragment, null, listeners, /*#__PURE__*/React.createElement(Text, {
as: "span",
visuallyHidden: true
}, /*#__PURE__*/React.createElement("div", {
"aria-live": "polite"
}, loading ? loading : null)), /*#__PURE__*/React.createElement(ListboxContext.Provider, {
value: listboxContext
}, /*#__PURE__*/React.createElement(WithinListboxContext.Provider, {
value: true
}, children ? /*#__PURE__*/React.createElement("ul", {
tabIndex: 0,
role: "listbox",
className: styles.Listbox,
"aria-label": inCombobox ? undefined : accessibilityLabel,
"aria-labelledby": textFieldLabelId,
"aria-busy": Boolean(loading),
"aria-activedescendant": activeOption && activeOption.domId,
id: listId,
onFocus: inCombobox ? undefined : handleFocus,
onBlur: inCombobox ? undefined : handleBlur,
ref: listboxRef
}, children) : null)));
}
Listbox.Option = Option;
Listbox.TextOption = TextOption;
Listbox.Loading = Loading;
Listbox.Section = Section;
Listbox.Header = Header;
Listbox.Action = Action;
export { AutoSelection, Listbox };