+
+ );
+ };
+ render();
+
+ const input = screen.getByRole("combobox", {
+ name: "City",
+ });
+
+ // Make sure value is selected.
+ screen.getByText("Value = london|");
+
+ // Change value.
+ const user = userEvent.setup();
+ await user.click(input);
+
+ // Make sure the option is selected.
+ const option: HTMLLIElement = screen.getByRole("option", {
+ name: "London",
+ });
+ expectOptionToBeSelected(option);
+
+ // List should show only selected value.
+ expectOptions(["London"]);
+
+ // Clear value.
+ const button = screen.getByRole("button", {
+ name: "Clear",
+ });
+ await user.click(button);
+
+ // Select an option.
+ await user.click(input);
+ await user.click(
+ screen.getByRole("option", {
+ name: "New York",
+ })
+ );
+
+ // Make sure value is selected.
+ screen.getByText("Value = new_york|");
+
+ // clear value.
+ await user.click(button);
+
+ // Make sure value is cleared.
+ screen.getByText("Value = |");
+ });
+ it("renders disabled", async () => {
+ render(
+
+
+
+ );
+ const input = screen.getByRole("combobox", {
+ name: "City",
+ });
+ expect(input).toHaveAttribute("disabled");
+
+ const button: HTMLButtonElement = document.querySelector(
+ ".c__select__inner__actions__open"
+ )!;
+ expect(button).toBeDisabled();
+
+ const menu: HTMLDivElement = screen.getByRole("listbox", {
+ name: "City",
+ });
+ expectMenuToBeClosed(menu);
+
+ const user = userEvent.setup();
+
+ // Try to open the menu.
+ await user.click(input);
+
+ // Make sure menu is still closed.
+ expectMenuToBeClosed(menu);
+
+ // Make sure no value is rendered
+ const valueRendered = document.querySelector(".c__select__inner__value");
+ expect(valueRendered).toHaveTextContent("");
+
+ // Try to type
+ await user.type(input, "Pa");
+ expectMenuToBeClosed(menu);
+ });
+ it("submits form data", async () => {
+ let formData: any;
+ const Wrapper = () => {
+ const onSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ const data = new FormData(e.currentTarget);
+ formData = {
+ city: data.get("city"),
+ };
+ };
+
+ return (
+
+
+
+
+
+ );
+ };
+ render();
+
+ const user = userEvent.setup();
+ const input = screen.getByRole("combobox", {
+ name: "City",
+ });
+ const menu: HTMLDivElement = screen.getByRole("listbox", {
+ name: "City",
+ });
+ const button = screen.getByRole("button", {
+ name: "Submit",
+ });
+
+ // Submit the form being empty.
+ await user.click(button);
+ expect(formData).toEqual({
+ city: null,
+ });
+
+ // Try to type something and verify that is does not submit it.
+ await user.type(input, "Pa");
+ await user.click(button);
+ expect(formData).toEqual({
+ city: null,
+ });
+
+ // Select an option
+ await user.clear(input);
+ await user.click(input);
+ expectMenuToBeOpen(menu);
+
+ await user.click(
+ screen.getByRole("option", {
+ name: "New York",
+ })
+ );
+
+ // Submit the form being fulfilled.
+ await user.click(button);
+ expect(formData).toEqual({
+ city: "new_york",
+ });
+
+ // Clear selection.
+ const clearButton = screen.getByRole("button", {
+ name: "Clear selection",
+ });
+ await userEvent.click(clearButton);
+
+ // Submit again.
+ await user.click(button);
+ expect(formData).toEqual({
+ city: null,
+ });
+ });
+ });
+
+ describe("Simple", () => {
+ it("should select an option and unselect it", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+
+ );
+ const input = screen.getByRole("combobox", {
+ name: "City",
+ });
+ const menu: HTMLDivElement = screen.getByRole("listbox", {
+ name: "City",
+ });
+ const label = screen.getByText("City");
+ const valueRendered = document.querySelector(".c__select__inner__value");
+
+ // Make sure no value is rendered.
+ expect(valueRendered).toHaveTextContent("");
+ expectMenuToBeClosed(menu);
+ expect(
+ screen.queryByRole("option", { name: "Paris" })
+ ).not.toBeInTheDocument();
+
+ // Make sure the label is set as placeholder.
+ expect(Array.from(label.classList)).toContain("placeholder");
+
+ await user.click(input);
+
+ // Make sure the menu is opened and options are rendered.
+ expectMenuToBeOpen(menu);
+ expect(
+ screen.queryByRole("option", { name: "Paris" })
+ ).toBeInTheDocument();
+
+ // Make sure the option is not selected.
+ let option: HTMLLIElement = screen.getByRole("option", {
+ name: "London",
+ });
+ expectOptionToBeUnselected(option);
+
+ // Select an option.
+ await user.click(option);
+
+ // Make sure option is selected.
+ expect(valueRendered).toHaveTextContent("London");
+ expect(Array.from(label.classList)).not.toContain("placeholder");
+
+ // Make sure menu is automatically closed.
+ expectMenuToBeClosed(menu);
+
+ // Open it again
+ await user.click(input);
+
+ expectMenuToBeOpen(menu);
+
+ // Make sure the option is marked as selected.
+ option = screen.getByRole("option", { name: "London" });
+ expectOptionToBeSelected(option);
+
+ // Clear selection.
+ const clearButton = screen.getByRole("button", {
+ name: "Clear selection",
+ });
+ await userEvent.click(clearButton);
+
+ // Make sure value is cleared.
+ expect(valueRendered).toHaveTextContent("");
+ expect(Array.from(label.classList)).toContain("placeholder");
+
+ // Make sure the option is unselected.
+ option = screen.getByRole("option", { name: "London" });
+ await waitFor(() => expectOptionToBeUnselected(option));
+ });
+ it("should select with defaultValue using label", () => {
+ render(
+
+
+
+ );
+ const menu: HTMLDivElement = screen.getByRole("listbox", {
+ name: "City",
+ });
+ const label = screen.getByText("City");
+ const valueRendered = document.querySelector(".c__select__inner__value");
+
+ // Make sure option is selected.
+ expect(valueRendered).toHaveTextContent("Tokyo");
+ expect(Array.from(label.classList)).not.toContain("placeholder");
+
+ // Make sure menu is automatically closed.
+ expectMenuToBeClosed(menu);
+ });
+ it("should select with defaultValue using value", () => {
+ render(
+
+
+
+ );
+ const menu: HTMLDivElement = screen.getByRole("listbox", {
+ name: "City",
+ });
+ const label = screen.getByText("City");
+ const valueRendered = document.querySelector(".c__select__inner__value");
+
+ // Make sure option is selected.
+ expect(valueRendered).toHaveTextContent("New York");
+ expect(Array.from(label.classList)).not.toContain("placeholder");
+
+ // Make sure menu is automatically closed.
+ expectMenuToBeClosed(menu);
+ });
+ it("works controlled", async () => {
+ const Wrapper = () => {
+ const [value, setValue] = useState(
+ "london"
+ );
+ return (
+
+
+
Value = {value}|
+
+
+
+ );
+ };
+ render();
+
+ const input = screen.getByRole("combobox", {
+ name: "City",
+ });
+
+ // Make sure value is selected.
+ screen.getByText("Value = london|");
+
+ // Change value.
+ const user = userEvent.setup();
+ await user.click(input);
+
+ // Make sure the option is selected.
+ const option: HTMLLIElement = screen.getByRole("option", {
+ name: "London",
+ });
+ expectOptionToBeSelected(option);
+
+ // Select an option.
+ await user.click(
+ screen.getByRole("option", {
+ name: "New York",
+ })
+ );
+
+ // Make sure value is selected.
+ screen.getByText("Value = new_york|");
+
+ // clear value.
+ const button = screen.getByRole("button", {
+ name: "Clear",
+ });
+ await user.click(button);
+
+ // Make sure value is cleared.
+ screen.getByText("Value = |");
+ });
+
+ it("renders disabled", async () => {
+ render(
+
+
+
+ );
+ const input = screen.getByRole("combobox", {
+ name: "City",
+ });
+ expect(input).toHaveAttribute("disabled");
+
+ const button: HTMLButtonElement = document.querySelector(
+ ".c__select__inner__actions__open"
+ )!;
+ expect(button).toBeDisabled();
+
+ const menu: HTMLDivElement = screen.getByRole("listbox", {
+ name: "City",
+ });
+ expectMenuToBeClosed(menu);
+
+ const user = userEvent.setup();
+
+ // Try to open the menu.
+ await user.click(input);
+
+ // Make sure menu is still closed.
+ expectMenuToBeClosed(menu);
+
+ // Make sure no value is rendered
+ const valueRendered = document.querySelector(".c__select__inner__value");
+ expect(valueRendered).toHaveTextContent("");
+ });
+ it("renders with text", async () => {
+ render(
+
+
+
+ );
+ screen.getByText("This is a text");
+ });
+ it("renders with state=error", async () => {
+ render(
+
+
+
+ );
+ expect(
+ document.querySelector(".c__select.c__select--error")
+ ).toBeInTheDocument();
+ });
+ it("renders with state=success", async () => {
+ render(
+
+
+
+ );
+ expect(
+ document.querySelector(".c__select.c__select--success")
+ ).toBeInTheDocument();
+ });
+ it("submits form data", async () => {
+ let formData: any;
+ const Wrapper = () => {
+ const onSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ const data = new FormData(e.currentTarget);
+ formData = {
+ city: data.get("city"),
+ };
+ };
+
+ return (
+
+
+
+
+
+ );
+ };
+ render();
+
+ const user = userEvent.setup();
+ const input = screen.getByRole("combobox", {
+ name: "City",
+ });
+ const menu: HTMLDivElement = screen.getByRole("listbox", {
+ name: "City",
+ });
+ const button = screen.getByRole("button", {
+ name: "Submit",
+ });
+
+ // Submit the form being empty.
+ await user.click(button);
+ expect(formData).toEqual({
+ city: null,
+ });
+
+ // Select an option
+ await user.click(input);
+ expectMenuToBeOpen(menu);
+
+ await user.click(
+ screen.getByRole("option", {
+ name: "New York",
+ })
+ );
+
+ // Submit the form being fulfilled.
+ await user.click(button);
+ expect(formData).toEqual({
+ city: "new_york",
+ });
+
+ // Clear selection.
+ const clearButton = screen.getByRole("button", {
+ name: "Clear selection",
+ });
+ await userEvent.click(clearButton);
+
+ // Submit again.
+ await user.click(button);
+ expect(formData).toEqual({
+ city: null,
+ });
+ });
+ });
+});
diff --git a/packages/react/src/components/Forms/Select/index.stories.mdx b/packages/react/src/components/Forms/Select/index.stories.mdx
new file mode 100644
index 0000000..cb03bb9
--- /dev/null
+++ b/packages/react/src/components/Forms/Select/index.stories.mdx
@@ -0,0 +1,134 @@
+import { Canvas, Meta, Story, Source, ArgsTable } from '@storybook/addon-docs';
+import { Select } from "./index";
+
+
+
+export const Template = (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.
+
+
+
+## Options
+
+The available options must be given via the `options` props. It is an array of objects with the following shape:
+
+
+
+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.
+
+
+
+## States
+
+You can use the following props to change the state of the Select component by using the `state` props.
+
+
+
+
+
+## Disabled
+
+As a regular select, you can disable it by using the `disabled` props.
+
+
+
+## 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.
+
+
+
+## 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.
+
+
+
+## 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.
+
+
+
+## Props
+
+The props of this component are as close as possible to the native select component. You can see the list of props below.
+
+
+
+## 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
+
+
+
+##
+
+
+
+##
+
+
+
+##
+
+
\ No newline at end of file
diff --git a/packages/react/src/components/Forms/Select/index.tsx b/packages/react/src/components/Forms/Select/index.tsx
new file mode 100644
index 0000000..f3d8cc8
--- /dev/null
+++ b/packages/react/src/components/Forms/Select/index.tsx
@@ -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;
+ selectedItem?: Option | null;
+ getLabelProps: any;
+ toggleButtonProps: any;
+ getMenuProps: any;
+ getItemProps: any;
+ highlightedIndex: number;
+ selectItem: UseSelectReturnValue