@shopify/polaris
Version:
Shopify’s admin product component library
341 lines (311 loc) • 12.9 kB
JavaScript
import React, { useState, 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.scss.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 { useUniqueId } from '../../utilities/unique-id/hooks.js';
import { KeypressListener } from '../KeypressListener/KeypressListener.js';
import { VisuallyHidden } from '../VisuallyHidden/VisuallyHidden.js';
import { Option } from './components/Option/Option.js';
let AutoSelection;
(function (AutoSelection) {
AutoSelection["FirstSelected"] = "FIRST_SELECTED";
AutoSelection["First"] = "FIRST";
})(AutoSelection || (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 = useUniqueId('Listbox');
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 === null || activeOption === void 0 ? void 0 : activeOption.element.removeAttribute(OPTION_FOCUS_ATTRIBUTE);
nextOption.element.setAttribute(OPTION_FOCUS_ATTRIBUTE, 'true');
handleScrollIntoViewDebounced(nextOption);
setActiveOption(nextOption);
setActiveOptionId === null || setActiveOptionId === void 0 ? void 0 : setActiveOptionId(nextOption.domId);
onActiveOptionChange === null || onActiveOptionChange === void 0 ? void 0 : 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(() => {
var _nextOption, _nextOption2, _nextOption3;
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 = nextOption) === null || _nextOption === void 0 ? void 0 : _nextOption.domId) === (activeOption === null || activeOption === void 0 ? void 0 : activeOption.domId);
const actionContentHasUpdated = (activeOption === null || activeOption === void 0 ? void 0 : activeOption.isAction) && ((_nextOption2 = nextOption) === null || _nextOption2 === void 0 ? void 0 : _nextOption2.isAction) && ((_nextOption3 = nextOption) === null || _nextOption3 === void 0 ? void 0 : _nextOption3.value) !== (activeOption === null || activeOption === void 0 ? void 0 : 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;
});
if (listIsUnchanged) {
if (optionIsAlreadyActive && actionContentHasUpdated) {
setCurrentOptions(nextOptions);
handleChangeActiveOption(nextOption);
}
return;
}
setCurrentOptions(nextOptions);
if (lazyLoading) {
setLazyLoading(false);
return;
}
handleChangeActiveOption(nextOption);
}, [lazyLoading, currentOptions, activeOption, getFirstNavigableOption, getNavigableOptions, getFormattedOption, handleChangeActiveOption]);
useEffect(() => {
if (!loading && children && Children.count(children) > 0) {
resetActiveOption();
}
}, [children, 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 === null || activeOption === void 0 ? void 0 : activeOption.index) || 0;
let nextIndex = 0;
let element = activeOption === null || activeOption === void 0 ? void 0 : activeOption.element;
let totalOptions = -1;
while (totalOptions++ < lastIndex) {
var _element;
nextIndex = getNextIndex(currentIndex, lastIndex, key);
element = currentOptions[nextIndex];
const triggerLazyLoad = nextIndex >= lastIndex;
const isDisabled = ((_element = element) === null || _element === void 0 ? void 0 : _element.getAttribute('aria-disabled')) === 'true';
if (triggerLazyLoad && willLoadMoreOptions) {
await handleKeyToBottom();
}
if (isDisabled) {
currentIndex = nextIndex;
element = undefined;
continue;
}
break;
}
return {
element,
nextIndex
};
}, [currentOptions, activeOption, willLoadMoreOptions, getNextIndex, handleKeyToBottom]);
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(VisuallyHidden, null, /*#__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 };