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
|
@ -317,7 +317,7 @@
|
|||
list-style: none;
|
||||
}
|
||||
|
||||
[data-reach-combobox-token] {
|
||||
[data-reach-combobox-token] button {
|
||||
border: solid 1px $border-grey;
|
||||
color: $black;
|
||||
border-radius: 4px;
|
||||
|
@ -328,14 +328,9 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
[data-reach-combobox-token]:focus {
|
||||
[data-reach-combobox-token] button:focus {
|
||||
background-color: $black;
|
||||
color: $white;
|
||||
|
||||
[data-combobox-remove-token] {
|
||||
background-color: $black;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.editable-champ {
|
||||
|
@ -493,13 +488,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
[data-react-class]:not([data-react-class="ComboMultipleDropdownList"]) {
|
||||
[data-react-class]:not([data-react-class^="ComboMultiple"]) {
|
||||
[data-reach-combobox-input]:not(.no-margin) {
|
||||
margin-bottom: $default-fields-spacer;
|
||||
}
|
||||
}
|
||||
|
||||
[data-react-class="ComboMultipleDropdownList"] {
|
||||
[data-react-class^="ComboMultiple"] {
|
||||
margin-bottom: $default-fields-spacer;
|
||||
|
||||
[data-reach-combobox-input] {
|
||||
|
@ -516,7 +511,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
[data-combobox-token-label] {
|
||||
[data-reach-combobox-token-label] {
|
||||
border: 1px solid #CCCCCC;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
|
@ -533,14 +528,14 @@
|
|||
color: $white;
|
||||
}
|
||||
|
||||
[data-combobox-separator] {
|
||||
[data-reach-combobox-separator] {
|
||||
font-size: 16px;
|
||||
color: $dark-grey;
|
||||
background: $light-grey;
|
||||
padding: $default-spacer;
|
||||
}
|
||||
|
||||
[data-combobox-remove-token] {
|
||||
[data-reach-combobox-token] button {
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
|
@ -552,7 +547,7 @@
|
|||
align-items: center !important;
|
||||
}
|
||||
|
||||
[data-reach-combobox-input]:focus {
|
||||
[data-reach-combobox-input] button:focus {
|
||||
outline-color: $light-blue;
|
||||
}
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
margin-left: 16px;
|
||||
}
|
||||
|
||||
[data-react-class="ComboMultipleDropdownList"] {
|
||||
[data-react-class^="ComboMultiple"] {
|
||||
margin-bottom: $default-fields-spacer;
|
||||
|
||||
[data-reach-combobox-token-list] {
|
||||
|
@ -17,7 +17,7 @@
|
|||
display: flex;
|
||||
}
|
||||
|
||||
[data-reach-combobox-token] {
|
||||
[data-reach-combobox-token] button {
|
||||
border: solid 1px $border-grey;
|
||||
color: $black;
|
||||
margin-top: 0.5 * $default-padding;
|
||||
|
@ -29,7 +29,7 @@
|
|||
list-style: none;
|
||||
}
|
||||
|
||||
[data-reach-combobox-token]:focus {
|
||||
[data-reach-combobox-token] button:focus {
|
||||
background-color: $black;
|
||||
color: $white;
|
||||
}
|
||||
|
|
|
@ -62,7 +62,7 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
[data-react-class="ComboMultipleDropdownList"] {
|
||||
[data-react-class^="ComboMultiple"] {
|
||||
margin-bottom: $default-fields-spacer;
|
||||
|
||||
[data-reach-combobox-token-list] {
|
||||
|
@ -71,7 +71,7 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
[data-reach-combobox-token] {
|
||||
[data-reach-combobox-token] button {
|
||||
border: solid 1px $border-grey;
|
||||
color: $black;
|
||||
margin: 0.25 * $default-padding;
|
||||
|
@ -83,14 +83,9 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
[data-reach-combobox-token]:focus {
|
||||
[data-reach-combobox-token] button:focus {
|
||||
background-color: $black;
|
||||
color: $white;
|
||||
|
||||
[data-combobox-remove-token] {
|
||||
background-color: $black;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -6,42 +6,29 @@ import ComboSearch from './ComboSearch';
|
|||
import { queryClient } from './shared/queryClient';
|
||||
|
||||
function ComboAdresseSearch({
|
||||
mandatory,
|
||||
placeholder,
|
||||
hiddenFieldId,
|
||||
onChange,
|
||||
transformResult = ({ properties: { label } }) => [label, label],
|
||||
allowInputValues = true,
|
||||
className
|
||||
...props
|
||||
}) {
|
||||
const transformResults = useCallback((_, { features }) => features);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ComboSearch
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
required={mandatory}
|
||||
hiddenFieldId={hiddenFieldId}
|
||||
onChange={onChange}
|
||||
allowInputValues={allowInputValues}
|
||||
scope="adresse"
|
||||
minimumInputLength={2}
|
||||
transformResult={transformResult}
|
||||
transformResults={transformResults}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
ComboAdresseSearch.propTypes = {
|
||||
className: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
mandatory: PropTypes.bool,
|
||||
hiddenFieldId: PropTypes.string,
|
||||
transformResult: PropTypes.func,
|
||||
allowInputValues: PropTypes.bool,
|
||||
onChange: PropTypes.func
|
||||
allowInputValues: PropTypes.bool
|
||||
};
|
||||
|
||||
export default ComboAdresseSearch;
|
||||
|
|
|
@ -4,12 +4,10 @@ import { QueryClientProvider } from 'react-query';
|
|||
import ComboSearch from './ComboSearch';
|
||||
import { queryClient } from './shared/queryClient';
|
||||
|
||||
function ComboAnnuaireEducationSearch(params) {
|
||||
function ComboAnnuaireEducationSearch(props) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ComboSearch
|
||||
required={params.mandatory}
|
||||
hiddenFieldId={params.hiddenFieldId}
|
||||
scope="annuaire-education"
|
||||
minimumInputLength={3}
|
||||
transformResults={(_, { records }) => records}
|
||||
|
@ -20,6 +18,7 @@ function ComboAnnuaireEducationSearch(params) {
|
|||
nom_commune
|
||||
}
|
||||
}) => [id, `${nom_etablissement}, ${nom_commune} (${id})`]}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import { QueryClientProvider } from 'react-query';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ComboSearch from './ComboSearch';
|
||||
import { queryClient } from './shared/queryClient';
|
||||
import { ComboDepartementsSearch } from './ComboDepartementsSearch';
|
||||
import { useHiddenField, groupId } from './shared/hooks';
|
||||
|
||||
// Avoid hiding similar matches for precise queries (like "Sainte Marie")
|
||||
function searchResultsLimit(term) {
|
||||
|
@ -48,34 +50,18 @@ const [placeholderDepartement, placeholderCommune] =
|
|||
Math.floor(Math.random() * (placeholderDepartements.length - 1))
|
||||
];
|
||||
|
||||
function ComboCommunesSearch(params) {
|
||||
const hiddenDepartementFieldId = `${params.hiddenFieldId}:departement`;
|
||||
const hiddenDepartementField = useMemo(
|
||||
() =>
|
||||
document.querySelector(`input[data-attr="${hiddenDepartementFieldId}"]`),
|
||||
[params.hiddenFieldId]
|
||||
function ComboCommunesSearch({ id, ...props }) {
|
||||
const group = groupId(id);
|
||||
const [departementValue, setDepartementValue] = useHiddenField(
|
||||
group,
|
||||
'departement'
|
||||
);
|
||||
const hiddenCodeDepartementField = useMemo(
|
||||
() =>
|
||||
document.querySelector(
|
||||
`input[data-attr="${params.hiddenFieldId}:code_departement"]`
|
||||
),
|
||||
[params.hiddenFieldId]
|
||||
const [codeDepartement, setCodeDepartement] = useHiddenField(
|
||||
group,
|
||||
'code_departement'
|
||||
);
|
||||
const inputId = useMemo(
|
||||
() =>
|
||||
document.querySelector(`input[data-uuid="${params.hiddenFieldId}"]`)?.id,
|
||||
[params.hiddenFieldId]
|
||||
);
|
||||
const [departementCode, setDepartementCode] = useState(
|
||||
() => hiddenCodeDepartementField?.value
|
||||
);
|
||||
const departementValue = useMemo(
|
||||
() => hiddenDepartementField?.value,
|
||||
[hiddenDepartementField]
|
||||
);
|
||||
const departementDescribedBy = `${inputId}_departement_notice`;
|
||||
const communeDescribedBy = `${inputId}_commune_notice`;
|
||||
const departementDescribedBy = `${id}_departement_notice`;
|
||||
const communeDescribedBy = `${id}_commune_notice`;
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
@ -87,22 +73,19 @@ function ComboCommunesSearch(params) {
|
|||
</p>
|
||||
</div>
|
||||
<ComboDepartementsSearch
|
||||
value={departementValue}
|
||||
inputId={!departementCode ? inputId : null}
|
||||
aria-describedby={departementDescribedBy}
|
||||
{...props}
|
||||
id={!codeDepartement ? id : null}
|
||||
describedby={departementDescribedBy}
|
||||
placeholder={placeholderDepartement}
|
||||
addForeignDepartement={false}
|
||||
required={params.mandatory}
|
||||
value={departementValue}
|
||||
onChange={(_, result) => {
|
||||
setDepartementCode(result?.code);
|
||||
if (hiddenDepartementField && hiddenCodeDepartementField) {
|
||||
hiddenDepartementField.setAttribute('value', result?.nom);
|
||||
hiddenCodeDepartementField.setAttribute('value', result?.code);
|
||||
}
|
||||
setDepartementValue(result?.nom);
|
||||
setCodeDepartement(result?.code);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{departementCode ? (
|
||||
{codeDepartement ? (
|
||||
<div>
|
||||
<div className="notice" id={communeDescribedBy}>
|
||||
<p>
|
||||
|
@ -111,14 +94,12 @@ function ComboCommunesSearch(params) {
|
|||
</p>
|
||||
</div>
|
||||
<ComboSearch
|
||||
autoFocus
|
||||
inputId={inputId}
|
||||
aria-describedby={communeDescribedBy}
|
||||
{...props}
|
||||
id={id}
|
||||
describedby={communeDescribedBy}
|
||||
placeholder={placeholderCommune}
|
||||
required={params.mandatory}
|
||||
hiddenFieldId={params.hiddenFieldId}
|
||||
scope="communes"
|
||||
scopeExtra={departementCode}
|
||||
scopeExtra={codeDepartement}
|
||||
minimumInputLength={2}
|
||||
transformResult={({ code, nom, codesPostaux }) => [
|
||||
code,
|
||||
|
@ -132,4 +113,8 @@ function ComboCommunesSearch(params) {
|
|||
);
|
||||
}
|
||||
|
||||
ComboCommunesSearch.propTypes = {
|
||||
id: PropTypes.string
|
||||
};
|
||||
|
||||
export default ComboCommunesSearch;
|
||||
|
|
|
@ -19,11 +19,11 @@ function expandResultsWithForeignDepartement(term, results) {
|
|||
|
||||
export function ComboDepartementsSearch({
|
||||
addForeignDepartement = true,
|
||||
...params
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<ComboSearch
|
||||
{...params}
|
||||
{...props}
|
||||
scope="departements"
|
||||
minimumInputLength={1}
|
||||
transformResult={({ code, nom }) => [code, `${code} - ${nom}`]}
|
||||
|
@ -37,10 +37,7 @@ export function ComboDepartementsSearch({
|
|||
function ComboDepartementsSearchDefault(params) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ComboDepartementsSearch
|
||||
required={params.mandatory}
|
||||
hiddenFieldId={params.hiddenFieldId}
|
||||
/>
|
||||
<ComboDepartementsSearch {...params} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
|
314
app/javascript/components/ComboMultiple.jsx
Normal file
314
app/javascript/components/ComboMultiple.jsx
Normal file
|
@ -0,0 +1,314 @@
|
|||
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 { useId } from '@reach/auto-id';
|
||||
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();
|
||||
|
||||
function ComboMultiple({
|
||||
options,
|
||||
id,
|
||||
labelledby,
|
||||
describedby,
|
||||
label,
|
||||
group,
|
||||
name = 'value',
|
||||
selected,
|
||||
acceptNewValues = false
|
||||
}) {
|
||||
invariant(id || label, 'ComboMultiple: `id` or a `label` are required');
|
||||
invariant(group, 'ComboMultiple: `group` is required');
|
||||
|
||||
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 inputId = useId(id);
|
||||
const removedLabelledby = `${inputId}-remove`;
|
||||
const selectedLabelledby = `${inputId}-selected`;
|
||||
|
||||
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 [, setHiddenFieldValue, hiddenField] = useHiddenField(group, name);
|
||||
const awaitFormSubmit = useDeferredSubmit(hiddenField);
|
||||
|
||||
const handleChange = (event) => {
|
||||
setTerm(event.target.value);
|
||||
};
|
||||
|
||||
const saveSelection = (fn) => {
|
||||
setSelections((selections) => {
|
||||
selections = fn(selections);
|
||||
setHiddenFieldValue(JSON.stringify(selections));
|
||||
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}>
|
||||
<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}
|
||||
describedby={removedLabelledby}
|
||||
value={optionLabelByValue(selection)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<ComboboxInput
|
||||
ref={inputRef}
|
||||
value={term}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={onBlur}
|
||||
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">
|
||||
{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 ComboboxTokenLabel({ onRemove, ...props }) {
|
||||
const selectionsRef = useRef([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
selectionsRef.current = [];
|
||||
return () => (selectionsRef.current = []);
|
||||
});
|
||||
|
||||
const context = {
|
||||
onRemove,
|
||||
selectionsRef
|
||||
};
|
||||
|
||||
return (
|
||||
<Context.Provider value={context}>
|
||||
<div data-reach-combobox-token-label {...props} />
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
ComboboxTokenLabel.propTypes = {
|
||||
onRemove: PropTypes.func
|
||||
};
|
||||
|
||||
function ComboboxSeparator({ value }) {
|
||||
return (
|
||||
<li aria-disabled="true" role="option" data-reach-combobox-separator>
|
||||
{value.slice(2, -2)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
ComboboxSeparator.propTypes = {
|
||||
value: PropTypes.string
|
||||
};
|
||||
|
||||
function ComboboxToken({ value, describedby, ...props }) {
|
||||
const { selectionsRef, onRemove } = useContext(Context);
|
||||
useEffect(() => {
|
||||
selectionsRef.current.push(value);
|
||||
});
|
||||
|
||||
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" />
|
||||
{value}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
ComboboxToken.propTypes = {
|
||||
value: PropTypes.string,
|
||||
describedby: PropTypes.string
|
||||
};
|
||||
|
||||
ComboMultiple.propTypes = {
|
||||
options: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.string),
|
||||
PropTypes.arrayOf(
|
||||
PropTypes.arrayOf(
|
||||
PropTypes.oneOfType([PropTypes.string, PropTypes.number])
|
||||
)
|
||||
)
|
||||
]),
|
||||
selected: PropTypes.arrayOf(PropTypes.string),
|
||||
arraySelected: PropTypes.arrayOf(PropTypes.array),
|
||||
acceptNewValues: PropTypes.bool,
|
||||
mandatory: PropTypes.bool,
|
||||
id: PropTypes.string,
|
||||
group: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
labelledby: PropTypes.string,
|
||||
describedby: PropTypes.string,
|
||||
label: PropTypes.string
|
||||
};
|
||||
|
||||
export default ComboMultiple;
|
|
@ -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;
|
||||
|
|
|
@ -4,15 +4,14 @@ import { QueryClientProvider } from 'react-query';
|
|||
import ComboSearch from './ComboSearch';
|
||||
import { queryClient } from './shared/queryClient';
|
||||
|
||||
function ComboPaysSearch(params) {
|
||||
function ComboPaysSearch(props) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ComboSearch
|
||||
required={params.mandatory}
|
||||
hiddenFieldId={params.hiddenFieldId}
|
||||
scope="pays"
|
||||
minimumInputLength={0}
|
||||
transformResult={({ code, value, label }) => [code, value, label]}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
|
|
@ -4,15 +4,14 @@ import { QueryClientProvider } from 'react-query';
|
|||
import ComboSearch from './ComboSearch';
|
||||
import { queryClient } from './shared/queryClient';
|
||||
|
||||
function ComboRegionsSearch(params) {
|
||||
function ComboRegionsSearch(props) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ComboSearch
|
||||
required={params.mandatory}
|
||||
hiddenFieldId={params.hiddenFieldId}
|
||||
scope="regions"
|
||||
minimumInputLength={0}
|
||||
transformResult={({ code, nom }) => [code, nom]}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
|
|
@ -1,10 +1,4 @@
|
|||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect
|
||||
} from 'react';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { useQuery } from 'react-query';
|
||||
import PropTypes from 'prop-types';
|
||||
|
@ -16,42 +10,33 @@ import {
|
|||
ComboboxOption
|
||||
} from '@reach/combobox';
|
||||
import '@reach/combobox/styles.css';
|
||||
import { fire } from '@utils';
|
||||
import invariant from 'tiny-invariant';
|
||||
|
||||
import { useDeferredSubmit } from './shared/hooks';
|
||||
import { useDeferredSubmit, useHiddenField, groupId } from './shared/hooks';
|
||||
|
||||
function defaultTransformResults(_, results) {
|
||||
return results;
|
||||
}
|
||||
|
||||
function ComboSearch({
|
||||
hiddenFieldId,
|
||||
onChange,
|
||||
value: controlledValue,
|
||||
scope,
|
||||
inputId,
|
||||
scopeExtra,
|
||||
minimumInputLength,
|
||||
transformResult,
|
||||
allowInputValues = false,
|
||||
transformResults = defaultTransformResults,
|
||||
id,
|
||||
describedby,
|
||||
...props
|
||||
}) {
|
||||
const hiddenValueField = useMemo(
|
||||
() => document.querySelector(`input[data-uuid="${hiddenFieldId}"]`),
|
||||
[hiddenFieldId]
|
||||
);
|
||||
const comboInputId = useMemo(
|
||||
() => hiddenValueField?.id || inputId,
|
||||
[inputId, hiddenValueField]
|
||||
);
|
||||
const hiddenIdField = useMemo(
|
||||
() =>
|
||||
document.querySelector(
|
||||
`input[data-uuid="${hiddenFieldId}"] + input[data-reference]`
|
||||
),
|
||||
[hiddenFieldId]
|
||||
);
|
||||
const initialValue = hiddenValueField ? hiddenValueField.value : props.value;
|
||||
invariant(id || onChange, 'ComboSearch: `id` or `onChange` are required');
|
||||
|
||||
const group = !onChange ? groupId(id) : null;
|
||||
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, 300);
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
@ -60,41 +45,26 @@ function ComboSearch({
|
|||
const [, value, label] = transformResult(result);
|
||||
return label ?? value;
|
||||
};
|
||||
const setExternalValue = useCallback(
|
||||
(value) => {
|
||||
if (hiddenValueField) {
|
||||
hiddenValueField.setAttribute('value', value);
|
||||
fire(hiddenValueField, 'autosave:trigger');
|
||||
}
|
||||
},
|
||||
[hiddenValueField]
|
||||
);
|
||||
const setExternalId = useCallback(
|
||||
(key) => {
|
||||
if (hiddenIdField) {
|
||||
hiddenIdField.setAttribute('value', key);
|
||||
}
|
||||
},
|
||||
[hiddenIdField]
|
||||
);
|
||||
const setExternalValueAndId = useCallback((label) => {
|
||||
const { key, value, result } = resultsMap.current[label];
|
||||
setExternalId(key);
|
||||
setExternalValue(value);
|
||||
if (onChange) {
|
||||
onChange(value, result);
|
||||
} else {
|
||||
setExternalId(key);
|
||||
setExternalValue(value);
|
||||
}
|
||||
}, []);
|
||||
const awaitFormSubmit = useDeferredSubmit(hiddenValueField);
|
||||
const awaitFormSubmit = useDeferredSubmit(hiddenField);
|
||||
|
||||
const handleOnChange = useCallback(
|
||||
({ target: { value } }) => {
|
||||
setValue(value);
|
||||
if (!value) {
|
||||
setExternalId('');
|
||||
setExternalValue('');
|
||||
if (onChange) {
|
||||
onChange(null);
|
||||
} else {
|
||||
setExternalId('');
|
||||
setExternalValue('');
|
||||
}
|
||||
} else if (value.length >= minimumInputLength) {
|
||||
setSearchTerm(value.trim());
|
||||
|
@ -133,20 +103,16 @@ function ComboSearch({
|
|||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
document
|
||||
.querySelector(`#${comboInputId}[type="hidden"]`)
|
||||
?.removeAttribute('id');
|
||||
}, [comboInputId]);
|
||||
|
||||
return (
|
||||
<Combobox onSelect={handleOnSelect}>
|
||||
<ComboboxInput
|
||||
{...props}
|
||||
id={comboInputId}
|
||||
onChange={handleOnChange}
|
||||
onBlur={onBlur}
|
||||
value={value}
|
||||
autocomplete={false}
|
||||
id={id}
|
||||
aria-describedby={describedby}
|
||||
/>
|
||||
{isSuccess && (
|
||||
<ComboboxPopover className="shadow-popup">
|
||||
|
@ -178,15 +144,16 @@ function ComboSearch({
|
|||
|
||||
ComboSearch.propTypes = {
|
||||
value: PropTypes.string,
|
||||
hiddenFieldId: PropTypes.string,
|
||||
scope: PropTypes.string,
|
||||
minimumInputLength: PropTypes.number,
|
||||
transformResult: PropTypes.func,
|
||||
transformResults: PropTypes.func,
|
||||
allowInputValues: PropTypes.bool,
|
||||
onChange: PropTypes.func,
|
||||
inputId: PropTypes.string,
|
||||
scopeExtra: PropTypes.string
|
||||
scopeExtra: PropTypes.string,
|
||||
mandatory: PropTypes.bool,
|
||||
id: PropTypes.string,
|
||||
describedby: PropTypes.string
|
||||
};
|
||||
|
||||
export default ComboSearch;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { useRef, useCallback } from 'react';
|
||||
import { useRef, useCallback, useMemo, useState } from 'react';
|
||||
import { fire } from '@utils';
|
||||
|
||||
export function useDeferredSubmit(input) {
|
||||
const calledRef = useRef(false);
|
||||
|
@ -31,3 +32,35 @@ export function useDeferredSubmit(input) {
|
|||
};
|
||||
return awaitFormSubmit;
|
||||
}
|
||||
|
||||
export function groupId(id) {
|
||||
return `#champ-${id.replace(/-input$/, '')}`;
|
||||
}
|
||||
|
||||
export function useHiddenField(group, name = 'value') {
|
||||
const hiddenField = useMemo(
|
||||
() => selectInputInGroup(group, name),
|
||||
[group, name]
|
||||
);
|
||||
const [value, setValue] = useState(() => hiddenField?.value);
|
||||
|
||||
return [
|
||||
value,
|
||||
(value) => {
|
||||
if (hiddenField) {
|
||||
hiddenField.setAttribute('value', value);
|
||||
setValue(value);
|
||||
fire(hiddenField, 'autosave:trigger');
|
||||
}
|
||||
},
|
||||
hiddenField
|
||||
];
|
||||
}
|
||||
|
||||
function selectInputInGroup(group, name) {
|
||||
if (group) {
|
||||
return document.querySelector(
|
||||
`${group} input[type="hidden"][name$="[${name}]"], ${group} input[type="hidden"][name="${name}"]`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -69,6 +69,7 @@ registerReactComponents({
|
|||
ComboMultipleDropdownList: Loadable(() =>
|
||||
import('../components/ComboMultipleDropdownList')
|
||||
),
|
||||
ComboMultiple: Loadable(() => import('../components/ComboMultiple')),
|
||||
ComboPaysSearch: Loadable(() => import('../components/ComboPaysSearch')),
|
||||
ComboRegionsSearch: Loadable(() =>
|
||||
import('../components/ComboRegionsSearch')
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
"@rails/activestorage": "^6.1.4-1",
|
||||
"@rails/ujs": "^6.1.4-1",
|
||||
"@rails/webpacker": "5.4.3",
|
||||
"@reach/auto-id": "^0.16.0",
|
||||
"@reach/combobox": "^0.13.0",
|
||||
"@reach/slider": "^0.15.0",
|
||||
"@reach/visually-hidden": "^0.15.2",
|
||||
|
@ -36,6 +37,7 @@
|
|||
"react-popper": "^2.2.5",
|
||||
"react-query": "^3.9.7",
|
||||
"react-sortable-hoc": "^1.11.0",
|
||||
"tiny-invariant": "^1.2.0",
|
||||
"trix": "^1.2.3",
|
||||
"use-debounce": "^5.2.0",
|
||||
"webpack": "^4.46.0",
|
||||
|
|
21
yarn.lock
21
yarn.lock
|
@ -1838,6 +1838,14 @@
|
|||
"@reach/utils" "0.15.3"
|
||||
tslib "^2.3.0"
|
||||
|
||||
"@reach/auto-id@^0.16.0":
|
||||
version "0.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.16.0.tgz#dfabc3227844e8c04f8e6e45203a8e14a8edbaed"
|
||||
integrity sha512-5ssbeP5bCkM39uVsfQCwBBL+KT8YColdnMN5/Eto6Rj7929ql95R3HZUOkKIvj7mgPtEb60BLQxd1P3o6cjbmg==
|
||||
dependencies:
|
||||
"@reach/utils" "0.16.0"
|
||||
tslib "^2.3.0"
|
||||
|
||||
"@reach/combobox@^0.13.0":
|
||||
version "0.13.2"
|
||||
resolved "https://registry.yarnpkg.com/@reach/combobox/-/combobox-0.13.2.tgz#f0a38c685a2704f6a189709e63cd7e25809da1e4"
|
||||
|
@ -1922,6 +1930,14 @@
|
|||
tiny-warning "^1.0.3"
|
||||
tslib "^2.3.0"
|
||||
|
||||
"@reach/utils@0.16.0":
|
||||
version "0.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.16.0.tgz#5b0777cf16a7cab1ddd4728d5d02762df0ba84ce"
|
||||
integrity sha512-PCggBet3qaQmwFNcmQ/GqHSefadAFyNCUekq9RrWoaU9hh/S4iaFgf2MBMdM47eQj5i/Bk0Mm07cP/XPFlkN+Q==
|
||||
dependencies:
|
||||
tiny-warning "^1.0.3"
|
||||
tslib "^2.3.0"
|
||||
|
||||
"@reach/visually-hidden@^0.15.2":
|
||||
version "0.15.2"
|
||||
resolved "https://registry.yarnpkg.com/@reach/visually-hidden/-/visually-hidden-0.15.2.tgz#07794cb53f4bd23a9452d53a0ad7778711ee323f"
|
||||
|
@ -12445,6 +12461,11 @@ timsort@^0.3.0:
|
|||
resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4"
|
||||
integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=
|
||||
|
||||
tiny-invariant@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.2.0.tgz#a1141f86b672a9148c72e978a19a73b9b94a15a9"
|
||||
integrity sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==
|
||||
|
||||
tiny-warning@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
|
||||
|
|
Loading…
Reference in a new issue