✨(react) add Switch component
Implement a shiny new component that can be used as an alternative to the regular checkbox.
This commit is contained in:
137
packages/react/src/components/Forms/Switch/index.mdx
Normal file
137
packages/react/src/components/Forms/Switch/index.mdx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Canvas, Story, Meta, ArgsTable, Source } from '@storybook/addon-docs';
|
||||
import { Switch } from './index';
|
||||
import * as Stories from './index.stories';
|
||||
|
||||
<Meta of={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.
|
||||
|
||||
<Canvas>
|
||||
<Story id="components-forms-switch--form-example"/>
|
||||
</Canvas>
|
||||
|
||||
|
||||
## Label
|
||||
|
||||
The `label` props is optional, but you can use it to provide a description of the switch.
|
||||
|
||||
**Without label**
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-switch--default"/>
|
||||
</Canvas>
|
||||
|
||||
**With label**
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-switch--with-label"/>
|
||||
</Canvas>
|
||||
|
||||
## 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)**
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-switch--with-label"/>
|
||||
</Canvas>
|
||||
|
||||
**Label on the right**
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-switch--with-label-right"/>
|
||||
</Canvas>
|
||||
|
||||
## Disabled
|
||||
|
||||
You can disable the switch by using the `disabled` prop.
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-switch--disabled"/>
|
||||
<Story id="components-forms-switch--disabled-checked"/>
|
||||
</Canvas>
|
||||
|
||||
## States
|
||||
|
||||
You can use the following props to change the state of the component by using the `state` props.
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-switch--with-text"/>
|
||||
</Canvas>
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-switch--success"/>
|
||||
</Canvas>
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-switch--error"/>
|
||||
</Canvas>
|
||||
|
||||
## 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.
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-switch--full-width"/>
|
||||
</Canvas>
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-switch--with-label-right-and-full-width"/>
|
||||
</Canvas>
|
||||
|
||||
## 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.
|
||||
|
||||
<Canvas sourceState="shown">
|
||||
<Story id="components-forms-switch--controlled"/>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
The props of this component are as close as possible to the native checkbox component. You can see the list of props below.
|
||||
|
||||
<ArgsTable of={Switch} />
|
||||
|
||||
## 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)
|
||||
|
||||
|
||||
##
|
||||
|
||||
<img src="components/Forms/Switch/resources/dd_1.svg"/>
|
||||
|
||||
##
|
||||
|
||||
<img src="components/Forms/Switch/resources/dd_2.svg"/>
|
||||
|
||||
##
|
||||
|
||||
<img src="components/Forms/Switch/resources/dd_3.svg"/>
|
||||
89
packages/react/src/components/Forms/Switch/index.scss
Normal file
89
packages/react/src/components/Forms/Switch/index.scss
Normal file
@@ -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%;
|
||||
}
|
||||
}
|
||||
135
packages/react/src/components/Forms/Switch/index.spec.tsx
Normal file
135
packages/react/src/components/Forms/Switch/index.spec.tsx
Normal file
@@ -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("<Switch/>", () => {
|
||||
it("renders and can be checked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Switch label="Newsletter" />);
|
||||
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(<Switch label="Newsletter" defaultChecked={true} />);
|
||||
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(<Switch label="Newsletter" disabled={true} />);
|
||||
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(<Switch label="Newsletter" text="Text" />);
|
||||
screen.getByText("Text");
|
||||
});
|
||||
|
||||
it("renders with state=success", async () => {
|
||||
render(<Switch label="Newsletter" state="success" text="Success text" />);
|
||||
screen.getByText("Success text");
|
||||
expect(
|
||||
document.querySelector(".c__field.c__field--success")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders with state=error", async () => {
|
||||
render(<Switch label="Newsletter" state="error" text="Error text" />);
|
||||
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(
|
||||
<div>
|
||||
<Switch label="Newsletter" />
|
||||
<Switch label="Notifications" />
|
||||
<Switch label="Phone" />
|
||||
</div>
|
||||
);
|
||||
// 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(<Switch label="Newsletter" labelSide="right" />);
|
||||
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 (
|
||||
<div>
|
||||
<div>Value: {JSON.stringify(checked)}.</div>
|
||||
<Switch
|
||||
label="Newsletter"
|
||||
checked={checked}
|
||||
onChange={(e) => setChecked(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
render(<Wrapper />);
|
||||
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.");
|
||||
});
|
||||
});
|
||||
191
packages/react/src/components/Forms/Switch/index.stories.tsx
Normal file
191
packages/react/src/components/Forms/Switch/index.stories.tsx
Normal file
@@ -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<typeof Switch>;
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div>Value: {JSON.stringify(checked)}</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={(e) => setChecked(e.target.checked)}
|
||||
/>
|
||||
<Button onClick={() => setChecked(!checked)}>Toggle</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const FormExample = {
|
||||
render: () => {
|
||||
return (
|
||||
<form>
|
||||
<div style={{ width: "300px" }}>
|
||||
<Switch
|
||||
label="Energy efficiency"
|
||||
fullWidth={true}
|
||||
text="Increases power by 15%"
|
||||
/>
|
||||
<Switch
|
||||
label="Battery percentage"
|
||||
fullWidth={true}
|
||||
defaultChecked={true}
|
||||
/>
|
||||
<Switch label="Wi-Fi" fullWidth={true} defaultChecked={true} />
|
||||
<Switch label="Bluetooth" fullWidth={true} />
|
||||
<Switch
|
||||
label="VPN"
|
||||
fullWidth={true}
|
||||
text="You must pay for this feature"
|
||||
state="error"
|
||||
disabled={true}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const FormExampleRight = {
|
||||
render: () => {
|
||||
return (
|
||||
<form>
|
||||
<div style={{ width: "300px" }}>
|
||||
<Switch
|
||||
label="Energy efficiency"
|
||||
fullWidth={true}
|
||||
labelSide="right"
|
||||
text="Increases power by 15%"
|
||||
/>
|
||||
<Switch
|
||||
label="Battery percentage"
|
||||
fullWidth={true}
|
||||
defaultChecked={true}
|
||||
labelSide="right"
|
||||
/>
|
||||
<Switch
|
||||
label="Wi-Fi"
|
||||
fullWidth={true}
|
||||
defaultChecked={true}
|
||||
labelSide="right"
|
||||
/>
|
||||
<Switch label="Bluetooth" fullWidth={true} labelSide="right" />
|
||||
<Switch
|
||||
label="VPN"
|
||||
fullWidth={true}
|
||||
labelSide="right"
|
||||
text="You must pay for this feature"
|
||||
state="error"
|
||||
disabled={true}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
},
|
||||
};
|
||||
43
packages/react/src/components/Forms/Switch/index.tsx
Normal file
43
packages/react/src/components/Forms/Switch/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { InputHTMLAttributes } from "react";
|
||||
import classNames from "classnames";
|
||||
import { Field, FieldProps } from ":/components/Forms/Field";
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> &
|
||||
FieldProps & {
|
||||
label?: string;
|
||||
labelSide?: "left" | "right";
|
||||
};
|
||||
|
||||
export const Switch = ({
|
||||
label,
|
||||
text,
|
||||
state,
|
||||
fullWidth,
|
||||
labelSide = "left",
|
||||
|
||||
...props
|
||||
}: Props) => {
|
||||
return (
|
||||
<label
|
||||
className={classNames(
|
||||
"c__checkbox",
|
||||
"c__switch",
|
||||
"c__switch--" + labelSide,
|
||||
{
|
||||
"c__checkbox--disabled": props.disabled,
|
||||
"c__switch--full-width": fullWidth,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Field text={text} compact={true} state={state} fullWidth={fullWidth}>
|
||||
<div className="c__checkbox__container">
|
||||
{label && <div className="c__checkbox__label">{label}</div>}
|
||||
<div className="c__switch__rail__wrapper">
|
||||
<input type="checkbox" {...props} />
|
||||
<div className="c__switch__rail" />
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 185 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 191 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 268 KiB |
11
packages/react/src/components/Forms/Switch/tokens.ts
Normal file
11
packages/react/src/components/Forms/Switch/tokens.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { DefaultTokens } from "@openfun/cunningham-tokens";
|
||||
|
||||
export const tokens = (defaults: DefaultTokens) => ({
|
||||
"accent-color": defaults.theme.colors["success-700"],
|
||||
"rail-background-color": defaults.theme.colors["greyscale-500"],
|
||||
"rail-background-color--disabled": defaults.theme.colors["greyscale-400"],
|
||||
"rail-border-radius": "50vw",
|
||||
"handle-background-color": "white",
|
||||
"handle-background-color--disabled": defaults.theme.colors["greyscale-200"],
|
||||
"handle-border-radius": "50%",
|
||||
});
|
||||
Reference in New Issue
Block a user