a11y(combobox): add support for describedby and labelledby and improuve external fields handling
This commit is contained in:
parent
fc058f721d
commit
d6b6bb0f2a
16 changed files with 458 additions and 451 deletions
|
@ -1,302 +1,15 @@
|
|||
import React, {
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
useContext,
|
||||
createContext,
|
||||
useEffect,
|
||||
useLayoutEffect
|
||||
} from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxList,
|
||||
ComboboxOption,
|
||||
ComboboxPopover
|
||||
} from '@reach/combobox';
|
||||
import '@reach/combobox/styles.css';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
import { fire } from '@utils';
|
||||
import { XIcon } from '@heroicons/react/outline';
|
||||
import isHotkey from 'is-hotkey';
|
||||
|
||||
import { useDeferredSubmit } from './shared/hooks';
|
||||
import { groupId } from './shared/hooks';
|
||||
import ComboMultiple from './ComboMultiple';
|
||||
|
||||
const Context = createContext();
|
||||
|
||||
function ComboMultipleDropdownList({
|
||||
options,
|
||||
hiddenFieldId,
|
||||
selected,
|
||||
label,
|
||||
acceptNewValues = false
|
||||
}) {
|
||||
if (label == undefined) {
|
||||
label = 'Choisir une option';
|
||||
}
|
||||
if (!Array.isArray(options[0])) {
|
||||
options = options.filter((o) => o).map((o) => [o, o]);
|
||||
}
|
||||
const inputRef = useRef();
|
||||
const [term, setTerm] = useState('');
|
||||
const [selections, setSelections] = useState(selected);
|
||||
const [newValues, setNewValues] = useState([]);
|
||||
|
||||
const optionValueByLabel = (label) => {
|
||||
const maybeOption = newValues.includes(label)
|
||||
? [label, label]
|
||||
: options.find(([optionLabel]) => optionLabel == label);
|
||||
return maybeOption ? maybeOption[1] : undefined;
|
||||
};
|
||||
const optionLabelByValue = (value) => {
|
||||
const maybeOption = newValues.includes(value)
|
||||
? [value, value]
|
||||
: options.find(([, optionValue]) => optionValue == value);
|
||||
return maybeOption ? maybeOption[0] : undefined;
|
||||
};
|
||||
|
||||
const extraOptions = useMemo(
|
||||
() =>
|
||||
acceptNewValues && term && term.length > 2 && !optionLabelByValue(term)
|
||||
? [[term, term]]
|
||||
: [],
|
||||
[acceptNewValues, term, newValues.join(',')]
|
||||
);
|
||||
const results = useMemo(
|
||||
() =>
|
||||
[
|
||||
...extraOptions,
|
||||
...(term
|
||||
? matchSorter(
|
||||
options.filter(([label]) => !label.startsWith('--')),
|
||||
term
|
||||
)
|
||||
: options)
|
||||
].filter(([, value]) => !selections.includes(value)),
|
||||
[term, selections.join(','), newValues.join(',')]
|
||||
);
|
||||
const hiddenField = useMemo(
|
||||
() => document.querySelector(`input[data-uuid="${hiddenFieldId}"]`),
|
||||
[hiddenFieldId]
|
||||
);
|
||||
const awaitFormSubmit = useDeferredSubmit(hiddenField);
|
||||
|
||||
const handleChange = (event) => {
|
||||
setTerm(event.target.value);
|
||||
};
|
||||
|
||||
const saveSelection = (fn) => {
|
||||
setSelections((selections) => {
|
||||
selections = fn(selections);
|
||||
if (hiddenField) {
|
||||
hiddenField.setAttribute('value', JSON.stringify(selections));
|
||||
fire(hiddenField, 'autosave:trigger');
|
||||
}
|
||||
return selections;
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = (value) => {
|
||||
const maybeValue = [...extraOptions, ...options].find(
|
||||
([val]) => val == value
|
||||
);
|
||||
const selectedValue = maybeValue && maybeValue[1];
|
||||
if (selectedValue) {
|
||||
if (
|
||||
acceptNewValues &&
|
||||
extraOptions[0] &&
|
||||
extraOptions[0][0] == selectedValue
|
||||
) {
|
||||
setNewValues((newValues) => [...newValues, selectedValue]);
|
||||
}
|
||||
saveSelection((selections) => [...selections, selectedValue]);
|
||||
}
|
||||
setTerm('');
|
||||
awaitFormSubmit.done();
|
||||
};
|
||||
|
||||
const onRemove = (label) => {
|
||||
const optionValue = optionValueByLabel(label);
|
||||
if (optionValue) {
|
||||
saveSelection((selections) =>
|
||||
selections.filter((value) => value != optionValue)
|
||||
);
|
||||
setNewValues((newValues) =>
|
||||
newValues.filter((value) => value != optionValue)
|
||||
);
|
||||
}
|
||||
inputRef.current.focus();
|
||||
};
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
if (
|
||||
isHotkey('enter', event) ||
|
||||
isHotkey(' ', event) ||
|
||||
isHotkey(',', event) ||
|
||||
isHotkey(';', event)
|
||||
) {
|
||||
if (
|
||||
term &&
|
||||
[...extraOptions, ...options].map(([label]) => label).includes(term)
|
||||
) {
|
||||
event.preventDefault();
|
||||
onSelect(term);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
if (
|
||||
term &&
|
||||
[...extraOptions, ...options].map(([label]) => label).includes(term)
|
||||
) {
|
||||
awaitFormSubmit(() => {
|
||||
onSelect(term);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox openOnFocus={true} onSelect={onSelect} aria-label={label}>
|
||||
<ComboboxTokenLabel onRemove={onRemove}>
|
||||
<ul
|
||||
aria-live="polite"
|
||||
aria-atomic={true}
|
||||
data-reach-combobox-token-list
|
||||
>
|
||||
{selections.map((selection) => (
|
||||
<ComboboxToken
|
||||
key={selection}
|
||||
value={optionLabelByValue(selection)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<ComboboxInput
|
||||
ref={inputRef}
|
||||
value={term}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={onBlur}
|
||||
autocomplete={false}
|
||||
/>
|
||||
</ComboboxTokenLabel>
|
||||
{results && (results.length > 0 || !acceptNewValues) && (
|
||||
<ComboboxPopover className="shadow-popup">
|
||||
{results.length === 0 && (
|
||||
<p>
|
||||
Aucun résultat{' '}
|
||||
<button onClick={() => setTerm('')}>Effacer</button>
|
||||
</p>
|
||||
)}
|
||||
<ComboboxList>
|
||||
{results.map(([label, value], index) => {
|
||||
if (label.startsWith('--')) {
|
||||
return <ComboboxSeparator key={index} value={label} />;
|
||||
}
|
||||
return (
|
||||
<ComboboxOption
|
||||
key={index}
|
||||
value={label}
|
||||
data-option-value={value}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ComboboxList>
|
||||
</ComboboxPopover>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
function ComboMultipleDropdownList({ id, ...props }) {
|
||||
return <ComboMultiple group={groupId(id)} id={id} {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxTokenLabel({ onRemove, ...props }) {
|
||||
const selectionsRef = useRef([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
selectionsRef.current = [];
|
||||
return () => (selectionsRef.current = []);
|
||||
});
|
||||
|
||||
const context = {
|
||||
onRemove,
|
||||
selectionsRef
|
||||
};
|
||||
|
||||
return (
|
||||
<Context.Provider value={context}>
|
||||
<div data-combobox-token-label {...props} />
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
ComboboxTokenLabel.propTypes = {
|
||||
onRemove: PropTypes.func
|
||||
};
|
||||
|
||||
function ComboboxSeparator({ value }) {
|
||||
return (
|
||||
<li aria-disabled="true" role="option" data-combobox-separator>
|
||||
{value.slice(2, -2)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
ComboboxSeparator.propTypes = {
|
||||
value: PropTypes.string
|
||||
};
|
||||
|
||||
function ComboboxToken({ value, ...props }) {
|
||||
const { selectionsRef, onRemove } = useContext(Context);
|
||||
useEffect(() => {
|
||||
selectionsRef.current.push(value);
|
||||
});
|
||||
|
||||
return (
|
||||
<li
|
||||
data-reach-combobox-token
|
||||
tabIndex="0"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Backspace') {
|
||||
onRemove(value);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
data-combobox-remove-token
|
||||
onClick={() => {
|
||||
onRemove(value);
|
||||
}}
|
||||
>
|
||||
<XIcon className="icon-size" />
|
||||
<span className="screen-reader-text">Désélectionner</span>
|
||||
</button>
|
||||
{value}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
ComboboxToken.propTypes = {
|
||||
value: PropTypes.string,
|
||||
label: PropTypes.string
|
||||
};
|
||||
|
||||
ComboMultipleDropdownList.propTypes = {
|
||||
options: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
PropTypes.arrayOf(
|
||||
PropTypes.arrayOf(
|
||||
PropTypes.oneOfType([PropTypes.string, PropTypes.number])
|
||||
)
|
||||
)
|
||||
]),
|
||||
hiddenFieldId: PropTypes.string,
|
||||
selected: PropTypes.arrayOf(PropTypes.string),
|
||||
arraySelected: PropTypes.arrayOf(PropTypes.array),
|
||||
label: PropTypes.string,
|
||||
acceptNewValues: PropTypes.bool
|
||||
id: PropTypes.string
|
||||
};
|
||||
|
||||
export default ComboMultipleDropdownList;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue