(react) add Select component

Finally our powerful Select component is available to make great forms!
This commit is contained in:
Nathan Vasse
2023-05-05 16:04:50 +02:00
committed by NathanVss
parent 270484c0e7
commit 2ff5fc5d29
21 changed files with 2441 additions and 158 deletions

View File

@@ -0,0 +1,183 @@
.c__select {
position: relative;
&__wrapper {
border-radius: var(--c--components--forms-select--border-radius);
border-width: var(--c--components--forms-select--border-width);
border-color: var(--c--components--forms-select--border-color);
border-style: var(--c--components--forms-select--border-style);
display: flex;
align-items: center;
transition: border var(--c--theme--transitions--duration) var(--c--theme--transitions--ease-out);
padding: 0 0.75rem;
gap: 1rem;
color: var(--c--components--forms-select--color);
box-sizing: border-box;
height: var(--c--components--forms-select--height);
cursor: pointer;
background-color: var(--c--components--forms-select--background-color);
position: relative;
overflow: hidden;
label {
cursor: pointer;
}
&:hover {
border-radius: var(--c--components--forms-select--border-radius--hover);
border-color: var(--c--components--forms-select--border-color--hover);
}
&:focus-within {
border-radius: var(--c--components--forms-select--border-radius--focus);
border-color: var(--c--components--forms-select--border-color--focus);
}
}
&__inner {
flex-grow: 1;
display: flex;
justify-content: space-between;
user-select: none;
min-width: 0;
&__value {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
font-size: var(--c--components--forms-select--font-size);
input {
outline: 0;
border: 0;
padding: 0;
margin: 0;
color: var(--c--components--forms-select--color);
font-size: var(--c--components--forms-select--font-size);
}
}
&__actions {
position: relative;
top: -14px;
display: flex;
align-items: center;
span {
font-size: 1.25rem;
transition: all var(--c--theme--transitions--duration) var(--c--theme--transitions--ease-out);
&.opened {
transform: rotate(180deg);
}
}
&__clear {
color: var(--c--theme--colors--greyscale-500);
}
&__separator {
background-color: var(--c--theme--colors--greyscale-400);
height: 24px;
width: 1px;
}
&__open {
color: var(--c--theme--colors--greyscale-900);
}
}
}
&__menu {
position: absolute;
overflow: auto;
width: calc(100% - 4px);
max-height: 10rem;
box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.3);
background-color: var(--c--components--forms-select--menu-background-color);
transform: translate(2px, 0);
display: none;
z-index: 1;
&--opened {
display: block;
}
ul {
list-style-type: none;
padding: 0;
margin: 0;
padding-top: 3px;
}
&__item {
padding: 0.75rem;
font-size: var(--c--components--forms-select--item-font-size);
color: var(--c--components--forms-select--item-color);
cursor: pointer;
&--highlight {
background-color: var(--c--components--forms-select--item-background-color--hover);
}
&--selected {
background-color: var(--c--components--forms-select--item-background-color--selected);
}
}
}
/** Modifiers */
&--disabled {
.c__select__wrapper {
color: var(--c--theme--colors--greyscale-600);
border-color: var(--c--theme--colors--greyscale-200);
cursor: default;
label {
cursor: default;
}
input {
color: var(--c--theme--colors--greyscale-600);
background-color: white;
}
}
.c__input__inner {
.c__input, label {
color: var(--c--theme--colors--greyscale-600);
}
}
&:hover {
border-color: var(--c--theme--colors--greyscale-200);
}
}
&--error {
.c__select__wrapper {
border-color: var(--c--theme--colors--danger-600);
}
&:not(.c__select__wrapper--disabled) {
.c__select__wrapper:hover {
border-color: var(--c--theme--colors--danger-200);
}
}
}
&--success {
.c__select__wrapper {
border-color: var(--c--theme--colors--success-600);
}
&:not(.c__select__wrapper--disabled) {
.c__select__wrapper:hover {
border-color: var(--c--theme--colors--success-400);
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,134 @@
import { Canvas, Meta, Story, Source, ArgsTable } from '@storybook/addon-docs';
import { Select } from "./index";
<Meta title="Components/Forms/Select/Doc" component={Select}/>
export const Template = (args) => <Input {...args} />;
# Select
Cunningham provides a versatile 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.
> For now it is only available for mono selection, multiple selection will be available soon.
<Canvas>
<Story id="components-forms-select-mono--uncontrolled"/>
</Canvas>
## Options
The available options must be given via the `options` props. It is an array of objects with the following shape:
<Source
language='ts'
dark
format={false}
code={`{
label: string
value?: string
}`}
/>
As you can see the `value` is optional, if not provided, the `label` will be used as the value.
## Searchable
You can enable the text live filtering simply by using the `searchable` props.
<Canvas withSource="open">
<Story id="components-forms-select-mono--searchable-uncontrolled"/>
</Canvas>
## States
You can use the following props to change the state of the Select component by using the `state` props.
<Canvas withSource="open">
<Story id="components-forms-select-mono--success"/>
</Canvas>
<Canvas withSource="open">
<Story id="components-forms-select-mono--error"/>
</Canvas>
## Disabled
As a regular select, you can disable it by using the `disabled` props.
<Canvas withSource="open">
<Story id="components-forms-select-mono--disabled"/>
</Canvas>
## Texts
As the component uses [Field](?path=/story/components-forms-field-doc--page), you can use the `text` props to provide a description of the checkbox.
<Canvas withSource="open">
<Story id="components-forms-select-mono--with-text"/>
</Canvas>
## Width
By default, the 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 withSource="open">
<Story id="components-forms-select-mono--full-width"/>
</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 withSource="open">
<Story id="components-forms-select-mono--controlled"/>
</Canvas>
## Props
The props of this component are as close as possible to the native select component. You can see the list of props below.
<ArgsTable of={Select} />
## Design tokens
Here are the custom design tokens defined by the select.
| Token | Description |
|--------------- |----------------------------- |
| background-color | Background color of the select |
| border-color | Border color of the select |
| border-color--hover | Border color of the select on mouse hover |
| border-color--focus | Border color of the select when focus |
| border-radius | Border radius of the select |
| border-radius--hover | Border radius of the select on mouse hover |
| border-radius--focus | Border radius of the select when focused |
| color | Value color |
| font-size | Value font size |
| height | Height of the combo box |
| item-background-color--hover | Background color of the item on mouse hover |
| item-background-color--selected | Background color of the selected item |
| item-color | Color of the item |
| item-font-size | Font size of the item |
| menu-background-color | Background color of the menu |
See also [Field](?path=/story/components-forms-field-doc--page)
## Form Example
<Canvas>
<Story id="components-forms-select-mono--form-example"/>
</Canvas>
##
<img src="components/Forms/Select/resources/dd_1.svg"/>
##
<img src="components/Forms/Select/resources/dd_2.svg"/>
##
<img src="components/Forms/Select/resources/dd_3.svg"/>

View File

@@ -0,0 +1,352 @@
import React, {
HTMLAttributes,
PropsWithChildren,
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 {
value?: string;
label: string;
}
type Props = PropsWithChildren &
FieldProps & {
label: string;
options: Option[];
searchable?: boolean;
name?: string;
defaultValue?: string | number;
value?: string | number;
onChange?: (event: {
target: { value: string | number | undefined };
}) => void;
disabled?: 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,
labelAsPlaceholder,
downshiftProps,
downshiftReturn,
value,
disabled,
}: 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}
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">
{!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) => (
<li
className={classNames("c__select__menu__item", {
"c__select__menu__item--highlight":
downshiftReturn.highlightedIndex === index,
"c__select__menu__item--selected":
downshiftReturn.selectedItem === item,
})}
key={`${item.value}${index}`}
{...downshiftReturn.getItemProps({ item, index })}
>
<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) {
throw new Error(
"You cannot use both defaultValue and value props on Select component"
);
}
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 ? (
<SelectSearchable {...props} downshiftProps={commonDownshiftProps} />
) : (
<SelectSimple {...props} downshiftProps={commonDownshiftProps} />
);
};

View File

@@ -0,0 +1,258 @@
import { ComponentMeta, ComponentStory } from "@storybook/react";
import React, { useState } from "react";
import { faker } from "@faker-js/faker";
import { Select } from ":/components/Forms/Select";
import { Button } from ":/components/Button";
import { CunninghamProvider } from ":/components/Provider";
export default {
title: "Components/Forms/Select/Mono",
component: Select,
} as ComponentMeta<typeof Select>;
const Template: ComponentStory<typeof Select> = (args) => (
<div style={{ paddingBottom: "200px" }}>
<CunninghamProvider>
<Select {...args} />
</CunninghamProvider>
</div>
);
const CITIES = Array.from({ length: 10 }).map(() => faker.address.city());
const OPTIONS = CITIES.map((city) => ({
label: city,
value: city.toLowerCase(),
}));
export const Uncontrolled = Template.bind({});
Uncontrolled.args = {
label: "Select a city",
options: OPTIONS,
defaultValue: OPTIONS[4].value,
};
export const Disabled = Template.bind({});
Disabled.args = {
label: "Select a city",
options: OPTIONS,
defaultValue: OPTIONS[4].value,
disabled: true,
};
export const WithText = Template.bind({});
WithText.args = {
label: "Select a city",
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 Controlled = () => {
const [value, setValue] = useState(OPTIONS[8].value);
return (
<CunninghamProvider>
<div>
<div>
Value: <span>{value}</span>
</div>
<Select
label="Select a city"
options={OPTIONS}
value={value}
onChange={(e) => setValue(e.target.value as string)}
/>
<Button onClick={() => setValue("")}>Reset</Button>
</div>
</CunninghamProvider>
);
};
export const Overflow = Template.bind({});
Overflow.args = {
label: "Select a city",
options: [
{
value: "1",
label: "Very long long long long long long long city name",
},
],
defaultValue: "1",
};
export const SearchableEmpty = Template.bind({});
SearchableEmpty.args = {
label: "Select a city",
options: OPTIONS,
searchable: true,
};
export const SearchableUncontrolled = Template.bind({});
SearchableUncontrolled.args = {
label: "Select a city",
options: OPTIONS,
defaultValue: OPTIONS[4].value,
searchable: true,
};
export const SearchableDisabled = Template.bind({});
SearchableDisabled.args = {
label: "Select a city",
options: OPTIONS,
defaultValue: OPTIONS[4].value,
searchable: true,
disabled: true,
};
export const SearchableControlled = () => {
const [value, setValue] = useState(OPTIONS[8].value);
return (
<CunninghamProvider>
<div>
<div>
Value: <span>{value}</span>
</div>
<Select
label="Select a city"
options={OPTIONS}
searchable={true}
value={value}
onChange={(e) => setValue(e.target.value as string)}
/>
<Button onClick={() => setValue("")}>Reset</Button>
</div>
</CunninghamProvider>
);
};
export const FullWidth = Template.bind({});
FullWidth.args = {
label: "Select a city",
options: OPTIONS,
fullWidth: true,
};
export const Success = Template.bind({});
Success.args = {
label: "Select a city",
options: OPTIONS,
state: "success",
text: "Well done",
};
export const Error = Template.bind({});
Error.args = {
label: "Select a city",
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("city"));
};
return (
<CunninghamProvider>
<form onSubmit={handleSubmit}>
<div className="mb-s">
<Select
label="Your city"
name="city"
options={OPTIONS}
defaultValue={OPTIONS[2].value}
text="The city you were born in"
state="success"
/>
</div>
<div className="mb-s">
<Select
label="Your gender"
name="gender"
options={[
{
label: "Male",
},
{
label: "Female",
},
{
label: "Other",
},
]}
/>
</div>
<div className="mb-s">
<Select
label="Your department"
name="department"
searchable={true}
options={[
{
label: "Legal",
},
{
label: "Tech",
},
{
label: "AI",
},
{
label: "Accounting",
},
]}
/>
</div>
<div className="mb-s">
<Select
label="Your grade"
text="Any error you want"
name="grade"
searchable={true}
options={[
{
label: "Level 1",
},
{
label: "Level 2",
},
{
label: "Level 3",
},
{
label: "Emperor",
},
]}
state="error"
/>
</div>
<div className="mb-s">
<Select
label="Your plan"
text="This field is disabled"
name="grade"
disabled={true}
options={[
{
label: "Bronze",
},
{
label: "Silver",
},
{
label: "Gold",
},
{
label: "Platinum",
},
]}
defaultValue="Platinum"
/>
</div>
<Button>Submit</Button>
</form>
</CunninghamProvider>
);
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 266 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 364 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 268 KiB

View File

@@ -0,0 +1,21 @@
import { DefaultTokens } from "@openfun/cunningham-tokens";
export const tokens = (defaults: DefaultTokens) => ({
"border-color": defaults.theme.colors["greyscale-300"],
"border-color--focus": defaults.theme.colors["primary-600"],
"border-color--hover": defaults.theme.colors["greyscale-500"],
"border-radius": "8px",
"border-radius--focus": "2px",
"border-radius--hover": "2px",
"border-style": "solid",
"border-width": "2px",
color: defaults.theme.colors["greyscale-800"],
"font-size": defaults.theme.font.sizes.l,
height: "3.5rem",
"item-background-color--hover": defaults.theme.colors["greyscale-200"],
"item-background-color--selected": defaults.theme.colors["primary-100"],
"item-color": defaults.theme.colors["greyscale-800"],
"item-font-size": defaults.theme.font.sizes.l,
"background-color": "white",
"menu-background-color": "white",
});