From c66bf1af5034387c270b3b2ce5ed152636fa0159 Mon Sep 17 00:00:00 2001 From: ahello-pass <185243577+ahello-pass@users.noreply.github.com> Date: Fri, 20 Dec 2024 16:06:43 +0100 Subject: [PATCH] wip --- .../MultiSelect/MultiSelect.module.scss | 149 ++++++++++++++ .../ui-kit/MultiSelect/MultiSelect.spec.tsx | 29 +++ .../MultiSelect/MultiSelect.stories.tsx | 59 ++++++ pro/src/ui-kit/MultiSelect/MultiSelect.tsx | 191 ++++++++++++++++++ .../ui-kit/MultiSelect/MultiSelectPanel.tsx | 101 +++++++++ .../ui-kit/MultiSelect/MultiSelectTrigger.tsx | 61 ++++++ pro/src/ui-kit/MultiSelect/TODO.md | 17 ++ .../BaseCheckbox/BaseCheckbox.module.scss | 1 + 8 files changed, 608 insertions(+) create mode 100644 pro/src/ui-kit/MultiSelect/MultiSelect.module.scss create mode 100644 pro/src/ui-kit/MultiSelect/MultiSelect.spec.tsx create mode 100644 pro/src/ui-kit/MultiSelect/MultiSelect.stories.tsx create mode 100644 pro/src/ui-kit/MultiSelect/MultiSelect.tsx create mode 100644 pro/src/ui-kit/MultiSelect/MultiSelectPanel.tsx create mode 100644 pro/src/ui-kit/MultiSelect/MultiSelectTrigger.tsx create mode 100644 pro/src/ui-kit/MultiSelect/TODO.md 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(, { ...options }) +} + +describe('', () => { + 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 = { + args: { + ...defaultProps, + }, +} + +export const WithDefaultOptions: StoryObj = { + args: { + ...defaultProps, + defaultOptions: [ + { id: '2', label: '75 - Paris' }, + { id: '3', label: '44 - Nantes' }, + ], + }, +} + +export const WithSearchInput: StoryObj = { + 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..331178b987b --- /dev/null +++ b/pro/src/ui-kit/MultiSelect/MultiSelect.tsx @@ -0,0 +1,191 @@ +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(null) + const [selectedItems, setSelectedItems] = useState( + 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) => { + event.preventDefault() + 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 ( +
+ + + {isOpen && ( + ({ + ...option, + checked: selectedItems.some((item) => item.id === option.id), + }))} + onOptionSelect={handleSelectOrRemoveItem} + hasSearch={hasSearch} + searchExample={searchExample} + searchLabel={searchLabel} + hasSelectAllOptions={hasSelectAllOptions} + /> + )} + + item.id)} + removeOption={handleRemoveItem} + fieldName="tags" + optionsLabelById={selectedItems.reduce( + (acc, item) => ({ ...acc, [item.id]: item.label }), + {} + )} + /> +
+ ) +} + +{ + /*
+ +
+ + // icon visuelle + +
    +
  • +
  • +
  • +
+
+
+
+
Vert
+
Orange
+
Rouge
+
Bleu
+
Violet
+
Pervenche
+
+*/ +} + +{ + /*
+ {departments.map(department => ( + + ))} +
*/ +} 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 ( +
+ {hasSearch && ( + <> + + setSearchValue(e.target.value)} + /> + {searchExample} + + )} + + {searchedValues.length > 0 ? ( +
    + {hasSelectAllOptions && ( +
  • + onToggleAllOptions(isSelectAllChecked)} + /> +
    +
  • + )} + {searchedValues.map((option) => ( +
  • + onToggleOption(option, option.checked)} + /> +
  • + ))} +
+ ) : ( + {'Aucun résultat trouvé pour votre recherche.'} + )} +
+ ) +} 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} + + + + ) +} diff --git a/pro/src/ui-kit/MultiSelect/TODO.md b/pro/src/ui-kit/MultiSelect/TODO.md new file mode 100644 index 00000000000..42d132cd1ab --- /dev/null +++ b/pro/src/ui-kit/MultiSelect/TODO.md @@ -0,0 +1,17 @@ +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 +- [] 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 1ff51b640f3..5221244c818 100644 --- a/pro/src/ui-kit/form/shared/BaseCheckbox/BaseCheckbox.module.scss +++ b/pro/src/ui-kit/form/shared/BaseCheckbox/BaseCheckbox.module.scss @@ -18,6 +18,7 @@ &-label { display: inline-flex; flex-direction: column; + height: 100%; width: 100%; justify-items: center; }