diff --git a/.changeset/three-days-fold.md b/.changeset/three-days-fold.md new file mode 100644 index 0000000..c3cdb5f --- /dev/null +++ b/.changeset/three-days-fold.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": minor +--- + +add Switch component diff --git a/packages/react/src/components/Forms/Switch/index.mdx b/packages/react/src/components/Forms/Switch/index.mdx new file mode 100644 index 0000000..3fa5768 --- /dev/null +++ b/packages/react/src/components/Forms/Switch/index.mdx @@ -0,0 +1,137 @@ +import { Canvas, Story, Meta, ArgsTable, Source } from '@storybook/addon-docs'; +import { Switch } from './index'; +import * as Stories from './index.stories'; + + + +# Switch + +Cunningham provides a Switch component that can be used in a variety of ways. + +> To better understand the Switch component, keep in mind that it is kind of a wrapper around the native HTML checkbox element. + + + + + + +## Label + +The `label` props is optional, but you can use it to provide a description of the switch. + +**Without label** + + + + + +**With label** + + + + + +## Label Side + +You can decide where to place the label by using the `labelSide` prop. + +> By default the label is placed on the left. + +**Label on the left (default)** + + + + + +**Label on the right** + + + + + +## Disabled + +You can disable the switch by using the `disabled` prop. + + + + + + +## States + +You can use the following props to change the state of the component by using the `state` props. + + + + + + + + + + + + + +## Width + +By default, the Switch will automatically take the minimum needed width. But you can force it to take the full width of +its container by using the `fullWidth` prop. + + + + + + + + + +## Controlled / Non Controlled + +In order to control the value of the switch, you can use the `checked` or `defaultChecked` props, as the native HTML checkbox element. You can't use both at the same time. + +> If you use the `checked` prop, you will need to handle the `onChange` event to update the value of the switch. + +> If you use the `defaultChecked` prop, the switch will be uncontrolled. + + + + + +## Props + +The props of this component are as close as possible to the native checkbox component. You can see the list of props below. + + + +## Design tokens + +Here are available custom design tokens. + +| Token | Description | +|--------------- |----------------------------- | +| accent-color | Color of the background | +| rail-background-color | Color of the switch rail background | +| rail-background-color--disabled | Color of the switch rail background when disabled | +| rail-border-radius | Border radius of the switch rail | +| handle-background-color | Background color of the switch handle | +| handle-background-color--disabled | Background color of the switch handle when disabled | +| handle-border-radius | Border radius of the switch handle when disabled | + +The design tokens `font-size`, `font-weight`, `color`, `width`, `height` are shared with [Checkbox](?path=/story/components-forms-checkbox-doc--page) + +See also [Field](?path=/story/components-forms-field-doc--page) + + +## + + + +## + + + +## + + diff --git a/packages/react/src/components/Forms/Switch/index.scss b/packages/react/src/components/Forms/Switch/index.scss new file mode 100644 index 0000000..edc6381 --- /dev/null +++ b/packages/react/src/components/Forms/Switch/index.scss @@ -0,0 +1,89 @@ +.c__switch { + + input { + opacity: 0; + width: 0; + height: 0; + margin: 0; + // This is made to prevent a bug on Chromium were was the labelSide is set to right then it + // creates an artificial margin between successive switches. + position: absolute; + } + + .c__checkbox__container { + justify-content: space-between; + } + + input:checked + &__rail { + background-color: var(--c--components--forms-switch--accent-color); + } + + input:checked + &__rail:before { + transform: translateX(20px); + } + + &__rail__wrapper { + display: inline-flex; + } + + &__rail { + position: relative; + cursor: pointer; + width: 2.8125rem; + height: 1.5rem; + background-color: var(--c--components--forms-switch--rail-background-color); + transition: var(--c--theme--transitions--duration) var(--c--theme--transitions--ease-out); + border-radius: var(--c--components--forms-switch--rail-border-radius); + + &:before { + position: absolute; + content: ""; + height: 1.125rem; + width: 1.125rem; + left: 4px; + top: 3px; + background-color: var(--c--components--forms-switch--handle-background-color); + transition: var(--c--theme--transitions--duration) var(--c--theme--transitions--ease-out); + border-radius: var(--c--components--forms-switch--handle-border-radius); + } + } + + .c__field__footer { + padding: 0.25rem 0 0 0; + } + + &.c__checkbox--disabled { + + input:not(:checked) + .c__switch__rail { + background-color: var(--c--components--forms-switch--rail-background-color--disabled); + } + + .c__switch__rail { + cursor: default; + + &:before { + background-color: var(--c--components--forms-switch--handle-background-color--disabled); + } + } + } + + &--right { + .c__checkbox__container { + flex-direction: row-reverse; + } + + .c__field__footer { + padding: 0.25rem 0 0 3.3rem; + } + + &.c__switch--full-width { + .c__field__footer { + flex-direction: row-reverse; + } + } + } + + &--full-width { + width: 100%; + } +} diff --git a/packages/react/src/components/Forms/Switch/index.spec.tsx b/packages/react/src/components/Forms/Switch/index.spec.tsx new file mode 100644 index 0000000..8470bba --- /dev/null +++ b/packages/react/src/components/Forms/Switch/index.spec.tsx @@ -0,0 +1,135 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import userEvent from "@testing-library/user-event"; +import { Switch } from ":/components/Forms/Switch/index"; + +describe("", () => { + it("renders and can be checked", async () => { + const user = userEvent.setup(); + render(); + const input: HTMLInputElement = screen.getByRole("checkbox", { + name: "Newsletter", + }); + expect(input.checked).toEqual(false); + await user.click(input); + expect(input.checked).toEqual(true); + }); + + it("renders with default value and can be unchecked", async () => { + const user = userEvent.setup(); + render(); + const input: HTMLInputElement = screen.getByRole("checkbox", { + name: "Newsletter", + }); + expect(input.checked).toEqual(true); + await user.click(input); + expect(input.checked).toEqual(false); + }); + + it("renders disabled", async () => { + render(); + expect(screen.getByRole("checkbox", { name: "Newsletter" })).toBeDisabled(); + // Click and expect the checkbox does not get checked + const user = userEvent.setup(); + const input: HTMLInputElement = screen.getByRole("checkbox", { + name: "Newsletter", + }); + expect(input.checked).toEqual(false); + await user.click(input); + expect(input.checked).toEqual(false); + }); + + it("renders with text", async () => { + render(); + screen.getByText("Text"); + }); + + it("renders with state=success", async () => { + render(); + screen.getByText("Success text"); + expect( + document.querySelector(".c__field.c__field--success") + ).toBeInTheDocument(); + }); + + it("renders with state=error", async () => { + render(); + screen.getByText("Error text"); + expect( + document.querySelector(".c__field.c__field--error") + ).toBeInTheDocument(); + }); + + it("renders multiple", async () => { + // make sure switching one does not switch the others. + const user = userEvent.setup(); + render( +
+ + + +
+ ); + // expect all checkboxes to be unchecked + const newsletter: HTMLInputElement = screen.getByRole("checkbox", { + name: "Newsletter", + }); + const notifications: HTMLInputElement = screen.getByRole("checkbox", { + name: "Notifications", + }); + const phone: HTMLInputElement = screen.getByRole("checkbox", { + name: "Phone", + }); + expect(newsletter.checked).toEqual(false); + expect(notifications.checked).toEqual(false); + expect(phone.checked).toEqual(false); + + // Turn on only one checkbox. + await user.click(newsletter); + expect(newsletter.checked).toEqual(true); + expect(notifications.checked).toEqual(false); + expect(phone.checked).toEqual(false); + + // Turn off only one checkbox. + await user.click(newsletter); + expect(newsletter.checked).toEqual(false); + expect(notifications.checked).toEqual(false); + expect(phone.checked).toEqual(false); + }); + + it("renders with label right", async () => { + render(); + const input: HTMLInputElement = screen.getByRole("checkbox", { + name: "Newsletter", + }); + expect(input.closest(".c__switch")).toHaveClass("c__switch--right"); + }); + + it("renders controlled", async () => { + const Wrapper = () => { + const [checked, setChecked] = React.useState(false); + return ( +
+
Value: {JSON.stringify(checked)}.
+ setChecked(e.target.checked)} + /> +
+ ); + }; + render(); + const input: HTMLInputElement = screen.getByRole("checkbox", { + name: "Newsletter", + }); + expect(input.checked).toEqual(false); + screen.queryByText("Value: false."); + await userEvent.click(input); + expect(input.checked).toEqual(true); + screen.queryByText("Value: true."); + await userEvent.click(input); + expect(input.checked).toEqual(false); + screen.queryByText("Value: false."); + }); +}); diff --git a/packages/react/src/components/Forms/Switch/index.stories.tsx b/packages/react/src/components/Forms/Switch/index.stories.tsx new file mode 100644 index 0000000..eefa847 --- /dev/null +++ b/packages/react/src/components/Forms/Switch/index.stories.tsx @@ -0,0 +1,191 @@ +import { Meta } from "@storybook/react"; +import React from "react"; +import { Switch } from ":/components/Forms/Switch/index"; +import { Button } from ":/components/Button"; + +export default { + title: "Components/Forms/Switch", + component: Switch, +} as Meta; + +export const Default = { + args: {}, +}; + +export const Checked = { + args: { + checked: true, + }, +}; + +export const WithLabel = { + args: { + label: "Label", + }, +}; + +export const WithLabelChecked = { + args: { + label: "Label", + checked: true, + }, +}; + +export const WithText = { + args: { + label: "Label", + text: "This is an optional text", + checked: true, + }, +}; + +export const FullWidth = { + args: { + label: "Label", + text: "This is an optional text", + fullWidth: true, + }, +}; + +export const WithLabelRight = { + args: { + label: "Label", + labelSide: "right", + }, +}; + +export const WithLabelRightAndText = { + args: { + label: "Label", + labelSide: "right", + text: "This is an optional text", + }, +}; + +export const WithLabelRightAndFullWidth = { + args: { + label: "Label", + text: "This is an optional text", + fullWidth: true, + labelSide: "right", + }, +}; + +export const Disabled = { + args: { + label: "Label", + text: "This is an optional text", + disabled: true, + }, +}; + +export const DisabledChecked = { + args: { + label: "Label", + text: "This is an optional text", + disabled: true, + defaultChecked: true, + }, +}; + +export const Error = { + args: { + label: "Label", + text: "This is an optional text", + state: "error", + defaultChecked: true, + }, +}; + +export const Success = { + args: { + label: "Label", + text: "This is an optional text", + state: "success", + defaultChecked: true, + }, +}; + +export const Controlled = { + render: () => { + const [checked, setChecked] = React.useState(false); + return ( +
+
Value: {JSON.stringify(checked)}
+ setChecked(e.target.checked)} + /> + +
+ ); + }, +}; + +export const FormExample = { + render: () => { + return ( +
+
+ + + + + +
+
+ ); + }, +}; + +export const FormExampleRight = { + render: () => { + return ( +
+
+ + + + + +
+
+ ); + }, +}; diff --git a/packages/react/src/components/Forms/Switch/index.tsx b/packages/react/src/components/Forms/Switch/index.tsx new file mode 100644 index 0000000..0a0202a --- /dev/null +++ b/packages/react/src/components/Forms/Switch/index.tsx @@ -0,0 +1,43 @@ +import React, { InputHTMLAttributes } from "react"; +import classNames from "classnames"; +import { Field, FieldProps } from ":/components/Forms/Field"; + +type Props = InputHTMLAttributes & + FieldProps & { + label?: string; + labelSide?: "left" | "right"; + }; + +export const Switch = ({ + label, + text, + state, + fullWidth, + labelSide = "left", + + ...props +}: Props) => { + return ( +