From 98f00b7fce29c06e21a798e212ed77e6bab0b1e5 Mon Sep 17 00:00:00 2001 From: fffan64 Date: Mon, 20 Jan 2025 09:24:46 +0100 Subject: [PATCH] fix(look&feel): css + aria (#758) Co-authored-by: Jonathan VACHERAT --- .../css/src/Form/Select/Select.scss | 31 ++- .../react/src/Form/Select/Select.tsx | 186 +++++++++--------- .../Form/Select/arialiveMessagesDefault.ts | 86 ++++++++ 3 files changed, 200 insertions(+), 103 deletions(-) create mode 100644 client/look-and-feel/react/src/Form/Select/arialiveMessagesDefault.ts diff --git a/client/look-and-feel/css/src/Form/Select/Select.scss b/client/look-and-feel/css/src/Form/Select/Select.scss index f06030af6..b03897188 100644 --- a/client/look-and-feel/css/src/Form/Select/Select.scss +++ b/client/look-and-feel/css/src/Form/Select/Select.scss @@ -5,6 +5,7 @@ .af-form { &__select { &-label { + display: inline-block; margin-bottom: 0.5rem; font-size: common.rem(18); font-weight: 600; @@ -35,9 +36,14 @@ &__input-select { min-height: unset !important; - font-size: 1rem; + font-size: 1.125rem; color: var(--color-gray-700); + &[aria-disabled] { + cursor: not-allowed !important; + pointer-events: auto !important; + } + &-container { padding: 0.75rem 1rem; border: 1px solid var(--color-gray-700); @@ -46,30 +52,35 @@ background-color: var(--color-white); &-error { - border-width: 2px; + margin-bottom: 0.5rem; + border-width: 1px; border-color: var(--color-red-700); - &:hover, - &:active { + &:not([class*="-disabled"]):hover, + &:not([class*="-disabled"]):active { + border-color: var(--color-red-700); box-shadow: 0 0 0 1px var(--color-red-700) inset !important; } } - &:hover, - &:active { - box-shadow: 0 0 0 2px var(--color-axa) inset; - } - &-focused, &:focus-visible { - box-shadow: 0 0 0 2px var(--color-axa) inset; + box-shadow: 0 0 0 1px var(--color-axa) inset; outline: 2px solid var(--color-focus); outline-offset: 3px; } + &:not(&-disabled, &-error):hover, + &:not(&-disabled, &-error):active { + border-color: var(--color-axa); + box-shadow: 0 0 0 1px var(--color-axa) inset; + } + &-disabled { border-color: var(--color-gray-400); background-color: var(--color-gray-200); + cursor: not-allowed !important; + pointer-events: auto !important; } &-icon { diff --git a/client/look-and-feel/react/src/Form/Select/Select.tsx b/client/look-and-feel/react/src/Form/Select/Select.tsx index 335eb4e32..9b6ce22f0 100644 --- a/client/look-and-feel/react/src/Form/Select/Select.tsx +++ b/client/look-and-feel/react/src/Form/Select/Select.tsx @@ -1,23 +1,24 @@ import "@axa-fr/design-system-look-and-feel-css/dist/Form/Select/Select.scss"; -import ErrorOutline from "@material-symbols/svg-400/outlined/error.svg"; import ReactSelect, { ActionMeta, ContainerProps, GroupBase, OptionProps, + SelectInstance, SingleValue, SingleValueProps, } from "react-select"; -import { ComponentPropsWithoutRef, PropsWithChildren } from "react"; +import { ComponentPropsWithRef, forwardRef, useId } from "react"; import classNames from "classnames"; import { DropdownIndicator } from "./DropdownIndicator"; -import { Svg } from "../../Svg"; import { CustomOption } from "./CustomOption"; +import { defaultAriaLiveMessages } from "./arialiveMessagesDefault"; +import { InputError } from "../InputError"; type Option = { label: string; value: string | number }; type Props = Omit< - ComponentPropsWithoutRef, + ComponentPropsWithRef, "placeholder" | "noOptionsMessage" > & { id: string; @@ -35,95 +36,94 @@ type Props = Omit< required?: boolean; }; -const Select = ({ - id, - required, - disabled, - label, - errorLabel, - noOptionsMessage, - ...otherProps -}: PropsWithChildren) => { - return ( - <> - {label && ( - - )} - noOptionsMessage || "Aucun résultat"} - components={{ - DropdownIndicator, - Option: CustomOption, - }} - classNames={{ - control: () => "af-form__input-select", - menu: () => "af-form__input-select-menu", - menuList: () => "af-form__select-menu-list", - container: ({ - isFocused, - isDisabled, - selectProps: { menuIsOpen }, - }: ContainerProps>) => - classNames( - "af-form__input-select-container", - isFocused && "af-form__input-select-container-focused", - isDisabled && "af-form__input-select-container-disabled", - errorLabel && - !isFocused && - !menuIsOpen && - "af-form__input-select-container-error", - ), - option: ({ - isSelected, - isFocused, - }: OptionProps>) => - classNames( - "af-form__input-select-menu-options", - isSelected && "af-form__input-select-menu-options-selected", - isFocused && "af-form__input-select-menu-options-focused", - ), - singleValue: ({ - isDisabled, - }: SingleValueProps>) => - classNames( - "af-form__select-single-value", - isDisabled && "af-form__select-single-value-disabled", - ), - dropdownIndicator: () => "af-form__select-dropdown-indicator", - }} - className="af-form__input-select" - {...otherProps} - /> - {errorLabel && ( -
- - {errorLabel} -
- )} - - ); -}; +const Select = forwardRef< + SelectInstance>, + Props +>( + ( + { + id, + required, + disabled, + label, + errorLabel, + noOptionsMessage, + ...otherProps + }, + inputRef, + ) => { + const idError = useId(); + let inputId = useId(); + inputId = id || inputId; + + return ( + <> + {label && ( + + )} + + `${count} résultat${count > 1 ? "s" : ""} disponible${count > 1 ? "s" : ""}` + } + unstyled + isDisabled={disabled} + noOptionsMessage={() => noOptionsMessage || "Aucun résultat"} + components={{ + DropdownIndicator, + Option: CustomOption, + }} + classNames={{ + control: () => "af-form__input-select", + menu: () => "af-form__input-select-menu", + menuList: () => "af-form__select-menu-list", + container: ({ + isFocused, + isDisabled, + selectProps: { menuIsOpen }, + }: ContainerProps>) => + classNames( + "af-form__input-select-container", + isFocused && "af-form__input-select-container-focused", + isDisabled && "af-form__input-select-container-disabled", + errorLabel && + !isFocused && + !menuIsOpen && + "af-form__input-select-container-error", + ), + option: ({ + isSelected, + isFocused, + }: OptionProps>) => + classNames( + "af-form__input-select-menu-options", + isSelected && "af-form__input-select-menu-options-selected", + isFocused && "af-form__input-select-menu-options-focused", + ), + singleValue: ({ + isDisabled, + }: SingleValueProps>) => + classNames( + "af-form__select-single-value", + isDisabled && "af-form__select-single-value-disabled", + ), + dropdownIndicator: () => "af-form__select-dropdown-indicator", + }} + className="af-form__input-select" + {...otherProps} + ref={inputRef} + /> + {errorLabel && } + + ); + }, +); Select.displayName = "Select"; diff --git a/client/look-and-feel/react/src/Form/Select/arialiveMessagesDefault.ts b/client/look-and-feel/react/src/Form/Select/arialiveMessagesDefault.ts new file mode 100644 index 000000000..06c88d230 --- /dev/null +++ b/client/look-and-feel/react/src/Form/Select/arialiveMessagesDefault.ts @@ -0,0 +1,86 @@ +import { + AriaGuidanceProps, + AriaOnChangeProps, + AriaOnFilterProps, + AriaOnFocusProps, + GroupBase, + OptionsOrGroups, +} from "react-select"; + +export const defaultAriaLiveMessages = { + guidance: (props: AriaGuidanceProps) => { + const { isSearchable, isMulti, tabSelectsValue, context, isInitialFocus } = + props; + switch (context) { + case "menu": + return `Utilisez les flèches Haut et Bas pour choisir des options, appuyez sur Entrée pour sélectionner l'option actuellement mise au point, appuyez sur Échap pour quitter le menu${tabSelectsValue ? ", appuyez sur Tab pour sélectionner l'option et quitter le menu" : ""}.`; + case "input": + return isInitialFocus + ? `${props["aria-label"] || "Sélectionner"} est sélectionné ${isSearchable ? ", tapez du texte pour affiner la liste" : ""}, appuyez sur Bas pour ouvrir le menu, ${isMulti ? " appuyez sur Gauche pour mettre au point les valeurs sélectionnées" : ""}` + : ""; + case "value": + return "Utilisez les flèches Gauche et Droite pour basculer entre les valeurs sélectionnées, appuyez sur Retour arrière pour supprimer la valeur actuellement sélectionnée."; + default: + return ""; + } + }, + + onChange: ( + props: AriaOnChangeProps, + ) => { + const { action, label = "", labels, isDisabled } = props; + switch (action) { + case "deselect-option": + case "pop-value": + case "remove-value": + return `option ${label}, désélectionnée.`; + case "clear": + return "Toutes les options sélectionnées ont été effacées."; + case "initial-input-focus": + return `${labels.length > 1 ? "les " : "l'"}option${labels.length > 1 ? "s" : ""} ${labels.join(",")} ${labels.length > 1 ? "sont" : "est"} sélectionnée${labels.length > 1 ? "s" : ""}.`; + case "select-option": + return isDisabled + ? `l'option ${label} est désactivée. Sélectionnez une autre option.` + : `l'option ${label} est sélectionnée.`; + default: + return ""; + } + }, + + onFocus: >( + props: AriaOnFocusProps, + ) => { + const { + context, + focused, + options, + label = "", + selectValue, + isDisabled, + isSelected, + isAppleDevice, + } = props; + + const getArrayIndex = ( + arr: OptionsOrGroups, + item: Option, + ) => + arr && arr.length ? `${arr.indexOf(item) + 1} sur ${arr.length}` : ""; + + if (context === "value" && selectValue) { + return `la valeur ${label} est sélectionnée, ${getArrayIndex(selectValue, focused)}.`; + } + + if (context === "menu" && isAppleDevice) { + const disabled = isDisabled ? " désactivé" : ""; + const status = `${isSelected ? " sélectionné" : ""}${disabled}`; + return `${label}${status}, ${getArrayIndex(options, focused)}.`; + } + return ""; + }, + + onFilter: (props: AriaOnFilterProps) => { + const { inputValue, resultsMessage } = props; + return `${resultsMessage}${inputValue ? ` pour le terme de recherche : ${inputValue}` : ""}.`; + }, +};