import React, { useMemo, useState, useRef, useContext, createContext, useId, ReactNode, ChangeEventHandler, KeyboardEventHandler } from 'react'; import { Combobox, ComboboxInput, ComboboxList, ComboboxOption, ComboboxPopover } from '@reach/combobox'; import '@reach/combobox/styles.css'; import { matchSorter } from 'match-sorter'; import { XIcon } from '@heroicons/react/outline'; import isHotkey from 'is-hotkey'; import invariant from 'tiny-invariant'; import { useDeferredSubmit, useHiddenField } from './shared/hooks'; const Context = createContext<{ onRemove: (value: string) => void; } | null>(null); type Option = [label: string, value: string]; function isOptions(options: string[] | Option[]): options is Option[] { return Array.isArray(options[0]); } const optionLabelByValue = ( values: string[], options: Option[], value: string ): string => { const maybeOption: Option | undefined = values.includes(value) ? [value, value] : options.find(([, optionValue]) => optionValue == value); return maybeOption ? maybeOption[0] : ''; }; export type ComboMultipleProps = { options: string[] | Option[]; id: string; labelledby: string; describedby: string; label: string; group: string; name?: string; selected: string[]; acceptNewValues?: boolean; }; export default function ComboMultiple({ options, id, labelledby, describedby, label, group, name = 'value', selected, acceptNewValues = false }: ComboMultipleProps) { invariant(id || label, 'ComboMultiple: `id` or a `label` are required'); invariant(group, 'ComboMultiple: `group` is required'); const inputRef = useRef<HTMLInputElement>(null); const [term, setTerm] = useState(''); const [selections, setSelections] = useState(selected); const [newValues, setNewValues] = useState<string[]>([]); const internalId = useId(); const inputId = id ?? internalId; const removedLabelledby = `${inputId}-remove`; const selectedLabelledby = `${inputId}-selected`; const optionsWithLabels = useMemo<Option[]>( () => isOptions(options) ? options : options.filter((o) => o).map((o) => [o, o]), [options] ); const extraOptions = useMemo( () => acceptNewValues && term && term.length > 2 && !optionLabelByValue(newValues, optionsWithLabels, term) ? [[term, term]] : [], [acceptNewValues, term, optionsWithLabels, newValues] ); const results = useMemo( () => [ ...extraOptions, ...(term ? matchSorter( optionsWithLabels.filter(([label]) => !label.startsWith('--')), term ) : optionsWithLabels) ].filter(([, value]) => !selections.includes(value)), [term, selections, extraOptions, optionsWithLabels] ); const [, setHiddenFieldValue, hiddenField] = useHiddenField(group, name); const awaitFormSubmit = useDeferredSubmit(hiddenField); const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => { setTerm(event.target.value); }; const saveSelection = (fn: (selections: string[]) => string[]) => { setSelections((selections) => { selections = fn(selections); setHiddenFieldValue(JSON.stringify(selections)); return selections; }); }; const onSelect = (value: string) => { const maybeValue = [...extraOptions, ...optionsWithLabels].find( ([, val]) => val == value ); const selectedValue = maybeValue && maybeValue[1]; if (selectedValue) { if ( acceptNewValues && extraOptions[0] && extraOptions[0][0] == selectedValue ) { setNewValues((newValues) => { const set = new Set(newValues); set.add(selectedValue); return [...set]; }); } saveSelection((selections) => { const set = new Set(selections); set.add(selectedValue); return [...set]; }); } setTerm(''); awaitFormSubmit.done(); hidePopover(); }; const onRemove = (optionValue: string) => { if (optionValue) { saveSelection((selections) => selections.filter((value) => value != optionValue) ); setNewValues((newValues) => newValues.filter((value) => value != optionValue) ); } inputRef.current?.focus(); }; const onKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => { if ( isHotkey('enter', event) || isHotkey(' ', event) || isHotkey(',', event) || isHotkey(';', event) ) { if ( term && [...extraOptions, ...optionsWithLabels] .map(([label]) => label) .includes(term) ) { event.preventDefault(); onSelect(term); } } }; const hidePopover = () => { document .querySelector(`[data-reach-combobox-popover-id="${inputId}"]`) ?.setAttribute('hidden', 'true'); }; const showPopover = () => { document .querySelector(`[data-reach-combobox-popover-id="${inputId}"]`) ?.removeAttribute('hidden'); }; const onBlur = () => { const shouldSelect = term && [...extraOptions, ...optionsWithLabels] .map(([label]) => label) .includes(term); awaitFormSubmit(() => { if (shouldSelect) { onSelect(term); } else { hidePopover(); } }); }; return ( <Combobox openOnFocus={true} onSelect={onSelect}> <ComboboxTokenLabel onRemove={onRemove}> <span id={removedLabelledby} className="hidden"> désélectionner </span> <ul id={selectedLabelledby} aria-live="polite" aria-atomic={true} data-reach-combobox-token-list > {selections.map((selection) => ( <ComboboxToken key={selection} value={selection} describedby={removedLabelledby} > {optionLabelByValue(newValues, optionsWithLabels, selection)} </ComboboxToken> ))} </ul> <ComboboxInput ref={inputRef} value={term} onChange={handleChange} onKeyDown={onKeyDown} onBlur={onBlur} onClick={showPopover} autocomplete={false} id={inputId} aria-label={label} aria-labelledby={[labelledby, selectedLabelledby] .filter(Boolean) .join(' ')} aria-describedby={describedby} /> </ComboboxTokenLabel> {results && (results.length > 0 || !acceptNewValues) && ( <ComboboxPopover className="shadow-popup" data-reach-combobox-popover-id={inputId} > <ComboboxList> {results.length === 0 && ( <li data-reach-combobox-no-results> Aucun résultat{' '} <button onClick={() => { setTerm(''); inputRef.current?.focus(); }} className="button" > Effacer </button> </li> )} {results.map(([label, value], index) => { if (label.startsWith('--')) { return <ComboboxSeparator key={index} value={label} />; } return ( <ComboboxOption key={index} value={value}> {label} </ComboboxOption> ); })} </ComboboxList> </ComboboxPopover> )} </Combobox> ); } function ComboboxTokenLabel({ onRemove, children }: { onRemove: (value: string) => void; children: ReactNode; }) { return ( <Context.Provider value={{ onRemove }}> <div data-reach-combobox-token-label>{children}</div> </Context.Provider> ); } function ComboboxSeparator({ value }: { value: string }) { return ( <li aria-disabled="true" role="option" data-reach-combobox-separator> {value.slice(2, -2)} </li> ); } function ComboboxToken({ value, describedby, children, ...props }: { value: string; describedby: string; children: ReactNode; }) { const context = useContext(Context); invariant(context, 'invalid context'); const { onRemove } = context; return ( <li data-reach-combobox-token {...props}> <button type="button" onClick={() => { onRemove(value); }} onKeyDown={(event) => { if (event.key === 'Backspace') { onRemove(value); } }} aria-describedby={describedby} > <XIcon className="icon-size mr-1" aria-hidden="true" /> {children} </button> </li> ); }