✨(react) add multi select
Adding this new variant makes necessary to reorganize the files to keep a clear separations of concerns. As of now Select/index.tsx is just an entrypoint to render either the mono or multi variant of the select.
This commit is contained in:
5
.changeset/seven-bulldogs-bow.md
Normal file
5
.changeset/seven-bulldogs-bow.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@openfun/cunningham-react": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
add multi select
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
border-color: var(--c--components--forms-select--border-color);
|
border-color: var(--c--components--forms-select--border-color);
|
||||||
border-style: var(--c--components--forms-select--border-style);
|
border-style: var(--c--components--forms-select--border-style);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
transition: border var(--c--theme--transitions--duration) var(--c--theme--transitions--ease-out);
|
transition: border var(--c--theme--transitions--duration) var(--c--theme--transitions--ease-out);
|
||||||
padding: 0 0.75rem;
|
padding: 0 0.75rem;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -48,8 +47,6 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
&__value {
|
&__value {
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
font-size: var(--c--components--forms-select--font-size);
|
font-size: var(--c--components--forms-select--font-size);
|
||||||
@@ -66,9 +63,11 @@
|
|||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -14px;
|
top: 3px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
// This is made to avoid this relative element to force its container height.
|
||||||
|
height: 0;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
@@ -152,7 +151,7 @@
|
|||||||
border-color: var(--c--theme--colors--greyscale-200);
|
border-color: var(--c--theme--colors--greyscale-200);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|
||||||
label {
|
label, input {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
color: var(--c--theme--colors--greyscale-600);
|
color: var(--c--theme--colors--greyscale-600);
|
||||||
}
|
}
|
||||||
@@ -188,4 +187,121 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--mono {
|
||||||
|
.c__select__inner {
|
||||||
|
&__value {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--multi {
|
||||||
|
.c__select__wrapper {
|
||||||
|
height: auto;
|
||||||
|
min-height: var(--c--components--forms-select--height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c__select__inner {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
&__pill {
|
||||||
|
background-color: var(--c--components--forms-select--multi-pill-background-color);
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border-radius: var(--c--components--forms-select--multi-pill-border-radius);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
max-width: var(--c--components--forms-select--multi-pill-max-width);
|
||||||
|
|
||||||
|
> span {
|
||||||
|
min-width: 0;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__clear {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
|
||||||
|
.material-icons {
|
||||||
|
font-size: 1.1250rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after,
|
||||||
|
input {
|
||||||
|
width: auto;
|
||||||
|
min-width: 1em;
|
||||||
|
grid-area: 1 / 2;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
resize: none;
|
||||||
|
background: none;
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: attr(data-value) ' ';
|
||||||
|
visibility: hidden;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
float: right;
|
||||||
|
position: relative;
|
||||||
|
height: 0;
|
||||||
|
top: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Modifiers */
|
||||||
|
|
||||||
|
&.c__select--populated {
|
||||||
|
.c__select__inner__value {
|
||||||
|
.c__select__inner__value__input {
|
||||||
|
// To match the height of the pills.
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.c__select__inner__actions {
|
||||||
|
// Now we need it to occupy space in order to make float: right work.
|
||||||
|
height: auto;
|
||||||
|
top: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.labelled-box--no-label {
|
||||||
|
.c__select__inner__actions {
|
||||||
|
top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c__select__inner__value {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,367 +1,13 @@
|
|||||||
import React, {
|
import React from "react";
|
||||||
HTMLAttributes,
|
import { SelectMulti } from ":/components/Forms/Select/multi";
|
||||||
PropsWithChildren,
|
import { SelectMono, SelectProps } from ":/components/Forms/Select/mono";
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import {
|
|
||||||
useCombobox,
|
|
||||||
useSelect,
|
|
||||||
UseSelectReturnValue,
|
|
||||||
UseSelectStateChange,
|
|
||||||
} from "downshift";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { useCunningham } from ":/components/Provider";
|
|
||||||
import { Field, FieldProps } from ":/components/Forms/Field";
|
|
||||||
import { LabelledBox } from ":/components/Forms/LabelledBox";
|
|
||||||
import { Button } from ":/components/Button";
|
|
||||||
|
|
||||||
interface Option {
|
export const Select = (props: SelectProps) => {
|
||||||
value?: string;
|
|
||||||
label: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = PropsWithChildren &
|
|
||||||
FieldProps & {
|
|
||||||
label: string;
|
|
||||||
hideLabel?: boolean;
|
|
||||||
options: Option[];
|
|
||||||
searchable?: boolean;
|
|
||||||
name?: string;
|
|
||||||
defaultValue?: string | number;
|
|
||||||
value?: string | number;
|
|
||||||
onChange?: (event: {
|
|
||||||
target: { value: string | number | undefined };
|
|
||||||
}) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
clearable?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getOptionsFilter(inputValue?: string) {
|
|
||||||
return (option: Option) => {
|
|
||||||
return (
|
|
||||||
!inputValue ||
|
|
||||||
option.label.toLowerCase().includes(inputValue.toLowerCase()) ||
|
|
||||||
option.value?.toLowerCase().includes(inputValue.toLowerCase())
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const optionToString = (option: Option | null) => {
|
|
||||||
return option ? option.label : "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const optionToValue = (option: Option) => {
|
|
||||||
return option.value ?? option.label;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SubProps extends Props {
|
|
||||||
defaultSelectedItem?: Option;
|
|
||||||
downshiftProps: {
|
|
||||||
initialSelectedItem?: Option;
|
|
||||||
onSelectedItemChange?: any;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SelectAuxProps extends SubProps {
|
|
||||||
options: Option[];
|
|
||||||
labelAsPlaceholder: boolean;
|
|
||||||
downshiftReturn: {
|
|
||||||
isOpen: boolean;
|
|
||||||
wrapperProps?: HTMLAttributes<HTMLDivElement>;
|
|
||||||
selectedItem?: Option | null;
|
|
||||||
getLabelProps: any;
|
|
||||||
toggleButtonProps: any;
|
|
||||||
getMenuProps: any;
|
|
||||||
getItemProps: any;
|
|
||||||
highlightedIndex: number;
|
|
||||||
selectItem: UseSelectReturnValue<Option>["selectItem"];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This component is used by searchable and non-searchable select components.
|
|
||||||
* It contains the common logic between the two.
|
|
||||||
*/
|
|
||||||
const SelectAux = ({
|
|
||||||
children,
|
|
||||||
state = "default",
|
|
||||||
text,
|
|
||||||
rightText,
|
|
||||||
fullWidth,
|
|
||||||
options,
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
hideLabel,
|
|
||||||
labelAsPlaceholder,
|
|
||||||
downshiftProps,
|
|
||||||
downshiftReturn,
|
|
||||||
value,
|
|
||||||
disabled,
|
|
||||||
clearable = true,
|
|
||||||
}: SelectAuxProps) => {
|
|
||||||
const { t } = useCunningham();
|
|
||||||
const labelProps = downshiftReturn.getLabelProps();
|
|
||||||
|
|
||||||
// When component is controlled, this useEffect will update the local selected item.
|
|
||||||
useEffect(() => {
|
|
||||||
if (downshiftProps.initialSelectedItem !== undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const optionToSelect = options.find(
|
|
||||||
(option) => optionToValue(option) === value
|
|
||||||
);
|
|
||||||
downshiftReturn.selectItem(optionToSelect ?? null);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Field
|
|
||||||
state={state}
|
|
||||||
text={text}
|
|
||||||
rightText={rightText}
|
|
||||||
fullWidth={fullWidth}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames("c__select", "c__select--" + state, {
|
|
||||||
"c__select--disabled": disabled,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{/* We disabled linting for this specific line because we consider that the onClick props is only used for */}
|
|
||||||
{/* mouse users, so this do not engender any issue for accessibility. */}
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
|
||||||
<div
|
|
||||||
className={classNames("c__select__wrapper", {
|
|
||||||
"c__select__wrapper--focus": downshiftReturn.isOpen && !disabled,
|
|
||||||
})}
|
|
||||||
{...downshiftReturn.wrapperProps}
|
|
||||||
>
|
|
||||||
{downshiftReturn.selectedItem && (
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name={name}
|
|
||||||
value={optionToValue(downshiftReturn.selectedItem)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<LabelledBox
|
|
||||||
label={label}
|
|
||||||
hideLabel={hideLabel}
|
|
||||||
labelAsPlaceholder={labelAsPlaceholder}
|
|
||||||
htmlFor={labelProps.htmlFor}
|
|
||||||
labelId={labelProps.id}
|
|
||||||
>
|
|
||||||
<div className="c__select__inner">
|
|
||||||
<div className="c__select__inner__value">{children}</div>
|
|
||||||
<div className="c__select__inner__actions">
|
|
||||||
{clearable && !disabled && downshiftReturn.selectedItem && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
color="tertiary"
|
|
||||||
size="small"
|
|
||||||
aria-label={t(
|
|
||||||
"components.forms.select.clear_button_aria_label"
|
|
||||||
)}
|
|
||||||
className="c__select__inner__actions__clear"
|
|
||||||
onClick={(e) => {
|
|
||||||
downshiftReturn.selectItem(null);
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
icon={<span className="material-icons">close</span>}
|
|
||||||
/>
|
|
||||||
<div className="c__select__inner__actions__separator" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="tertiary"
|
|
||||||
size="small"
|
|
||||||
className="c__select__inner__actions__open"
|
|
||||||
icon={
|
|
||||||
<span
|
|
||||||
className={classNames("material-icons", {
|
|
||||||
opened: downshiftReturn.isOpen,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
arrow_drop_down
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
disabled={disabled}
|
|
||||||
{...downshiftReturn.toggleButtonProps}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</LabelledBox>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={classNames("c__select__menu", {
|
|
||||||
"c__select__menu--opened": downshiftReturn.isOpen,
|
|
||||||
})}
|
|
||||||
{...downshiftReturn.getMenuProps()}
|
|
||||||
>
|
|
||||||
<ul>
|
|
||||||
{downshiftReturn.isOpen &&
|
|
||||||
options.map((item, index) => {
|
|
||||||
const isActive = index === downshiftReturn.highlightedIndex;
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
className={classNames("c__select__menu__item", {
|
|
||||||
"c__select__menu__item--highlight": isActive,
|
|
||||||
"c__select__menu__item--selected":
|
|
||||||
downshiftReturn.selectedItem === item,
|
|
||||||
"c__select__menu__item--disabled": item.disabled,
|
|
||||||
})}
|
|
||||||
key={`${item.value}${index}`}
|
|
||||||
{...downshiftReturn.getItemProps({
|
|
||||||
item,
|
|
||||||
index,
|
|
||||||
isActive,
|
|
||||||
disabled: item.disabled,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SelectSimple = (props: SubProps) => {
|
|
||||||
const downshiftReturn = useSelect({
|
|
||||||
...props.downshiftProps,
|
|
||||||
items: props.options,
|
|
||||||
itemToString: optionToString,
|
|
||||||
});
|
|
||||||
const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(
|
|
||||||
!downshiftReturn.selectedItem
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLabelAsPlaceholder(!downshiftReturn.selectedItem);
|
|
||||||
}, [downshiftReturn.selectedItem]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SelectAux
|
|
||||||
{...props}
|
|
||||||
downshiftReturn={{
|
|
||||||
...downshiftReturn,
|
|
||||||
wrapperProps: downshiftReturn.getToggleButtonProps({
|
|
||||||
disabled: props.disabled,
|
|
||||||
}),
|
|
||||||
toggleButtonProps: {},
|
|
||||||
}}
|
|
||||||
labelAsPlaceholder={labelAsPlaceholder}
|
|
||||||
>
|
|
||||||
{downshiftReturn.selectedItem && (
|
|
||||||
<span>{optionToString(downshiftReturn.selectedItem)}</span>
|
|
||||||
)}
|
|
||||||
</SelectAux>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const SelectSearchable = (props: SubProps) => {
|
|
||||||
const { t } = useCunningham();
|
|
||||||
const [optionsToDisplay, setOptionsToDisplay] = useState(props.options);
|
|
||||||
const [hasInputFocused, setHasInputFocused] = useState(false);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const downshiftReturn = useCombobox({
|
|
||||||
...props.downshiftProps,
|
|
||||||
items: optionsToDisplay,
|
|
||||||
itemToString: optionToString,
|
|
||||||
onInputValueChange: (e) => {
|
|
||||||
setOptionsToDisplay(props.options.filter(getOptionsFilter(e.inputValue)));
|
|
||||||
if (!e.inputValue) {
|
|
||||||
downshiftReturn.selectItem(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(
|
|
||||||
!downshiftReturn.selectedItem
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasInputFocused || downshiftReturn.inputValue) {
|
|
||||||
setLabelAsPlaceholder(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLabelAsPlaceholder(!downshiftReturn.selectedItem);
|
|
||||||
}, [
|
|
||||||
downshiftReturn.selectedItem,
|
|
||||||
hasInputFocused,
|
|
||||||
downshiftReturn.inputValue,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const inputProps = downshiftReturn.getInputProps({
|
|
||||||
ref: inputRef,
|
|
||||||
disabled: props.disabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SelectAux
|
|
||||||
{...props}
|
|
||||||
downshiftReturn={{
|
|
||||||
...downshiftReturn,
|
|
||||||
wrapperProps: {
|
|
||||||
onClick: () => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
toggleButtonProps: downshiftReturn.getToggleButtonProps({
|
|
||||||
disabled: props.disabled,
|
|
||||||
"aria-label": t("components.forms.select.toggle_button_aria_label"),
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
labelAsPlaceholder={labelAsPlaceholder}
|
|
||||||
options={optionsToDisplay}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="w-full p-1.5"
|
|
||||||
{...inputProps}
|
|
||||||
onFocus={(e) => {
|
|
||||||
inputProps.onFocus(e);
|
|
||||||
setHasInputFocused(true);
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
inputProps.onBlur(e);
|
|
||||||
setHasInputFocused(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SelectAux>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Select = (props: Props) => {
|
|
||||||
if (props.defaultValue && props.value) {
|
if (props.defaultValue && props.value) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"You cannot use both defaultValue and value props on Select component"
|
"You cannot use both defaultValue and value props on Select component"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSelectedItem = props.defaultValue
|
return props.multi ? <SelectMulti {...props} /> : <SelectMono {...props} />;
|
||||||
? props.options.find(
|
|
||||||
(option) => optionToValue(option) === props.defaultValue
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const commonDownshiftProps: SubProps["downshiftProps"] = {
|
|
||||||
initialSelectedItem: defaultSelectedItem,
|
|
||||||
onSelectedItemChange: (e: UseSelectStateChange<Option>) => {
|
|
||||||
props.onChange?.({
|
|
||||||
target: {
|
|
||||||
value: e.selectedItem ? optionToValue(e.selectedItem) : undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return props.searchable ? (
|
|
||||||
<SelectSearchable {...props} downshiftProps={commonDownshiftProps} />
|
|
||||||
) : (
|
|
||||||
<SelectSimple {...props} downshiftProps={commonDownshiftProps} />
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
200
packages/react/src/components/Forms/Select/mono-common.tsx
Normal file
200
packages/react/src/components/Forms/Select/mono-common.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import React, { HTMLAttributes, useEffect } from "react";
|
||||||
|
import { UseSelectReturnValue } from "downshift";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useCunningham } from ":/components/Provider";
|
||||||
|
import { Field } from ":/components/Forms/Field";
|
||||||
|
import { LabelledBox } from ":/components/Forms/LabelledBox";
|
||||||
|
import { Button } from ":/components/Button";
|
||||||
|
import { Option, SelectProps } from ":/components/Forms/Select/mono";
|
||||||
|
|
||||||
|
export function getOptionsFilter(inputValue?: string) {
|
||||||
|
return (option: Option) => {
|
||||||
|
return (
|
||||||
|
!inputValue ||
|
||||||
|
option.label.toLowerCase().includes(inputValue.toLowerCase()) ||
|
||||||
|
option.value?.toLowerCase().includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const optionToString = (option: Option | null) => {
|
||||||
|
return option ? option.label : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const optionToValue = (option: Option) => {
|
||||||
|
return option.value ?? option.label;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SubProps extends SelectProps {
|
||||||
|
defaultSelectedItem?: Option;
|
||||||
|
downshiftProps: {
|
||||||
|
initialSelectedItem?: Option;
|
||||||
|
onSelectedItemChange?: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectAuxProps extends SubProps {
|
||||||
|
options: Option[];
|
||||||
|
labelAsPlaceholder: boolean;
|
||||||
|
downshiftReturn: {
|
||||||
|
isOpen: boolean;
|
||||||
|
wrapperProps?: HTMLAttributes<HTMLDivElement>;
|
||||||
|
selectedItem?: Option | null;
|
||||||
|
getLabelProps: any;
|
||||||
|
toggleButtonProps: any;
|
||||||
|
getMenuProps: any;
|
||||||
|
getItemProps: any;
|
||||||
|
highlightedIndex: number;
|
||||||
|
selectItem: UseSelectReturnValue<Option>["selectItem"];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component is used by searchable and non-searchable select components.
|
||||||
|
* It contains the common logic between the two.
|
||||||
|
*/
|
||||||
|
export const SelectMonoAux = ({
|
||||||
|
children,
|
||||||
|
state = "default",
|
||||||
|
text,
|
||||||
|
rightText,
|
||||||
|
fullWidth,
|
||||||
|
options,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
hideLabel,
|
||||||
|
labelAsPlaceholder,
|
||||||
|
downshiftProps,
|
||||||
|
downshiftReturn,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
clearable = true,
|
||||||
|
}: SelectAuxProps) => {
|
||||||
|
const { t } = useCunningham();
|
||||||
|
const labelProps = downshiftReturn.getLabelProps();
|
||||||
|
|
||||||
|
// When component is controlled, this useEffect will update the local selected item.
|
||||||
|
useEffect(() => {
|
||||||
|
if (downshiftProps.initialSelectedItem !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const optionToSelect = options.find(
|
||||||
|
(option) => optionToValue(option) === value
|
||||||
|
);
|
||||||
|
downshiftReturn.selectItem(optionToSelect ?? null);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
state={state}
|
||||||
|
text={text}
|
||||||
|
rightText={rightText}
|
||||||
|
fullWidth={fullWidth}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames("c__select", "c__select--" + state, {
|
||||||
|
"c__select--disabled": disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* We disabled linting for this specific line because we consider that the onClick props is only used for */}
|
||||||
|
{/* mouse users, so this do not engender any issue for accessibility. */}
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||||
|
<div
|
||||||
|
className={classNames("c__select__wrapper", {
|
||||||
|
"c__select__wrapper--focus": downshiftReturn.isOpen && !disabled,
|
||||||
|
})}
|
||||||
|
{...downshiftReturn.wrapperProps}
|
||||||
|
>
|
||||||
|
{downshiftReturn.selectedItem && (
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name={name}
|
||||||
|
value={optionToValue(downshiftReturn.selectedItem)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LabelledBox
|
||||||
|
label={label}
|
||||||
|
hideLabel={hideLabel}
|
||||||
|
labelAsPlaceholder={labelAsPlaceholder}
|
||||||
|
htmlFor={labelProps.htmlFor}
|
||||||
|
labelId={labelProps.id}
|
||||||
|
>
|
||||||
|
<div className="c__select__inner">
|
||||||
|
<div className="c__select__inner__value">{children}</div>
|
||||||
|
<div className="c__select__inner__actions">
|
||||||
|
{clearable && !disabled && downshiftReturn.selectedItem && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
color="tertiary"
|
||||||
|
size="small"
|
||||||
|
aria-label={t(
|
||||||
|
"components.forms.select.clear_button_aria_label"
|
||||||
|
)}
|
||||||
|
className="c__select__inner__actions__clear"
|
||||||
|
onClick={(e) => {
|
||||||
|
downshiftReturn.selectItem(null);
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
icon={<span className="material-icons">close</span>}
|
||||||
|
/>
|
||||||
|
<div className="c__select__inner__actions__separator" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="tertiary"
|
||||||
|
size="small"
|
||||||
|
className="c__select__inner__actions__open"
|
||||||
|
icon={
|
||||||
|
<span
|
||||||
|
className={classNames("material-icons", {
|
||||||
|
opened: downshiftReturn.isOpen,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
arrow_drop_down
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
{...downshiftReturn.toggleButtonProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</LabelledBox>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={classNames("c__select__menu", {
|
||||||
|
"c__select__menu--opened": downshiftReturn.isOpen,
|
||||||
|
})}
|
||||||
|
{...downshiftReturn.getMenuProps()}
|
||||||
|
>
|
||||||
|
<ul>
|
||||||
|
{downshiftReturn.isOpen &&
|
||||||
|
options.map((item, index) => {
|
||||||
|
const isActive = index === downshiftReturn.highlightedIndex;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={classNames("c__select__menu__item", {
|
||||||
|
"c__select__menu__item--highlight": isActive,
|
||||||
|
"c__select__menu__item--selected":
|
||||||
|
downshiftReturn.selectedItem === item,
|
||||||
|
"c__select__menu__item--disabled": item.disabled,
|
||||||
|
})}
|
||||||
|
key={`${item.value}${index}`}
|
||||||
|
{...downshiftReturn.getItemProps({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
isActive,
|
||||||
|
disabled: item.disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useCombobox } from "downshift";
|
||||||
|
import { useCunningham } from ":/components/Provider";
|
||||||
|
import {
|
||||||
|
getOptionsFilter,
|
||||||
|
optionToString,
|
||||||
|
SelectMonoAux,
|
||||||
|
SubProps,
|
||||||
|
} from ":/components/Forms/Select/mono-common";
|
||||||
|
|
||||||
|
export const SelectMonoSearchable = (props: SubProps) => {
|
||||||
|
const { t } = useCunningham();
|
||||||
|
const [optionsToDisplay, setOptionsToDisplay] = useState(props.options);
|
||||||
|
const [hasInputFocused, setHasInputFocused] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const downshiftReturn = useCombobox({
|
||||||
|
...props.downshiftProps,
|
||||||
|
items: optionsToDisplay,
|
||||||
|
itemToString: optionToString,
|
||||||
|
onInputValueChange: (e) => {
|
||||||
|
setOptionsToDisplay(props.options.filter(getOptionsFilter(e.inputValue)));
|
||||||
|
if (!e.inputValue) {
|
||||||
|
downshiftReturn.selectItem(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(
|
||||||
|
!downshiftReturn.selectedItem
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasInputFocused || downshiftReturn.inputValue) {
|
||||||
|
setLabelAsPlaceholder(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLabelAsPlaceholder(!downshiftReturn.selectedItem);
|
||||||
|
}, [
|
||||||
|
downshiftReturn.selectedItem,
|
||||||
|
hasInputFocused,
|
||||||
|
downshiftReturn.inputValue,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inputProps = downshiftReturn.getInputProps({
|
||||||
|
ref: inputRef,
|
||||||
|
disabled: props.disabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectMonoAux
|
||||||
|
{...props}
|
||||||
|
downshiftReturn={{
|
||||||
|
...downshiftReturn,
|
||||||
|
wrapperProps: {
|
||||||
|
onClick: () => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toggleButtonProps: downshiftReturn.getToggleButtonProps({
|
||||||
|
disabled: props.disabled,
|
||||||
|
"aria-label": t("components.forms.select.toggle_button_aria_label"),
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
labelAsPlaceholder={labelAsPlaceholder}
|
||||||
|
options={optionsToDisplay}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
{...inputProps}
|
||||||
|
onFocus={(e) => {
|
||||||
|
inputProps.onFocus(e);
|
||||||
|
setHasInputFocused(true);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
inputProps.onBlur(e);
|
||||||
|
setHasInputFocused(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SelectMonoAux>
|
||||||
|
);
|
||||||
|
};
|
||||||
33
packages/react/src/components/Forms/Select/mono-simple.tsx
Normal file
33
packages/react/src/components/Forms/Select/mono-simple.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useSelect } from "downshift";
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
optionToString,
|
||||||
|
SelectMonoAux,
|
||||||
|
SubProps,
|
||||||
|
} from ":/components/Forms/Select/mono-common";
|
||||||
|
|
||||||
|
export const SelectMonoSimple = (props: SubProps) => {
|
||||||
|
const downshiftReturn = useSelect({
|
||||||
|
...props.downshiftProps,
|
||||||
|
items: props.options,
|
||||||
|
itemToString: optionToString,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectMonoAux
|
||||||
|
{...props}
|
||||||
|
downshiftReturn={{
|
||||||
|
...downshiftReturn,
|
||||||
|
wrapperProps: downshiftReturn.getToggleButtonProps({
|
||||||
|
disabled: props.disabled,
|
||||||
|
}),
|
||||||
|
toggleButtonProps: {},
|
||||||
|
}}
|
||||||
|
labelAsPlaceholder={!downshiftReturn.selectedItem}
|
||||||
|
>
|
||||||
|
{downshiftReturn.selectedItem && (
|
||||||
|
<span>{optionToString(downshiftReturn.selectedItem)}</span>
|
||||||
|
)}
|
||||||
|
</SelectMonoAux>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,43 +5,16 @@ import React, { FormEvent, useState } from "react";
|
|||||||
import { Select } from ":/components/Forms/Select/index";
|
import { Select } from ":/components/Forms/Select/index";
|
||||||
import { Button } from ":/components/Button";
|
import { Button } from ":/components/Button";
|
||||||
import { CunninghamProvider } from ":/components/Provider";
|
import { CunninghamProvider } from ":/components/Provider";
|
||||||
|
import {
|
||||||
|
expectMenuToBeClosed,
|
||||||
|
expectMenuToBeOpen,
|
||||||
|
expectOptions,
|
||||||
|
expectOptionToBeDisabled,
|
||||||
|
expectOptionToBeSelected,
|
||||||
|
expectOptionToBeUnselected,
|
||||||
|
} from ":/components/Forms/Select/test-utils";
|
||||||
|
|
||||||
describe("<Select/>", () => {
|
describe("<Select/>", () => {
|
||||||
const expectMenuToBeOpen = (menu: HTMLDivElement) => {
|
|
||||||
expect(Array.from(menu.classList)).contains("c__select__menu--opened");
|
|
||||||
};
|
|
||||||
const expectOptions = (expectedOptions: string[]) => {
|
|
||||||
const options = screen.getAllByRole("option");
|
|
||||||
expect(options.length).toBe(expectedOptions.length);
|
|
||||||
options.forEach((option, i) => {
|
|
||||||
expect(option).toHaveTextContent(expectedOptions[i]);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const expectMenuToBeClosed = (menu: HTMLDivElement) => {
|
|
||||||
expect(Array.from(menu.classList)).not.contains("c__select__menu--opened");
|
|
||||||
};
|
|
||||||
const expectOptionToBeSelected = (option: HTMLLIElement) => {
|
|
||||||
expect(option).toHaveAttribute("aria-selected", "true");
|
|
||||||
expect(Array.from(option.classList)).contains(
|
|
||||||
"c__select__menu__item--selected"
|
|
||||||
);
|
|
||||||
expect(Array.from(option.classList)).contains(
|
|
||||||
"c__select__menu__item--highlight"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const expectOptionToBeUnselected = (option: HTMLLIElement) => {
|
|
||||||
expect(option).toHaveAttribute("aria-selected", "false");
|
|
||||||
expect(Array.from(option.classList)).not.contains(
|
|
||||||
"c__select__menu__item--selected"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const expectOptionToBeDisabled = (option: HTMLLIElement) => {
|
|
||||||
expect(option).toHaveAttribute("disabled");
|
|
||||||
expect(Array.from(option.classList)).contains(
|
|
||||||
"c__select__menu__item--disabled"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Searchable", () => {
|
describe("Searchable", () => {
|
||||||
it("shows all options when clicking on the input", async () => {
|
it("shows all options when clicking on the input", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -343,7 +316,7 @@ describe("<Select/>", () => {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value as string)}
|
||||||
searchable={true}
|
searchable={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -752,7 +725,7 @@ describe("<Select/>", () => {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value as string)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CunninghamProvider>
|
</CunninghamProvider>
|
||||||
54
packages/react/src/components/Forms/Select/mono.tsx
Normal file
54
packages/react/src/components/Forms/Select/mono.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import React, { PropsWithChildren } from "react";
|
||||||
|
import { UseSelectStateChange } from "downshift";
|
||||||
|
import { FieldProps } from ":/components/Forms/Field";
|
||||||
|
import { optionToValue, SubProps } from ":/components/Forms/Select/mono-common";
|
||||||
|
import { SelectMonoSearchable } from ":/components/Forms/Select/mono-searchable";
|
||||||
|
import { SelectMonoSimple } from ":/components/Forms/Select/mono-simple";
|
||||||
|
|
||||||
|
export interface Option {
|
||||||
|
value?: string;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SelectProps = PropsWithChildren &
|
||||||
|
FieldProps & {
|
||||||
|
label: string;
|
||||||
|
hideLabel?: boolean;
|
||||||
|
options: Option[];
|
||||||
|
searchable?: boolean;
|
||||||
|
name?: string;
|
||||||
|
defaultValue?: string | number | string[];
|
||||||
|
value?: string | number | string[];
|
||||||
|
onChange?: (event: {
|
||||||
|
target: { value: string | number | undefined | string[] };
|
||||||
|
}) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
clearable?: boolean;
|
||||||
|
multi?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectMono = (props: SelectProps) => {
|
||||||
|
const defaultSelectedItem = props.defaultValue
|
||||||
|
? props.options.find(
|
||||||
|
(option) => optionToValue(option) === props.defaultValue
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const commonDownshiftProps: SubProps["downshiftProps"] = {
|
||||||
|
initialSelectedItem: defaultSelectedItem,
|
||||||
|
onSelectedItemChange: (e: UseSelectStateChange<Option>) => {
|
||||||
|
props.onChange?.({
|
||||||
|
target: {
|
||||||
|
value: e.selectedItem ? optionToValue(e.selectedItem) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return props.searchable ? (
|
||||||
|
<SelectMonoSearchable {...props} downshiftProps={commonDownshiftProps} />
|
||||||
|
) : (
|
||||||
|
<SelectMonoSimple {...props} downshiftProps={commonDownshiftProps} />
|
||||||
|
);
|
||||||
|
};
|
||||||
218
packages/react/src/components/Forms/Select/multi-common.tsx
Normal file
218
packages/react/src/components/Forms/Select/multi-common.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import React, { HTMLAttributes } from "react";
|
||||||
|
import { useMultipleSelection } from "downshift";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { Field } from ":/components/Forms/Field";
|
||||||
|
import { LabelledBox } from ":/components/Forms/LabelledBox";
|
||||||
|
import { Button } from ":/components/Button";
|
||||||
|
import { useCunningham } from ":/components/Provider";
|
||||||
|
import { Option, SelectProps } from ":/components/Forms/Select/mono";
|
||||||
|
import {
|
||||||
|
getOptionsFilter,
|
||||||
|
optionToValue,
|
||||||
|
} from ":/components/Forms/Select/mono-common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method returns a comparator that can be used to filter out options for multi select.
|
||||||
|
* For an option to be visible it must:
|
||||||
|
* - Match the input value in terms of search
|
||||||
|
* - Not be selected already
|
||||||
|
*
|
||||||
|
* @param selectedOptions
|
||||||
|
* @param inputValue
|
||||||
|
*/
|
||||||
|
export function getMultiOptionsFilter(
|
||||||
|
selectedOptions: Option[],
|
||||||
|
inputValue?: string
|
||||||
|
) {
|
||||||
|
const optionsFilter = getOptionsFilter(inputValue);
|
||||||
|
return (option: Option) => {
|
||||||
|
return (
|
||||||
|
!selectedOptions.find(
|
||||||
|
(selectedOption) =>
|
||||||
|
optionToValue(selectedOption) === optionToValue(option)
|
||||||
|
) && optionsFilter(option)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SelectMultiProps = Omit<SelectProps, "onChange"> & {
|
||||||
|
onChange?: (event: { target: { value: string[] } }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SubProps extends SelectMultiProps {
|
||||||
|
selectedItems: Option[];
|
||||||
|
onSelectedItemsChange: (selectedItems: Option[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectMultiAux = ({
|
||||||
|
options,
|
||||||
|
labelAsPlaceholder,
|
||||||
|
selectedItems,
|
||||||
|
clearable = true,
|
||||||
|
disabled,
|
||||||
|
hideLabel,
|
||||||
|
name,
|
||||||
|
downshiftReturn,
|
||||||
|
useMultipleSelectionReturn,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SubProps & {
|
||||||
|
options: Option[];
|
||||||
|
labelAsPlaceholder: boolean;
|
||||||
|
selectedItems: Option[];
|
||||||
|
clearable?: boolean;
|
||||||
|
downshiftReturn: {
|
||||||
|
isOpen: boolean;
|
||||||
|
getLabelProps: any;
|
||||||
|
toggleButtonProps: any;
|
||||||
|
getMenuProps: any;
|
||||||
|
getItemProps: any;
|
||||||
|
highlightedIndex: number;
|
||||||
|
wrapperProps?: HTMLAttributes<HTMLDivElement>;
|
||||||
|
};
|
||||||
|
useMultipleSelectionReturn: ReturnType<typeof useMultipleSelection<Option>>;
|
||||||
|
}) => {
|
||||||
|
const { t } = useCunningham();
|
||||||
|
const labelProps = downshiftReturn.getLabelProps();
|
||||||
|
return (
|
||||||
|
<Field {...props}>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"c__select",
|
||||||
|
"c__select--multi",
|
||||||
|
"c__select--" + props.state,
|
||||||
|
{
|
||||||
|
"c__select--disabled": disabled,
|
||||||
|
"c__select--populated": selectedItems.length > 0,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames("c__select__wrapper", {
|
||||||
|
"c__select__wrapper--focus": downshiftReturn.isOpen && !disabled,
|
||||||
|
})}
|
||||||
|
{...downshiftReturn.wrapperProps}
|
||||||
|
>
|
||||||
|
{selectedItems.map((selectedItem, index) => (
|
||||||
|
<input
|
||||||
|
key={`${selectedItem.value}${index}`}
|
||||||
|
type="hidden"
|
||||||
|
name={name}
|
||||||
|
value={optionToValue(selectedItem)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<LabelledBox
|
||||||
|
label={props.label}
|
||||||
|
labelAsPlaceholder={labelAsPlaceholder}
|
||||||
|
htmlFor={labelProps.htmlFor}
|
||||||
|
labelId={labelProps.id}
|
||||||
|
hideLabel={hideLabel}
|
||||||
|
>
|
||||||
|
<div className="c__select__inner">
|
||||||
|
<div className="c__select__inner__actions">
|
||||||
|
{clearable && !disabled && selectedItems.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
color="tertiary"
|
||||||
|
size="small"
|
||||||
|
aria-label={t(
|
||||||
|
"components.forms.select.clear_all_button_aria_label"
|
||||||
|
)}
|
||||||
|
className="c__select__inner__actions__clear"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
props.onSelectedItemsChange([]);
|
||||||
|
}}
|
||||||
|
icon={<span className="material-icons">close</span>}
|
||||||
|
/>
|
||||||
|
<div className="c__select__inner__actions__separator" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
color="tertiary"
|
||||||
|
size="small"
|
||||||
|
className="c__select__inner__actions__open"
|
||||||
|
icon={
|
||||||
|
<span
|
||||||
|
className={classNames("material-icons", {
|
||||||
|
opened: downshiftReturn.isOpen,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
arrow_drop_down
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
{...downshiftReturn.toggleButtonProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="c__select__inner__value">
|
||||||
|
{selectedItems.map((selectedItemForRender, index) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="c__select__inner__value__pill"
|
||||||
|
key={`${selectedItemForRender.value}${index}`}
|
||||||
|
{...useMultipleSelectionReturn.getSelectedItemProps({
|
||||||
|
selectedItem: selectedItemForRender,
|
||||||
|
index,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span>{selectedItemForRender.label}</span>
|
||||||
|
<Button
|
||||||
|
tabIndex={-1}
|
||||||
|
color="tertiary"
|
||||||
|
disabled={disabled}
|
||||||
|
size="small"
|
||||||
|
aria-label={t(
|
||||||
|
"components.forms.select.clear_button_aria_label"
|
||||||
|
)}
|
||||||
|
className="c__select__inner__value__pill__clear"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
useMultipleSelectionReturn.removeSelectedItem(
|
||||||
|
selectedItemForRender
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
icon={<span className="material-icons">close</span>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</LabelledBox>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={classNames("c__select__menu", {
|
||||||
|
"c__select__menu--opened": downshiftReturn.isOpen,
|
||||||
|
})}
|
||||||
|
{...downshiftReturn.getMenuProps()}
|
||||||
|
>
|
||||||
|
<ul>
|
||||||
|
{downshiftReturn.isOpen &&
|
||||||
|
options.map((option, index) => {
|
||||||
|
const isActive = index === downshiftReturn.highlightedIndex;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={classNames("c__select__menu__item", {
|
||||||
|
"c__select__menu__item--highlight": isActive,
|
||||||
|
"c__select__menu__item--disabled": option.disabled,
|
||||||
|
})}
|
||||||
|
key={`${option.value}${index}`}
|
||||||
|
{...downshiftReturn.getItemProps({
|
||||||
|
item: option,
|
||||||
|
index,
|
||||||
|
disabled: option.disabled,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
};
|
||||||
143
packages/react/src/components/Forms/Select/multi-searchable.tsx
Normal file
143
packages/react/src/components/Forms/Select/multi-searchable.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useCombobox, useMultipleSelection } from "downshift";
|
||||||
|
import { optionToString } from ":/components/Forms/Select/mono-common";
|
||||||
|
import {
|
||||||
|
getMultiOptionsFilter,
|
||||||
|
SelectMultiAux,
|
||||||
|
SubProps,
|
||||||
|
} from ":/components/Forms/Select/multi-common";
|
||||||
|
|
||||||
|
export const SelectMultiSearchable = (props: SubProps) => {
|
||||||
|
const [inputValue, setInputValue] = React.useState<string>("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const options = React.useMemo(
|
||||||
|
() =>
|
||||||
|
props.options.filter(
|
||||||
|
getMultiOptionsFilter(props.selectedItems, inputValue)
|
||||||
|
),
|
||||||
|
[props.selectedItems, inputValue]
|
||||||
|
);
|
||||||
|
const [hasInputFocused, setHasInputFocused] = useState(false);
|
||||||
|
const useMultipleSelectionReturn = useMultipleSelection({
|
||||||
|
selectedItems: props.selectedItems,
|
||||||
|
onStateChange({ selectedItems: newSelectedItems, type }) {
|
||||||
|
switch (type) {
|
||||||
|
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
|
||||||
|
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
|
||||||
|
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
||||||
|
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
||||||
|
props.onSelectedItemsChange(newSelectedItems ?? []);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const downshiftReturn = useCombobox({
|
||||||
|
items: options,
|
||||||
|
itemToString: optionToString,
|
||||||
|
defaultHighlightedIndex: 0, // after selection, highlight the first item.
|
||||||
|
selectedItem: null,
|
||||||
|
stateReducer: (state, actionAndChanges) => {
|
||||||
|
const { changes, type } = actionAndChanges;
|
||||||
|
switch (type) {
|
||||||
|
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||||
|
case useCombobox.stateChangeTypes.ItemClick:
|
||||||
|
return {
|
||||||
|
...changes,
|
||||||
|
isOpen: true, // keep the menu open after selection.
|
||||||
|
highlightedIndex: 0, // with the first option highlighted.
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStateChange: ({
|
||||||
|
inputValue: newInputValue,
|
||||||
|
type,
|
||||||
|
selectedItem: newSelectedItem,
|
||||||
|
}) => {
|
||||||
|
switch (type) {
|
||||||
|
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||||
|
case useCombobox.stateChangeTypes.ItemClick:
|
||||||
|
case useCombobox.stateChangeTypes.InputBlur:
|
||||||
|
if (newSelectedItem && !newSelectedItem.disabled) {
|
||||||
|
props.onSelectedItemsChange([
|
||||||
|
...props.selectedItems,
|
||||||
|
newSelectedItem,
|
||||||
|
]);
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case useCombobox.stateChangeTypes.InputChange:
|
||||||
|
setInputValue(newInputValue ?? "");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputProps = downshiftReturn.getInputProps({
|
||||||
|
...useMultipleSelectionReturn.getDropdownProps({
|
||||||
|
preventKeyAction: downshiftReturn.isOpen,
|
||||||
|
ref: inputRef,
|
||||||
|
disabled: props.disabled,
|
||||||
|
}),
|
||||||
|
value: inputValue,
|
||||||
|
});
|
||||||
|
// We want to extend the default behavior of the input onKeyDown.
|
||||||
|
const { onKeyDown } = inputProps;
|
||||||
|
inputProps.onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||||
|
switch (event.code) {
|
||||||
|
case "Backspace":
|
||||||
|
if (!inputValue) {
|
||||||
|
props.onSelectedItemsChange(props.selectedItems.slice(0, -1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onKeyDown?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [labelAsPlaceholder, setLabelAsPlaceholder] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasInputFocused || inputValue) {
|
||||||
|
setLabelAsPlaceholder(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLabelAsPlaceholder(props.selectedItems.length === 0);
|
||||||
|
}, [props.selectedItems, hasInputFocused, inputValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectMultiAux
|
||||||
|
{...props}
|
||||||
|
options={options}
|
||||||
|
labelAsPlaceholder={labelAsPlaceholder}
|
||||||
|
selectedItems={props.selectedItems}
|
||||||
|
downshiftReturn={{
|
||||||
|
...downshiftReturn,
|
||||||
|
wrapperProps: {
|
||||||
|
onClick: () => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toggleButtonProps: downshiftReturn.getToggleButtonProps(),
|
||||||
|
}}
|
||||||
|
useMultipleSelectionReturn={useMultipleSelectionReturn}
|
||||||
|
>
|
||||||
|
<span className="c__select__inner__value__input" data-value={inputValue}>
|
||||||
|
<input
|
||||||
|
{...inputProps}
|
||||||
|
onFocus={(e) => {
|
||||||
|
inputProps.onFocus(e);
|
||||||
|
setHasInputFocused(true);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
inputProps.onBlur(e);
|
||||||
|
setHasInputFocused(false);
|
||||||
|
}}
|
||||||
|
size={4}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</SelectMultiAux>
|
||||||
|
);
|
||||||
|
};
|
||||||
99
packages/react/src/components/Forms/Select/multi-simple.tsx
Normal file
99
packages/react/src/components/Forms/Select/multi-simple.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useMultipleSelection, useSelect } from "downshift";
|
||||||
|
import {
|
||||||
|
getMultiOptionsFilter,
|
||||||
|
SelectMultiAux,
|
||||||
|
SubProps,
|
||||||
|
} from ":/components/Forms/Select/multi-common";
|
||||||
|
import { optionToString } from ":/components/Forms/Select/mono-common";
|
||||||
|
|
||||||
|
export const SelectMultiSimple = (props: SubProps) => {
|
||||||
|
const options = React.useMemo(
|
||||||
|
() => props.options.filter(getMultiOptionsFilter(props.selectedItems, "")),
|
||||||
|
[props.selectedItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
const useMultipleSelectionReturn = useMultipleSelection({
|
||||||
|
selectedItems: props.selectedItems,
|
||||||
|
onStateChange({ selectedItems: newSelectedItems, type }) {
|
||||||
|
switch (type) {
|
||||||
|
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
|
||||||
|
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
|
||||||
|
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
|
||||||
|
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
|
||||||
|
props.onSelectedItemsChange(newSelectedItems ?? []);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const downshiftReturn = useSelect({
|
||||||
|
items: options,
|
||||||
|
itemToString: optionToString,
|
||||||
|
defaultHighlightedIndex: 0, // after selection, highlight the first item.
|
||||||
|
stateReducer: (state, actionAndChanges) => {
|
||||||
|
const { changes, type } = actionAndChanges;
|
||||||
|
switch (type) {
|
||||||
|
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
|
||||||
|
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
|
||||||
|
case useSelect.stateChangeTypes.ItemClick:
|
||||||
|
return {
|
||||||
|
...changes,
|
||||||
|
isOpen: true, // keep the menu open after selection.
|
||||||
|
highlightedIndex: 0, // with the first option highlighted.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
},
|
||||||
|
onStateChange: ({ type, selectedItem: newSelectedItem }) => {
|
||||||
|
switch (type) {
|
||||||
|
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
|
||||||
|
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
|
||||||
|
case useSelect.stateChangeTypes.ItemClick:
|
||||||
|
case useSelect.stateChangeTypes.ToggleButtonBlur:
|
||||||
|
if (newSelectedItem) {
|
||||||
|
props.onSelectedItemsChange([
|
||||||
|
...props.selectedItems,
|
||||||
|
newSelectedItem,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectMultiAux
|
||||||
|
{...props}
|
||||||
|
options={options}
|
||||||
|
labelAsPlaceholder={props.selectedItems.length === 0}
|
||||||
|
selectedItems={props.selectedItems}
|
||||||
|
downshiftReturn={{
|
||||||
|
...downshiftReturn,
|
||||||
|
wrapperProps: {
|
||||||
|
onClick: () => {
|
||||||
|
if (!props.disabled) {
|
||||||
|
downshiftReturn.toggleMenu();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toggleButtonProps: downshiftReturn.getToggleButtonProps({
|
||||||
|
...useMultipleSelectionReturn.getDropdownProps({
|
||||||
|
preventKeyAction: downshiftReturn.isOpen,
|
||||||
|
}),
|
||||||
|
disabled: props.disabled,
|
||||||
|
onClick: (e) => {
|
||||||
|
// As the wrapper also has an onClick handler, we need to stop the event propagation here on it will toggle
|
||||||
|
// twice the menu opening which will ... do nothing :).
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
useMultipleSelectionReturn={useMultipleSelectionReturn}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
112
packages/react/src/components/Forms/Select/multi.mdx
Normal file
112
packages/react/src/components/Forms/Select/multi.mdx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { Canvas, Meta, Story, Source, ArgTypes } from '@storybook/blocks';
|
||||||
|
import { Select } from "./index";
|
||||||
|
import * as Stories from './multi.stories';
|
||||||
|
|
||||||
|
<Meta of={Stories} name="Docs"/>
|
||||||
|
|
||||||
|
# Multi-Select
|
||||||
|
|
||||||
|
Cunningham provides a versatile Multi-Select component that you can use in your forms. This component follows the [ARIA design pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/)
|
||||||
|
using [Downshift](https://www.downshift-js.com/), so that mean there is no `select` wrapped inside it.
|
||||||
|
|
||||||
|
The Multi-Select leverages the regular [Select](?path=/docs/components-forms-select-mono--docs) component, you just have to add the `multi` props.
|
||||||
|
|
||||||
|
<Canvas>
|
||||||
|
<Story id="components-forms-select-multi--full-width"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
The `options` props works the same as the `options` props in the [Select](?path=/docs/components-forms-select-mono--docs#options) component.
|
||||||
|
|
||||||
|
## Searchable
|
||||||
|
|
||||||
|
You can enable the text live filtering simply by using the `searchable` props.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-select-multi--searchable-uncontrolled"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## States
|
||||||
|
|
||||||
|
You can use the following props to change the state of the Multi-Select component by using the `state` props.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-select-multi--success"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-select-multi--error"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Disabled
|
||||||
|
|
||||||
|
As a regular select, you can disable it by using the `disabled` props. In this mode the user can't select nor unselect existing options.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-select-multi--disabled"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Texts
|
||||||
|
|
||||||
|
As the component uses [Field](?path=/docs/components-forms-field--docs), you can use the `text` props to provide a description of the checkbox.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-select-multi--with-text"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Width
|
||||||
|
|
||||||
|
By default, the Multi-Select has a default width, like all inputs. But you can force it to take the full width of its container by using the `fullWidth` props.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-select-multi--full-width"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Clearable
|
||||||
|
|
||||||
|
By default, the Multi-Select is clearable ( the cross icon on the right is shown ). You can disable it by using the `clearable` props.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-select-multi--not-clearable"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Disabled options
|
||||||
|
|
||||||
|
You can disable some options by using the `disabled` props on the `Option` object.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-select-multi--disabled-options"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Hide label
|
||||||
|
|
||||||
|
For some reasons you might want to hide the label of the Multi-Select. You can do that by using the `hideLabel` props.
|
||||||
|
|
||||||
|
> It is important for accessibility to always have a label for your inputs. Keep in mind that setting `hideLabel` to `true`, will only hide the label visually, but it will still be available for screen readers in the DOM.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-select-multi--hidden-label"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Controlled / Non Controlled
|
||||||
|
|
||||||
|
Like a native select, you can use the Select component in a controlled or non controlled way. You can see the example below
|
||||||
|
using the component in a controlled way.
|
||||||
|
|
||||||
|
<Canvas sourceState="shown">
|
||||||
|
<Story id="components-forms-select-multi--controlled"/>
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
They are the same as the [Select](?path=/docs/components-forms-select-mono--docs#props) component.
|
||||||
|
|
||||||
|
## Design tokens
|
||||||
|
|
||||||
|
They are the same as the [Select](?path=/docs/components-forms-select-mono--docs#design-tokens) component plus the following:
|
||||||
|
|
||||||
|
| Token | Description |
|
||||||
|
|--------------- |----------------------------- |
|
||||||
|
| multi-pill-background-color | Background color of the pills of the multi select |
|
||||||
|
| multi-pill-border-radius | Border radius of the pills of the multi select |
|
||||||
1236
packages/react/src/components/Forms/Select/multi.spec.tsx
Normal file
1236
packages/react/src/components/Forms/Select/multi.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
306
packages/react/src/components/Forms/Select/multi.stories.tsx
Normal file
306
packages/react/src/components/Forms/Select/multi.stories.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Meta, StoryFn } from "@storybook/react";
|
||||||
|
import { faker } from "@faker-js/faker";
|
||||||
|
import { Select } from ":/components/Forms/Select";
|
||||||
|
import { CunninghamProvider } from ":/components/Provider";
|
||||||
|
import { Button } from ":/components/Button";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "Components/Forms/Select/Multi",
|
||||||
|
component: Select,
|
||||||
|
} as Meta<typeof Select>;
|
||||||
|
|
||||||
|
const Template: StoryFn<typeof Select> = (args) => (
|
||||||
|
<div style={{ paddingBottom: "200px" }}>
|
||||||
|
<CunninghamProvider>
|
||||||
|
<Select {...args} multi={true} />
|
||||||
|
</CunninghamProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CITIES = Array.from({ length: 10 }).map(() => faker.location.city());
|
||||||
|
const OPTIONS = CITIES.map((city) => ({
|
||||||
|
label: city,
|
||||||
|
value: city.toLowerCase(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const Uncontrolled = {
|
||||||
|
render: Template,
|
||||||
|
args: {
|
||||||
|
label: "Select cities",
|
||||||
|
options: OPTIONS,
|
||||||
|
defaultValue: [OPTIONS[4].value],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled = {
|
||||||
|
render: Template,
|
||||||
|
args: {
|
||||||
|
label: "Select cities",
|
||||||
|
options: OPTIONS,
|
||||||
|
defaultValue: OPTIONS[4].value,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithText = {
|
||||||
|
render: Template,
|
||||||
|
args: {
|
||||||
|
label: "Select cities",
|
||||||
|
options: OPTIONS,
|
||||||
|
defaultValue: OPTIONS[4].value,
|
||||||
|
text: "This is a text, you can display anything you want here like warnings, information or errors.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HiddenLabel = {
|
||||||
|
render: Template,
|
||||||
|
args: {
|
||||||
|
label: "Select cities",
|
||||||
|
hideLabel: true,
|
||||||
|
options: OPTIONS,
|
||||||
|
defaultValue: OPTIONS[4].value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Controlled = () => {
|
||||||
|
const [value, setValue] = useState([OPTIONS[6].value, OPTIONS[8].value]);
|
||||||
|
return (
|
||||||
|
<CunninghamProvider>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
Value: <span>{JSON.stringify(value)}</span>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
label="Select cities"
|
||||||
|
multi={true}
|
||||||
|
options={OPTIONS}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value as string[])}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => setValue([])}>Reset</Button>
|
||||||
|
</div>
|
||||||
|
</CunninghamProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Overflow = {
|
||||||
|
render: Template,
|
||||||
|
args: {
|
||||||
|
label: "Select cities",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "1",
|
||||||
|
label: "Very long long long long long long long city name",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultValue: "1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SearchableEmpty = {
|
||||||
|
render: Template,
|
||||||
|
args: {
|
||||||
|
label: "Select cities",
|
||||||
|
options: OPTIONS,
|
||||||
|
searchable: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SearchableUncontrolled = {
|
||||||
|
render: Template,
|
||||||
|
args: {
|
||||||
|
label: "Select cities",
|
||||||
|
options: OPTIONS,
|
||||||
|
defaultValue: OPTIONS[4].value,
|
||||||
|
searchable: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SearchableDisabled = {
|
||||||
|
render: Template,
|
||||||
|
args: {
|
||||||
|
label: "Select cities",
|
||||||
|
options: OPTIONS,
|
||||||
|
defaultValue: OPTIONS[4].value,
|
||||||
|
searchable: true,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SearchableControlled = () => {
|
||||||
|
const [value, setValue] = useState([OPTIONS[6].value, OPTIONS[8].value]);
|
||||||
|
return (
|
||||||
|
<CunninghamProvider>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
Value: <span>{JSON.stringify(value)}</span>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
label="Select cities"
|
||||||
|
options={OPTIONS}
|
||||||
|
searchable={true}
|
||||||
|
multi={true}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value as string[])}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => setValue([])}>Reset</Button>
|
||||||
|
</div>
|
||||||
|
</CunninghamProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FullWidth = {
|
||||||
|
render: Template,
|
||||||
|
args: {
|
||||||
|
label: "Select cities",
|
||||||
|
options: OPTIONS,
|
||||||
|
defaultValue: OPTIONS[4].value,
|
||||||
|
searchable: true,
|
||||||
|
fullWidth: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NotClearable = {
|
||||||
|
render: Template,
|
||||||
|
args: {
|
||||||
|
label: "Select cities",
|
||||||
|
options: OPTIONS,
|
||||||
|
defaultValue: OPTIONS[4].value,
|
||||||
|
clearable: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DisabledOptions = {
|
||||||
|
render: Template,
|
||||||
|
args: {
|
||||||
|
label: "Select cities",
|
||||||
|
options: OPTIONS.map((option, i) => ({ ...option, disabled: i % 3 === 0 })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SearchableDisabledOptions = {
|
||||||
|
render: Template,
|
||||||
|
args: {
|
||||||
|
searchable: true,
|
||||||
|
label: "Select cities",
|
||||||
|
options: OPTIONS.map((option, i) => ({ ...option, disabled: i % 3 === 0 })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Success = {
|
||||||
|
render: Template,
|
||||||
|
args: {
|
||||||
|
label: "Select cities",
|
||||||
|
options: OPTIONS,
|
||||||
|
state: "success",
|
||||||
|
text: "Well done",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Error = {
|
||||||
|
render: Template,
|
||||||
|
args: {
|
||||||
|
label: "Select cities",
|
||||||
|
options: OPTIONS,
|
||||||
|
state: "error",
|
||||||
|
text: "Something went wrong",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormExample = () => {
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = new FormData(e.target as any);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(data.getAll("cities"));
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(data.getAll("test"));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CunninghamProvider>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-s">
|
||||||
|
<Select
|
||||||
|
label="Your favorite cities"
|
||||||
|
name="cities"
|
||||||
|
options={OPTIONS}
|
||||||
|
text="The cities you love the most"
|
||||||
|
state="success"
|
||||||
|
defaultValue={[OPTIONS[4].value]}
|
||||||
|
multi={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-s">
|
||||||
|
<Select
|
||||||
|
label="Your departments"
|
||||||
|
name="departments"
|
||||||
|
searchable={true}
|
||||||
|
multi={true}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: "Legal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tech",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "AI",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Accounting",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-s">
|
||||||
|
<Select
|
||||||
|
label="Your skills"
|
||||||
|
text="Any error you want"
|
||||||
|
name="skills"
|
||||||
|
multi={true}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: "Front-end",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Back-end",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Full-stack",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
state="error"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-s">
|
||||||
|
<Select
|
||||||
|
label="Your options"
|
||||||
|
text="This field is disabled"
|
||||||
|
name="grade"
|
||||||
|
disabled={true}
|
||||||
|
multi={true}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: "Bronze",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Silver",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Gold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Platinum",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
defaultValue={["Platinum", "Gold"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button>Submit</Button>
|
||||||
|
</form>
|
||||||
|
</CunninghamProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
58
packages/react/src/components/Forms/Select/multi.tsx
Normal file
58
packages/react/src/components/Forms/Select/multi.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { optionToValue } from ":/components/Forms/Select/mono-common";
|
||||||
|
import { SelectMultiSearchable } from ":/components/Forms/Select/multi-searchable";
|
||||||
|
import { SelectMultiSimple } from ":/components/Forms/Select/multi-simple";
|
||||||
|
import { SubProps } from ":/components/Forms/Select/multi-common";
|
||||||
|
import { Option, SelectProps } from ":/components/Forms/Select/mono";
|
||||||
|
|
||||||
|
export type SelectMultiProps = Omit<SelectProps, "onChange"> & {
|
||||||
|
onChange?: (event: { target: { value: string[] } }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectMulti = (props: SelectMultiProps) => {
|
||||||
|
const getSelectedItemsFromProps = () => {
|
||||||
|
const valueToUse = props.defaultValue ?? props.value ?? [];
|
||||||
|
return props.options.filter((option) =>
|
||||||
|
(valueToUse as string[]).includes(optionToValue(option))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [selectedItems, setSelectedItems] = React.useState<Option[]>(
|
||||||
|
getSelectedItemsFromProps()
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the component is used as a controlled component, we need to update the local value when the value prop changes.
|
||||||
|
useEffect(() => {
|
||||||
|
// Means it is not controlled.
|
||||||
|
if (props.defaultValue !== undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedItems(getSelectedItemsFromProps());
|
||||||
|
}, [JSON.stringify(props.value)]);
|
||||||
|
|
||||||
|
// If the component is used as an uncontrolled component, we need to update the parent value when the local value changes.
|
||||||
|
useEffect(() => {
|
||||||
|
props.onChange?.({ target: { value: selectedItems.map(optionToValue) } });
|
||||||
|
}, [JSON.stringify(selectedItems)]);
|
||||||
|
|
||||||
|
const onSelectedItemsChange: SubProps["onSelectedItemsChange"] = (
|
||||||
|
newSelectedItems
|
||||||
|
) => {
|
||||||
|
setSelectedItems(newSelectedItems);
|
||||||
|
// props.onSelectedItemsChange?.(newSelectedItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
return props.searchable ? (
|
||||||
|
<SelectMultiSearchable
|
||||||
|
{...props}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onSelectedItemsChange={onSelectedItemsChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SelectMultiSimple
|
||||||
|
{...props}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onSelectedItemsChange={onSelectedItemsChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
45
packages/react/src/components/Forms/Select/test-utils.tsx
Normal file
45
packages/react/src/components/Forms/Select/test-utils.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
export const expectMenuToBeOpen = (menu: HTMLDivElement) => {
|
||||||
|
expect(Array.from(menu.classList)).contains("c__select__menu--opened");
|
||||||
|
};
|
||||||
|
export const expectOptions = (expectedOptions: string[]) => {
|
||||||
|
const options = screen.getAllByRole("option");
|
||||||
|
expect(options.length).toBe(expectedOptions.length);
|
||||||
|
options.forEach((option, i) => {
|
||||||
|
expect(option).toHaveTextContent(expectedOptions[i]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const expectMenuToBeClosed = (menu: HTMLDivElement) => {
|
||||||
|
expect(Array.from(menu.classList)).not.contains("c__select__menu--opened");
|
||||||
|
};
|
||||||
|
export const expectOptionToBeSelected = (option: HTMLLIElement) => {
|
||||||
|
expect(option).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(Array.from(option.classList)).contains(
|
||||||
|
"c__select__menu__item--selected"
|
||||||
|
);
|
||||||
|
expect(Array.from(option.classList)).contains(
|
||||||
|
"c__select__menu__item--highlight"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const expectOptionToBeUnselected = (option: HTMLLIElement) => {
|
||||||
|
expect(option).toHaveAttribute("aria-selected", "false");
|
||||||
|
expect(Array.from(option.classList)).not.contains(
|
||||||
|
"c__select__menu__item--selected"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export const expectOptionToBeDisabled = (option: HTMLLIElement) => {
|
||||||
|
expect(option).toHaveAttribute("disabled");
|
||||||
|
expect(Array.from(option.classList)).contains(
|
||||||
|
"c__select__menu__item--disabled"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expectSelectedOptions = (expectedOptions: string[]) => {
|
||||||
|
const pills = document.querySelectorAll(".c__select__inner__value__pill");
|
||||||
|
const actualOptions = Array.from(pills).map((pill) => {
|
||||||
|
return pill.textContent?.replace("close", "");
|
||||||
|
});
|
||||||
|
expect(actualOptions).toEqual(expectedOptions);
|
||||||
|
};
|
||||||
@@ -20,4 +20,6 @@ export const tokens = (defaults: DefaultTokens) => ({
|
|||||||
"background-color": "white",
|
"background-color": "white",
|
||||||
"menu-background-color": "white",
|
"menu-background-color": "white",
|
||||||
"label-color--focus": defaults.theme.colors["primary-600"],
|
"label-color--focus": defaults.theme.colors["primary-600"],
|
||||||
|
"multi-pill-background-color": defaults.theme.colors["greyscale-200"],
|
||||||
|
"multi-pill-border-radius": "2px",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -123,6 +123,8 @@
|
|||||||
--c--components--forms-select--background-color: white;
|
--c--components--forms-select--background-color: white;
|
||||||
--c--components--forms-select--menu-background-color: white;
|
--c--components--forms-select--menu-background-color: white;
|
||||||
--c--components--forms-select--label-color--focus: #0556BF;
|
--c--components--forms-select--label-color--focus: #0556BF;
|
||||||
|
--c--components--forms-select--multi-pill-background-color: #F3F4F4;
|
||||||
|
--c--components--forms-select--multi-pill-border-radius: 2px;
|
||||||
--c--components--forms-radio--border-color: #E7E8EA;
|
--c--components--forms-radio--border-color: #E7E8EA;
|
||||||
--c--components--forms-radio--accent-color: #419A14;
|
--c--components--forms-radio--accent-color: #419A14;
|
||||||
--c--components--forms-radio--background-color: white;
|
--c--components--forms-radio--background-color: white;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const tokens = {"theme":{"colors":{"primary-text":"#FFFFFF","primary-100":"#EBF2FC","primary-200":"#8CB5EA","primary-300":"#5894E1","primary-400":"#377FDB","primary-500":"#055FD2","primary-600":"#0556BF","primary-700":"#044395","primary-800":"#033474","primary-900":"#022858","secondary-text":"#555F6B","secondary-100":"#F2F7FC","secondary-200":"#EBF3FA","secondary-300":"#E2EEF8","secondary-400":"#DDEAF7","secondary-500":"#D4E5F5","secondary-600":"#C1D0DF","secondary-700":"#97A3AE","secondary-800":"#757E87","secondary-900":"#596067","greyscale-000":"#FFFFFF","greyscale-100":"#FAFAFB","greyscale-200":"#F3F4F4","greyscale-300":"#E7E8EA","greyscale-400":"#C2C6CA","greyscale-500":"#9EA3AA","greyscale-600":"#79818A","greyscale-700":"#555F6B","greyscale-800":"#303C4B","greyscale-900":"#0C1A2B","success-text":"#FFFFFF","success-100":"#EFFCD3","success-200":"#DBFAA9","success-300":"#BEF27C","success-400":"#A0E659","success-500":"#76D628","success-600":"#5AB81D","success-700":"#419A14","success-800":"#2C7C0C","success-900":"#1D6607","info-text":"#FFFFFF","info-100":"#EBF2FC","info-200":"#8CB5EA","info-300":"#5894E1","info-400":"#377FDB","info-500":"#055FD2","info-600":"#0556BF","info-700":"#044395","info-800":"#033474","info-900":"#022858","warning-text":"#FFFFFF","warning-100":"#FFF8CD","warning-200":"#FFEF9B","warning-300":"#FFE469","warning-400":"#FFDA43","warning-500":"#FFC805","warning-600":"#DBA603","warning-700":"#B78702","warning-800":"#936901","warning-900":"#7A5400","danger-text":"#FFFFFF","danger-100":"#F4B0B0","danger-200":"#EE8A8A","danger-300":"#E65454","danger-400":"#E13333","danger-500":"#DA0000","danger-600":"#C60000","danger-700":"#9B0000","danger-800":"#780000","danger-900":"#5C0000"},"font":{"sizes":{"h1":"1.75rem","h2":"1.375rem","h3":"1.125rem","h4":"0.8125rem","h5":"0.625rem","h6":"0.5rem","l":"1rem","m":"0.8125rem","s":"0.6875rem"},"weights":{"thin":200,"light":300,"regular":400,"medium":500,"bold":600,"extrabold":700,"black":800},"families":{"base":"\"Roboto Flex Variable\", sans-serif","accent":"\"Roboto Flex Variable\", sans-serif"}},"spacings":{"xl":"4rem","l":"3rem","b":"1.625rem","s":"1rem","t":"0.5rem","st":"0.25rem"},"transitions":{"ease-in":"cubic-bezier(0.32, 0, 0.67, 0)","ease-out":"cubic-bezier(0.33, 1, 0.68, 1)","ease-in-out":"cubic-bezier(0.65, 0, 0.35, 1)","duration":"250ms"}},"components":{"forms-switch":{"accent-color":"#419A14","rail-background-color":"#9EA3AA","rail-background-color--disabled":"#C2C6CA","rail-border-radius":"50vw","handle-background-color":"white","handle-background-color--disabled":"#F3F4F4","handle-border-radius":"50%"},"forms-select":{"border-color":"#E7E8EA","border-color--focus":"#0556BF","border-color--hover":"#9EA3AA","border-radius":"8px","border-radius--focus":"2px","border-radius--hover":"2px","border-style":"solid","border-width":"1px","color":"#303C4B","font-size":"1rem","height":"3.5rem","item-background-color--hover":"#F3F4F4","item-background-color--selected":"#EBF2FC","item-color":"#303C4B","item-color--disabled":"#9EA3AA","item-font-size":"1rem","background-color":"white","menu-background-color":"white","label-color--focus":"#0556BF"},"forms-radio":{"border-color":"#E7E8EA","accent-color":"#419A14","background-color":"white"},"forms-input":{"font-weight":400,"font-size":"1rem","border-radius":"8px","border-radius--hover":"2px","border-radius--focus":"2px","border-width":"1px","border-color":"#E7E8EA","border-color--hover":"#9EA3AA","border-color--focus":"#0556BF","border-style":"solid","color":"#303C4B","label-color--focus":"#0556BF","background-color":"white"},"forms-field":{"width":"292px","font-size":"0.6875rem","color":"#79818A"},"forms-datepicker":{"border-color":"#E7E8EA","border-color--focus":"#0556BF","border-color--hover":"#9EA3AA","border-radius":"8px","border-radius--focus":"2px","border-radius--hover":"2px","border-style":"solid","border-width":"2px","color":"#303C4B","font-size":"1rem","height":"3.5rem","item-background-color--hover":"#F3F4F4","item-background-color--selected":"#EBF2FC","item-color":"#303C4B","item-font-size":"1rem","background-color":"white","menu-background-color":"white","grid-cell--border-color--today":"#0556BF","grid-cell--color--today":"#0556BF"},"forms-checkbox":{"background-color--hover":"#F3F4F4","background-color":"white","font-size":"0.8125rem","font-weight":500,"color":"#0C1A2B","border-color":"#E7E8EA","border-radius":"2px","accent-color":"#419A14","size":"1.5rem"},"button":{"border-radius":"8px","border-radius--active":"2px","medium-height":"48px","small-height":"32px","medium-font-size":"1rem","small-font-size":"0.8125rem","font-weight":400}}};
|
export const tokens = {"theme":{"colors":{"primary-text":"#FFFFFF","primary-100":"#EBF2FC","primary-200":"#8CB5EA","primary-300":"#5894E1","primary-400":"#377FDB","primary-500":"#055FD2","primary-600":"#0556BF","primary-700":"#044395","primary-800":"#033474","primary-900":"#022858","secondary-text":"#555F6B","secondary-100":"#F2F7FC","secondary-200":"#EBF3FA","secondary-300":"#E2EEF8","secondary-400":"#DDEAF7","secondary-500":"#D4E5F5","secondary-600":"#C1D0DF","secondary-700":"#97A3AE","secondary-800":"#757E87","secondary-900":"#596067","greyscale-000":"#FFFFFF","greyscale-100":"#FAFAFB","greyscale-200":"#F3F4F4","greyscale-300":"#E7E8EA","greyscale-400":"#C2C6CA","greyscale-500":"#9EA3AA","greyscale-600":"#79818A","greyscale-700":"#555F6B","greyscale-800":"#303C4B","greyscale-900":"#0C1A2B","success-text":"#FFFFFF","success-100":"#EFFCD3","success-200":"#DBFAA9","success-300":"#BEF27C","success-400":"#A0E659","success-500":"#76D628","success-600":"#5AB81D","success-700":"#419A14","success-800":"#2C7C0C","success-900":"#1D6607","info-text":"#FFFFFF","info-100":"#EBF2FC","info-200":"#8CB5EA","info-300":"#5894E1","info-400":"#377FDB","info-500":"#055FD2","info-600":"#0556BF","info-700":"#044395","info-800":"#033474","info-900":"#022858","warning-text":"#FFFFFF","warning-100":"#FFF8CD","warning-200":"#FFEF9B","warning-300":"#FFE469","warning-400":"#FFDA43","warning-500":"#FFC805","warning-600":"#DBA603","warning-700":"#B78702","warning-800":"#936901","warning-900":"#7A5400","danger-text":"#FFFFFF","danger-100":"#F4B0B0","danger-200":"#EE8A8A","danger-300":"#E65454","danger-400":"#E13333","danger-500":"#DA0000","danger-600":"#C60000","danger-700":"#9B0000","danger-800":"#780000","danger-900":"#5C0000"},"font":{"sizes":{"h1":"1.75rem","h2":"1.375rem","h3":"1.125rem","h4":"0.8125rem","h5":"0.625rem","h6":"0.5rem","l":"1rem","m":"0.8125rem","s":"0.6875rem"},"weights":{"thin":200,"light":300,"regular":400,"medium":500,"bold":600,"extrabold":700,"black":800},"families":{"base":"\"Roboto Flex Variable\", sans-serif","accent":"\"Roboto Flex Variable\", sans-serif"}},"spacings":{"xl":"4rem","l":"3rem","b":"1.625rem","s":"1rem","t":"0.5rem","st":"0.25rem"},"transitions":{"ease-in":"cubic-bezier(0.32, 0, 0.67, 0)","ease-out":"cubic-bezier(0.33, 1, 0.68, 1)","ease-in-out":"cubic-bezier(0.65, 0, 0.35, 1)","duration":"250ms"}},"components":{"forms-switch":{"accent-color":"#419A14","rail-background-color":"#9EA3AA","rail-background-color--disabled":"#C2C6CA","rail-border-radius":"50vw","handle-background-color":"white","handle-background-color--disabled":"#F3F4F4","handle-border-radius":"50%"},"forms-select":{"border-color":"#E7E8EA","border-color--focus":"#0556BF","border-color--hover":"#9EA3AA","border-radius":"8px","border-radius--focus":"2px","border-radius--hover":"2px","border-style":"solid","border-width":"1px","color":"#303C4B","font-size":"1rem","height":"3.5rem","item-background-color--hover":"#F3F4F4","item-background-color--selected":"#EBF2FC","item-color":"#303C4B","item-color--disabled":"#9EA3AA","item-font-size":"1rem","background-color":"white","menu-background-color":"white","label-color--focus":"#0556BF","multi-pill-background-color":"#F3F4F4","multi-pill-border-radius":"2px"},"forms-radio":{"border-color":"#E7E8EA","accent-color":"#419A14","background-color":"white"},"forms-input":{"font-weight":400,"font-size":"1rem","border-radius":"8px","border-radius--hover":"2px","border-radius--focus":"2px","border-width":"1px","border-color":"#E7E8EA","border-color--hover":"#9EA3AA","border-color--focus":"#0556BF","border-style":"solid","color":"#303C4B","label-color--focus":"#0556BF","background-color":"white"},"forms-field":{"width":"292px","font-size":"0.6875rem","color":"#79818A"},"forms-datepicker":{"border-color":"#E7E8EA","border-color--focus":"#0556BF","border-color--hover":"#9EA3AA","border-radius":"8px","border-radius--focus":"2px","border-radius--hover":"2px","border-style":"solid","border-width":"2px","color":"#303C4B","font-size":"1rem","height":"3.5rem","item-background-color--hover":"#F3F4F4","item-background-color--selected":"#EBF2FC","item-color":"#303C4B","item-font-size":"1rem","background-color":"white","menu-background-color":"white","grid-cell--border-color--today":"#0556BF","grid-cell--color--today":"#0556BF"},"forms-checkbox":{"background-color--hover":"#F3F4F4","background-color":"white","font-size":"0.8125rem","font-weight":500,"color":"#0C1A2B","border-color":"#E7E8EA","border-radius":"2px","accent-color":"#419A14","size":"1.5rem"},"button":{"border-radius":"8px","border-radius--active":"2px","medium-height":"48px","small-height":"32px","medium-font-size":"1rem","small-font-size":"0.8125rem","font-weight":400}}};
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const tokens = {"theme":{"colors":{"primary-text":"#FFFFFF","primary-100":"#EBF2FC","primary-200":"#8CB5EA","primary-300":"#5894E1","primary-400":"#377FDB","primary-500":"#055FD2","primary-600":"#0556BF","primary-700":"#044395","primary-800":"#033474","primary-900":"#022858","secondary-text":"#555F6B","secondary-100":"#F2F7FC","secondary-200":"#EBF3FA","secondary-300":"#E2EEF8","secondary-400":"#DDEAF7","secondary-500":"#D4E5F5","secondary-600":"#C1D0DF","secondary-700":"#97A3AE","secondary-800":"#757E87","secondary-900":"#596067","greyscale-000":"#FFFFFF","greyscale-100":"#FAFAFB","greyscale-200":"#F3F4F4","greyscale-300":"#E7E8EA","greyscale-400":"#C2C6CA","greyscale-500":"#9EA3AA","greyscale-600":"#79818A","greyscale-700":"#555F6B","greyscale-800":"#303C4B","greyscale-900":"#0C1A2B","success-text":"#FFFFFF","success-100":"#EFFCD3","success-200":"#DBFAA9","success-300":"#BEF27C","success-400":"#A0E659","success-500":"#76D628","success-600":"#5AB81D","success-700":"#419A14","success-800":"#2C7C0C","success-900":"#1D6607","info-text":"#FFFFFF","info-100":"#EBF2FC","info-200":"#8CB5EA","info-300":"#5894E1","info-400":"#377FDB","info-500":"#055FD2","info-600":"#0556BF","info-700":"#044395","info-800":"#033474","info-900":"#022858","warning-text":"#FFFFFF","warning-100":"#FFF8CD","warning-200":"#FFEF9B","warning-300":"#FFE469","warning-400":"#FFDA43","warning-500":"#FFC805","warning-600":"#DBA603","warning-700":"#B78702","warning-800":"#936901","warning-900":"#7A5400","danger-text":"#FFFFFF","danger-100":"#F4B0B0","danger-200":"#EE8A8A","danger-300":"#E65454","danger-400":"#E13333","danger-500":"#DA0000","danger-600":"#C60000","danger-700":"#9B0000","danger-800":"#780000","danger-900":"#5C0000"},"font":{"sizes":{"h1":"1.75rem","h2":"1.375rem","h3":"1.125rem","h4":"0.8125rem","h5":"0.625rem","h6":"0.5rem","l":"1rem","m":"0.8125rem","s":"0.6875rem"},"weights":{"thin":200,"light":300,"regular":400,"medium":500,"bold":600,"extrabold":700,"black":800},"families":{"base":"\"Roboto Flex Variable\", sans-serif","accent":"\"Roboto Flex Variable\", sans-serif"}},"spacings":{"xl":"4rem","l":"3rem","b":"1.625rem","s":"1rem","t":"0.5rem","st":"0.25rem"},"transitions":{"ease-in":"cubic-bezier(0.32, 0, 0.67, 0)","ease-out":"cubic-bezier(0.33, 1, 0.68, 1)","ease-in-out":"cubic-bezier(0.65, 0, 0.35, 1)","duration":"250ms"}},"components":{"forms-switch":{"accent-color":"#419A14","rail-background-color":"#9EA3AA","rail-background-color--disabled":"#C2C6CA","rail-border-radius":"50vw","handle-background-color":"white","handle-background-color--disabled":"#F3F4F4","handle-border-radius":"50%"},"forms-select":{"border-color":"#E7E8EA","border-color--focus":"#0556BF","border-color--hover":"#9EA3AA","border-radius":"8px","border-radius--focus":"2px","border-radius--hover":"2px","border-style":"solid","border-width":"1px","color":"#303C4B","font-size":"1rem","height":"3.5rem","item-background-color--hover":"#F3F4F4","item-background-color--selected":"#EBF2FC","item-color":"#303C4B","item-color--disabled":"#9EA3AA","item-font-size":"1rem","background-color":"white","menu-background-color":"white","label-color--focus":"#0556BF"},"forms-radio":{"border-color":"#E7E8EA","accent-color":"#419A14","background-color":"white"},"forms-input":{"font-weight":400,"font-size":"1rem","border-radius":"8px","border-radius--hover":"2px","border-radius--focus":"2px","border-width":"1px","border-color":"#E7E8EA","border-color--hover":"#9EA3AA","border-color--focus":"#0556BF","border-style":"solid","color":"#303C4B","label-color--focus":"#0556BF","background-color":"white"},"forms-field":{"width":"292px","font-size":"0.6875rem","color":"#79818A"},"forms-datepicker":{"border-color":"#E7E8EA","border-color--focus":"#0556BF","border-color--hover":"#9EA3AA","border-radius":"8px","border-radius--focus":"2px","border-radius--hover":"2px","border-style":"solid","border-width":"2px","color":"#303C4B","font-size":"1rem","height":"3.5rem","item-background-color--hover":"#F3F4F4","item-background-color--selected":"#EBF2FC","item-color":"#303C4B","item-font-size":"1rem","background-color":"white","menu-background-color":"white","grid-cell--border-color--today":"#0556BF","grid-cell--color--today":"#0556BF"},"forms-checkbox":{"background-color--hover":"#F3F4F4","background-color":"white","font-size":"0.8125rem","font-weight":500,"color":"#0C1A2B","border-color":"#E7E8EA","border-radius":"2px","accent-color":"#419A14","size":"1.5rem"},"button":{"border-radius":"8px","border-radius--active":"2px","medium-height":"48px","small-height":"32px","medium-font-size":"1rem","small-font-size":"0.8125rem","font-weight":400}}};
|
export const tokens = {"theme":{"colors":{"primary-text":"#FFFFFF","primary-100":"#EBF2FC","primary-200":"#8CB5EA","primary-300":"#5894E1","primary-400":"#377FDB","primary-500":"#055FD2","primary-600":"#0556BF","primary-700":"#044395","primary-800":"#033474","primary-900":"#022858","secondary-text":"#555F6B","secondary-100":"#F2F7FC","secondary-200":"#EBF3FA","secondary-300":"#E2EEF8","secondary-400":"#DDEAF7","secondary-500":"#D4E5F5","secondary-600":"#C1D0DF","secondary-700":"#97A3AE","secondary-800":"#757E87","secondary-900":"#596067","greyscale-000":"#FFFFFF","greyscale-100":"#FAFAFB","greyscale-200":"#F3F4F4","greyscale-300":"#E7E8EA","greyscale-400":"#C2C6CA","greyscale-500":"#9EA3AA","greyscale-600":"#79818A","greyscale-700":"#555F6B","greyscale-800":"#303C4B","greyscale-900":"#0C1A2B","success-text":"#FFFFFF","success-100":"#EFFCD3","success-200":"#DBFAA9","success-300":"#BEF27C","success-400":"#A0E659","success-500":"#76D628","success-600":"#5AB81D","success-700":"#419A14","success-800":"#2C7C0C","success-900":"#1D6607","info-text":"#FFFFFF","info-100":"#EBF2FC","info-200":"#8CB5EA","info-300":"#5894E1","info-400":"#377FDB","info-500":"#055FD2","info-600":"#0556BF","info-700":"#044395","info-800":"#033474","info-900":"#022858","warning-text":"#FFFFFF","warning-100":"#FFF8CD","warning-200":"#FFEF9B","warning-300":"#FFE469","warning-400":"#FFDA43","warning-500":"#FFC805","warning-600":"#DBA603","warning-700":"#B78702","warning-800":"#936901","warning-900":"#7A5400","danger-text":"#FFFFFF","danger-100":"#F4B0B0","danger-200":"#EE8A8A","danger-300":"#E65454","danger-400":"#E13333","danger-500":"#DA0000","danger-600":"#C60000","danger-700":"#9B0000","danger-800":"#780000","danger-900":"#5C0000"},"font":{"sizes":{"h1":"1.75rem","h2":"1.375rem","h3":"1.125rem","h4":"0.8125rem","h5":"0.625rem","h6":"0.5rem","l":"1rem","m":"0.8125rem","s":"0.6875rem"},"weights":{"thin":200,"light":300,"regular":400,"medium":500,"bold":600,"extrabold":700,"black":800},"families":{"base":"\"Roboto Flex Variable\", sans-serif","accent":"\"Roboto Flex Variable\", sans-serif"}},"spacings":{"xl":"4rem","l":"3rem","b":"1.625rem","s":"1rem","t":"0.5rem","st":"0.25rem"},"transitions":{"ease-in":"cubic-bezier(0.32, 0, 0.67, 0)","ease-out":"cubic-bezier(0.33, 1, 0.68, 1)","ease-in-out":"cubic-bezier(0.65, 0, 0.35, 1)","duration":"250ms"}},"components":{"forms-switch":{"accent-color":"#419A14","rail-background-color":"#9EA3AA","rail-background-color--disabled":"#C2C6CA","rail-border-radius":"50vw","handle-background-color":"white","handle-background-color--disabled":"#F3F4F4","handle-border-radius":"50%"},"forms-select":{"border-color":"#E7E8EA","border-color--focus":"#0556BF","border-color--hover":"#9EA3AA","border-radius":"8px","border-radius--focus":"2px","border-radius--hover":"2px","border-style":"solid","border-width":"1px","color":"#303C4B","font-size":"1rem","height":"3.5rem","item-background-color--hover":"#F3F4F4","item-background-color--selected":"#EBF2FC","item-color":"#303C4B","item-color--disabled":"#9EA3AA","item-font-size":"1rem","background-color":"white","menu-background-color":"white","label-color--focus":"#0556BF","multi-pill-background-color":"#F3F4F4","multi-pill-border-radius":"2px"},"forms-radio":{"border-color":"#E7E8EA","accent-color":"#419A14","background-color":"white"},"forms-input":{"font-weight":400,"font-size":"1rem","border-radius":"8px","border-radius--hover":"2px","border-radius--focus":"2px","border-width":"1px","border-color":"#E7E8EA","border-color--hover":"#9EA3AA","border-color--focus":"#0556BF","border-style":"solid","color":"#303C4B","label-color--focus":"#0556BF","background-color":"white"},"forms-field":{"width":"292px","font-size":"0.6875rem","color":"#79818A"},"forms-datepicker":{"border-color":"#E7E8EA","border-color--focus":"#0556BF","border-color--hover":"#9EA3AA","border-radius":"8px","border-radius--focus":"2px","border-radius--hover":"2px","border-style":"solid","border-width":"2px","color":"#303C4B","font-size":"1rem","height":"3.5rem","item-background-color--hover":"#F3F4F4","item-background-color--selected":"#EBF2FC","item-color":"#303C4B","item-font-size":"1rem","background-color":"white","menu-background-color":"white","grid-cell--border-color--today":"#0556BF","grid-cell--color--today":"#0556BF"},"forms-checkbox":{"background-color--hover":"#F3F4F4","background-color":"white","font-size":"0.8125rem","font-weight":500,"color":"#0C1A2B","border-color":"#E7E8EA","border-radius":"2px","accent-color":"#419A14","size":"1.5rem"},"button":{"border-radius":"8px","border-radius--active":"2px","medium-height":"48px","small-height":"32px","medium-font-size":"1rem","small-font-size":"0.8125rem","font-weight":400}}};
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
"forms": {
|
"forms": {
|
||||||
"select": {
|
"select": {
|
||||||
"toggle_button_aria_label": "Toggle dropdown",
|
"toggle_button_aria_label": "Toggle dropdown",
|
||||||
"clear_button_aria_label": "Clear selection"
|
"clear_button_aria_label": "Clear selection",
|
||||||
|
"clear_all_button_aria_label": "Clear all selections"
|
||||||
},
|
},
|
||||||
"date_picker": {
|
"date_picker": {
|
||||||
"toggle_button_aria_label_open": "Open calendar",
|
"toggle_button_aria_label_open": "Open calendar",
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
"forms": {
|
"forms": {
|
||||||
"select": {
|
"select": {
|
||||||
"toggle_button_aria_label": "Ouvrir le menu",
|
"toggle_button_aria_label": "Ouvrir le menu",
|
||||||
"clear_button_aria_label": "Effacer la sélection"
|
"clear_button_aria_label": "Effacer la sélection",
|
||||||
|
"clear_all_button_aria_label": "Effacer toutes les sélections"
|
||||||
},
|
},
|
||||||
"date_picker": {
|
"date_picker": {
|
||||||
"toggle_button_aria_label_open": "Ouvrir le calendrier",
|
"toggle_button_aria_label_open": "Ouvrir le calendrier",
|
||||||
|
|||||||
Reference in New Issue
Block a user