UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

429 lines (411 loc) • 16.7 kB
'use strict'; 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;