UNPKG

@utahdts/utah-design-system

Version:
308 lines (297 loc) 12 kB
import { identity, isFunction } from 'lodash-es'; import { useCallback, useEffect, useRef } from 'react'; import { joinClassNames } from '../../../../util/joinClassNames'; import { useOnKeyUp } from '../../../../util/useOnKeyUp'; import { IconButton } from '../../../buttons/IconButton'; import { useMultiSelectContext } from '../../MultiSelect/context/useMultiSelectContext'; import { TextInput } from '../../TextInput'; import { useComboBoxContext } from '../context/useComboBoxContext'; import { clearComboBoxSelection } from '../functions/clearComboBoxSelection'; import { moveComboBoxSelectionDown } from '../functions/moveComboBoxSelectionDown'; import { moveComboBoxSelectionUp } from '../functions/moveComboBoxSelectionUp'; /** @typedef {import('@utahdts/utah-design-system').EventAction} EventAction */ /** * @template MutableRefT * @typedef {import('@utahdts/utah-design-system').MutableRef<MutableRefT>} MutableRef */ /** * @param {object} props * @param {boolean} [props.allowCustomEntry] * @param {string} [props.className] * @param {string} props.comboBoxListId * @param {string} [props.errorMessage] * @param {(isOptionsExpanded: boolean) => React.ReactNode} [props.iconCallback] Can provide a custom icon to show for the popup icon * @param {string} props.id * @param {MutableRef<HTMLInputElement | null>} [props.innerRef] * @param {boolean} [props.isClearable] * @param {boolean} [props.isDisabled] * @param {boolean} [props.isInvalid] * @param {boolean} [props.isRequired] * @param {boolean} [props.isShowingClearableIcon] if `isClearable` is true, this can override the logic for showing the clearable `x` * @param {string} props.label * @param {string} [props.labelClassName] * @param {string} [props.name] * @param {import('react').UIEventHandler} [props.onBlur] * @param {EventAction} [props.onClear] * @param {(customValue: string) => void} [props.onCustomEntry] * @param {(e: Event, currentFilterValue: string) => boolean} [props.onKeyUp] return true if the key press was handled by this handler * @param {string} [props.placeholder] * @param {string} [props.wrapperClassName] * @returns {import('react').JSX.Element} */ export function ComboBoxTextInput({ allowCustomEntry, className, comboBoxListId, errorMessage, iconCallback, id, innerRef: draftInnerRef, isClearable, isInvalid, isShowingClearableIcon, isDisabled, onBlur, onClear, onCustomEntry, onKeyUp, placeholder, ...rest }) { const [multiSelectContext, , multiSelectContextRefs] = useMultiSelectContext(); const [ { filterValue, isOptionsExpanded, onClear: onClearComboBoxContext, onKeyUp: onKeyUpFromContext, onChange, options, optionValueFocusedId, optionValueSelected, }, setComboBoxContext, comboBoxContextNonStateRef, ] = useComboBoxContext(); const onCancelKeyPress = useOnKeyUp('Escape', useCallback(() => isClearable && setComboBoxContext(clearComboBoxSelection), [isClearable, setComboBoxContext])); const onUpArrowPress = useOnKeyUp( 'ArrowUp', useCallback( () => setComboBoxContext( (draftContext) => moveComboBoxSelectionUp(draftContext, comboBoxContextNonStateRef.current.textInput, multiSelectContext) ), [comboBoxContextNonStateRef, multiSelectContext, setComboBoxContext] ) ); const onDownArrowPress = useOnKeyUp( 'ArrowDown', useCallback( () => { // if there are no options left from which to pick, don't open menu if (multiSelectContext.selectedValues.length !== options.filter((option) => !option.isGroupLabel).length) { setComboBoxContext((draftContext) => moveComboBoxSelectionDown(draftContext, multiSelectContext)); } }, [multiSelectContext, options, setComboBoxContext] ) ); const onEnterPress = useOnKeyUp( 'Enter', useCallback( /** @param {React.KeyboardEvent<HTMLInputElement>} e */ (e) => { /** @type {HTMLInputElement} */ // @ts-expect-error const { target } = e; const currentTextInputValue = /** @type {string} */ (target.value); const currentTextInputValueLowerCase = currentTextInputValue.toLowerCase(); // if already have entered this custom item or if it matches an existing option, don't add it again. const matchingOption = options.find((option) => option.labelLowerCase === currentTextInputValueLowerCase); // redundant to check the allowCustomEntry flag, but lends to better readability if (!matchingOption && allowCustomEntry) { // let caller know a new option has been added onCustomEntry?.(currentTextInputValue); // let caller know the new option is also selected onChange(currentTextInputValue); // close expanded options after selection since normally have to press enter on the Select Option and it closes popup setComboBoxContext((draftContext) => { draftContext.isOptionsExpanded = false; }); } }, [allowCustomEntry, multiSelectContext, options, setComboBoxContext] ) ); const clearIconRef = useRef(/** @type {HTMLButtonElement | null} */(null)); // for backSpacing, the onChange event fires BEFORE the onKeyUp event so the filterValue was getting the changed value and not the previous value const onKeyUpPreviousValue = useRef(''); useEffect( () => { // had something selected, and no longer have something selected, so clear filter value so that the input shows no value if (!optionValueSelected) { setComboBoxContext((draftContext) => { draftContext.filterValue = ''; }); } }, [optionValueSelected] ); const textInputRef = useRef(/** @type {HTMLInputElement | null} */(null)); return ( <div> <TextInput aria-activedescendant={optionValueFocusedId} aria-autocomplete="list" aria-controls={comboBoxListId} aria-expanded={isOptionsExpanded} aria-haspopup="listbox" aria-owns={comboBoxListId} className={joinClassNames('combo-box-input', className)} clearIconRef={clearIconRef} id={id} innerRef={(ref) => { const input = ref?.querySelector('input') ?? null; textInputRef.current = input; comboBoxContextNonStateRef.current.textInput = input; if (multiSelectContextRefs) { multiSelectContextRefs.current.textInput = input; } if (draftInnerRef) { if (isFunction(draftInnerRef)) { draftInnerRef(input); } else { draftInnerRef.current = input; } } }} isClearable={isClearable} isDisabled={isDisabled} isInvalid={!!errorMessage || isInvalid} isShowingClearableIcon={isShowingClearableIcon} errorMessage={errorMessage} // @ts-expect-error onBlur={(e) => { onBlur?.(e); onKeyUpPreviousValue.current = filterValue; // wait for combo box option to register that it has focus setTimeout( () => { // clicking the "x" clear icon button takes focus away from the text input and on to the x-button // without checking if the clear button has focus, this was trumping the clear button's onclick if (clearIconRef.current !== document.activeElement) { setComboBoxContext((draftContext) => { // ul is focused, with no option focused, if clicking on the scroll-bar for the ul (ul has max-height and auto overflow) if ( !draftContext.optionValueFocused && !document.activeElement?.classList.contains('combo-box-input__list-box') && !document.activeElement?.classList.contains('multi-select__chevron') ) { const selectedOption = options.find((option) => option.value === optionValueSelected); draftContext.filterValue = selectedOption?.label ?? optionValueSelected ?? ''; draftContext.isFilterValueDirty = false; draftContext.isOptionsExpanded = false; } }); } }, 1 ); }} onChange={(e) => { const newValue = e.target.value; setComboBoxContext((draftContext) => { draftContext.filterValue = newValue; draftContext.isFilterValueDirty = true; }); }} onClear={ isClearable ? ( (e) => { if (onClear) { // @ts-expect-error onClear(e); } else if (onClearComboBoxContext) { onClearComboBoxContext(); setComboBoxContext((draftContext) => { draftContext.filterValue = ''; draftContext.isFilterValueDirty = false; }); } else { setComboBoxContext((draftContext) => { draftContext.filterValue = ''; draftContext.isFilterValueDirty = false; draftContext.isOptionsExpanded = false; draftContext.optionValueHighlighted = null; draftContext.optionValueSelected = null; }); } } ) : undefined } onClick={() => { setComboBoxContext((draftContext) => { draftContext.isOptionsExpanded = true; }); }} // @ts-expect-error onKeyDown={(e) => e.stopPropagation()} onKeyUp={(e) => { // @ts-expect-error if (!onKeyUp?.(e, onKeyUpPreviousValue.current) && !onKeyUpFromContext?.(e, onKeyUpPreviousValue.current)) { if (![ onCancelKeyPress(e), onUpArrowPress(e), onDownArrowPress(e), allowCustomEntry && onEnterPress(e), ].some(identity)) { if (!['Alt', 'Control', 'Meta', 'Tab', 'Shift', 'ShiftLeft', 'ShiftRight'].includes(e.key)) { setComboBoxContext((draftContext) => { if (draftContext.filterValue) { // if key wasn't one of the others, expand the options draftContext.isOptionsExpanded = true; } else { draftContext.isFilterValueDirty = false; } }); } } } onKeyUpPreviousValue.current = filterValue; }} placeholder={placeholder} rightContent={( <IconButton className="combo-box-input__chevron icon-button--borderless icon-button--small1x" icon={ iconCallback?.(isOptionsExpanded) ?? ( <span aria-hidden="true" className={isOptionsExpanded ? 'utds-icon-before-chevron-up' : 'utds-icon-before-chevron-down'} /> ) } isDisabled={isDisabled} onClick={(e) => { e.stopPropagation(); setComboBoxContext((draftContext) => { draftContext.isOptionsExpanded = !draftContext.isOptionsExpanded; textInputRef.current?.focus(); }); }} title="Toggle popup menu" // @ts-expect-error // prevent the chevron from closing and reopening the popup onMouseDown={(e) => e.preventDefault()} /> )} role="combobox" value={filterValue} // eslint-disable-next-line react/jsx-props-no-spreading {...rest} /> </div> ); }