UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

566 lines (556 loc) 22.5 kB
import { c } from 'react-compiler-runtime'; import { scrollIntoView, FocusKeys } from '@primer/behaviors'; import { useCallback, useRef, useState, useEffect, useMemo, forwardRef } from 'react'; import { ActionList } from '../ActionList/index.js'; import { useFocusZone } from '../hooks/useFocusZone.js'; import { useId } from '../hooks/useId.js'; import { useProvidedRefOrCreate } from '../hooks/useProvidedRefOrCreate.js'; import { useProvidedStateOrCreate } from '../hooks/useProvidedStateOrCreate.js'; import useScrollFlash from '../hooks/useScrollFlash.js'; import { FilteredActionListLoadingTypes, FilteredActionListBodyLoader } from './FilteredActionListLoaders.js'; import classes from './FilteredActionList.module.css.js'; import { ActionListContainerContext } from '../ActionList/ActionListContainerContext.js'; import { isValidElementType } from 'react-is'; import { useAnnouncements } from './useAnnouncements.js'; import { clsx } from 'clsx'; import { useVirtualizer } from '@tanstack/react-virtual'; import { FilteredActionListInput } from './FilteredActionListInput.js'; import { jsxs, jsx } from 'react/jsx-runtime'; import { VisuallyHidden } from '../VisuallyHidden/VisuallyHidden.js'; import Checkbox from '../Checkbox/Checkbox.js'; const menuScrollMargins = { startMargin: 0, endMargin: 8 }; function FilteredActionList({ loading = false, placeholderText, filterValue: externalFilterValue, loadingType = FilteredActionListLoadingTypes.bodySpinner, onFilterChange, onListContainerRefChanged, onInputRefChanged, items, textInputProps, inputRef: providedInputRef, scrollContainerRef: providedScrollContainerRef, groupMetadata, showItemDividers, message, messageText, className, selectionVariant, announcementsEnabled = true, fullScreenOnNarrow, onSelectAllChange, actionListProps, focusOutBehavior = 'wrap', _PrivateFocusManagement = 'active-descendant', onActiveDescendantChanged, disableSelectOnHover = false, setInitialFocus = false, focusPrependedElements, scrollBehavior, virtualized = false, ...listProps }) { if (process.env.NODE_ENV !== "production") { if (virtualized && groupMetadata !== null && groupMetadata !== void 0 && groupMetadata.length) { // eslint-disable-next-line no-console console.warn('FilteredActionList: `virtualized` has no effect when `groupMetadata` is provided. ' + 'Grouped lists are rendered without virtualization.'); } } // Virtualization is disabled when groups are present — grouped lists render // normally regardless of the `virtualized` prop. const isVirtualized = virtualized && !(groupMetadata !== null && groupMetadata !== void 0 && groupMetadata.length); const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate(externalFilterValue, undefined, ''); const onInputChange = useCallback(e => { const value = e.target.value; onFilterChange(value, e); setInternalFilterValue(value); }, [onFilterChange, setInternalFilterValue]); const inputAndListContainerRef = useRef(null); const listRef = useRef(null); const scrollContainerRef = useProvidedRefOrCreate(providedScrollContainerRef); const inputRef = useProvidedRefOrCreate(providedInputRef); const usingRovingTabindex = _PrivateFocusManagement === 'roving-tabindex'; const [listContainerElement, setListContainerElement] = useState(null); const activeDescendantRef = useRef(); const listId = useId(actionListProps === null || actionListProps === void 0 ? void 0 : actionListProps.id); const inputDescriptionTextId = useId(); const [isInputFocused, setIsInputFocused] = useState(false); const selectAllChecked = items.length > 0 && items.every(item => item.selected); const selectAllIndeterminate = !selectAllChecked && items.some(item_0 => item_0.selected); const selectAllLabelText = selectAllChecked ? 'Deselect all' : 'Select all'; const getItemListForEachGroup = useCallback(groupId => { const itemsInGroup = []; for (const item_1 of items) { // Look up the group associated with the current item. if (item_1.groupId === groupId) { itemsInGroup.push(item_1); } } return itemsInGroup; }, [items]); const onInputKeyDown = useCallback(event => { if (event.key === 'ArrowDown') { if (listRef.current) { const firstSelectedItem = listRef.current.querySelector('[role="option"]'); firstSelectedItem === null || firstSelectedItem === void 0 ? void 0 : firstSelectedItem.focus(); event.preventDefault(); } } else if (event.key === 'Enter') { let firstItem; // If there are groups, it's not guaranteed that the first item is the actual first item in the first - // as groups are rendered in the order of the groupId provided if (groupMetadata) { let firstGroupIndex = 0; for (let i = 0; i < groupMetadata.length; i++) { if (getItemListForEachGroup(groupMetadata[i].groupId).length > 0) { break; } else { firstGroupIndex++; } } const firstGroup = groupMetadata[firstGroupIndex].groupId; firstItem = items.filter(item_2 => item_2.groupId === firstGroup)[0]; } else { firstItem = items[0]; } if (firstItem.onAction) { firstItem.onAction(firstItem, event); event.preventDefault(); } } }, [items, groupMetadata, getItemListForEachGroup]); const onInputKeyPress = useCallback(event_0 => { if (event_0.key === 'Enter' && activeDescendantRef.current) { event_0.preventDefault(); event_0.nativeEvent.stopImmediatePropagation(); // Forward Enter key press to active descendant so that item gets activated const activeDescendantEvent = new KeyboardEvent(event_0.type, event_0.nativeEvent); activeDescendantRef.current.dispatchEvent(activeDescendantEvent); } }, [activeDescendantRef]); const listContainerRefCallback = useCallback(node => { setListContainerElement(node); onListContainerRefChanged === null || onListContainerRefChanged === void 0 ? void 0 : onListContainerRefChanged(node); }, [onListContainerRefChanged]); useEffect(() => { onInputRefChanged === null || onInputRefChanged === void 0 ? void 0 : onInputRefChanged(inputRef); }, [inputRef, onInputRefChanged]); // Matches the most common ActionList.Item height (single-line text + description). // Items are measured dynamically via `measureElement`, so this only affects the // initial total-height estimate before items scroll into view. const DEFAULT_VIRTUAL_ITEM_HEIGHT = 32; // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => scrollContainerRef.current, estimateSize: () => DEFAULT_VIRTUAL_ITEM_HEIGHT, overscan: 10, enabled: isVirtualized, getItemKey: index => { var _ref, _item_3$key, _item_3$id; const item_3 = items[index]; return (_ref = (_item_3$key = item_3.key) !== null && _item_3$key !== void 0 ? _item_3$key : (_item_3$id = item_3.id) === null || _item_3$id === void 0 ? void 0 : _item_3$id.toString()) !== null && _ref !== void 0 ? _ref : index.toString(); }, measureElement: el => el.scrollHeight }); const virtualItems = isVirtualized ? virtualizer.getVirtualItems() : undefined; const virtualizedItemEntries = useMemo(() => { if (!isVirtualized || !virtualItems) return undefined; return virtualItems.map(virtualItem => { const item_4 = items[virtualItem.index]; return { virtualItem, item: item_4, index: virtualItem.index }; }); }, [isVirtualized, virtualItems, items]); useFocusZone(!usingRovingTabindex ? { containerRef: { current: listContainerElement }, bindKeys: FocusKeys.ArrowVertical | FocusKeys.PageUpDown, // With virtualization, only a subset of items exists in the DOM at any time. // 'wrap' would cycle focus within the visible window instead of reaching the // true end of the list. 'stop' lets the virtualizer's scrollToIndex bring // the correct items into view when navigating past the rendered boundaries. focusOutBehavior: isVirtualized ? 'stop' : focusOutBehavior, focusableElementFilter: element => { return !(element instanceof HTMLInputElement); }, activeDescendantFocus: inputRef, onActiveDescendantChanged: (current, previous, directlyActivated) => { activeDescendantRef.current = current; if (isVirtualized && current) { const index_0 = current.getAttribute('data-index'); const range = virtualizer.range; if (index_0 !== null && range && (Number(index_0) < range.startIndex || Number(index_0) >= range.endIndex)) { virtualizer.scrollToIndex(Number(index_0), { align: 'auto' }); } } if (current && scrollContainerRef.current && (directlyActivated || focusPrependedElements)) { scrollIntoView(current, scrollContainerRef.current, { ...menuScrollMargins, behavior: scrollBehavior }); } onActiveDescendantChanged === null || onActiveDescendantChanged === void 0 ? void 0 : onActiveDescendantChanged(current, previous, directlyActivated); }, focusInStrategy: setInitialFocus ? 'initial' : 'previous', ignoreHoverEvents: disableSelectOnHover, focusPrependedElements } : undefined, [listContainerElement, usingRovingTabindex, onActiveDescendantChanged, focusPrependedElements, isVirtualized]); useEffect(() => { if (activeDescendantRef.current && scrollContainerRef.current) { scrollIntoView(activeDescendantRef.current, scrollContainerRef.current, { ...menuScrollMargins, behavior: scrollBehavior }); } }, [items, inputRef, scrollContainerRef, scrollBehavior]); useEffect(() => { if (usingRovingTabindex) { const inputAndListContainerElement = inputAndListContainerRef.current; if (!inputAndListContainerElement) return; const list = listRef.current; if (!list) return; // Listen for focus changes within the container const handleFocusIn = event_1 => { if (event_1.target === inputRef.current || list.contains(event_1.target)) { setIsInputFocused(inputRef.current && inputRef.current === document.activeElement ? true : false); } }; inputAndListContainerElement.addEventListener('focusin', handleFocusIn); return () => { inputAndListContainerElement.removeEventListener('focusin', handleFocusIn); }; } }, [items, inputRef, listContainerElement, usingRovingTabindex]); // Re-run when items change to update active indicators useEffect(() => { if (usingRovingTabindex && !loading) { setIsInputFocused(inputRef.current && inputRef.current === document.activeElement ? true : false); } }, [loading, inputRef, usingRovingTabindex]); useAnnouncements(items, usingRovingTabindex ? listRef : { current: listContainerElement }, inputRef, announcementsEnabled, loading, messageText, _PrivateFocusManagement); useScrollFlash(scrollContainerRef); const handleSelectAllChange = useCallback(e_0 => { if (onSelectAllChange) { onSelectAllChange(e_0.target.checked); } }, [onSelectAllChange]); function getBodyContent() { if (loading && scrollContainerRef.current && loadingType.appearsInBody) { return /*#__PURE__*/jsx(FilteredActionListBodyLoader, { loadingType: loadingType, height: scrollContainerRef.current.clientHeight }); } if (message) { return message; } let firstGroupIndex_0 = 0; const renderListItems = () => { if (groupMetadata !== null && groupMetadata !== void 0 && groupMetadata.length) { return groupMetadata.map((group, index_1) => { var _group$header, _group$header2; if (index_1 === firstGroupIndex_0 && getItemListForEachGroup(group.groupId).length === 0) { firstGroupIndex_0++; } return /*#__PURE__*/jsxs(ActionList.Group, { children: [/*#__PURE__*/jsx(ActionList.GroupHeading, { variant: (_group$header = group.header) !== null && _group$header !== void 0 && _group$header.variant ? group.header.variant : undefined, children: (_group$header2 = group.header) !== null && _group$header2 !== void 0 && _group$header2.title ? group.header.title : `Group ${group.groupId}` }), getItemListForEachGroup(group.groupId).map(({ key: itemKey, ...item_5 }, itemIndex) => { var _ref2, _item_5$id; const key = (_ref2 = itemKey !== null && itemKey !== void 0 ? itemKey : (_item_5$id = item_5.id) === null || _item_5$id === void 0 ? void 0 : _item_5$id.toString()) !== null && _ref2 !== void 0 ? _ref2 : itemIndex.toString(); return /*#__PURE__*/jsx(MappedActionListItem, { className: clsx(classes.ActionListItem, 'className' in item_5 ? item_5.className : undefined), "data-input-focused": isInputFocused ? '' : undefined, "data-first-child": index_1 === firstGroupIndex_0 && itemIndex === 0 ? '' : undefined, ...item_5, renderItem: listProps.renderItem }, key); })] }, index_1); }); } if (isVirtualized && virtualizedItemEntries) { return virtualizedItemEntries.map(({ virtualItem: virtualItem_0, item: { key: itemKey_0, ...item_6 }, index: index_2 }) => { var _ref3, _item_6$id; const key_0 = (_ref3 = itemKey_0 !== null && itemKey_0 !== void 0 ? itemKey_0 : (_item_6$id = item_6.id) === null || _item_6$id === void 0 ? void 0 : _item_6$id.toString()) !== null && _ref3 !== void 0 ? _ref3 : index_2.toString(); return /*#__PURE__*/jsx(MappedActionListItem, { className: clsx(classes.ActionListItem, 'className' in item_6 ? item_6.className : undefined), "data-input-focused": isInputFocused ? '' : undefined, "data-first-child": index_2 === 0 ? '' : undefined, "data-index": virtualItem_0.index, ref: node_0 => { if (node_0) { virtualizer.measureElement(node_0); } }, style: { position: 'absolute', top: 0, left: 0, right: 0, transform: `translateY(${virtualItem_0.start}px)` }, ...item_6, renderItem: listProps.renderItem }, key_0); }); } return items.map(({ key: itemKey_1, ...item_7 }, index_3) => { var _ref4, _item_7$id; const key_1 = (_ref4 = itemKey_1 !== null && itemKey_1 !== void 0 ? itemKey_1 : (_item_7$id = item_7.id) === null || _item_7$id === void 0 ? void 0 : _item_7$id.toString()) !== null && _ref4 !== void 0 ? _ref4 : index_3.toString(); return /*#__PURE__*/jsx(MappedActionListItem, { className: clsx(classes.ActionListItem, 'className' in item_7 ? item_7.className : undefined), "data-input-focused": isInputFocused ? '' : undefined, "data-first-child": index_3 === 0 ? '' : undefined, ...item_7, renderItem: listProps.renderItem }, key_1); }); }; const actionListContent = /*#__PURE__*/jsx(ActionList, { ref: usingRovingTabindex ? listRef : listContainerRefCallback, showDividers: showItemDividers, selectionVariant: selectionVariant, ...listProps, ...actionListProps, role: "listbox", id: listId, className: clsx(classes.ActionList, actionListProps === null || actionListProps === void 0 ? void 0 : actionListProps.className) // When virtualized, the ActionList needs `position: relative` so that absolutely-positioned // virtual items are placed correctly, and its `height` must equal the total virtual content // size so the scroll container produces the right scrollbar. // These styles are independent of SelectPanel's `height`/`width` props, which control the // outer overlay dimensions, not the list content area. , style: isVirtualized ? { ...(actionListProps === null || actionListProps === void 0 ? void 0 : actionListProps.style), height: virtualizer.getTotalSize(), position: 'relative' } : actionListProps === null || actionListProps === void 0 ? void 0 : actionListProps.style, children: renderListItems() }); // Use ActionListContainerContext.Provider only for the old behavior (when feature flag is disabled) if (usingRovingTabindex) { return /*#__PURE__*/jsx(ActionListContainerContext.Provider, { value: { container: 'FilteredActionList', listRole: 'listbox', selectionAttribute: 'aria-selected', selectionVariant, enableFocusZone: true }, children: actionListContent }); } else { return actionListContent; } } return /*#__PURE__*/jsxs("div", { ref: inputAndListContainerRef, className: clsx(className, classes.Root), "data-testid": "filtered-action-list", "data-component": "FilteredActionList", children: [/*#__PURE__*/jsx(FilteredActionListInput, { inputRef: inputRef, value: filterValue, onInputChange: onInputChange, onInputKeyPress: onInputKeyPress, onInputKeyDown: usingRovingTabindex ? onInputKeyDown : undefined, placeholderText: placeholderText, listId: listId, inputDescriptionTextId: inputDescriptionTextId, loading: loading && !loadingType.appearsInBody, fullScreenOnNarrow: fullScreenOnNarrow, ...textInputProps }), /*#__PURE__*/jsx(VisuallyHidden, { id: inputDescriptionTextId, children: "Items will be filtered as you type" }), onSelectAllChange !== undefined && /*#__PURE__*/jsxs("div", { className: classes.SelectAllContainer, "data-component": "FilteredActionList.SelectAll", children: [/*#__PURE__*/jsx(Checkbox, { id: "select-all-checkbox", className: classes.SelectAllCheckbox, checked: selectAllChecked, indeterminate: selectAllIndeterminate, onChange: handleSelectAllChange, "data-component": "FilteredActionList.SelectAllCheckbox" }), /*#__PURE__*/jsx("label", { className: classes.SelectAllLabel, htmlFor: "select-all-checkbox", "data-component": "FilteredActionList.SelectAllLabel", children: selectAllLabelText })] }), /*#__PURE__*/jsx("div", { ref: scrollContainerRef, className: classes.Container, children: getBodyContent() })] }); } FilteredActionList.displayName = "FilteredActionList"; const MappedActionListItem = /*#__PURE__*/forwardRef((item, ref) => { const $ = c(36); if (typeof item.renderItem === "function") { let t0; if ($[0] !== item) { t0 = item.renderItem(item); $[0] = item; $[1] = t0; } else { t0 = $[1]; } return t0; } let LeadingVisual; let TrailingIcon; let TrailingVisual; let children; let description; let descriptionVariant; let id; let onAction; let rest; let text; let trailingText; if ($[2] !== item) { ({ id, description, descriptionVariant, text, trailingVisual: TrailingVisual, leadingVisual: LeadingVisual, trailingText, trailingIcon: TrailingIcon, onAction, children, ...rest } = item); $[2] = item; $[3] = LeadingVisual; $[4] = TrailingIcon; $[5] = TrailingVisual; $[6] = children; $[7] = description; $[8] = descriptionVariant; $[9] = id; $[10] = onAction; $[11] = rest; $[12] = text; $[13] = trailingText; } else { LeadingVisual = $[3]; TrailingIcon = $[4]; TrailingVisual = $[5]; children = $[6]; description = $[7]; descriptionVariant = $[8]; id = $[9]; onAction = $[10]; rest = $[11]; text = $[12]; trailingText = $[13]; } let t0; if ($[14] !== item || $[15] !== onAction) { t0 = e => { if (typeof onAction === "function") { onAction(item, e); } }; $[14] = item; $[15] = onAction; $[16] = t0; } else { t0 = $[16]; } let t1; if ($[17] !== LeadingVisual) { t1 = LeadingVisual ? /*#__PURE__*/jsx(ActionList.LeadingVisual, { children: /*#__PURE__*/jsx(LeadingVisual, {}) }) : null; $[17] = LeadingVisual; $[18] = t1; } else { t1 = $[18]; } let t2; if ($[19] !== description || $[20] !== descriptionVariant) { t2 = description ? /*#__PURE__*/jsx(ActionList.Description, { variant: descriptionVariant, children: description }) : null; $[19] = description; $[20] = descriptionVariant; $[21] = t2; } else { t2 = $[21]; } let t3; if ($[22] !== TrailingIcon || $[23] !== TrailingVisual || $[24] !== trailingText) { t3 = TrailingVisual ? /*#__PURE__*/jsx(ActionList.TrailingVisual, { children: typeof TrailingVisual !== "string" && isValidElementType(TrailingVisual) ? /*#__PURE__*/jsx(TrailingVisual, {}) : TrailingVisual }) : TrailingIcon || trailingText ? /*#__PURE__*/jsxs(ActionList.TrailingVisual, { children: [trailingText, TrailingIcon && /*#__PURE__*/jsx(TrailingIcon, {})] }) : null; $[22] = TrailingIcon; $[23] = TrailingVisual; $[24] = trailingText; $[25] = t3; } else { t3 = $[25]; } let t4; if ($[26] !== children || $[27] !== id || $[28] !== ref || $[29] !== rest || $[30] !== t0 || $[31] !== t1 || $[32] !== t2 || $[33] !== t3 || $[34] !== text) { t4 = /*#__PURE__*/jsxs(ActionList.Item, { role: "option", onSelect: t0, "data-id": id, ref: ref, ...rest, children: [t1, children, text, t2, t3] }); $[26] = children; $[27] = id; $[28] = ref; $[29] = rest; $[30] = t0; $[31] = t1; $[32] = t2; $[33] = t3; $[34] = text; $[35] = t4; } else { t4 = $[35]; } return t4; }); FilteredActionList.displayName = 'FilteredActionList'; export { FilteredActionList };