2021-02-11 15:25:30 +01:00
|
|
|
import React, {
|
|
|
|
useMemo,
|
|
|
|
useState,
|
|
|
|
useRef,
|
|
|
|
useContext,
|
|
|
|
createContext,
|
|
|
|
useEffect,
|
|
|
|
useLayoutEffect
|
|
|
|
} from 'react';
|
|
|
|
import PropTypes from 'prop-types';
|
|
|
|
import {
|
|
|
|
Combobox,
|
|
|
|
ComboboxInput,
|
|
|
|
ComboboxList,
|
|
|
|
ComboboxOption,
|
|
|
|
ComboboxPopover
|
|
|
|
} from '@reach/combobox';
|
|
|
|
import '@reach/combobox/styles.css';
|
2021-02-16 13:17:36 +01:00
|
|
|
import { matchSorter } from 'match-sorter';
|
2021-02-11 15:25:30 +01:00
|
|
|
import { fire } from '@utils';
|
2021-04-23 13:45:04 +02:00
|
|
|
import { XIcon } from '@heroicons/react/outline';
|
2021-05-20 11:51:39 +02:00
|
|
|
import isHotkey from 'is-hotkey';
|
2021-02-11 15:25:30 +01:00
|
|
|
|
2021-06-09 17:13:26 +02:00
|
|
|
import { useDeferredSubmit } from './shared/hooks';
|
|
|
|
|
2021-02-11 15:25:30 +01:00
|
|
|
const Context = createContext();
|
|
|
|
|
|
|
|
function ComboMultipleDropdownList({
|
|
|
|
options,
|
|
|
|
hiddenFieldId,
|
|
|
|
selected,
|
2021-02-16 14:15:34 +01:00
|
|
|
label,
|
|
|
|
acceptNewValues = false
|
2021-02-11 15:25:30 +01:00
|
|
|
}) {
|
|
|
|
if (label == undefined) {
|
|
|
|
label = 'Choisir une option';
|
|
|
|
}
|
2021-02-18 18:38:56 +01:00
|
|
|
if (!Array.isArray(options[0])) {
|
|
|
|
options = options.filter((o) => o).map((o) => [o, o]);
|
2021-02-11 15:25:30 +01:00
|
|
|
}
|
|
|
|
const inputRef = useRef();
|
|
|
|
const [term, setTerm] = useState('');
|
|
|
|
const [selections, setSelections] = useState(selected);
|
2021-02-16 14:15:34 +01:00
|
|
|
const [newValues, setNewValues] = useState([]);
|
2021-02-18 18:38:56 +01:00
|
|
|
|
|
|
|
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(',')]
|
|
|
|
);
|
2021-02-11 15:25:30 +01:00
|
|
|
const results = useMemo(
|
|
|
|
() =>
|
2021-02-18 18:38:56 +01:00
|
|
|
[
|
|
|
|
...extraOptions,
|
|
|
|
...(term
|
|
|
|
? matchSorter(
|
|
|
|
options.filter(([label]) => !label.startsWith('--')),
|
|
|
|
term
|
|
|
|
)
|
|
|
|
: options)
|
|
|
|
].filter(([, value]) => !selections.includes(value)),
|
|
|
|
[term, selections.join(','), newValues.join(',')]
|
2021-02-11 15:25:30 +01:00
|
|
|
);
|
|
|
|
const hiddenField = useMemo(
|
|
|
|
() => document.querySelector(`input[data-uuid="${hiddenFieldId}"]`),
|
|
|
|
[hiddenFieldId]
|
|
|
|
);
|
2021-06-09 17:13:26 +02:00
|
|
|
const awaitFormSubmit = useDeferredSubmit(hiddenField);
|
2021-02-11 15:25:30 +01:00
|
|
|
|
|
|
|
const handleChange = (event) => {
|
|
|
|
setTerm(event.target.value);
|
|
|
|
};
|
|
|
|
|
2021-05-20 13:05:36 +02:00
|
|
|
const saveSelection = (fn) => {
|
|
|
|
setSelections((selections) => {
|
|
|
|
selections = fn(selections);
|
|
|
|
if (hiddenField) {
|
|
|
|
hiddenField.setAttribute('value', JSON.stringify(selections));
|
|
|
|
fire(hiddenField, 'autosave:trigger');
|
|
|
|
}
|
|
|
|
return selections;
|
|
|
|
});
|
2021-02-11 15:25:30 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
const onSelect = (value) => {
|
2021-02-18 18:38:56 +01:00
|
|
|
const maybeValue = [...extraOptions, ...options].find(
|
|
|
|
([val]) => val == value
|
|
|
|
);
|
|
|
|
const selectedValue = maybeValue && maybeValue[1];
|
2021-05-20 13:05:36 +02:00
|
|
|
if (selectedValue) {
|
2021-02-19 12:08:41 +01:00
|
|
|
if (
|
|
|
|
acceptNewValues &&
|
|
|
|
extraOptions[0] &&
|
|
|
|
extraOptions[0][0] == selectedValue
|
|
|
|
) {
|
2021-05-20 13:05:36 +02:00
|
|
|
setNewValues((newValues) => [...newValues, selectedValue]);
|
2021-02-19 12:08:41 +01:00
|
|
|
}
|
2021-05-20 13:05:36 +02:00
|
|
|
saveSelection((selections) => [...selections, selectedValue]);
|
2021-02-18 18:38:56 +01:00
|
|
|
}
|
2021-02-11 15:25:30 +01:00
|
|
|
setTerm('');
|
2021-06-09 17:13:26 +02:00
|
|
|
awaitFormSubmit.done();
|
2021-02-11 15:25:30 +01:00
|
|
|
};
|
|
|
|
|
2021-02-18 18:38:56 +01:00
|
|
|
const onRemove = (label) => {
|
|
|
|
const optionValue = optionValueByLabel(label);
|
|
|
|
if (optionValue) {
|
2021-05-20 13:05:36 +02:00
|
|
|
saveSelection((selections) =>
|
|
|
|
selections.filter((value) => value != optionValue)
|
|
|
|
);
|
|
|
|
setNewValues((newValues) =>
|
|
|
|
newValues.filter((value) => value != optionValue)
|
|
|
|
);
|
2021-02-18 18:38:56 +01:00
|
|
|
}
|
2021-02-11 15:25:30 +01:00
|
|
|
inputRef.current.focus();
|
|
|
|
};
|
|
|
|
|
2021-06-09 17:13:26 +02:00
|
|
|
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);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2021-02-11 15:25:30 +01:00
|
|
|
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}
|
2021-02-18 18:38:56 +01:00
|
|
|
value={optionLabelByValue(selection)}
|
2021-02-11 15:25:30 +01:00
|
|
|
/>
|
|
|
|
))}
|
|
|
|
</ul>
|
|
|
|
<ComboboxInput
|
|
|
|
ref={inputRef}
|
|
|
|
value={term}
|
|
|
|
onChange={handleChange}
|
2021-02-16 14:15:34 +01:00
|
|
|
onKeyDown={onKeyDown}
|
2021-05-20 13:15:27 +02:00
|
|
|
onBlur={onBlur}
|
2021-02-11 15:25:30 +01:00
|
|
|
autocomplete={false}
|
|
|
|
/>
|
|
|
|
</ComboboxTokenLabel>
|
2021-05-20 13:11:49 +02:00
|
|
|
{results && (results.length > 0 || !acceptNewValues) && (
|
2021-04-23 13:45:04 +02:00
|
|
|
<ComboboxPopover className="shadow-popup">
|
2021-02-11 15:25:30 +01:00
|
|
|
{results.length === 0 && (
|
|
|
|
<p>
|
|
|
|
Aucun résultat{' '}
|
2021-02-18 18:38:56 +01:00
|
|
|
<button onClick={() => setTerm('')}>Effacer</button>
|
2021-02-11 15:25:30 +01:00
|
|
|
</p>
|
|
|
|
)}
|
|
|
|
<ComboboxList>
|
2021-04-23 13:45:04 +02:00
|
|
|
{results.map(([label, value], index) => {
|
2021-02-18 18:38:56 +01:00
|
|
|
if (label.startsWith('--')) {
|
|
|
|
return <ComboboxSeparator key={index} value={label} />;
|
2021-02-11 15:25:30 +01:00
|
|
|
}
|
2021-04-23 13:45:04 +02:00
|
|
|
return (
|
|
|
|
<ComboboxOption
|
|
|
|
key={index}
|
|
|
|
value={label}
|
|
|
|
data-option-value={value}
|
|
|
|
/>
|
|
|
|
);
|
2021-02-11 15:25:30 +01:00
|
|
|
})}
|
|
|
|
</ComboboxList>
|
|
|
|
</ComboboxPopover>
|
|
|
|
)}
|
|
|
|
</Combobox>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
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}
|
|
|
|
>
|
2021-04-23 13:45:04 +02:00
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
tabIndex={-1}
|
2021-02-11 15:25:30 +01:00
|
|
|
data-combobox-remove-token
|
|
|
|
onClick={() => {
|
|
|
|
onRemove(value);
|
|
|
|
}}
|
|
|
|
>
|
2021-04-28 16:05:44 +02:00
|
|
|
<XIcon className="icon-size" />
|
2021-04-23 13:45:04 +02:00
|
|
|
<span className="screen-reader-text">Désélectionner</span>
|
|
|
|
</button>
|
2021-02-11 15:25:30 +01:00
|
|
|
{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),
|
2021-02-16 14:15:34 +01:00
|
|
|
label: PropTypes.string,
|
|
|
|
acceptNewValues: PropTypes.bool
|
2021-02-11 15:25:30 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
export default ComboMultipleDropdownList;
|