diff --git a/.changeset/good-coins-divide.md b/.changeset/good-coins-divide.md new file mode 100644 index 0000000..4bd21c5 --- /dev/null +++ b/.changeset/good-coins-divide.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": minor +--- + +add Select component diff --git a/.changeset/lazy-crews-press.md b/.changeset/lazy-crews-press.md new file mode 100644 index 0000000..d196158 --- /dev/null +++ b/.changeset/lazy-crews-press.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": patch +--- + +add forwardRef to Button diff --git a/.changeset/moody-glasses-fly.md b/.changeset/moody-glasses-fly.md new file mode 100644 index 0000000..cf9886c --- /dev/null +++ b/.changeset/moody-glasses-fly.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": patch +--- + +create a generic LabelledBox diff --git a/packages/react/package.json b/packages/react/package.json index 94d5b9f..d3d4955 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -43,6 +43,7 @@ "@openfun/cunningham-tokens": "*", "@tanstack/react-table": "8.8.4", "classnames": "2.3.2", + "downshift": "7.6.0", "react": "18.2.0", "react-dom": "18.2.0" }, @@ -51,9 +52,9 @@ }, "devDependencies": { "@babel/core": "7.21.3", - "@babel/preset-typescript": "7.21.4", "@babel/plugin-proposal-decorators": "7.21.0", "@babel/plugin-proposal-export-default-from": "7.18.10", + "@babel/preset-typescript": "7.21.4", "@faker-js/faker": "7.6.0", "@openfun/cunningham-tokens": "*", "@openfun/typescript-configs": "*", diff --git a/packages/react/src/components/Forms/Select/index.scss b/packages/react/src/components/Forms/Select/index.scss new file mode 100644 index 0000000..8791ef4 --- /dev/null +++ b/packages/react/src/components/Forms/Select/index.scss @@ -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); + } + } + } +} \ No newline at end of file diff --git a/packages/react/src/components/Forms/Select/index.spec.tsx b/packages/react/src/components/Forms/Select/index.spec.tsx new file mode 100644 index 0000000..5c592d6 --- /dev/null +++ b/packages/react/src/components/Forms/Select/index.spec.tsx @@ -0,0 +1,1032 @@ +import userEvent from "@testing-library/user-event"; +import { render, screen, waitFor } from "@testing-library/react"; +import { expect } from "vitest"; +import React, { FormEvent, useState } from "react"; +import { Select } from ":/components/Forms/Select/index"; +import { Button } from ":/components/Button"; +import { CunninghamProvider } from ":/components/Provider"; + +describe(" + + ); + + const input = screen.getByRole("combobox", { + name: "City", + }); + // It returns the input. + expect(input.tagName).toEqual("INPUT"); + + const menu: HTMLDivElement = screen.getByRole("listbox", { + name: "City", + }); + + expectMenuToBeClosed(menu); + + // Click on the input. + await user.click(input); + expectMenuToBeOpen(menu); + expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]); + + // Select an option. + const option: HTMLLIElement = screen.getByRole("option", { + name: "New York", + }); + await user.click(option); + + // The menu should be closed. + expectMenuToBeClosed(menu); + + // The input should have the selected value. + expect(input).toHaveValue("New York"); + }); + it("filters options when typing", async () => { + const user = userEvent.setup(); + render( + + + + ); + + const user = userEvent.setup(); + const input = screen.getByRole("combobox", { + name: "City", + }); + expect(input).toHaveValue("New York"); + + // Clear selection. + const clearButton = screen.getByRole("button", { + name: "Clear selection", + }); + await user.click(clearButton); + expect(input).toHaveValue(""); + }); + it("should select with defaultValue using label", async () => { + render( + + + + ); + + const input = screen.getByRole("combobox", { + name: "City", + }); + expect(input).toHaveValue("New York"); + }); + it("should not select any value if there is not match", async () => { + render( + + setValue(e.target.value)} + searchable={true} + /> + + + ); + }; + 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( + + + + + + + ); + }; + 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 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( + + setValue(e.target.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( + + + + ); + screen.getByText("This is a text"); + }); + it("renders with state=error", 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 ( + +
+
+ ; + +# 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