From c575388dda8bc587747f9a9d3728b9fcee6cac4e Mon Sep 17 00:00:00 2001 From: juliebrunetto83 <122871677+juliebrunetto83@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:49:10 +0200 Subject: [PATCH] feat(UI): passer le select en composition (#3204) --- .../ui/Form/Select/Select.module.scss | 39 ++++++---- .../components/ui/Form/Select/Select.test.tsx | 21 +++++ .../components/ui/Form/Select/Select.tsx | 16 +++- .../ui/Form/Select/SelectContext.tsx | 22 ++++++ .../ui/Form/Select/SelectMultiple.tsx | 53 +++++-------- .../ui/Form/Select/SelectOption.tsx | 32 ++++++++ .../ui/Form/Select/SelectReducer.tsx | 4 +- .../ui/Form/Select/SelectSimple.tsx | 77 ++++++++----------- 8 files changed, 164 insertions(+), 100 deletions(-) create mode 100644 src/client/components/ui/Form/Select/SelectContext.tsx create mode 100644 src/client/components/ui/Form/Select/SelectOption.tsx diff --git a/src/client/components/ui/Form/Select/Select.module.scss b/src/client/components/ui/Form/Select/Select.module.scss index 007b6d1f2c..03b82a1c8d 100644 --- a/src/client/components/ui/Form/Select/Select.module.scss +++ b/src/client/components/ui/Form/Select/Select.module.scss @@ -34,7 +34,7 @@ $border-width: inputStyles.$border-width; position: relative; border-radius: $border-radius; - & .combobox { + & [role="combobox"] { cursor: pointer; @extend %outlined; @include utilities.text-interactive-medium; @@ -44,9 +44,14 @@ $border-width: inputStyles.$border-width; border-radius: $border-radius; padding: 0.5rem 1rem; background-color: transparent; + + &[aria-expanded="true"] svg { + transform: rotate(-180deg); + transition: transform 200ms linear; + } } - input:invalid + .combobox[data-touched="true"] { + input:invalid + [role="combobox"][data-touched="true"] { border-color: $color-error; border-width: $error-border-width; } @@ -63,42 +68,44 @@ $border-width: inputStyles.$border-width; max-height: 10em; overflow-y: scroll; - & li { + li[role="option"] { display: grid; grid-template-columns: auto 1fr; align-items: center; padding: .5rem 1ch; + cursor: pointer; - &[role="option"] { - cursor: pointer; + &:hover, + &.optionVisuallyFocus { + background-color: $color-option-hover; + font-weight: bold; } + } - &.optionComboboxSimple::before { + &:not([aria-multiselectable="true"]) { + li[role="option"]::before { @extend %radio; margin-right: 0.5rem; } - &[role="option"][aria-selected="true"].optionComboboxSimple::before { + li[role="option"][aria-selected="true"]::before { @extend %radio-checked; } + } - &.optionComboboxMultiple::before { + + &[aria-multiselectable="true"] { + li[role="option"]::before { @extend %checkbox; } - &[role="option"][aria-selected="true"].optionComboboxMultiple::before { + li[role="option"][aria-selected="true"]::before { @extend %checkbox-checked; } - - &[role="option"]:hover, - &[role="option"].optionVisuallyFocus { - background-color: $color-option-hover; - font-weight: bold; - } } } - & .inputHiddenValue { + & input[aria-hidden="true"] { position: absolute; pointer-events: none; opacity: 0; diff --git a/src/client/components/ui/Form/Select/Select.test.tsx b/src/client/components/ui/Form/Select/Select.test.tsx index 57ac8c70f1..58bf2175bd 100644 --- a/src/client/components/ui/Form/Select/Select.test.tsx +++ b/src/client/components/ui/Form/Select/Select.test.tsx @@ -9,6 +9,7 @@ import React, { FormEvent } from 'react'; import { KeyBoard } from '~/client/components/keyboard.fixture'; import { Select } from '~/client/components/ui/Form/Select/Select'; +import { SelectSimple } from '~/client/components/ui/Form/Select/SelectSimple'; import { mockScrollIntoView } from '~/client/components/window.mock'; const SELECT_SIMPLE_LABEL_DEFAULT_OPTION = 'Sélectionnez votre choix'; @@ -1673,6 +1674,26 @@ describe('', () => { }); }); }); + + describe('SelectOption', () => { + it('accepte un id et le passe à l‘option', () => { + render( + option 1 + option 2 + ); + + expect(screen.getByRole('option', { hidden: true, name:'option 1' })).toHaveAttribute('id', 'id1'); + }); + + it('accepte une value et la passe à l‘option', () => { + render( + option 1 + option 2 + ); + + expect(screen.getByRole('option', { hidden: true, name:'option 1' })).toHaveAttribute('data-value', '1'); + }); + }); }); function getAllFormData(event: FormEvent, name: string) { diff --git a/src/client/components/ui/Form/Select/Select.tsx b/src/client/components/ui/Form/Select/Select.tsx index 07f867b69d..45fa2b10b5 100644 --- a/src/client/components/ui/Form/Select/Select.tsx +++ b/src/client/components/ui/Form/Select/Select.tsx @@ -16,6 +16,7 @@ export interface OptionSelect { type SelectProps = { label: string; labelComplement?: string + optionList: OptionSelect[] } & ( SelectSimpleProps & { multiple?: false } | SelectMultipleProps & { multiple: true } @@ -27,6 +28,7 @@ export function Select(props: SelectProps) { label, labelComplement, multiple, + optionList, ...rest } = props; const labelledBy = useId(); @@ -46,8 +48,18 @@ export function Select(props: SelectProps) { {label} {labelComplement && {labelComplement}} - {isSelectSimpleProps(rest) && } - {isSelectMultipleProps(rest) && } + {isSelectSimpleProps(rest) && + {optionList.map((option) => + {option.libellé}, + )} + } + + {isSelectMultipleProps(rest) && + {optionList.map((option) => + {option.libellé}, + )} + } + diff --git a/src/client/components/ui/Form/Select/SelectContext.tsx b/src/client/components/ui/Form/Select/SelectContext.tsx new file mode 100644 index 0000000000..7a8c9de404 --- /dev/null +++ b/src/client/components/ui/Form/Select/SelectContext.tsx @@ -0,0 +1,22 @@ +import { createContext, useContext } from 'react'; + +import NoProviderError from '~/client/Errors/NoProviderError'; + + +type SelectContext = { + activeDescendant: string | undefined, + onOptionSelection: (optionId: string) => void, + isCurrentItemSelected: (optionValue: string) => boolean +} + +export const SelectContext = createContext(null); + +export function useSelectContext() { + const selectContext = useContext(SelectContext); + + if (selectContext == null) { + throw new NoProviderError(SelectContext); + } + + return selectContext; +} diff --git a/src/client/components/ui/Form/Select/SelectMultiple.tsx b/src/client/components/ui/Form/Select/SelectMultiple.tsx index 273493f56d..c5f5f0c2b7 100644 --- a/src/client/components/ui/Form/Select/SelectMultiple.tsx +++ b/src/client/components/ui/Form/Select/SelectMultiple.tsx @@ -1,4 +1,3 @@ -import classNames from 'classnames'; import debounce from 'lodash.debounce'; import React, { FocusEvent, @@ -15,10 +14,11 @@ import React, { import { KeyBoard } from '~/client/components/keyboard/keyboard.enum'; import { Input } from '~/client/components/ui/Form/Input'; -import { OptionSelect } from '~/client/components/ui/Form/Select/Select'; +import { SelectOption } from '~/client/components/ui/Form/Select/SelectOption'; import { Icon } from '~/client/components/ui/Icon/Icon'; import styles from './Select.module.scss'; +import { SelectContext } from './SelectContext'; import { SelectMultipleAction, SelectMultipleReducer } from './SelectReducer'; const SELECT_PLACEHOLDER_MULTIPLE = 'Sélectionnez vos choix'; @@ -26,7 +26,6 @@ const ERROR_LABEL_REQUIRED_MULTIPLE = 'Séléctionnez au moins un élément de l const DEFAULT_DEBOUNCE_TIMEOUT = 300; export type SelectMultipleProps = Omit, 'onChange'> & { - optionList: OptionSelect[]; value?: Array; onChange?: (value: HTMLElement) => void; defaultValue?: Array; @@ -35,7 +34,7 @@ export type SelectMultipleProps = Omit, 'onCha export function SelectMultiple(props: SelectMultipleProps & { labelledBy: string }) { const { - optionList, + children, value, placeholder, name, @@ -50,7 +49,6 @@ export function SelectMultiple(props: SelectMultipleProps & { labelledBy: string const listboxRef = useRef(null); const firstInputHiddenRef = useRef(null); - const optionsId = useId(); const listboxId = useId(); const [touched, setTouched] = useState(false); @@ -93,11 +91,6 @@ export function SelectMultiple(props: SelectMultipleProps & { labelledBy: string } }, [state.activeDescendant]); - // NOTE (BRUJ 17-05-2023): Sinon on perd le focus avant la fin du clique ==> élément invalid pour la sélection. - const onMouseDown = useCallback(function preventBlurOnOptionSelection(event: React.MouseEvent) { - event.preventDefault(); - }, []); - const onBlur = useCallback(function onBlur(event: FocusEvent) { const newFocusStillInCombobox = event.currentTarget.contains(event.relatedTarget); if (newFocusStillInCombobox) { @@ -218,30 +211,30 @@ export function SelectMultiple(props: SelectMultipleProps & { labelledBy: string } return ( - + - {optionsSelectedValues.slice(1).map((optionValue) => { - return ( + ; - })} + value={optionValue}/> + ))} - {state.isListOptionsOpen ? : } + - {optionList.map((option, index) => { - const optionId = `${optionsId}-${index}`; - return selectOption(optionId)} - aria-selected={isCurrentItemSelected(option.valeur)}> - {option.libellé} - ; - })} + {children} - + ); } @@ -291,3 +272,5 @@ function cancelEvent(event: SyntheticEvent) { function doNothing() { return; } + +SelectMultiple.Option = SelectOption; diff --git a/src/client/components/ui/Form/Select/SelectOption.tsx b/src/client/components/ui/Form/Select/SelectOption.tsx new file mode 100644 index 0000000000..3c9b6aca1c --- /dev/null +++ b/src/client/components/ui/Form/Select/SelectOption.tsx @@ -0,0 +1,32 @@ +import classNames from 'classnames'; +import React, { useCallback, useId } from 'react'; + +import styles from './Select.module.scss'; +import { useSelectContext } from './SelectContext'; + +type SelectOptionProps = Omit, 'value'> & { + value: { toString: () => string }, +}; + +export function SelectOption({ className, value: valueProps, id: idProps, ...rest }: SelectOptionProps) { + const value = valueProps.toString(); + const defaultId = useId(); + const id = idProps ?? value ?? defaultId; + const { onOptionSelection, activeDescendant, isCurrentItemSelected } = useSelectContext(); + + // NOTE (BRUJ 17-05-2023): Sinon on perd le focus avant la fin du clique ==> élément invalid pour la sélection. + const onMouseDown = useCallback(function preventBlurOnOptionSelection(event: React.MouseEvent) { + event.preventDefault(); + }, []); + + return onOptionSelection(id)} + aria-selected={isCurrentItemSelected(value)} + {...rest}> + ; +} diff --git a/src/client/components/ui/Form/Select/SelectReducer.tsx b/src/client/components/ui/Form/Select/SelectReducer.tsx index 7a223cd44b..c8620bb16a 100644 --- a/src/client/components/ui/Form/Select/SelectReducer.tsx +++ b/src/client/components/ui/Form/Select/SelectReducer.tsx @@ -9,7 +9,7 @@ export type SelectSimpleState = { valueTypedByUser: string } -function getOptionsElement(refListOption: RefObject) { +export function getOptionsElement(refListOption: RefObject) { return Array.from(refListOption.current?.querySelectorAll('[role="option"]') ?? []); } @@ -167,7 +167,7 @@ export namespace SelectSimpleAction { } } -export function SelectReducer(state: SelectSimpleState, action: SelectSimpleAction): SelectSimpleState { +export function SelectSimpleReducer(state: SelectSimpleState, action: SelectSimpleAction): SelectSimpleState { return action.execute(state); } diff --git a/src/client/components/ui/Form/Select/SelectSimple.tsx b/src/client/components/ui/Form/Select/SelectSimple.tsx index 5e7c94b32b..19539c2b47 100644 --- a/src/client/components/ui/Form/Select/SelectSimple.tsx +++ b/src/client/components/ui/Form/Select/SelectSimple.tsx @@ -1,10 +1,10 @@ -import classNames from 'classnames'; import debounce from 'lodash.debounce'; import React, { FocusEvent, KeyboardEvent, SyntheticEvent, useCallback, + useEffect, useId, useLayoutEffect, useMemo, @@ -15,18 +15,18 @@ import React, { import { KeyBoard } from '~/client/components/keyboard/keyboard.enum'; import { Input } from '~/client/components/ui/Form/Input'; -import { OptionSelect } from '~/client/components/ui/Form/Select/Select'; +import { SelectContext } from '~/client/components/ui/Form/Select/SelectContext'; +import { SelectOption } from '~/client/components/ui/Form/Select/SelectOption'; import { Icon } from '~/client/components/ui/Icon/Icon'; import styles from './Select.module.scss'; -import { SelectReducer, SelectSimpleAction } from './SelectReducer'; +import { getOptionsElement, SelectSimpleAction, SelectSimpleReducer } from './SelectReducer'; const ERROR_LABEL_REQUIRED_SIMPLE = 'Séléctionnez un élément de la liste'; const SELECT_PLACEHOLDER_SINGULAR = 'Sélectionnez votre choix'; const DEFAULT_DEBOUNCE_TIMEOUT = 300; export type SelectSimpleProps = Omit, 'onChange'> & { - optionList: OptionSelect[]; value?: string; onChange?: (value: HTMLElement) => void; defaultValue?: string; @@ -35,9 +35,9 @@ export type SelectSimpleProps = Omit, 'onChang export function SelectSimple(props: SelectSimpleProps & { labelledBy: string }) { const { - optionList, + children, value, - placeholder, + placeholder: placeholderProps, name, onChange: onChangeProps = doNothing, onInvalid: onInvalidProps = doNothing, @@ -49,13 +49,13 @@ export function SelectSimple(props: SelectSimpleProps & { labelledBy: string }) } = props; const listboxRef = useRef(null); const inputHiddenRef = useRef(null); - - const optionsId = useId(); const listboxId = useId(); const [touched, setTouched] = useState(false); + const [placeholder, setPlaceholder] = useState(); + const [state, dispatch] = useReducer( - SelectReducer, { + SelectSimpleReducer, { activeDescendant: undefined, isListOptionsOpen: false, optionSelectedValue: defaultValue ? defaultValue : '', @@ -67,6 +67,18 @@ export function SelectSimple(props: SelectSimpleProps & { labelledBy: string }) const optionSelectedValue = value ?? state.optionSelectedValue; + const placeholderWhenValueSelected = useCallback(() => { + if (optionSelectedValue) { + const options = getOptionsElement(listboxRef); + const optionSelected = options.find((option) => option.getAttribute('data-value') === optionSelectedValue); + return optionSelected?.textContent; + } + }, [optionSelectedValue]); + + useEffect(() => { + setPlaceholder(placeholderWhenValueSelected() ?? placeholderProps ?? SELECT_PLACEHOLDER_SINGULAR); + }, [optionSelectedValue, placeholderProps, placeholderWhenValueSelected]); + const selectOption = useCallback((optionId: string) => { setTouched(true); onTouchProps(true); @@ -94,11 +106,6 @@ export function SelectSimple(props: SelectSimpleProps & { labelledBy: string }) } }, [state.activeDescendant]); - // NOTE (BRUJ 17-05-2023): Sinon on perd le focus avant la fin du clique ==> élément invalid pour la sélection. - const onMouseDown = useCallback(function preventBlurOnOptionSelection(event: React.MouseEvent) { - event.preventDefault(); - }, []); - const onBlur = useCallback(function onBlur(event: FocusEvent) { const newFocusStillInSelect = event.currentTarget.contains(event.relatedTarget); if (newFocusStillInSelect) { @@ -217,23 +224,15 @@ export function SelectSimple(props: SelectSimpleProps & { labelledBy: string }) } }, [closeList, handlefocusOnTypeLetterDebounce, selectOption, state]); - function PlaceholderSelectedValue() { - function getLabelByValue(value: string) { - const optionValue = optionList.find((option) => option.valeur === value); - return optionValue?.libellé ?? ''; - } - - if (optionSelectedValue) return getLabelByValue(optionSelectedValue); - if (placeholder) return placeholder; - return SELECT_PLACEHOLDER_SINGULAR; - } - return ( - + - - {state.isListOptionsOpen ? : } + {placeholder} + - {optionList.map((option, index) => { - const optionId = `${optionsId}-${index}`; - return selectOption(optionId)} - aria-selected={isCurrentItemSelected(option.valeur)}> - {option.libellé} - ; - })} + {children} - + ); } @@ -294,3 +279,5 @@ function cancelEvent(event: SyntheticEvent) { function doNothing() { return; } + +SelectSimple.Option = SelectOption;