Skip to content

Commit

Permalink
fix(look&feel): css + aria (#758)
Browse files Browse the repository at this point in the history
Co-authored-by: Jonathan VACHERAT <[email protected]>
  • Loading branch information
fffan64 and Jonathan VACHERAT authored Jan 20, 2025
1 parent a8ebe87 commit 98f00b7
Show file tree
Hide file tree
Showing 3 changed files with 200 additions and 103 deletions.
31 changes: 21 additions & 10 deletions client/look-and-feel/css/src/Form/Select/Select.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
.af-form {
&__select {
&-label {
display: inline-block;
margin-bottom: 0.5rem;
font-size: common.rem(18);
font-weight: 600;
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down
186 changes: 93 additions & 93 deletions client/look-and-feel/react/src/Form/Select/Select.tsx
Original file line number Diff line number Diff line change
@@ -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<ReactSelect>,
ComponentPropsWithRef<ReactSelect>,
"placeholder" | "noOptionsMessage"
> & {
id: string;
Expand All @@ -35,95 +36,94 @@ type Props = Omit<
required?: boolean;
};

const Select = ({
id,
required,
disabled,
label,
errorLabel,
noOptionsMessage,
...otherProps
}: PropsWithChildren<Props>) => {
return (
<>
{label && (
<label
id={`${id}__label`}
htmlFor={`${id}__input`}
className="af-form__select-label"
>
{label}
{required && <span> *</span>}
</label>
)}
<ReactSelect
inputId={`${id}__input`}
aria-labelledby={`${id}__label`}
aria-errormessage={`${id}__error`}
aria-invalid={Boolean(errorLabel)}
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<unknown, boolean, GroupBase<unknown>>) =>
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<unknown, boolean, GroupBase<unknown>>) =>
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<unknown, boolean, GroupBase<unknown>>) =>
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 && (
<div
id={`${id}__error`}
className="af-form__select-error"
aria-live="assertive"
>
<Svg
src={ErrorOutline}
width={16}
className="af-form__select-error-icon"
/>
{errorLabel}
</div>
)}
</>
);
};
const Select = forwardRef<
SelectInstance<unknown, boolean, GroupBase<unknown>>,
Props
>(
(
{
id,
required,
disabled,
label,
errorLabel,
noOptionsMessage,
...otherProps
},
inputRef,
) => {
const idError = useId();
let inputId = useId();
inputId = id || inputId;

return (
<>
{label && (
<label htmlFor={inputId} className="af-form__select-label">
{label}
{required && <span aria-hidden="true"> *</span>}
</label>
)}
<ReactSelect
id={inputId}
aria-errormessage={idError}
aria-invalid={Boolean(errorLabel)}
ariaLiveMessages={defaultAriaLiveMessages}
screenReaderStatus={({ count }: { count: number }) =>
`${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<unknown, boolean, GroupBase<unknown>>) =>
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<unknown, boolean, GroupBase<unknown>>) =>
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<unknown, boolean, GroupBase<unknown>>) =>
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 && <InputError id={idError} message={errorLabel} />}
</>
);
},
);

Select.displayName = "Select";

Expand Down
Original file line number Diff line number Diff line change
@@ -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: <Option, IsMulti extends boolean>(
props: AriaOnChangeProps<Option, IsMulti>,
) => {
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: <Option, Group extends GroupBase<Option>>(
props: AriaOnFocusProps<Option, Group>,
) => {
const {
context,
focused,
options,
label = "",
selectValue,
isDisabled,
isSelected,
isAppleDevice,
} = props;

const getArrayIndex = (
arr: OptionsOrGroups<Option, Group>,
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}` : ""}.`;
},
};

0 comments on commit 98f00b7

Please sign in to comment.