UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

246 lines (241 loc) • 10.9 kB
import React, { useContext, useRef, useState, useMemo, useEffect } from 'react'; import { debounce } from '../node_modules/@github/mini-throttle/dist/index.js'; import { announce } from '@primer/live-region-element'; import { scrollIntoView } from '@primer/behaviors'; import { ActionList } from '../ActionList/index.js'; import { useFocusZone } from '../hooks/useFocusZone.js'; import { useId } from '../hooks/useId.js'; import { AutocompleteContext } from './AutocompleteContext.js'; import { PlusIcon } from '@primer/octicons-react'; import VisuallyHidden from '../_VisuallyHidden.js'; import { isElement } from 'react-is'; import classes from './AutocompleteMenu.module.css.js'; import { jsx, jsxs } from 'react/jsx-runtime'; import StyledSpinner from '../Spinner/Spinner.js'; const getDefaultSortFn = isItemSelectedFn => (itemIdA, itemIdB) => isItemSelectedFn(itemIdA) === isItemSelectedFn(itemIdB) ? 0 : isItemSelectedFn(itemIdA) ? -1 : 1; const menuScrollMargins = { startMargin: 0, endMargin: 8 }; function getDefaultItemFilter(filterValue) { return function (item, _i) { var _item$text; return Boolean((_item$text = item.text) === null || _item$text === void 0 ? void 0 : _item$text.toLowerCase().startsWith(filterValue.toLowerCase())); }; } function getdefaultCheckedSelectionChange(setInputValueFn) { return function (itemOrItems) { const { text = '' } = Array.isArray(itemOrItems) ? itemOrItems.slice(-1)[0] : itemOrItems; setInputValueFn(text); }; } const isItemSelected = (itemId, selectedItemIds) => selectedItemIds.includes(itemId); function getItemById(itemId, items) { return items.find(item => item.id === itemId); } // eslint-disable-next-line @typescript-eslint/no-explicit-any /** * Announces a message to screen readers at a slowed-down rate. This is useful when you want to announce don't want to * overwhelm the user with too many announcements in rapid succession. */ const debounceAnnouncement = debounce(announcement => { announce(announcement); }, 250); function AutocompleteMenu(props) { const autocompleteContext = useContext(AutocompleteContext); if (autocompleteContext === null) { throw new Error('AutocompleteContext returned null values'); } const { activeDescendantRef, id, inputRef, inputValue = '', scrollContainerRef, setAutocompleteSuggestion, setShowMenu, setInputValue, setIsMenuDirectlyActivated, setSelectedItemLength, showMenu } = autocompleteContext; const { items, selectedItemIds, sortOnCloseFn, emptyStateText = 'No selectable options', addNewItem, loading, selectionVariant = 'single', filterFn, 'aria-labelledby': ariaLabelledBy, onOpenChange, onSelectedChange, customScrollContainerRef } = props; const listContainerRef = useRef(null); const allItemsToRenderRef = useRef([]); const [highlightedItem, setHighlightedItem] = useState(); const [sortedItemIds, setSortedItemIds] = useState(items.map(({ id: itemId }) => itemId)); const generatedUniqueId = useId(id); const selectableItems = useMemo(() => items.map(selectableItem => { return { ...selectableItem, role: 'option', id: selectableItem.id, active: (highlightedItem === null || highlightedItem === void 0 ? void 0 : highlightedItem.id) === selectableItem.id, selected: selectionVariant === 'multiple' ? selectedItemIds.includes(selectableItem.id) : undefined, onAction: item => { const otherSelectedItemIds = selectedItemIds.filter(selectedItemId => selectedItemId !== item.id); const newSelectedItemIds = selectedItemIds.includes(item.id) ? otherSelectedItemIds : [...otherSelectedItemIds, item.id]; const onSelectedChangeFn = onSelectedChange ? onSelectedChange : getdefaultCheckedSelectionChange(setInputValue); onSelectedChangeFn(newSelectedItemIds.map(newSelectedItemId => getItemById(newSelectedItemId, items))); if (selectionVariant === 'multiple') { setInputValue(''); setAutocompleteSuggestion(''); } else { var _inputRef$current; setShowMenu(false); (_inputRef$current = inputRef.current) === null || _inputRef$current === void 0 ? void 0 : _inputRef$current.setSelectionRange(inputRef.current.value.length, inputRef.current.value.length); } } }; }), [highlightedItem, items, selectedItemIds, inputRef, onSelectedChange, selectionVariant, setAutocompleteSuggestion, setInputValue, setShowMenu]); const itemSortOrderData = useMemo(() => sortedItemIds.reduce((acc, curr, i) => { acc[curr] = i; return acc; }, {}), [sortedItemIds]); const sortedAndFilteredItemsToRender = useMemo(() => selectableItems.filter(filterFn ? filterFn : getDefaultItemFilter(inputValue)).sort((a, b) => itemSortOrderData[a.id] - itemSortOrderData[b.id]), [selectableItems, itemSortOrderData, filterFn, inputValue]); const allItemsToRender = useMemo(() => [ // sorted and filtered selectable items ...sortedAndFilteredItemsToRender, // menu item used for creating a token from whatever is in the text input ...(addNewItem ? [{ ...addNewItem, role: 'option', key: addNewItem.id, active: (highlightedItem === null || highlightedItem === void 0 ? void 0 : highlightedItem.id) === addNewItem.id, selected: selectionVariant === 'multiple' ? selectedItemIds.includes(addNewItem.id) : undefined, leadingVisual: () => /*#__PURE__*/jsx(PlusIcon, {}), onAction: item_0 => { // TODO: make it possible to pass a leadingVisual when using `addNewItem` addNewItem.handleAddItem({ ...item_0, id: item_0.id || generatedUniqueId, leadingVisual: undefined }); if (selectionVariant === 'multiple') { setInputValue(''); setAutocompleteSuggestion(''); } } }] : [])], [sortedAndFilteredItemsToRender, addNewItem, setAutocompleteSuggestion, selectionVariant, setInputValue, generatedUniqueId, highlightedItem, selectedItemIds]); React.useEffect(() => { allItemsToRenderRef.current = allItemsToRender; }); React.useEffect(() => { if (allItemsToRender.length === 0) { debounceAnnouncement(emptyStateText); } }, [allItemsToRender, emptyStateText]); useFocusZone({ containerRef: listContainerRef, focusOutBehavior: 'wrap', focusableElementFilter: element => { return !(element instanceof HTMLInputElement); }, activeDescendantFocus: inputRef, onActiveDescendantChanged: (current, _previous, directlyActivated) => { // eslint-disable-next-line react-compiler/react-compiler activeDescendantRef.current = current || null; if (current) { const selectedItem = allItemsToRenderRef.current.find(item_1 => { var _current$closest; return item_1.id === ((_current$closest = current.closest('li')) === null || _current$closest === void 0 ? void 0 : _current$closest.getAttribute('data-id')); }); setHighlightedItem(selectedItem); setIsMenuDirectlyActivated(directlyActivated); } if (current && customScrollContainerRef && customScrollContainerRef.current && directlyActivated) { scrollIntoView(current, customScrollContainerRef.current, menuScrollMargins); } else if (current && scrollContainerRef.current && directlyActivated) { scrollIntoView(current, scrollContainerRef.current, menuScrollMargins); } } }, [loading]); useEffect(() => { var _highlightedItem$text; if (highlightedItem !== null && highlightedItem !== void 0 && (_highlightedItem$text = highlightedItem.text) !== null && _highlightedItem$text !== void 0 && _highlightedItem$text.startsWith(inputValue) && !selectedItemIds.includes(highlightedItem.id)) { setAutocompleteSuggestion(highlightedItem.text); } else { setAutocompleteSuggestion(''); } }, [highlightedItem, inputValue, selectedItemIds, setAutocompleteSuggestion]); useEffect(() => { const itemIdSortResult = [...sortedItemIds].sort(sortOnCloseFn ? sortOnCloseFn : getDefaultSortFn(itemId_0 => isItemSelected(itemId_0, selectedItemIds))); const sortResultMatchesState = itemIdSortResult.length === sortedItemIds.length && itemIdSortResult.every((element_0, index) => element_0 === sortedItemIds[index]); if (showMenu === false && !sortResultMatchesState) { setSortedItemIds(itemIdSortResult); } onOpenChange && onOpenChange(Boolean(showMenu)); }, [showMenu, onOpenChange, selectedItemIds, sortOnCloseFn, sortedItemIds]); useEffect(() => { if (selectedItemIds.length) { setSelectedItemLength(selectedItemIds.length); } }, [selectedItemIds, setSelectedItemLength]); if (selectionVariant === 'single' && selectedItemIds.length > 1) { throw new Error('Autocomplete: selectionVariant "single" cannot be used with multiple selected items'); } return /*#__PURE__*/jsx(VisuallyHidden, { isVisible: showMenu, children: loading ? /*#__PURE__*/jsx("div", { className: classes.SpinnerWrapper, children: /*#__PURE__*/jsx(StyledSpinner, {}) }) : /*#__PURE__*/jsx("div", { ref: listContainerRef, children: allItemsToRender.length ? /*#__PURE__*/jsx(ActionList, { selectionVariant: selectionVariant // TODO: make this configurable , role: "listbox", id: `${id}-listbox`, "aria-labelledby": ariaLabelledBy, children: allItemsToRender.map(item_2 => { const { id: id_0, onAction, children, text, leadingVisual: LeadingVisual, trailingVisual: TrailingVisual, // @ts-expect-error this is defined in the items above but is // missing in TS key, ...itemProps } = item_2; return /*#__PURE__*/jsxs(ActionList.Item, { onSelect: () => onAction(item_2), ...itemProps, id: id_0, "data-id": id_0, children: [LeadingVisual && /*#__PURE__*/jsx(ActionList.LeadingVisual, { children: isElement(LeadingVisual) ? LeadingVisual : /*#__PURE__*/jsx(LeadingVisual, {}) }), children !== null && children !== void 0 ? children : text, TrailingVisual && /*#__PURE__*/jsx(ActionList.TrailingVisual, { children: isElement(TrailingVisual) ? TrailingVisual : /*#__PURE__*/jsx(TrailingVisual, {}) })] }, key !== null && key !== void 0 ? key : id_0); }) }) : emptyStateText !== false && emptyStateText !== null ? /*#__PURE__*/jsx("div", { className: classes.EmptyStateWrapper, children: emptyStateText }) : null }) }); } AutocompleteMenu.displayName = "AutocompleteMenu"; AutocompleteMenu.displayName = 'AutocompleteMenu'; export { AutocompleteMenu as default };