diff --git a/pro/src/ui-kit/MultiSelect/MultiSelect.module.scss b/pro/src/ui-kit/MultiSelect/MultiSelect.module.scss new file mode 100644 index 00000000000..72908199f50 --- /dev/null +++ b/pro/src/ui-kit/MultiSelect/MultiSelect.module.scss @@ -0,0 +1,149 @@ +@use "styles/mixins/_fonts.scss" as fonts; +@use "styles/mixins/fonts-design-system.scss" as fonts-design-system; +@use "styles/mixins/_rem.scss" as rem; +@use "styles/mixins/_a11y.scss" as a11y; +@use "styles/mixins/_size.scss" as size; + +.container { + display: flex; + flex-direction: column; + width: 100%; + max-width: 400px; + position: relative; +} + +.legend { + // @include fonts-design-system.body; + @include fonts.body; + + color: var(--color-black); + margin-bottom: 8px; +} + +.trigger { + @include fonts.body; + + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + background-color: var(--color-white); + border: 1px solid var(--color-grey-dark); + border-radius: 10px; + width: 100%; + cursor: pointer; + + &:disabled { + background-color: var(--color-grey-light); + border: none; + color: var(--color-black); + } + + &:hover { + background: var(--color-grey-light); + } + + &:focus { + border: 1px solid var(--color-black); + } + + &:focus-visible { + outline: rem.torem(1px) solid var(--color-input-text-color); + outline-offset: rem.torem(2px); + border-radius: rem.torem(8px); + border: 1px solid var(--color-grey-dark); + } + + &-selected { + border: 2px solid var(--color-black); + } +} + +.trigger-content { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + max-width: 500px; +} + +.trigger-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.badge { + // @include fonts-design-system.body-semi-bold-xs; + @include fonts.caption; + + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + background-color: var(--color-primary); + color: var(--color-white); + border-radius: 50%; +} + +.chevron { + width: 16px; + height: 16px; + color: var(--color-black); +} + +.chevron-open { + transform: rotate(180deg); +} + +.item { + display: block; + width: 100%; + text-align: left; + font-size: 14px; + background: none; + border: none; + cursor: pointer; +} + +.checkbox { + padding-top: rem.torem(16px); +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.panel { + position: absolute; + background-color: var(--color-white); + left: 0; + right: 0; + top: rem.torem(48px); + box-shadow: 0 rem.torem(3px) rem.torem(4px) var(--color-medium-shadow); + padding: rem.torem(24px); +} + +.search-example { + // @include fonts-design-system.body-semi-bold-xs; + @include fonts.caption; + + display: block; + min-height: rem.torem(16px); + color: var(--color-grey-dark); + padding-top: 8px; + padding-bottom: 24px; +} + +.visually-hidden { + @include a11y.visually-hidden; +} + +.separator { + height: rem.torem(1px); + background: var(--color-grey-medium); + margin-top: rem.torem(12px); +} diff --git a/pro/src/ui-kit/MultiSelect/MultiSelect.spec.tsx b/pro/src/ui-kit/MultiSelect/MultiSelect.spec.tsx new file mode 100644 index 00000000000..ffc4c57bf9c --- /dev/null +++ b/pro/src/ui-kit/MultiSelect/MultiSelect.spec.tsx @@ -0,0 +1,29 @@ +import { screen } from '@testing-library/react' +import { axe } from 'vitest-axe' + +import { + renderWithProviders, + RenderWithProvidersOptions, +} from 'commons/utils/renderWithProviders' + +import { MultiSelect } from './MultiSelect' + +const renderMultiSelect = (options?: RenderWithProvidersOptions) => { + return renderWithProviders(<MultiSelect />, { ...options }) +} + +describe('<MultiSelect />', () => { + it('should render correctly', async () => { + renderMultiSelect() + + expect( + await screen.findByRole('heading', { name: /MultiSelect/ }) + ).toBeInTheDocument() + }) + + it('should not have accessibility violations', async () => { + const { container } = renderMultiSelect() + + expect(await axe(container)).toHaveNoViolations() + }) +}) diff --git a/pro/src/ui-kit/MultiSelect/MultiSelect.stories.tsx b/pro/src/ui-kit/MultiSelect/MultiSelect.stories.tsx new file mode 100644 index 00000000000..0040eefe920 --- /dev/null +++ b/pro/src/ui-kit/MultiSelect/MultiSelect.stories.tsx @@ -0,0 +1,59 @@ +import { StoryObj } from '@storybook/react' +import { withRouter } from 'storybook-addon-remix-react-router' + +import { MultiSelect } from './MultiSelect' + +export default { + title: 'ui-kit/MultiSelect', + decorators: [withRouter], + component: MultiSelect, + parameters: { + docs: { + story: { + inline: false, + iframeHeight: 380, + }, + }, + }, +} + +const defaultOptions = [ + { id: '1', label: '78 - Yvelines' }, + { id: '2', label: '75 - Paris' }, + { id: '3', label: '44 - Nantes' }, + { id: '4', label: '76 - Rouen' }, + { id: '5', label: '77 - Seine et Marne' }, +] + +const defaultProps = { + options: defaultOptions, + legend: 'Département', + label: 'Selectionner un département', +} + +export const Default: StoryObj<typeof MultiSelect> = { + args: { + ...defaultProps, + }, +} + +export const WithDefaultOptions: StoryObj<typeof MultiSelect> = { + args: { + ...defaultProps, + defaultOptions: [ + { id: '2', label: '75 - Paris' }, + { id: '3', label: '44 - Nantes' }, + ], + }, +} + +export const WithSearchInput: StoryObj<typeof MultiSelect> = { + args: { + ...defaultProps, + hasSearch: true, + searchExample: 'Ex : 44 - Nantes', + searchLabel: 'Rechercher des départements', + legend: 'Départements', + label: 'Selectionner des départements', + }, +} diff --git a/pro/src/ui-kit/MultiSelect/MultiSelect.tsx b/pro/src/ui-kit/MultiSelect/MultiSelect.tsx new file mode 100644 index 00000000000..d69d4e692bf --- /dev/null +++ b/pro/src/ui-kit/MultiSelect/MultiSelect.tsx @@ -0,0 +1,190 @@ +import { useEffect, useRef, useState } from 'react' + +import { SelectedValuesTags } from 'ui-kit/form/SelectAutoComplete/SelectedValuesTags/SelectedValuesTags' + +import styles from './MultiSelect.module.scss' +import { MultiSelectPanel } from './MultiSelectPanel' +import { MultiSelectTrigger } from './MultiSelectTrigger' + +export type Option = { + id: string + label: string +} + +type MultiSelectProps = { + options: Option[] + defaultOptions?: Option[] + label: string + legend: string + hasSearch?: boolean + searchExample?: string + searchLabel?: string + hasSelectAllOptions?: boolean + disabled?: boolean +} + +export const MultiSelect = ({ + options, + defaultOptions, + hasSearch, + searchExample, + searchLabel, + label, + legend, + hasSelectAllOptions, + disabled, +}: MultiSelectProps): JSX.Element => { + const [isOpen, setIsOpen] = useState(false) + const containerRef = useRef<HTMLFieldSetElement>(null) + const [selectedItems, setSelectedItems] = useState<Option[]>( + defaultOptions ?? [] + ) + + const handleSelectOrRemoveItem = (item: Option | 'all' | undefined) => { + if (item === 'all') { + setSelectedItems(options) + } else if (item === undefined) { + setSelectedItems([]) + } else { + setSelectedItems((prev) => + prev.some((prevItem) => prevItem.id === item.id) + ? prev.filter((prevItem) => prevItem.id !== item.id) + : [...prev, item] + ) + } + } + + const handleRemoveItem = (itemId: string) => { + setSelectedItems((prev) => prev.filter((item) => item.id !== itemId)) + } + + const toggleDropdown = () => setIsOpen(!isOpen) + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + toggleDropdown() + } + } + + useEffect(() => { + const handleWindowClick = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false) + } + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false) + } + } + + window.addEventListener('click', handleWindowClick) + window.addEventListener('keydown', handleKeyDown) + + return () => { + window.removeEventListener('click', handleWindowClick) + window.removeEventListener('keydown', handleKeyDown) + } + }, []) + + return ( + <fieldset + className={styles['container']} + style={{ position: 'relative' }} + ref={containerRef} + > + <MultiSelectTrigger + legend={legend} + label={label} + isOpen={isOpen} + toggleDropdown={toggleDropdown} + handleKeyDown={handleKeyDown} + selectedCount={selectedItems.length} + disabled={disabled} + /> + + {isOpen && ( + <MultiSelectPanel + label={label} + options={options.map((option) => ({ + ...option, + checked: selectedItems.some((item) => item.id === option.id), + }))} + onOptionSelect={handleSelectOrRemoveItem} + hasSearch={hasSearch} + searchExample={searchExample} + searchLabel={searchLabel} + hasSelectAllOptions={hasSelectAllOptions} + /> + )} + + <SelectedValuesTags + disabled={disabled} + selectedOptions={selectedItems.map((item) => item.id)} + removeOption={handleRemoveItem} + fieldName="tags" + optionsLabelById={selectedItems.reduce( + (acc, item) => ({ ...acc, [item.id]: item.label }), + {} + )} + /> + </fieldset> + ) +} + +{ + /* <fieldset> + <legend><button aria-controls="control-id" aria-expanded=...>Label du bouton</button></legend> + <div id="control-id"> + <label class="visually-hidden" for="id-input">Rechercher des ...</label> + <svg> // icon visuelle + <input type="search" id="id-input"/> + <ul> + <li><input /> <label>...</label></li> + <li></li> + <li></li> + </ul> + </div> +</fieldset> +<div + role="listbox" + tabindex="0" + id="listbox1" + onclick="return listItemClick(event);" + onkeydown="return listItemKeyEvent(event);" + onkeypress="return listItemKeyEvent(event);" + onfocus="this.className='focus';" + onblur="this.className='blur';" + aria-activedescendant="listbox1-1"> + <div role="option" id="listbox1-1" class="selected">Vert</div> + <div role="option" id="listbox1-2">Orange</div> + <div role="option" id="listbox1-3">Rouge</div> + <div role="option" id="listbox1-4">Bleu</div> + <div role="option" id="listbox1-5">Violet</div> + <div role="option" id="listbox1-6">Pervenche</div> +</div> +*/ +} + +{ + /* <div className={styles.container} role="listbox" aria-label="Liste des départements"> + {departments.map(department => ( + <label key={department.id} className={styles.item}> + <div className={styles.checkbox}> + <input + type="checkbox" + checked={department.checked} + onChange={() => handleCheckboxChange(department.id)} + className={styles.input} + /> + <span className={styles.checkmark} aria-hidden="true" /> + </div> + <span className={styles.label}>{department.name}</span> + </label> + ))} + </div> */ +} diff --git a/pro/src/ui-kit/MultiSelect/MultiSelectPanel.tsx b/pro/src/ui-kit/MultiSelect/MultiSelectPanel.tsx new file mode 100644 index 00000000000..b2558bf13f0 --- /dev/null +++ b/pro/src/ui-kit/MultiSelect/MultiSelectPanel.tsx @@ -0,0 +1,101 @@ +import { useMemo, useState } from 'react' + +import strokeSearch from 'icons/stroke-search.svg' +import { BaseCheckbox } from 'ui-kit/form/shared/BaseCheckbox/BaseCheckbox' +import { BaseInput } from 'ui-kit/form/shared/BaseInput/BaseInput' + +import { Option } from './MultiSelect' +import styles from './MultiSelect.module.scss' + +type MultiSelectPanelProps = { + className?: string + label: string + options: (Option & { checked: boolean })[] + onOptionSelect: (option: Option | 'all' | undefined) => void + hasSearch?: boolean + searchExample?: string + searchLabel?: string + hasSelectAllOptions?: boolean +} + +export const MultiSelectPanel = ({ + options, + onOptionSelect, + hasSearch = false, + searchExample, + searchLabel, + hasSelectAllOptions, +}: MultiSelectPanelProps): JSX.Element => { + const [searchValue, setSearchValue] = useState('') + const [isSelectAllChecked, setIsSelectAllChecked] = useState(false) + const searchedValues = useMemo( + () => + options.filter((option) => + option.label.toLowerCase().includes(searchValue.toLowerCase()) + ), + [options, searchValue] + ) + + const onToggleAllOptions = (checked: boolean) => { + if (checked) { + onOptionSelect(undefined) + } else { + onOptionSelect('all') + } + setIsSelectAllChecked(!checked) + } + + const onToggleOption = (option: Option, checked: boolean) => { + if (checked) { + setIsSelectAllChecked(false) + } + onOptionSelect(option) + } + + return ( + <div className={styles['panel']}> + {hasSearch && ( + <> + <label className={styles['visually-hidden']} htmlFor="search-input"> + {searchLabel} + </label> + <BaseInput + type="search" + id="search-input" + leftIcon={strokeSearch} + value={searchValue} + onChange={(e) => setSearchValue(e.target.value)} + /> + <span className={styles['search-example']}>{searchExample}</span> + </> + )} + + {searchedValues.length > 0 ? ( + <ul className={styles['container']} aria-label="Liste des options"> + {hasSelectAllOptions && ( + <li key={'all-options'} className={styles['item']}> + <BaseCheckbox + label={'Tout sélectionner'} + checked={isSelectAllChecked} + onChange={() => onToggleAllOptions(isSelectAllChecked)} + /> + <div className={styles['separator']} /> + </li> + )} + {searchedValues.map((option) => ( + <li key={option.id} className={styles.item}> + <BaseCheckbox + className={styles['checkbox']} + label={option.label} + checked={option.checked} + onChange={() => onToggleOption(option, option.checked)} + /> + </li> + ))} + </ul> + ) : ( + <span>{'Aucun résultat trouvé pour votre recherche.'}</span> + )} + </div> + ) +} diff --git a/pro/src/ui-kit/MultiSelect/MultiSelectTrigger.tsx b/pro/src/ui-kit/MultiSelect/MultiSelectTrigger.tsx new file mode 100644 index 00000000000..2b17e7275b8 --- /dev/null +++ b/pro/src/ui-kit/MultiSelect/MultiSelectTrigger.tsx @@ -0,0 +1,61 @@ +import cn from 'classnames' +import { useId } from 'react' + +import fullDownIcon from 'icons/full-down.svg' +import fullUpIcon from 'icons/full-up.svg' +import { SvgIcon } from 'ui-kit/SvgIcon/SvgIcon' + +import styles from './MultiSelect.module.scss' + +type MultiSelectTriggerProps = { + isOpen: boolean + selectedCount: number + toggleDropdown: () => void + handleKeyDown: (event: React.KeyboardEvent) => void + legend: string + label: string + disabled?: boolean +} + +export const MultiSelectTrigger = ({ + isOpen, + selectedCount, + toggleDropdown, + handleKeyDown, + legend, + label, + disabled, +}: MultiSelectTriggerProps): JSX.Element => { + const legendId = useId() + return ( + <> + <legend id={legendId} className={styles['legend']}> + {legend} + </legend> + <button + type="button" + className={cn(styles['trigger'], { + [styles['trigger-selected']]: selectedCount > 0, + })} + onClick={toggleDropdown} + onKeyDown={handleKeyDown} + aria-haspopup="listbox" + aria-expanded={isOpen} + aria-labelledby={legendId} + disabled={disabled} + > + <div className={styles['trigger-content']}> + {selectedCount > 0 && ( + <div className={styles['badge']}>{selectedCount}</div> + )} + <span className={styles['trigger-label']}>{label}</span> + </div> + <SvgIcon + className={`${styles['chevron']} ${isOpen ? styles['chevronOpen'] : ''}`} + alt="" + src={isOpen ? fullUpIcon : fullDownIcon} + /> + </button> + </> + ) +} diff --git a/pro/src/ui-kit/MultiSelect/TODO.md b/pro/src/ui-kit/MultiSelect/TODO.md new file mode 100644 index 00000000000..8d5e7655cba --- /dev/null +++ b/pro/src/ui-kit/MultiSelect/TODO.md @@ -0,0 +1,18 @@ +Fonctionnel + +- [] gérer la navigation clavier +- [] jsdoc +- [] ne pas afficher plus de 3 lignes de tags + +Design + +- [] corriger la zone de clic sur BaseCheckbox +- [] corriger le curseur de BaseCheckbox +- [] ajuster la hauteur des checkbox +- [] nettoyage du design (marges, variant violet du tag, variant violet du badge, stroke, fichier scss) +- [] utiliser les fonts du design system +- [] gérer le responsive + +Bonus + +- [] typage des props hasSearch et searchExample diff --git a/pro/src/ui-kit/form/shared/BaseCheckbox/BaseCheckbox.module.scss b/pro/src/ui-kit/form/shared/BaseCheckbox/BaseCheckbox.module.scss index 0ba32fb7202..dd33c9e69e9 100644 --- a/pro/src/ui-kit/form/shared/BaseCheckbox/BaseCheckbox.module.scss +++ b/pro/src/ui-kit/form/shared/BaseCheckbox/BaseCheckbox.module.scss @@ -7,10 +7,14 @@ .base-checkbox { display: inline-flex; cursor: pointer; + height: 100%; + width: 100%; &-label-row { display: inline-flex; align-items: flex-start; + height: 100%; + width: 100%; &-with-description { align-items: center; @@ -20,6 +24,7 @@ &-label { display: flex; flex-direction: column; + height: 100%; width: 100%; &-with-description {