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