Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add switch component #440

Merged
merged 9 commits into from
Aug 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/react-components/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import {
InlineAlertPage,
SelectPage,
TagGroupPage,
TooltipPage,
TextAreaPage,
TextFieldPage,
SwitchPage,
TooltipPage,
} from "@/pages";

// This icon is available as a plain SVG at src/assets/icon-menu.svg
Expand Down Expand Up @@ -147,6 +148,7 @@ function App() {
<main>
<h1>Components</h1>
<ButtonPage />
<SwitchPage />
<InlineAlertPage />
<SelectPage />
<TagGroupPage />
Expand Down
90 changes: 90 additions & 0 deletions packages/react-components/src/components/Switch/Switch.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
.bcds-react-aria-Switch {
display: flex;
align-items: center;
gap: var(--layout-margin-small);
font: var(--typography-regular-small-body);
color: var(--typography-color-secondary);
forced-color-adjust: none;
}

/* Container */
.bcds-react-aria-Switch > .indicator {
align-items: center;
width: var(--layout-margin-xxlarge);
height: var(--icons-size-medium);
background-color: var(--surface-color-forms-disabled);
border-radius: var(--icons-size-medium);
transition: all 200ms;
}

/* Indicator */
.bcds-react-aria-Switch > .indicator::before {
content: "";
display: block;
box-sizing: border-box;
width: var(--icons-size-medium);
height: var(--icons-size-medium);
background-color: var(--surface-color-background-light-gray);
border: var(--layout-border-width-medium) solid
var(--surface-color-border-medium);
border-radius: var(--icons-size-small);
transition: all 200ms;
}

/* Selected */
.bcds-react-aria-Switch[data-selected] > .indicator {
background-color: var(--surface-color-primary-button-default);
}

.bcds-react-aria-Switch[data-selected] > .indicator::before {
transform: translateX(100%);
border: var(--layout-border-width-medium) solid
var(--surface-color-primary-button-default);
}

/* Hover */
.bcds-react-aria-Switch[data-hovered] > .indicator {
cursor: pointer;
}

.bcds-react-aria-Switch[data-hovered][data-selected] > .indicator {
cursor: pointer;
background-color: var(--surface-color-primary-button-hover);
}

.bcds-react-aria-Switch[data-hovered] > .indicator::before {
border: var(--layout-border-width-medium) solid
var(--surface-color-border-dark);
}

.bcds-react-aria-Switch[data-hovered][data-selected] > .indicator::before {
border: var(--layout-border-width-medium) solid
var(--surface-color-primary-button-default);
}

/* Focused */
.bcds-react-aria-Switch[data-focus-visible] > .indicator {
outline: solid var(--layout-border-width-medium)
var(--surface-color-border-active);
outline-offset: var(--layout-margin-hair);
}

/* Disabled */
.bcds-react-aria-Switch[data-disabled] {
color: var(--typography-color-disabled);
}

.bcds-react-aria-Switch[data-disabled] > .indicator {
background-color: var(--surface-color-forms-disabled);
cursor: not-allowed;
}

.bcds-react-aria-Switch[data-disabled] > .indicator::before {
border: var(--layout-border-width-medium) solid
var(--surface-color-border-default);
}

/* Read only */
.bcds-react-aria-Switch[data-readonly] > .indicator {
cursor: not-allowed;
}
26 changes: 26 additions & 0 deletions packages/react-components/src/components/Switch/Switch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {
Switch as ReactAriaSwitch,
SwitchProps as ReactAriaSwitchProps,
} from "react-aria-components";

import "./Switch.css";

export interface SwitchProps extends ReactAriaSwitchProps {
children?: React.ReactNode;
/* Label positioning relative to switch */
labelPosition?: "left" | "right";
}

export default function Switch({
labelPosition = "right",
children,
...props
}: SwitchProps) {
return (
<ReactAriaSwitch className={`bcds-react-aria-Switch`} {...props}>
{labelPosition === "left" && <>{children}</>}
Copy link
Contributor Author

@mkernohanbc mkernohanbc Aug 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ty2k this pair of conditionals (lines 21 and 23) for positioning the label feels like a pretty inelegant solution, if you have a better way to do this would love to hear it!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could switch the flex direction of the thing like flex-direction: row and flex-direction: row-reverse based on that labelPosition prop, but this is probably the more clear solution. We can always complicate the heck out of this when we have to make all these components work for rtl instead of just ltr some day. :)

<div className="indicator" />
{labelPosition === "right" && <>{children}</>}
</ReactAriaSwitch>
);
}
2 changes: 2 additions & 0 deletions packages/react-components/src/components/Switch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from "./Switch";
export type { SwitchProps } from "./Switch";
1 change: 1 addition & 0 deletions packages/react-components/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ export { default as TagGroup } from "./TagGroup";
export { default as TagList } from "./TagList";
export { default as TextArea } from "./TextArea";
export { default as TextField } from "./TextField";
export { default as Switch } from "./Switch";
export { default as Tooltip, TooltipTrigger } from "./Tooltip";
23 changes: 23 additions & 0 deletions packages/react-components/src/pages/Switch/Switch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Switch } from "@/components";

export default function SwitchPage() {
return (
<>
<h2>Switch</h2>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "var(--layout-margin-medium",
}}
>
<Switch>Label</Switch>
<Switch labelPosition="left">Reversed label position</Switch>
<Switch isDisabled>Disabled switch</Switch>
<Switch labelPosition="left" defaultSelected>
Switch on by default
</Switch>
</div>
</>
);
}
3 changes: 3 additions & 0 deletions packages/react-components/src/pages/Switch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import SwitchPage from "./Switch";

export default SwitchPage;
2 changes: 2 additions & 0 deletions packages/react-components/src/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import SelectPage from "./Select";
import TagGroupPage from "./TagGroup";
import TextAreaPage from "./TextArea";
import TextFieldPage from "./TextField";
import SwitchPage from "./Switch";
import TooltipPage from "./Tooltip";

export {
Expand All @@ -13,5 +14,6 @@ export {
TagGroupPage,
TextAreaPage,
TextFieldPage,
SwitchPage,
TooltipPage,
};
68 changes: 68 additions & 0 deletions packages/react-components/src/stories/Switch.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{/* Switch.mdx */}

import {
Canvas,
Controls,
Meta,
Primary,
Source,
Story,
Subtitle,
} from "@storybook/blocks";

import * as SwitchStories from "./Switch.stories";

<Meta of={SwitchStories} />

# Switch

<Subtitle>A switch enables the user to toggle a setting on or off.</Subtitle>

<Source
code={`import { Switch } from "@bcgov/design-system-react-components"; `}
language="typescript"
/>

## Usage and resources

Learn more about working with the switch component:

- [Usage and best practice guidance](https://www2.gov.bc.ca/gov/content?id=A2B9EFCADAC940AA8E7A0A7E91D49097)
- [View the switch component in Figma](https://www2.gov.bc.ca/gov/content?id=8E36BE1D10E04A17B0CD4D913FA7AC43#designers)

This component is based on [React Aria Switch](https://react-spectrum.adobe.com/react-aria/Switch.html). It renders an `<input>` with the [ARIA switch role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/switch_role).

## Controls

<Primary of={SwitchStories} />
<Controls of={SwitchStories.SwitchTemplate} />

## Configuration

### Label

Pass a text label to the `children` slot. This text will render as a `<label>`, and will be automatically associated to the switch.

If you do not provide a visible label, you must use the `aria-label` or `aria-labelledby` props to label the switch for assistive technologies.

#### Label position

By default, the text label renders to the right of the switch. Use the `labelPosition` prop to move the label to the left:

<Canvas of={SwitchStories.LabelReversed} />

### States

You can use the `isSelected` prop to make a switch controlled. You can also use the `name` and `value` props to integrate a switch into an HTML form. Consult [the React Aria documentation](https://react-spectrum.adobe.com/react-aria/Switch.html#value) for more information.

By default, a switch is not selected. Pass `defaultSelected` to make a switch selected by default:

<Canvas of={SwitchStories.DefaultSelectedSwitch} />

Use `isDisabled` to disable a switch. A disabled switch cannot be focused or selected:

<Canvas of={SwitchStories.DisabledSwitch} />

Use `isReadOnly` to lock a switch to its current value. A read-only switch can be focused, but cannot be selected:

<Canvas of={SwitchStories.ReadOnlySwitch} />
79 changes: 79 additions & 0 deletions packages/react-components/src/stories/Switch.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Meta, StoryObj } from "@storybook/react";

import { Switch } from "../components";
import { SwitchProps } from "@/components/Switch";

const meta = {
title: "Components/Switch/Switch",
component: Switch,
parameters: {
layout: "centered",
},
argTypes: {
labelPosition: {
options: ["left", "right"],
control: { type: "radio" },
description: "Sets the position of the text label",
},
isSelected: {
control: "boolean",
description: "Whether a switch is currently selected",
},
isDisabled: {
control: "boolean",
description: "Disables the switch",
},
isReadOnly: {
control: "boolean",
description: "Sets the switch to read-only",
},
defaultSelected: {
control: "boolean",
description: "Sets the switch to 'on' by default",
},
},
} satisfies Meta<typeof Switch>;

export default meta;
type Story = StoryObj<typeof meta>;

export const SwitchTemplate: Story = {
args: {
children: "Label text",
labelPosition: "right",
},
render: ({ ...args }: SwitchProps) => <Switch {...args} />,
};

export const LabelReversed: Story = {
...SwitchTemplate,
args: {
children: "Label position reversed",
labelPosition: "left",
},
};

export const DefaultSelectedSwitch: Story = {
...SwitchTemplate,
args: {
children: "This switch is selected by default",
defaultSelected: true,
},
};

export const DisabledSwitch: Story = {
...SwitchTemplate,
args: {
children: "Disabled switch",
isDisabled: true,
},
};

export const ReadOnlySwitch: Story = {
...SwitchTemplate,
args: {
children: "Read-only switch",
isSelected: true,
isReadOnly: true,
},
};