demarches-normaliennes/app/javascript/components/ComboSearch.tsx

204 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, {
useState,
useEffect,
useRef,
useId,
ChangeEventHandler
} from 'react';
import { useDebounce } from 'use-debounce';
import { useQuery } from 'react-query';
import {
Combobox,
ComboboxInput,
ComboboxPopover,
ComboboxList,
ComboboxOption
} from '@reach/combobox';
import '@reach/combobox/styles.css';
import invariant from 'tiny-invariant';
import { useDeferredSubmit, useHiddenField, groupId } from './shared/hooks';
type TransformResults<Result> = (term: string, results: unknown) => Result[];
type TransformResult<Result> = (
result: Result
) => [key: string, value: string, label?: string];
export type ComboSearchProps<Result> = {
onChange?: (value: string | null, result?: Result) => void;
value?: string;
scope: string;
scopeExtra?: string;
minimumInputLength: number;
transformResults?: TransformResults<Result>;
transformResult: TransformResult<Result>;
allowInputValues?: boolean;
id?: string;
describedby?: string;
className?: string;
placeholder?: string;
debounceDelay?: number;
};
type QueryKey = readonly [
scope: string,
term: string,
extra: string | undefined
];
function ComboSearch<Result>({
onChange,
value: controlledValue,
scope,
scopeExtra,
minimumInputLength,
transformResult,
allowInputValues = false,
transformResults = (_, results) => results as Result[],
id,
describedby,
debounceDelay = 0,
...props
}: ComboSearchProps<Result>) {
invariant(id || onChange, 'ComboSearch: `id` or `onChange` are required');
const group = !onChange && id ? groupId(id) : undefined;
const [externalValue, setExternalValue, hiddenField] = useHiddenField(group);
const [, setExternalId] = useHiddenField(group, 'external_id');
const initialValue = externalValue ? externalValue : controlledValue;
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm] = useDebounce(searchTerm, debounceDelay);
const [value, setValue] = useState(initialValue);
const resultsMap = useRef<
Record<string, { key: string; value: string; result: Result }>
>({});
const getLabel = (result: Result) => {
const [, value, label] = transformResult(result);
return label ?? value;
};
const setExternalValueAndId = (label: string) => {
const { key, value, result } = resultsMap.current[label];
if (onChange) {
onChange(value, result);
} else {
setExternalId(key);
setExternalValue(value);
}
};
const awaitFormSubmit = useDeferredSubmit(hiddenField);
const handleOnChange: ChangeEventHandler<HTMLInputElement> = ({
target: { value }
}) => {
setValue(value);
if (!value) {
if (onChange) {
onChange(null);
} else {
setExternalId('');
setExternalValue('');
}
} else if (value.length >= minimumInputLength) {
setSearchTerm(value.trim());
if (allowInputValues) {
setExternalId('');
setExternalValue(value);
}
}
};
const handleOnSelect = (value: string) => {
setExternalValueAndId(value);
setValue(value);
setSearchTerm('');
awaitFormSubmit.done();
};
const { isSuccess, data } = useQuery<void, void, unknown, QueryKey>(
[scope, debouncedSearchTerm, scopeExtra],
{
enabled: !!debouncedSearchTerm,
refetchOnMount: false
}
);
const results =
isSuccess && data ? transformResults(debouncedSearchTerm, data) : [];
const onBlur = () => {
if (!allowInputValues && isSuccess && results[0]) {
const label = getLabel(results[0]);
awaitFormSubmit(() => {
handleOnSelect(label);
});
}
};
const [announceLive, setAnnounceLive] = useState('');
const a11yInstructions =
'utilisez les flèches haut et bas pour naviguer et la touche Entrée pour choisir.\
Sur un appareil tactile, explorez par toucher ou avec des gestes.';
const announceTimeout = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (isSuccess && results.length > 0) {
setAnnounceLive(
`${results.length} résultats disponibles, ${a11yInstructions}`
);
announceTimeout.current = setTimeout(() => {
setAnnounceLive('');
}, 4000);
} else {
setAnnounceLive('Aucun résultat trouvé.');
}
return () => clearTimeout(announceTimeout.current);
}, [results.length, isSuccess]);
const initInstrId = useId();
return (
<Combobox onSelect={handleOnSelect}>
<ComboboxInput
{...props}
onChange={handleOnChange}
onBlur={onBlur}
value={value ?? ''}
autocomplete={false}
id={id}
aria-describedby={describedby ?? initInstrId}
/>
{isSuccess && (
<ComboboxPopover className="shadow-popup">
{results.length > 0 ? (
<ComboboxList>
{results.map((result, index) => {
const label = getLabel(result);
const [key, value] = transformResult(result);
resultsMap.current[label] = { key, value, result };
return <ComboboxOption key={`${key}-${index}`} value={label} />;
})}
</ComboboxList>
) : (
<span style={{ display: 'block', margin: 8 }}>
Aucun résultat trouvé
</span>
)}
</ComboboxPopover>
)}
{!describedby && (
<span id={initInstrId} className="hidden">
Quand les résultats de lautocomplete sont disponibles,{' '}
{a11yInstructions}
</span>
)}
<div aria-live="assertive" className="sr-only">
{announceLive}
</div>
</Combobox>
);
}
export default ComboSearch;