From 5f1da86813da2596ecb6483c673fe4e3e1011207 Mon Sep 17 00:00:00 2001 From: Max S Date: Mon, 29 Jul 2024 11:48:55 +0200 Subject: [PATCH] Autocomplete Multi Select (chip input) --- .../DesignSystem/Autocomplete/index.tsx | 31 ++++--- .../Chip/classes.module.scss | 20 +++++ .../AutocompleteMultiSelect/Chip/index.tsx | 26 ++++++ .../ChipContainer/classes.module.scss | 73 +++++++++++++++ .../ChipContainer/index.tsx | 90 +++++++++++++++++++ .../classes.module.scss | 7 ++ .../AutocompleteMultiSelect/index.tsx | 88 ++++++++++++++++++ .../Dropdown/DropdownMenu/index.tsx | 35 ++++++-- .../DesignSystem/Dropdown/index.tsx | 12 ++- .../IconButton/classes.module.scss | 4 + .../DesignSystem/SearchBar/index.tsx | 32 +++++-- .../DesignSystem/Typography/index.tsx | 2 + .../Components/Layouts/DesignSystem/index.tsx | 37 +++++--- 13 files changed, 415 insertions(+), 42 deletions(-) create mode 100644 src/front/Components/DesignSystem/AutocompleteMultiSelect/Chip/classes.module.scss create mode 100644 src/front/Components/DesignSystem/AutocompleteMultiSelect/Chip/index.tsx create mode 100644 src/front/Components/DesignSystem/AutocompleteMultiSelect/ChipContainer/classes.module.scss create mode 100644 src/front/Components/DesignSystem/AutocompleteMultiSelect/ChipContainer/index.tsx create mode 100644 src/front/Components/DesignSystem/AutocompleteMultiSelect/classes.module.scss create mode 100644 src/front/Components/DesignSystem/AutocompleteMultiSelect/index.tsx diff --git a/src/front/Components/DesignSystem/Autocomplete/index.tsx b/src/front/Components/DesignSystem/Autocomplete/index.tsx index 8ecfc3db..7b081c11 100644 --- a/src/front/Components/DesignSystem/Autocomplete/index.tsx +++ b/src/front/Components/DesignSystem/Autocomplete/index.tsx @@ -6,6 +6,7 @@ import { IOption } from "../Dropdown/DropdownMenu/DropdownOption"; import SearchBar from "../SearchBar"; import Typography, { ETypo, ETypoColor } from "../Typography"; import classes from "./classes.module.scss"; +import { getLabel } from "../Dropdown"; type IProps = { options: IOption[]; @@ -51,24 +52,21 @@ export default function Autocomplete(props: IProps) { }, [selectedOptionProps]); const handleSelectOption = useCallback( - (option: IOption) => { - setSelectedOption(option); - setSearchValue(getLabel(option) || ""); + (newOption: IOption, _options: IOption[]) => { + setSelectedOption(newOption); + setSearchValue(getLabel(newOption) || ""); openable.close(); }, [openable], ); - function getLabel(option: IOption | null): string | null { - if (!option) return null; - if (typeof option.label === "string") { - return option.label; - } - return `${option.label.text} ${option.label.subtext}`; - } - return ( - +
{label && ( @@ -76,7 +74,14 @@ export default function Autocomplete(props: IProps) { )}
- + setSelectedOption(null)} + onFocus={openable.open} + />
); } diff --git a/src/front/Components/DesignSystem/AutocompleteMultiSelect/Chip/classes.module.scss b/src/front/Components/DesignSystem/AutocompleteMultiSelect/Chip/classes.module.scss new file mode 100644 index 00000000..9ebc273e --- /dev/null +++ b/src/front/Components/DesignSystem/AutocompleteMultiSelect/Chip/classes.module.scss @@ -0,0 +1,20 @@ +@import "@Themes/constants.scss"; + +.root { + width: fit-content; + height: 32px; + + display: inline-flex; + padding: 4px 12px; + align-items: center; + gap: 8px; + + border-radius: var(--input-chip-radius, 360px); + border: 1px solid var(--input-chip-default-border, #b7d1f1); + background: var(--input-chip-default-background, #e5eefa); + + &:hover { + background-color: var(--input-chip-hovered-background); + border-color: var(--input-chip-hovered-border); + } +} diff --git a/src/front/Components/DesignSystem/AutocompleteMultiSelect/Chip/index.tsx b/src/front/Components/DesignSystem/AutocompleteMultiSelect/Chip/index.tsx new file mode 100644 index 00000000..2bcfa230 --- /dev/null +++ b/src/front/Components/DesignSystem/AutocompleteMultiSelect/Chip/index.tsx @@ -0,0 +1,26 @@ +import { XMarkIcon } from "@heroicons/react/24/outline"; +import classNames from "classnames"; +import React from "react"; + +import IconButton from "../../IconButton"; +import Typography, { ETypo, ETypoColor } from "../../Typography"; +import classes from "./classes.module.scss"; + +type IProps = { + text: string; + className?: string; + onDelete?: () => void; +}; + +export default function Chip(props: IProps) { + const { className, text, onDelete } = props; + + return ( +
+ + {text} + + } onClick={onDelete} /> +
+ ); +} diff --git a/src/front/Components/DesignSystem/AutocompleteMultiSelect/ChipContainer/classes.module.scss b/src/front/Components/DesignSystem/AutocompleteMultiSelect/ChipContainer/classes.module.scss new file mode 100644 index 00000000..fa4937cd --- /dev/null +++ b/src/front/Components/DesignSystem/AutocompleteMultiSelect/ChipContainer/classes.module.scss @@ -0,0 +1,73 @@ +@import "@Themes/constants.scss"; + +.root { + border-radius: var(--input-radius, 0px); + border: 1px solid var(--input-main-border-filled, #6d7e8a); + background: var(--input-background, #fff); + + svg { + stroke: var(--button-icon-button-default-default); + } + + &:hover { + border-radius: var(--input-radius, 0px); + border: 1px solid var(--input-main-border-hovered, #b4bec5); + background: var(--input-background, #fff); + } + + &[data-has-value="true"] { + border-radius: var(--input-radius, 0px); + border: 1px solid var(--input-main-border-filled, #6d7e8a); + background: var(--input-background, #fff); + } + + &[data-is-focused="true"] { + border-radius: var(--input-radius, 0px); + border: 1px solid var(--input-main-border-focused, #005bcb); + background: var(--input-background, #fff); + } + + &[data-is-disabled="true"] { + opacity: var(--opacity-disabled, 0.3); + pointer-events: none; + } + + .content { + display: flex; + align-items: center; + align-content: center; + gap: 16px var(--spacing-2, 16px); + flex-wrap: wrap; + + min-height: 56px; + + padding: var(--spacing-1-5, 12px) var(--spacing-sm, 8px); + + .input { + flex: 1; + border: none; + + color: var(--input-placeholder-filled, #24282e); + + /* text/md/semibold */ + font-family: var(--font-text-family, Poppins); + font-size: 16px; + font-style: normal; + font-weight: var(--font-text-weight-semibold, 600); + line-height: normal; + letter-spacing: 0.08px; + width: 100%; + + &::placeholder { + color: var(--input-placeholder-empty, #6d7e8a); + /* text/md/regular */ + font-family: var(--font-text-family, Poppins); + font-size: 16px; + font-style: normal; + font-weight: var(--font-text-weight-regular, 400); + line-height: normal; + letter-spacing: 0.08px; + } + } + } +} diff --git a/src/front/Components/DesignSystem/AutocompleteMultiSelect/ChipContainer/index.tsx b/src/front/Components/DesignSystem/AutocompleteMultiSelect/ChipContainer/index.tsx new file mode 100644 index 00000000..5e926fdc --- /dev/null +++ b/src/front/Components/DesignSystem/AutocompleteMultiSelect/ChipContainer/index.tsx @@ -0,0 +1,90 @@ +import React, { useCallback, useEffect } from "react"; + +import { getLabel } from "../../Dropdown"; +import { IOption } from "../../Dropdown/DropdownMenu/DropdownOption"; +import Chip from "../Chip"; +import classes from "./classes.module.scss"; + +type IProps = { + selectedOptions: IOption[]; + onSelectedOptionsChange: (options: IOption[]) => void; + onChange?: (input: string) => void; + value?: string; + placeholder?: string; + disabled?: boolean; + onClear?: () => void; + onFocus?: () => void; + onBlur?: () => void; +}; + +export default function ChipContainer(props: IProps) { + const { + selectedOptions, + onChange, + value: propValue, + placeholder = "Rechercher", + disabled = false, + onFocus, + onBlur, + onSelectedOptionsChange, + } = props; + + const [isFocused, setIsFocused] = React.useState(false); + const [value, setValue] = React.useState(propValue || ""); + + const changeValue = useCallback( + (value: string) => { + setValue(value); + onChange && onChange(value); + }, + [onChange], + ); + + const handleOnChange = useCallback((event: React.ChangeEvent) => changeValue(event.target.value), [changeValue]); + + const handleFocus = useCallback(() => { + setIsFocused(true); + onFocus?.(); + }, [onFocus]); + + const handleBlur = useCallback( + (e: React.FocusEvent) => { + setIsFocused(false); + onBlur?.(); + }, + [onBlur], + ); + + const onChipDelete = useCallback( + (option: IOption) => { + const newSelectedOptions = selectedOptions.filter((selectedOption) => selectedOption.id !== option.id); + onSelectedOptionsChange && onSelectedOptionsChange(newSelectedOptions); + }, + [selectedOptions, onSelectedOptionsChange], + ); + + useEffect(() => { + if (propValue !== undefined) { + setValue(propValue); + } + }, [propValue]); + + return ( +
+
+ {selectedOptions.map((option) => ( + onChipDelete(option)} /> + ))} + +
+
+ ); +} diff --git a/src/front/Components/DesignSystem/AutocompleteMultiSelect/classes.module.scss b/src/front/Components/DesignSystem/AutocompleteMultiSelect/classes.module.scss new file mode 100644 index 00000000..0a331a1d --- /dev/null +++ b/src/front/Components/DesignSystem/AutocompleteMultiSelect/classes.module.scss @@ -0,0 +1,7 @@ +@import "@Themes/constants.scss"; + +.root { + .label { + padding: 0px var(--spacing-2, 16px); + } +} diff --git a/src/front/Components/DesignSystem/AutocompleteMultiSelect/index.tsx b/src/front/Components/DesignSystem/AutocompleteMultiSelect/index.tsx new file mode 100644 index 00000000..36182827 --- /dev/null +++ b/src/front/Components/DesignSystem/AutocompleteMultiSelect/index.tsx @@ -0,0 +1,88 @@ +import useOpenable from "@Front/Hooks/useOpenable"; +import { useCallback, useEffect, useState } from "react"; + +import DropdownMenu from "../Dropdown/DropdownMenu"; +import { IOption } from "../Dropdown/DropdownMenu/DropdownOption"; +import Typography, { ETypo, ETypoColor } from "../Typography"; +import classes from "./classes.module.scss"; +import ChipContainer from "./ChipContainer"; +import { getLabel } from "../Dropdown"; + +type IProps = { + options: IOption[]; + placeholder?: string; + disabled?: boolean; + label?: string; + onSelect?: (option: IOption) => void; + selectedOptions?: IOption[] | null; +}; + +export default function Autocomplete(props: IProps) { + const { options, placeholder, disabled, label, selectedOptions: selectedOptionsProps } = props; + const [selectedOptions, setSelectedOptions] = useState(selectedOptionsProps ?? null); + const [searchValue, setSearchValue] = useState(""); + const [filteredOptions, setFilteredOptions] = useState(options); + const openable = useOpenable({ defaultOpen: false }); + + useEffect(() => { + if (searchValue) { + const filteredOptions = options.filter((option) => getLabel(option)?.toLowerCase().includes(searchValue.toLowerCase())); + console.log(filteredOptions); + if (filteredOptions.length === 0) + return setFilteredOptions([{ id: "no-results", label: "Aucun résulats", notSelectable: true }]); + return setFilteredOptions(filteredOptions); + } + return setFilteredOptions(options); + }, [searchValue, options]); + + const handleSearchChange = useCallback( + (value: string) => { + setSearchValue(value); + if (value) { + openable.open(); + } else { + openable.close(); + } + }, + [openable], + ); + + useEffect(() => { + setSelectedOptions(selectedOptionsProps ?? null); + }, [selectedOptionsProps]); + + const handleSelectOption = useCallback( + (_newOption: IOption, options: IOption[]) => { + setSelectedOptions(options); + setSearchValue(""); + openable.close(); + }, + [openable], + ); + + return ( + +
+ {label && ( + + {label} + + )} +
+ setSelectedOptions(null)} + onFocus={openable.open} + selectedOptions={selectedOptions ?? []} + onSelectedOptionsChange={setSelectedOptions} + /> +
+ ); +} diff --git a/src/front/Components/DesignSystem/Dropdown/DropdownMenu/index.tsx b/src/front/Components/DesignSystem/Dropdown/DropdownMenu/index.tsx index c19f6982..5ad68d7b 100644 --- a/src/front/Components/DesignSystem/Dropdown/DropdownMenu/index.tsx +++ b/src/front/Components/DesignSystem/Dropdown/DropdownMenu/index.tsx @@ -1,12 +1,12 @@ import classNames from "classnames"; -import React, { useCallback } from "react"; +import React, { useCallback, useEffect, useRef } from "react"; import classes from "./classes.module.scss"; import DropdownOption, { IOption } from "./DropdownOption"; type IProps = { options: IOption[]; - selectedOption: IOption | null; + selectedOptions: IOption[]; children: React.ReactNode; openable: { isOpen: boolean; @@ -14,21 +14,40 @@ type IProps = { close: () => void; toggle: () => void; }; - onSelect?: (option: IOption) => void; + onSelect?: (newOption: IOption, options: IOption[]) => void; }; export default function DropdownMenu(props: IProps) { - const { children, options, onSelect, openable, selectedOption } = props; + const { children, options, onSelect, openable, selectedOptions } = props; + const ref = useRef(null); const handleSelect = useCallback( (option: IOption) => { - onSelect?.(option); + const newOptions = selectedOptions.some((selectedOption) => selectedOption.id === option.id) + ? selectedOptions + : [...selectedOptions, option]; + + onSelect?.(option, newOptions); openable.close(); }, - [onSelect, openable], + [onSelect, openable, selectedOptions], ); + const handleClickOutside = useCallback( + (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + openable.close(); + } + }, + [openable], + ); + + useEffect(() => { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [handleClickOutside]); + return ( -
+
{children}
{options.map((option) => { @@ -39,6 +58,6 @@ export default function DropdownMenu(props: IProps) { ); function isActive(option: IOption): boolean { - return selectedOption?.id === option.id; + return selectedOptions.some((selectedOption) => selectedOption.id === option.id); } } diff --git a/src/front/Components/DesignSystem/Dropdown/index.tsx b/src/front/Components/DesignSystem/Dropdown/index.tsx index a3875994..b00cacea 100644 --- a/src/front/Components/DesignSystem/Dropdown/index.tsx +++ b/src/front/Components/DesignSystem/Dropdown/index.tsx @@ -27,15 +27,19 @@ export default function Dropdown(props: IProps) { }, [selectedOptionProps]); const handleOnSelect = useCallback( - (option: IOption) => { - setSelectedOption(option); - onSelect?.(option); + (newOption: IOption, _options: IOption[]) => { + setSelectedOption(newOption); + onSelect?.(newOption); }, [onSelect], ); return ( - +
{label && ( diff --git a/src/front/Components/DesignSystem/IconButton/classes.module.scss b/src/front/Components/DesignSystem/IconButton/classes.module.scss index 920f424e..cc78b949 100644 --- a/src/front/Components/DesignSystem/IconButton/classes.module.scss +++ b/src/front/Components/DesignSystem/IconButton/classes.module.scss @@ -94,6 +94,10 @@ } } + &.default { + padding: 0; + } + &.disabled { cursor: default; opacity: var(--opacity-disabled, 0.3); diff --git a/src/front/Components/DesignSystem/SearchBar/index.tsx b/src/front/Components/DesignSystem/SearchBar/index.tsx index 113305ce..ebcce2fe 100644 --- a/src/front/Components/DesignSystem/SearchBar/index.tsx +++ b/src/front/Components/DesignSystem/SearchBar/index.tsx @@ -1,16 +1,21 @@ +import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; import React, { useCallback, useEffect } from "react"; import classes from "./classes.module.scss"; -import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; type IProps = { onChange?: (input: string) => void; value?: string; placeholder?: string; disabled?: boolean; + onClear?: () => void; + onFocus?: () => void; + onBlur?: () => void; }; -export default function SearchBar({ onChange, value: propValue, placeholder = "Rechercher", disabled = false }: IProps) { +export default function SearchBar(props: IProps) { + const { onChange, value: propValue, placeholder = "Rechercher", disabled = false, onClear, onFocus, onBlur } = props; + const [isFocused, setIsFocused] = React.useState(false); const [value, setValue] = React.useState(propValue || ""); @@ -22,10 +27,25 @@ export default function SearchBar({ onChange, value: propValue, placeholder = "R [onChange], ); - const handleOnChange = (event: React.ChangeEvent) => changeValue(event.target.value); - const handleFocus = () => setIsFocused(true); - const handleBlur = () => setIsFocused(false); - const clearValue = () => changeValue(""); + const handleOnChange = useCallback((event: React.ChangeEvent) => changeValue(event.target.value), [changeValue]); + + const handleFocus = useCallback(() => { + setIsFocused(true); + onFocus?.(); + }, [onFocus]); + + const handleBlur = useCallback( + (e: React.FocusEvent) => { + setIsFocused(false); + onBlur?.(); + }, + [onBlur], + ); + + const clearValue = useCallback(() => { + changeValue(""); + onClear?.(); + }, [changeValue, onClear]); useEffect(() => { if (propValue !== undefined) { diff --git a/src/front/Components/DesignSystem/Typography/index.tsx b/src/front/Components/DesignSystem/Typography/index.tsx index 9f63599c..584397a6 100644 --- a/src/front/Components/DesignSystem/Typography/index.tsx +++ b/src/front/Components/DesignSystem/Typography/index.tsx @@ -159,6 +159,8 @@ export enum ETypoColor { DROPDOWN_CONTRAST_DEFAULT = "--dropdown-contrast-default", DROPDOWN_CONTRAST_ACTIVE = "--dropdown-contrast-active", + + INPUT_CHIP_CONTRAST = "--input-chip-contrast", } export default function Typography(props: IProps) { diff --git a/src/front/Components/Layouts/DesignSystem/index.tsx b/src/front/Components/Layouts/DesignSystem/index.tsx index 1ca60d3e..d8121590 100644 --- a/src/front/Components/Layouts/DesignSystem/index.tsx +++ b/src/front/Components/Layouts/DesignSystem/index.tsx @@ -1,4 +1,6 @@ import Alert, { EAlertVariant } from "@Front/Components/DesignSystem/Alert"; +import Autocomplete from "@Front/Components/DesignSystem/Autocomplete"; +import AutocompleteMultiSelect from "@Front/Components/DesignSystem/AutocompleteMultiSelect"; import Button, { EButtonSize, EButtonstyletype, EButtonVariant } from "@Front/Components/DesignSystem/Button"; import CircleProgress from "@Front/Components/DesignSystem/CircleProgress"; import Dropdown from "@Front/Components/DesignSystem/Dropdown"; @@ -19,19 +21,10 @@ import NumberPicker from "@Front/Components/Elements/NumberPicker"; import Tabs from "@Front/Components/Elements/Tabs"; import DefaultTemplate from "@Front/Components/LayoutTemplates/DefaultTemplate"; import useOpenable from "@Front/Hooks/useOpenable"; -import { - ArchiveBoxIcon, - ArrowLongLeftIcon, - ArrowLongRightIcon, - EllipsisHorizontalIcon, - PencilSquareIcon, - UsersIcon, - XMarkIcon, -} from "@heroicons/react/24/outline"; +import { ArchiveBoxIcon, ArrowLongLeftIcon, ArrowLongRightIcon, EllipsisHorizontalIcon, PencilSquareIcon, UsersIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { useCallback, useMemo, useState } from "react"; import classes from "./classes.module.scss"; -import Autocomplete from "@Front/Components/DesignSystem/Autocomplete"; export default function DesignSystem() { const { isOpen, open, close } = useOpenable(); @@ -84,6 +77,29 @@ export default function DesignSystem() {
+ Autocomplete Multi Select + + Autocomplete Dropdown