@primer/react
Version:
An implementation of GitHub's Primer Design System using React
429 lines (411 loc) • 16.7 kB
JavaScript
;
var octiconsReact = require('@primer/octicons-react');
var React = require('react');
var AnchoredOverlay = require('../AnchoredOverlay/AnchoredOverlay.js');
var Box = require('../Box/Box.js');
var FilteredActionListEntry = require('../FilteredActionList/FilteredActionListEntry.js');
var Heading = require('../Heading/Heading.js');
require('../Button/ButtonBase.js');
require('../utils/defaultSxProp.js');
var Button = require('../Button/Button.js');
require('../TooltipV2/Tooltip.js');
require('../Tooltip/Tooltip.js');
var clsx = require('clsx');
var useProvidedRefOrCreate = require('../hooks/useProvidedRefOrCreate.js');
require('@primer/behaviors/utils');
require('@primer/behaviors');
var useProvidedStateOrCreate = require('../hooks/useProvidedStateOrCreate.js');
var useId = require('../hooks/useId.js');
var LiveRegion = require('../internal/components/LiveRegion.js');
var useSafeTimeout = require('../hooks/useSafeTimeout.js');
var FilteredActionListLoaders = require('../FilteredActionList/FilteredActionListLoaders.js');
require('../FeatureFlags/FeatureFlags.js');
var useFeatureFlag = require('../FeatureFlags/useFeatureFlag.js');
require('../FeatureFlags/DefaultFeatureFlags.js');
var liveRegionElement = require('@primer/live-region-element');
var SelectPanel_module = require('./SelectPanel.module.css.js');
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
var React__default = /*#__PURE__*/_interopDefault(React);
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
// we add a delay so that it does not interrupt default screen reader announcement and queues after it
const delayMs = 500;
const loadingDelayMs = 1000;
const getItemWithActiveDescendant = (listRef, items) => {
const listElement = listRef.current;
const activeItemElement = listElement === null || listElement === undefined ? undefined : listElement.querySelector('[data-is-active-descendant]');
if (!listElement || !(activeItemElement !== null && activeItemElement !== undefined && activeItemElement.textContent)) return;
const optionElements = listElement.querySelectorAll('[role="option"]');
const index = Array.from(optionElements).indexOf(activeItemElement);
const activeItem = items[index];
const text = activeItem === null || activeItem === undefined ? undefined : activeItem.text;
const selected = activeItem === null || activeItem === undefined ? undefined : activeItem.selected;
return {
index,
text,
selected
};
};
async function announceText(text) {
const liveRegion = document.querySelector('live-region');
liveRegion === null || liveRegion === undefined ? undefined : liveRegion.clear(); // clear previous announcements
await liveRegionElement.announce(text, {
delayMs,
from: liveRegion ? liveRegion : undefined // announce will create a liveRegion if it doesn't find one
});
}
async function announceFilterFocused() {
await announceText('Focus on filter text box and list of items');
}
async function announceNoItems() {
await announceText('No matching items.');
}
async function announceLoading() {
await announceText('Loading.');
}
async function announceItemsChanged(items, listContainerRef) {
const liveRegion = document.querySelector('live-region');
liveRegion === null || liveRegion === undefined ? undefined : liveRegion.clear(); // clear previous announcements
// give @primer/behaviors a moment to update active-descendant
await new Promise(resolve => window.requestAnimationFrame(resolve));
const activeItem = getItemWithActiveDescendant(listContainerRef, items);
if (!activeItem) return;
const {
index,
text,
selected
} = activeItem;
const announcementText = ['List updated', `Focused item: ${text}`, `${selected ? 'selected' : 'not selected'}`, `${index + 1} of ${items.length}`].join(', ');
await liveRegionElement.announce(announcementText, {
delayMs,
from: liveRegion ? liveRegion : undefined // announce will create a liveRegion if it doesn't find one
});
}
function isMultiSelectVariant(selected) {
return Array.isArray(selected);
}
const focusZoneSettings = {
// Let FilteredActionList handle focus zone
disabled: true
};
const areItemsEqual = (itemA, itemB) => {
// prefer checking equivality by item.id
if (typeof itemA.id !== 'undefined') return itemA.id === itemB.id;else return itemA === itemB;
};
const doesItemsIncludeItem = (items, item) => {
return items.some(i => areItemsEqual(i, item));
};
function SelectPanel({
open,
onOpenChange,
renderAnchor = props => {
const {
children,
...rest
} = props;
return /*#__PURE__*/React__default.default.createElement(Button.ButtonComponent, _extends({
trailingAction: octiconsReact.TriangleDownIcon
}, rest), children);
},
anchorRef: externalAnchorRef,
placeholder,
placeholderText = 'Filter items',
inputLabel = placeholderText,
selected,
title = isMultiSelectVariant(selected) ? 'Select items' : 'Select an item',
subtitle,
onSelectedChange,
filterValue: externalFilterValue,
onFilterChange: externalOnFilterChange,
items,
footer,
textInputProps,
overlayProps,
sx,
loading,
initialLoadingType = 'spinner',
className,
height,
width,
id,
...listProps
}) {
const titleId = useId.useId();
const subtitleId = useId.useId();
const [dataLoadedOnce, setDataLoadedOnce] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(false);
const [filterValue, setInternalFilterValue] = useProvidedStateOrCreate.useProvidedStateOrCreate(externalFilterValue, undefined, '');
const {
safeSetTimeout,
safeClearTimeout
} = useSafeTimeout();
const loadingDelayTimeoutId = React.useRef(null);
const loadingManagedInternally = loading === undefined;
const loadingManagedExternally = !loadingManagedInternally;
const [inputRef, setInputRef] = React__default.default.useState(null);
const [listContainerElement, setListContainerElement] = React.useState(null);
const [needItemsChangedAnnouncement, setNeedItemsChangedAnnouncement] = React.useState(false);
const onListContainerRefChanged = React.useCallback(node => {
setListContainerElement(node);
if (needItemsChangedAnnouncement) {
announceItemsChanged(items, {
current: node
});
setNeedItemsChangedAnnouncement(false);
}
}, [items, needItemsChangedAnnouncement]);
const onInputRefChanged = React.useCallback(ref => {
setInputRef(ref);
}, [setInputRef]);
const onFilterChange = React.useCallback((value, e) => {
if (loadingManagedInternally) {
if (loadingDelayTimeoutId.current) {
safeClearTimeout(loadingDelayTimeoutId.current);
}
if (dataLoadedOnce) {
// If data has already been loaded once, delay the spinner a bit. This also helps
// not show and then immediately hide the spinner if items are loaded quickly, i.e.
// not async.
loadingDelayTimeoutId.current = safeSetTimeout(() => {
setIsLoading(true);
announceLoading();
}, loadingDelayMs);
} else {
// If this is the first data load and there are no items, show the loading spinner
// immediately
if (items.length === 0) {
setIsLoading(true);
}
// We still want to announce if loading is taking too long
loadingDelayTimeoutId.current = safeSetTimeout(() => {
announceLoading();
}, loadingDelayMs);
}
}
externalOnFilterChange(value, e);
setInternalFilterValue(value);
}, [loadingManagedInternally, externalOnFilterChange, setInternalFilterValue, dataLoadedOnce, safeSetTimeout, safeClearTimeout, items.length]);
React.useEffect(() => {
if (open) {
if (items.length === 0) {
announceNoItems();
} else {
if (listContainerElement) {
announceItemsChanged(items, {
current: listContainerElement
});
} else {
setNeedItemsChangedAnnouncement(true);
}
}
}
if (loadingManagedExternally) {
if (items.length > 0) {
setDataLoadedOnce(true);
}
return;
}
if (isLoading) {
setIsLoading(false);
setDataLoadedOnce(true);
}
if (loadingDelayTimeoutId.current) {
safeClearTimeout(loadingDelayTimeoutId.current);
}
// Only fire this effect if items have changed
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items]);
React.useEffect(() => {
if (inputRef !== null && inputRef !== undefined && inputRef.current) {
const ref = inputRef.current;
const listener = () => {
announceFilterFocused();
};
if (document.activeElement === ref) {
listener();
}
ref.addEventListener('focus', listener);
// We would normally expect AnchoredOverlay's focus trap to automatically focus the input,
// but for some reason the ref isn't populated until _after_ the panel is open, which is
// too late. So, we focus manually here.
if (open) {
ref.focus();
}
return () => ref.removeEventListener('focus', listener);
}
}, [inputRef, open]);
// Populate panel with items on first open
React.useEffect(() => {
if (loadingManagedExternally) return;
// If data was already loaded once, do nothing
if (dataLoadedOnce) return;
// Only load data when the panel is open
if (open) {
// Only trigger filter change event if there are no items
if (items.length === 0) {
// Trigger filter event to populate panel on first open
onFilterChange(filterValue, null);
}
}
}, [open, dataLoadedOnce, onFilterChange, filterValue, items, loadingManagedExternally, listContainerElement]);
const CSS_MODULES_FEATURE_FLAG = 'primer_react_css_modules_staff';
const enabled = useFeatureFlag.useFeatureFlag(CSS_MODULES_FEATURE_FLAG);
const anchorRef = useProvidedRefOrCreate.useProvidedRefOrCreate(externalAnchorRef);
const onOpen = React.useCallback(gesture => onOpenChange(true, gesture), [onOpenChange]);
const onClose = React.useCallback(gesture => {
onOpenChange(false, gesture);
}, [onOpenChange]);
const renderMenuAnchor = React.useMemo(() => {
if (renderAnchor === null) {
return null;
}
const selectedItems = Array.isArray(selected) ? selected : [...(selected ? [selected] : [])];
return props => {
return renderAnchor({
...props,
children: selectedItems.length ? selectedItems.map(item => item.text).join(', ') : placeholder
});
};
}, [placeholder, renderAnchor, selected]);
const itemsToRender = React.useMemo(() => {
return items.map(item => {
const isItemSelected = isMultiSelectVariant(selected) ? doesItemsIncludeItem(selected, item) : selected === item;
return {
...item,
role: 'option',
selected: 'selected' in item && item.selected === undefined ? undefined : isItemSelected,
onAction: (itemFromAction, event) => {
var _item$onAction;
(_item$onAction = item.onAction) === null || _item$onAction === undefined ? undefined : _item$onAction.call(item, itemFromAction, event);
if (event.defaultPrevented) {
return;
}
if (isMultiSelectVariant(selected)) {
const otherSelectedItems = selected.filter(selectedItem => !areItemsEqual(selectedItem, item));
const newSelectedItems = doesItemsIncludeItem(selected, item) ? otherSelectedItems : [...otherSelectedItems, item];
const multiSelectOnChange = onSelectedChange;
multiSelectOnChange(newSelectedItems);
return;
}
// single select
const singleSelectOnChange = onSelectedChange;
singleSelectOnChange(item === selected ? undefined : item);
onClose('selection');
}
};
});
}, [onClose, onSelectedChange, items, selected]);
const focusTrapSettings = {
initialFocusRef: inputRef || undefined
};
const extendedTextInputProps = React.useMemo(() => {
return {
sx: {
m: 2
},
contrast: true,
leadingVisual: octiconsReact.SearchIcon,
'aria-label': inputLabel,
...textInputProps
};
}, [inputLabel, textInputProps]);
const loadingType = () => {
if (dataLoadedOnce) {
return FilteredActionListLoaders.FilteredActionListLoadingTypes.input;
} else {
if (initialLoadingType === 'spinner') {
return FilteredActionListLoaders.FilteredActionListLoadingTypes.bodySpinner;
} else {
return FilteredActionListLoaders.FilteredActionListLoadingTypes.bodySkeleton;
}
}
};
const usingModernActionList = useFeatureFlag.useFeatureFlag('primer_react_select_panel_with_modern_action_list');
return /*#__PURE__*/React__default.default.createElement(LiveRegion.LiveRegion, null, /*#__PURE__*/React__default.default.createElement(AnchoredOverlay.AnchoredOverlay, {
renderAnchor: renderMenuAnchor,
anchorRef: anchorRef,
open: open,
onOpen: onOpen,
onClose: onClose,
overlayProps: {
role: 'dialog',
'aria-labelledby': titleId,
'aria-describedby': subtitle ? subtitleId : undefined,
...overlayProps
},
focusTrapSettings: focusTrapSettings,
focusZoneSettings: focusZoneSettings,
height: height,
width: width,
anchorId: id,
pinPosition: !height
}, /*#__PURE__*/React__default.default.createElement(LiveRegion.LiveRegionOutlet, null), usingModernActionList ? null : /*#__PURE__*/React__default.default.createElement(LiveRegion.Message, {
value: filterValue === '' ? 'Showing all items' : items.length <= 0 ? 'No matching items' : `${items.length} matching ${items.length === 1 ? 'item' : 'items'}`
}), /*#__PURE__*/React__default.default.createElement(Box, {
sx: enabled ? undefined : {
display: 'flex',
flexDirection: 'column',
height: 'inherit',
maxHeight: 'inherit'
},
className: enabled ? SelectPanel_module.Wrapper : undefined
}, /*#__PURE__*/React__default.default.createElement(Box, {
sx: enabled ? undefined : {
pt: 2,
px: 3
},
className: enabled ? SelectPanel_module.Content : undefined
}, /*#__PURE__*/React__default.default.createElement(Heading, {
as: "h1",
id: titleId,
sx: enabled ? undefined : {
fontSize: 1
},
className: enabled ? SelectPanel_module.Title : undefined
}, title), subtitle ? /*#__PURE__*/React__default.default.createElement(Box, {
id: subtitleId,
sx: enabled ? undefined : {
fontSize: 0,
color: 'fg.muted'
},
className: enabled ? SelectPanel_module.Subtitle : undefined
}, subtitle) : null), /*#__PURE__*/React__default.default.createElement(FilteredActionListEntry.FilteredActionList, _extends({
filterValue: filterValue,
onFilterChange: onFilterChange,
onListContainerRefChanged: onListContainerRefChanged,
onInputRefChanged: onInputRefChanged,
placeholderText: placeholderText
}, listProps, {
role: "listbox"
// browsers give aria-labelledby precedence over aria-label so we need to make sure
// we don't accidentally override props.aria-label
,
"aria-labelledby": listProps['aria-label'] ? undefined : titleId,
"aria-multiselectable": isMultiSelectVariant(selected) ? 'true' : 'false',
selectionVariant: isMultiSelectVariant(selected) ? 'multiple' : 'single',
items: itemsToRender,
textInputProps: extendedTextInputProps,
loading: loading || isLoading,
loadingType: loadingType()
// inheriting height and maxHeight ensures that the FilteredActionList is never taller
// than the Overlay (which would break scrolling the items)
,
sx: enabled ? sx : {
...sx,
height: 'inherit',
maxHeight: 'inherit'
},
className: enabled ? clsx.clsx(className, SelectPanel_module.FilteredActionList) : className,
announcementsEnabled: false
})), footer && /*#__PURE__*/React__default.default.createElement(Box, {
sx: enabled ? undefined : {
display: 'flex',
borderTop: '1px solid',
borderColor: 'border.default',
padding: 2
},
className: enabled ? SelectPanel_module.Footer : undefined
}, footer))));
}
SelectPanel.displayName = "SelectPanel";
SelectPanel.displayName = 'SelectPanel';
exports.SelectPanel = SelectPanel;