Skip to content

Commit

Permalink
feat(UI): passer le select en composition (#3204)
Browse files Browse the repository at this point in the history
  • Loading branch information
juliebrunetto83 authored Jul 26, 2024
1 parent 73c8b55 commit c575388
Show file tree
Hide file tree
Showing 8 changed files with 164 additions and 100 deletions.
39 changes: 23 additions & 16 deletions src/client/components/ui/Form/Select/Select.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions src/client/components/ui/Form/Select/Select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1673,6 +1674,26 @@ describe('<Select />', () => {
});
});
});

describe('SelectOption', () => {
it('accepte un id et le passe à l‘option', () => {
render(<SelectSimple labelledBy={'id'}>
<SelectSimple.Option value="1" id="id1">option 1</SelectSimple.Option>
<SelectSimple.Option value="2">option 2</SelectSimple.Option>
</SelectSimple>);

expect(screen.getByRole('option', { hidden: true, name:'option 1' })).toHaveAttribute('id', 'id1');
});

it('accepte une value et la passe à l‘option', () => {
render(<SelectSimple labelledBy={'id'}>
<SelectSimple.Option value="1">option 1</SelectSimple.Option>
<SelectSimple.Option value="2">option 2</SelectSimple.Option>
</SelectSimple>);

expect(screen.getByRole('option', { hidden: true, name:'option 1' })).toHaveAttribute('data-value', '1');
});
});
});

function getAllFormData(event: FormEvent<HTMLFormElement>, name: string) {
Expand Down
16 changes: 14 additions & 2 deletions src/client/components/ui/Form/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface OptionSelect {
type SelectProps = {
label: string;
labelComplement?: string
optionList: OptionSelect[]
} & (
SelectSimpleProps & { multiple?: false }
| SelectMultipleProps & { multiple: true }
Expand All @@ -27,6 +28,7 @@ export function Select(props: SelectProps) {
label,
labelComplement,
multiple,
optionList,
...rest
} = props;
const labelledBy = useId();
Expand All @@ -46,8 +48,18 @@ export function Select(props: SelectProps) {
{label}
{labelComplement && <Champ.Label.Complement>{labelComplement}</Champ.Label.Complement>}
</Champ.Label>
{isSelectSimpleProps(rest) && <Champ.Input render={SelectSimple} labelledBy={labelledBy} {...rest}/>}
{isSelectMultipleProps(rest) && <Champ.Input render={SelectMultiple} labelledBy={labelledBy} {...rest}/>}
{isSelectSimpleProps(rest) && <Champ.Input render={SelectSimple} labelledBy={labelledBy} {...rest}>
{optionList.map((option) =>
<SelectSimple.Option key={option.libellé} value={option.valeur}>{option.libellé}</SelectSimple.Option>,
)}
</Champ.Input>}

{isSelectMultipleProps(rest) && <Champ.Input render={SelectMultiple} labelledBy={labelledBy} {...rest}>
{optionList.map((option) =>
<SelectMultiple.Option key={option.libellé} value={option.valeur}>{option.libellé}</SelectMultiple.Option>,
)}
</Champ.Input>}

<Champ.Error/>
</Champ>
</div>
Expand Down
22 changes: 22 additions & 0 deletions src/client/components/ui/Form/Select/SelectContext.tsx
Original file line number Diff line number Diff line change
@@ -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<SelectContext | null>(null);

export function useSelectContext() {
const selectContext = useContext(SelectContext);

if (selectContext == null) {
throw new NoProviderError(SelectContext);
}

return selectContext;
}
53 changes: 18 additions & 35 deletions src/client/components/ui/Form/Select/SelectMultiple.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import classNames from 'classnames';
import debounce from 'lodash.debounce';
import React, {
FocusEvent,
Expand All @@ -15,18 +14,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 { 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';
const ERROR_LABEL_REQUIRED_MULTIPLE = 'Séléctionnez au moins un élément de la liste';
const DEFAULT_DEBOUNCE_TIMEOUT = 300;

export type SelectMultipleProps = Omit<React.HTMLProps<HTMLInputElement>, 'onChange'> & {
optionList: OptionSelect[];
value?: Array<string>;
onChange?: (value: HTMLElement) => void;
defaultValue?: Array<string>;
Expand All @@ -35,7 +34,7 @@ export type SelectMultipleProps = Omit<React.HTMLProps<HTMLInputElement>, 'onCha

export function SelectMultiple(props: SelectMultipleProps & { labelledBy: string }) {
const {
optionList,
children,
value,
placeholder,
name,
Expand All @@ -50,7 +49,6 @@ export function SelectMultiple(props: SelectMultipleProps & { labelledBy: string
const listboxRef = useRef<HTMLUListElement>(null);
const firstInputHiddenRef = useRef<HTMLInputElement>(null);

const optionsId = useId();
const listboxId = useId();

const [touched, setTouched] = useState<boolean>(false);
Expand Down Expand Up @@ -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<HTMLLIElement>) {
event.preventDefault();
}, []);

const onBlur = useCallback(function onBlur(event: FocusEvent<HTMLDivElement>) {
const newFocusStillInCombobox = event.currentTarget.contains(event.relatedTarget);
if (newFocusStillInCombobox) {
Expand Down Expand Up @@ -218,30 +211,30 @@ export function SelectMultiple(props: SelectMultipleProps & { labelledBy: string
}

return (
<div>
<SelectContext.Provider value={{
activeDescendant: state.activeDescendant,
isCurrentItemSelected,
onOptionSelection: selectOption,
}}>
<div className={styles.container}>
<Input
ref={firstInputHiddenRef}
onInvalid={onInvalidProps}
tabIndex={-1}
className={styles.inputHiddenValue}
required={required}
aria-hidden="true"
name={name}
value={optionsSelectedValues[0] || ''}/>
{optionsSelectedValues.slice(1).map((optionValue) => {
return <Input
{optionsSelectedValues.slice(1).map((optionValue) => (
<Input
tabIndex={-1}
className={styles.inputHiddenValue}
key={optionValue}
aria-hidden="true"
name={name}
value={optionValue}
/>;
})}
value={optionValue}/>
))}
<div
role="combobox"
className={classNames(styles.combobox)}
aria-controls={listboxId}
aria-haspopup="listbox"
aria-expanded={state.isListOptionsOpen}
Expand All @@ -255,31 +248,19 @@ export function SelectMultiple(props: SelectMultipleProps & { labelledBy: string
{...rest}
>
<PlaceholderSelectedOptions/>
{state.isListOptionsOpen ? <Icon name={'angle-up'}/> : <Icon name={'angle-down'}/>}
<Icon name={'angle-down'}/>
</div>
<ul
aria-multiselectable="true"
role="listbox"
ref={listboxRef}
aria-labelledby={labelledBy}
id={listboxId}
hidden={!state.isListOptionsOpen}>
{optionList.map((option, index) => {
const optionId = `${optionsId}-${index}`;
return <li
className={classNames(styles.optionComboboxMultiple, state.activeDescendant === optionId ? styles.optionVisuallyFocus : '')}
id={optionId}
role="option"
key={index}
onMouseDown={onMouseDown}
data-value={option.valeur}
onClick={() => selectOption(optionId)}
aria-selected={isCurrentItemSelected(option.valeur)}>
<div className={styles.option}>{option.libellé}</div>
</li>;
})}
{children}
</ul>
</div>
</div>
</SelectContext.Provider>
);
}

Expand All @@ -291,3 +272,5 @@ function cancelEvent(event: SyntheticEvent) {
function doNothing() {
return;
}

SelectMultiple.Option = SelectOption;
32 changes: 32 additions & 0 deletions src/client/components/ui/Form/Select/SelectOption.tsx
Original file line number Diff line number Diff line change
@@ -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<React.ComponentPropsWithoutRef<'li'>, '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<HTMLLIElement>) {
event.preventDefault();
}, []);

return <li
className={classNames(className, { [styles.optionVisuallyFocus] : activeDescendant === id })}
id={id}
role="option"
onMouseDown={onMouseDown}
data-value={value.toString()}
onClick={() => onOptionSelection(id)}
aria-selected={isCurrentItemSelected(value)}
{...rest}>
</li>;
}
4 changes: 2 additions & 2 deletions src/client/components/ui/Form/Select/SelectReducer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type SelectSimpleState = {
valueTypedByUser: string
}

function getOptionsElement(refListOption: RefObject<HTMLUListElement>) {
export function getOptionsElement(refListOption: RefObject<HTMLUListElement>) {
return Array.from(refListOption.current?.querySelectorAll('[role="option"]') ?? []);
}

Expand Down Expand Up @@ -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);
}

Expand Down
Loading

0 comments on commit c575388

Please sign in to comment.