UNPKG

@utahdts/utah-design-system

Version:
148 lines (141 loc) 5.7 kB
import { useCallback, useEffect, useRef } from 'react'; import { useFloating, autoUpdate, offset as floatingOffset, shift, flip } from '@floating-ui/react-dom'; import { useAriaMessaging } from '../../../../contexts/UtahDesignSystemContext/hooks/useAriaMessaging'; import { popupPlacement } from '../../../../enums/popupPlacement'; import { useDebounceFunc } from '../../../../hooks/useDebounceFunc'; import { joinClassNames } from '../../../../util/joinClassNames'; import { useMultiSelectContext } from '../../MultiSelect/context/useMultiSelectContext'; import { ComboBoxOption } from '../ComboBoxOption'; import { useComboBoxContext } from '../context/useComboBoxContext'; import { isOptionGroupVisible } from '../functions/isOptionGroupVisible'; /** * @param {object} props * @param {boolean} [props.allowCustomEntry] if allowing custom entry, add the custom item if the list is empty; Must be a controlled component * @param {string} props.ariaLabelledById * @param {import('react').ReactNode | null} [props.children] * @param {HTMLElement | null} props.popupReferenceElement * @param {string} props.id * @returns {import('react').JSX.Element} */ export function CombBoxListBox({ allowCustomEntry, ariaLabelledById, children, id, popupReferenceElement, }) { const [{ selectedValues }] = useMultiSelectContext(); const { addPoliteMessage } = useAriaMessaging(); const [ { filterValue, isOptionsExpanded, options, optionsFiltered, optionsFilteredWithoutGroupLabels, optionValueFocused, optionValueSelected, }, /* array element `setState` is not used here */, comboBoxContextNonStateRef, ] = useComboBoxContext(); const ulRef = useRef(/** @type {HTMLUListElement | null} */(null)); const announcedArrowKeysRef = useRef(false); const { floatingStyles } = useFloating({ elements: { reference: popupReferenceElement, floating: ulRef.current, }, middleware: [ floatingOffset({mainAxis: 4, crossAxis: 0, alignmentAxis: 0}), flip(), shift(), ], open: isOptionsExpanded, placement: popupPlacement.BOTTOM, whileElementsMounted: autoUpdate, }); const lastMessageRef = useRef(/** @type {string | null} */(null)); const addPoliteMessageDebounced = useDebounceFunc( useCallback( (message) => { if (lastMessageRef.current !== message) { addPoliteMessage(message); lastMessageRef.current = message; } }, [addPoliteMessage] ), 1500 ); useEffect( () => { const message = []; // only announce if text input or an option for this combo box has focus if (optionValueFocused || document.activeElement === comboBoxContextNonStateRef.current.textInput) { // arrow key announcement only happens the first time the options pop open const sayArrowKeyAnnouncement = isOptionsExpanded && !announcedArrowKeysRef.current; if (sayArrowKeyAnnouncement) { // after first invocation, no longer announce about the arrow keys. announcedArrowKeysRef.current = true; } const numGroups = optionsFiltered.filter( (option) => ( option.isGroupLabel && isOptionGroupVisible( option.isGroupLabel ? option.optionGroupId ?? null : null, option.label, optionsFiltered, selectedValues ) ) ).length; if (numGroups) { // the options have "groups": '8 results available in 2 groups' message.push(`${optionsFilteredWithoutGroupLabels.length} result${optionsFilteredWithoutGroupLabels.length === 1 ? '' : 's'} available in ${numGroups} group${numGroups === 1 ? '' : 's'}.`); } else { // there are no groups: '8 results available' message.push(`${optionsFilteredWithoutGroupLabels.length} result${optionsFilteredWithoutGroupLabels.length === 1 ? '' : 's'} available.`); } if (allowCustomEntry && filterValue && !options.some((option) => option.labelLowerCase === filterValue.toLocaleLowerCase())) { message.push(`Press Enter to add ${filterValue} to the combo box list.`); } message.push('Use the down arrow key to begin selecting.'); addPoliteMessageDebounced(message.join(' ')); } }, // do not include `optionValueFocused` in the dependency list [isOptionsExpanded, optionsFilteredWithoutGroupLabels, filterValue] ); return ( <ul id={id} aria-labelledby={ariaLabelledById} className={joinClassNames( 'combo-box-input__list-box', !isOptionsExpanded && 'visually-hidden' )} ref={ulRef} role="listbox" style={{ ...floatingStyles, minWidth: popupReferenceElement?.scrollWidth, }} tabIndex={-1} > {children} { // not custom and no possible options (all filtered out) (!optionsFilteredWithoutGroupLabels.length && !allowCustomEntry) ? <ComboBoxOption isStatic isDisabled label="" value="">No results found</ComboBoxOption> : null } { // no possible options but allowing custom entry and haven't selected a value yet (!optionsFilteredWithoutGroupLabels.length && allowCustomEntry && optionValueSelected !== filterValue) ? <ComboBoxOption isStatic isDisabled label="" value="">Press enter to add custom item</ComboBoxOption> : null } {/* Note: a custom entered option (allowCustomEntry) is not rendered here. The controlling component must create the ComboBoxOption for it. */} </ul> ); }