From d6b6bb0f2a435dfbf19b03359dd5cf104d319a0d Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 5 Jan 2022 11:34:43 +0100 Subject: [PATCH] a11y(combobox): add support for describedby and labelledby and improuve external fields handling --- app/assets/stylesheets/forms.scss | 21 +- .../stylesheets/personnes_impliquees.scss | 6 +- app/assets/stylesheets/procedure_show.scss | 11 +- .../components/ComboAdresseSearch.jsx | 19 +- .../ComboAnnuaireEducationSearch.jsx | 5 +- .../components/ComboCommunesSearch.jsx | 71 ++-- .../components/ComboDepartementsSearch.jsx | 9 +- app/javascript/components/ComboMultiple.jsx | 314 ++++++++++++++++++ .../components/ComboMultipleDropdownList.jsx | 299 +---------------- app/javascript/components/ComboPaysSearch.jsx | 5 +- .../components/ComboRegionsSearch.jsx | 5 +- app/javascript/components/ComboSearch.jsx | 85 ++--- app/javascript/components/shared/hooks.js | 35 +- app/javascript/packs/application.js | 1 + package.json | 2 + yarn.lock | 21 ++ 16 files changed, 458 insertions(+), 451 deletions(-) create mode 100644 app/javascript/components/ComboMultiple.jsx diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index 922b89d97..031f96c37 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -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; } diff --git a/app/assets/stylesheets/personnes_impliquees.scss b/app/assets/stylesheets/personnes_impliquees.scss index 381886823..7be4fb89e 100644 --- a/app/assets/stylesheets/personnes_impliquees.scss +++ b/app/assets/stylesheets/personnes_impliquees.scss @@ -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; } diff --git a/app/assets/stylesheets/procedure_show.scss b/app/assets/stylesheets/procedure_show.scss index 5b4f6a9ba..182be6324 100644 --- a/app/assets/stylesheets/procedure_show.scss +++ b/app/assets/stylesheets/procedure_show.scss @@ -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; - } } diff --git a/app/javascript/components/ComboAdresseSearch.jsx b/app/javascript/components/ComboAdresseSearch.jsx index 78177dbe8..5691d76e0 100644 --- a/app/javascript/components/ComboAdresseSearch.jsx +++ b/app/javascript/components/ComboAdresseSearch.jsx @@ -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 ( ); } 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; diff --git a/app/javascript/components/ComboAnnuaireEducationSearch.jsx b/app/javascript/components/ComboAnnuaireEducationSearch.jsx index bbc81414a..a0bc4c969 100644 --- a/app/javascript/components/ComboAnnuaireEducationSearch.jsx +++ b/app/javascript/components/ComboAnnuaireEducationSearch.jsx @@ -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 ( records} @@ -20,6 +18,7 @@ function ComboAnnuaireEducationSearch(params) { nom_commune } }) => [id, `${nom_etablissement}, ${nom_commune} (${id})`]} + {...props} /> ); diff --git a/app/javascript/components/ComboCommunesSearch.jsx b/app/javascript/components/ComboCommunesSearch.jsx index c9dac0a21..85d1b54c3 100644 --- a/app/javascript/components/ComboCommunesSearch.jsx +++ b/app/javascript/components/ComboCommunesSearch.jsx @@ -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 ( @@ -87,22 +73,19 @@ function ComboCommunesSearch(params) {

{ - setDepartementCode(result?.code); - if (hiddenDepartementField && hiddenCodeDepartementField) { - hiddenDepartementField.setAttribute('value', result?.nom); - hiddenCodeDepartementField.setAttribute('value', result?.code); - } + setDepartementValue(result?.nom); + setCodeDepartement(result?.code); }} /> - {departementCode ? ( + {codeDepartement ? (

@@ -111,14 +94,12 @@ function ComboCommunesSearch(params) {

[ code, @@ -132,4 +113,8 @@ function ComboCommunesSearch(params) { ); } +ComboCommunesSearch.propTypes = { + id: PropTypes.string +}; + export default ComboCommunesSearch; diff --git a/app/javascript/components/ComboDepartementsSearch.jsx b/app/javascript/components/ComboDepartementsSearch.jsx index 3f4c3350a..dab35ebb4 100644 --- a/app/javascript/components/ComboDepartementsSearch.jsx +++ b/app/javascript/components/ComboDepartementsSearch.jsx @@ -19,11 +19,11 @@ function expandResultsWithForeignDepartement(term, results) { export function ComboDepartementsSearch({ addForeignDepartement = true, - ...params + ...props }) { return ( [code, `${code} - ${nom}`]} @@ -37,10 +37,7 @@ export function ComboDepartementsSearch({ function ComboDepartementsSearchDefault(params) { return ( - + ); } diff --git a/app/javascript/components/ComboMultiple.jsx b/app/javascript/components/ComboMultiple.jsx new file mode 100644 index 000000000..9b7d961ed --- /dev/null +++ b/app/javascript/components/ComboMultiple.jsx @@ -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 ( + + + + désélectionner + +
    + {selections.map((selection) => ( + + ))} +
+ +
+ {results && (results.length > 0 || !acceptNewValues) && ( + + {results.length === 0 && ( +

+ Aucun résultat{' '} + +

+ )} + + {results.map(([label, value], index) => { + if (label.startsWith('--')) { + return ; + } + return ( + + ); + })} + +
+ )} +
+ ); +} + +function ComboboxTokenLabel({ onRemove, ...props }) { + const selectionsRef = useRef([]); + + useLayoutEffect(() => { + selectionsRef.current = []; + return () => (selectionsRef.current = []); + }); + + const context = { + onRemove, + selectionsRef + }; + + return ( + +
+ + ); +} + +ComboboxTokenLabel.propTypes = { + onRemove: PropTypes.func +}; + +function ComboboxSeparator({ value }) { + return ( +
  • + {value.slice(2, -2)} +
  • + ); +} + +ComboboxSeparator.propTypes = { + value: PropTypes.string +}; + +function ComboboxToken({ value, describedby, ...props }) { + const { selectionsRef, onRemove } = useContext(Context); + useEffect(() => { + selectionsRef.current.push(value); + }); + + return ( +
  • + +
  • + ); +} + +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; diff --git a/app/javascript/components/ComboMultipleDropdownList.jsx b/app/javascript/components/ComboMultipleDropdownList.jsx index 93f5d4d22..b918a5603 100644 --- a/app/javascript/components/ComboMultipleDropdownList.jsx +++ b/app/javascript/components/ComboMultipleDropdownList.jsx @@ -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 ( - - -
      - {selections.map((selection) => ( - - ))} -
    - -
    - {results && (results.length > 0 || !acceptNewValues) && ( - - {results.length === 0 && ( -

    - Aucun résultat{' '} - -

    - )} - - {results.map(([label, value], index) => { - if (label.startsWith('--')) { - return ; - } - return ( - - ); - })} - -
    - )} -
    - ); +function ComboMultipleDropdownList({ id, ...props }) { + return ; } -function ComboboxTokenLabel({ onRemove, ...props }) { - const selectionsRef = useRef([]); - - useLayoutEffect(() => { - selectionsRef.current = []; - return () => (selectionsRef.current = []); - }); - - const context = { - onRemove, - selectionsRef - }; - - return ( - -
    - - ); -} - -ComboboxTokenLabel.propTypes = { - onRemove: PropTypes.func -}; - -function ComboboxSeparator({ value }) { - return ( -
  • - {value.slice(2, -2)} -
  • - ); -} - -ComboboxSeparator.propTypes = { - value: PropTypes.string -}; - -function ComboboxToken({ value, ...props }) { - const { selectionsRef, onRemove } = useContext(Context); - useEffect(() => { - selectionsRef.current.push(value); - }); - - return ( -
  • { - if (event.key === 'Backspace') { - onRemove(value); - } - }} - {...props} - > - - {value} -
  • - ); -} - -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; diff --git a/app/javascript/components/ComboPaysSearch.jsx b/app/javascript/components/ComboPaysSearch.jsx index 22b1cdcd6..d44fde47c 100644 --- a/app/javascript/components/ComboPaysSearch.jsx +++ b/app/javascript/components/ComboPaysSearch.jsx @@ -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 ( [code, value, label]} + {...props} /> ); diff --git a/app/javascript/components/ComboRegionsSearch.jsx b/app/javascript/components/ComboRegionsSearch.jsx index 0a1a5c723..e031cc5dd 100644 --- a/app/javascript/components/ComboRegionsSearch.jsx +++ b/app/javascript/components/ComboRegionsSearch.jsx @@ -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 ( [code, nom]} + {...props} /> ); diff --git a/app/javascript/components/ComboSearch.jsx b/app/javascript/components/ComboSearch.jsx index 8677f2b5d..bc209c6d0 100644 --- a/app/javascript/components/ComboSearch.jsx +++ b/app/javascript/components/ComboSearch.jsx @@ -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 ( {isSuccess && ( @@ -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; diff --git a/app/javascript/components/shared/hooks.js b/app/javascript/components/shared/hooks.js index a64aca7c5..a07191d75 100644 --- a/app/javascript/components/shared/hooks.js +++ b/app/javascript/components/shared/hooks.js @@ -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}"]` + ); + } +} diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index f0cc6ec15..898eea8f6 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -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') diff --git a/package.json b/package.json index 42bc33e43..7ff9df2bf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 14f977e5c..bf48941f5 100644 --- a/yarn.lock +++ b/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"