Autocomplete Multi Select (chip input)

This commit is contained in:
Max S 2024-07-29 11:48:55 +02:00
parent 07d368fae2
commit 5f1da86813
13 changed files with 415 additions and 42 deletions

View File

@ -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 (
<DropdownMenu options={filteredOptions} openable={openable} onSelect={handleSelectOption} selectedOption={selectedOption}>
<DropdownMenu
options={filteredOptions}
openable={openable}
onSelect={handleSelectOption}
selectedOptions={selectedOption ? [selectedOption] : []}
>
<div className={classes["root"]}>
{label && (
<Typography className={classes["label"]} typo={ETypo.TEXT_MD_REGULAR} color={ETypoColor.CONTRAST_DEFAULT}>
@ -76,7 +74,14 @@ export default function Autocomplete(props: IProps) {
</Typography>
)}
</div>
<SearchBar placeholder={placeholder} disabled={disabled} onChange={handleSearchChange} value={searchValue} />
<SearchBar
placeholder={placeholder}
disabled={disabled}
onChange={handleSearchChange}
value={searchValue}
onClear={() => setSelectedOption(null)}
onFocus={openable.open}
/>
</DropdownMenu>
);
}

View File

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

View File

@ -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 (
<div className={classNames(classes["root"], className)}>
<Typography typo={ETypo.TEXT_MD_SEMIBOLD} color={ETypoColor.INPUT_CHIP_CONTRAST}>
{text}
</Typography>
<IconButton icon={<XMarkIcon />} onClick={onDelete} />
</div>
);
}

View File

@ -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;
}
}
}
}

View File

@ -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<HTMLInputElement>) => changeValue(event.target.value), [changeValue]);
const handleFocus = useCallback(() => {
setIsFocused(true);
onFocus?.();
}, [onFocus]);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement, Element>) => {
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 (
<div className={classes["root"]} data-is-focused={isFocused} data-has-value={value !== ""} data-is-disabled={disabled}>
<div className={classes["content"]}>
{selectedOptions.map((option) => (
<Chip key={option.id} text={getLabel(option) ?? ""} onDelete={() => onChipDelete(option)} />
))}
<input
type="text"
value={value}
placeholder={placeholder}
className={classes["input"]}
onChange={handleOnChange}
onFocus={handleFocus}
onBlur={handleBlur}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,7 @@
@import "@Themes/constants.scss";
.root {
.label {
padding: 0px var(--spacing-2, 16px);
}
}

View File

@ -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<IOption[] | null>(selectedOptionsProps ?? null);
const [searchValue, setSearchValue] = useState("");
const [filteredOptions, setFilteredOptions] = useState<IOption[]>(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 (
<DropdownMenu
options={filteredOptions}
openable={openable}
onSelect={handleSelectOption}
selectedOptions={selectedOptions ? selectedOptions : []}>
<div className={classes["root"]}>
{label && (
<Typography className={classes["label"]} typo={ETypo.TEXT_MD_REGULAR} color={ETypoColor.CONTRAST_DEFAULT}>
{label}
</Typography>
)}
</div>
<ChipContainer
placeholder={placeholder}
disabled={disabled}
onChange={handleSearchChange}
value={searchValue}
onClear={() => setSelectedOptions(null)}
onFocus={openable.open}
selectedOptions={selectedOptions ?? []}
onSelectedOptionsChange={setSelectedOptions}
/>
</DropdownMenu>
);
}

View File

@ -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<HTMLDivElement>(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 (
<div className={classNames([classes["root"], openable.isOpen && classes["open"]])}>
<div className={classNames([classes["root"], openable.isOpen && classes["open"]])} ref={ref}>
{children}
<div className={classes["content"]}>
{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);
}
}

View File

@ -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 (
<DropdownMenu options={options} openable={openable} onSelect={handleOnSelect} selectedOption={selectedOption}>
<DropdownMenu
options={options}
openable={openable}
onSelect={handleOnSelect}
selectedOptions={selectedOption ? [selectedOption] : []}>
<div className={classes["root"]}>
{label && (
<Typography className={classes["label"]} typo={ETypo.TEXT_MD_REGULAR} color={ETypoColor.CONTRAST_DEFAULT}>

View File

@ -94,6 +94,10 @@
}
}
&.default {
padding: 0;
}
&.disabled {
cursor: default;
opacity: var(--opacity-disabled, 0.3);

View File

@ -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<HTMLInputElement>) => changeValue(event.target.value);
const handleFocus = () => setIsFocused(true);
const handleBlur = () => setIsFocused(false);
const clearValue = () => changeValue("");
const handleOnChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => changeValue(event.target.value), [changeValue]);
const handleFocus = useCallback(() => {
setIsFocused(true);
onFocus?.();
}, [onFocus]);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement, Element>) => {
setIsFocused(false);
onBlur?.();
},
[onBlur],
);
const clearValue = useCallback(() => {
changeValue("");
onClear?.();
}, [changeValue, onClear]);
useEffect(() => {
if (propValue !== undefined) {

View File

@ -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) {

View File

@ -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() {
<div className={classes["root"]}>
<div />
<div className={classes["components"]}>
<Typography typo={ETypo.TEXT_LG_BOLD}>Autocomplete Multi Select</Typography>
<AutocompleteMultiSelect
options={[
{
id: "1",
label: "Option 1",
},
{
id: "2",
label: "Option 2",
},
{
id: "3",
label: "Option 3",
},
{
id: "4",
label: { text: "Option 4", subtext: "Subtext" },
},
]}
label="Label"
/>
<Typography typo={ETypo.TEXT_LG_BOLD}>Autocomplete</Typography>
<Autocomplete
options={[
@ -104,7 +120,6 @@ export default function DesignSystem() {
label: { text: "Option 4", subtext: "Subtext" },
},
]}
placeholder="Placeholder"
label="Label"
/>
<Typography typo={ETypo.TEXT_LG_BOLD}>Dropdown</Typography>