@utahdts/utah-design-system
Version:
Utah Design System React Library
148 lines (141 loc) • 5.7 kB
JSX
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>
);
}