UNPKG

@utahdts/utah-design-system

Version:
245 lines (239 loc) 10.1 kB
import { uniq } from 'lodash-es'; import { useRef } from 'react'; import { useAriaMessaging } from '../../../contexts/UtahDesignSystemContext/hooks/useAriaMessaging'; import { useRefAlways } from '../../../hooks/useRefAlways'; import { joinClassNames } from '../../../util/joinClassNames'; import { IconButton } from '../../buttons/IconButton'; import { ComboBox } from '../ComboBox/ComboBox'; import { ErrorMessage } from '../ErrorMessage'; import { RequiredStar } from '../RequiredStar'; import { MultiSelectClearIcon } from './MultiSelectClearIcon'; import { MultiSelectTags } from './MultiSelectTags'; import { useMultiSelectContext } from './context/useMultiSelectContext'; /** * @template MutableRefT * @typedef {import('@utahdts/utah-design-system').MutableRef<MutableRefT>} MutableRef */ /** @typedef {import ('react').UIEventHandler} UIEventHandler */ /** * @param {object} props * @param {boolean} [props.allowCustomEntry] can the user type in their own items to add to the list? * @param {import('react').ReactNode} [props.children] * @param {string} [props.className] * @param {string} [props.errorMessage] * @param {MutableRef<HTMLDivElement | null>} [props.innerRef] * @param {boolean} [props.isClearable] * @param {boolean} [props.isDisabled] * @param {boolean} [props.isRequired] * @param {string} props.label * @param {string} [props.labelClassName] * @param {string} [props.name] * @param {UIEventHandler} [props.onBlur] * @param {UIEventHandler} [props.onFocus] * @param {(customValue: string) => void} [props.onCustomEntry] caller is responsible for adding options when they are added * @param {string} [props.placeholder] * @param {string} [props.wrapperClassName] * @returns {import('react').JSX.Element} */ export function MultiSelectComboBox({ allowCustomEntry, children, className, errorMessage, innerRef: draftInnerRef, isClearable, isDisabled, isRequired, label, labelClassName, name, onBlur, onCustomEntry, onFocus, placeholder, wrapperClassName, ...rest }) { const [multiSelectContextValue, setMultiSelectContextValue, multiSelectContextNonStateRef] = useMultiSelectContext(); const multiSelectContextValueRef = useRefAlways(multiSelectContextValue); const selectedValuesRef = useRefAlways(multiSelectContextValue.selectedValues); const { addPoliteMessage } = useAriaMessaging(); const wrapperRef = useRef(/** @type {HTMLDivElement | null} */(null)); return ( <div className={joinClassNames('input-wrapper input-wrapper--multi-select', wrapperClassName)} ref={(ref) => { if (draftInnerRef) { if (typeof draftInnerRef === 'function') { draftInnerRef(ref); } else { draftInnerRef.current = ref; } } wrapperRef.current = ref; }} > <label htmlFor={multiSelectContextValue.multiSelectId} className={labelClassName ?? undefined}> {label} {isRequired ? <RequiredStar /> : null} </label> <div className="multi-select__wrapper"> {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} <div aria-describedby={errorMessage ? `${multiSelectContextValue.multiSelectId}-error` : undefined} className={joinClassNames( className, 'multi-select', isClearable ? 'multi-select--clear-icon-visible' : '', isDisabled ? 'multi-select--disabled' : '', ( multiSelectContextValue.focusedValueTagIndex || multiSelectContextValue.focusedValueTagIndex === 0 || multiSelectContextValue.textInputHasFocus || multiSelectContextValue.clearButtonHasFocus ) ? 'multi-select--focused' : '', errorMessage ? 'invalid' : null )} onClick={() => { if (multiSelectContextValue.isOptionsExpanded) { multiSelectContextNonStateRef?.current.textInput?.blur(); } else { multiSelectContextNonStateRef?.current.textInput?.click(); } multiSelectContextNonStateRef?.current.textInput?.focus(); }} // prevent default so clicking doesn't cause focus to blur from textinput onMouseDown={(e) => e.preventDefault()} ref={(ref) => { if (multiSelectContextNonStateRef) { multiSelectContextNonStateRef.current.comboBoxDivElement = ref; } }} > <MultiSelectTags isDisabled={isDisabled} /> <ComboBox allowCustomEntry={allowCustomEntry} className="multi-select__combo-box" id={multiSelectContextValue.multiSelectId} isDisabled={isDisabled} isInvalid={!!errorMessage} isRequired={isRequired} isValueClearedOnSelection isWrapperSkipped label={label} labelClassName={joinClassNames('visually-hidden', labelClassName)} name={name} onChange={(newValue) => { multiSelectContextValue.onChange(uniq(selectedValuesRef.current.concat(newValue))); }} onCustomEntry={onCustomEntry} onKeyUp={(e, currentFilter) => { let eventIsHandled = false; // check that filter is blank and that there are options selected if (!currentFilter && multiSelectContextValueRef.current.selectedValues.length) { // @ts-expect-error if (e.key === 'Backspace') { eventIsHandled = true; setMultiSelectContextValue((draftContext) => { const deadTag = draftContext.selectedValues.pop(); addPoliteMessage(`${deadTag} removed`); }); // close the combo box popup. the state of the popup being open is in the combobox context and has no external controls // but, it closes when the input blurs, so this is a big hack to make the popup close on blur const { activeElement } = document; // @ts-expect-error activeElement?.blur(); // @ts-expect-error activeElement?.focus(); } // @ts-expect-error if (e.key === 'ArrowLeft') { eventIsHandled = true; setMultiSelectContextValue((draftContext) => { draftContext.focusedValueTagIndex = draftContext.selectedValues.length - 1; }); } } return eventIsHandled; }} placeholder={placeholder} popupContentRef={multiSelectContextNonStateRef?.current.comboBoxDivElement} // the value is always unset because the multi-select will own and show the current value value="" wrapperClassName={wrapperClassName} // @ts-expect-error isLabelSkipped // this gets spread down to the textInput so that there is only one label onFocus={ /** @type {UIEventHandler} */ ( (e) => { onFocus?.(e); setTimeout(() => setMultiSelectContextValue((draftContext) => { draftContext.textInputHasFocus = true; }), 0); } ) } onBlur={ /** @type {UIEventHandler} */ ( (e) => { onBlur?.(e); setTimeout( () => setMultiSelectContextValue((draftContext) => { draftContext.textInputHasFocus = false; if (!draftContext.clearButtonHasFocus) { draftContext.isOptionsExpanded = false; } }), 0 ); } ) } // eslint-disable-next-line react/jsx-props-no-spreading {...rest} > {/* children has the multiselect options in it */} {children} </ComboBox> <MultiSelectClearIcon isClearable={isClearable} isDisabled={isDisabled} /> <IconButton className={joinClassNames( 'multi-select__chevron', 'icon-button--borderless', 'icon-button--small1x', isDisabled ? 'multi-select__chevron--is-disabled' : '' )} icon={<span className={multiSelectContextValue.isOptionsExpanded ? 'utds-icon-before-chevron-up' : 'utds-icon-before-chevron-down'} aria-hidden="true" />} isDisabled={isDisabled} onClick={() => { if (multiSelectContextValue.isOptionsExpanded) { multiSelectContextNonStateRef?.current.textInput?.blur(); multiSelectContextNonStateRef?.current.textInput?.focus(); } else { multiSelectContextNonStateRef?.current.textInput?.click(); } }} title="Toggle popup menu" // @ts-expect-error onBlur={() => { // tabbing off of toggle icon while the popup options list was open was not closing the list // because the list didn't get focus when it was opened by a click setTimeout( () => { const { activeElement } = document; multiSelectContextNonStateRef?.current.textInput?.focus(); // @ts-expect-error activeElement?.focus(); }, 100 ); }} // @ts-expect-error onMouseDown={(e) => e.preventDefault()} /> </div> </div> <ErrorMessage errorMessage={errorMessage} id={multiSelectContextValue.multiSelectId} /> </div> ); }