@primer/react
Version:
An implementation of GitHub's Primer Design System using React
246 lines (241 loc) • 10.9 kB
JavaScript
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 };