From f8598770305e164ef2ba9aeaabe1182387f56085 Mon Sep 17 00:00:00 2001 From: Marcus Kernohan <135075821+mkernohanbc@users.noreply.github.com> Date: Wed, 11 Sep 2024 09:47:55 -0700 Subject: [PATCH] Add Checkbox and CheckboxGroup components (#463) * scaffolding Checkbox and CheckboxGroup components * roughing out component styling * component styling * isSelected and isIndeterminate state handling * roughing out CheckboxGroup * refining Checkbox and CheckboxGroup styling * CheckboxGroup error handling * fiddling with styling * slow, grinding progress * fix isSelected/isIndeterminate styling collision * required and error handling * stories and docs * cleaning up checkbox and label positioning * fleshing out checkbox stories and docs * add selected + invalid style * expanding checkboxgroup docs and stories * further docs and stories expansion * Merge pull request #468 from bcgov/feature/bump-react-component-library-deps Bump React component library dependencies * Revert "Merge pull request #468 from bcgov/feature/bump-react-component-library-deps" This reverts commit 60c564be6221ad8b23fe04186eb4c96aece7cd9d. * update Checkbox to use 3.1.0 tokens * Add controlled checkbox example to Vite kitchensink app * Move cursor CSS rules to Checkbox container (label) component * Fix typos in Checkbox stories * Disabled Checkbox label gets disabled font color * Add Checkbox styling for isDisabled + isIndeterminate state * Add Checkbox styling for isIndeterminate + isInvalid state * Change Checkbox flex container gap to 10px to match current design * Update Checkbox border styles for current design * Remove extended interface from Checkbox component * Remove children prop from CheckboxGroupProps interface in favor of children from ReactAriaCheckboxGroupProps * Add flexWrap prop to CheckboxGroup to allow wrapping * Add isInvalid and isSelected args to Checkbox story meta object * Add failing tests for Checkbox * Make tests for Checkbox pass by adding explicit type information to render props * Add failing tests for CheckboxGroup * Make tests for CheckboxGroup pass by adding explicit type information to render props * Move CheckboxGroup flexWrap styling prop to options list from parent container * add flexWrap to storybook args --------- Co-authored-by: Tyler Krys --- packages/react-components/src/App.tsx | 2 + .../src/components/Checkbox/Checkbox.css | 118 +++++++++++++ .../src/components/Checkbox/Checkbox.test.tsx | 22 +++ .../src/components/Checkbox/Checkbox.tsx | 32 ++++ .../src/components/Checkbox/index.ts | 2 + .../CheckboxGroup/CheckboxGroup.css | 48 ++++++ .../CheckboxGroup/CheckboxGroup.test.tsx | 32 ++++ .../CheckboxGroup/CheckboxGroup.tsx | 74 +++++++++ .../src/components/CheckboxGroup/index.ts | 2 + .../Icons/SvgCheckIcon/SvgCheckIcon.tsx | 18 ++ .../components/Icons/SvgCheckIcon/index.ts | 1 + .../Icons/SvgDashIcon/SvgDashIcon.tsx | 16 ++ .../src/components/Icons/SvgDashIcon/index.ts | 1 + .../react-components/src/components/index.ts | 4 + .../src/pages/CheckboxGroup/CheckboxGroup.tsx | 70 ++++++++ .../src/pages/CheckboxGroup/index.ts | 3 + packages/react-components/src/pages/index.ts | 2 + .../src/stories/Checkbox.stories.tsx | 95 +++++++++++ .../src/stories/CheckboxGroup.mdx | 127 ++++++++++++++ .../src/stories/CheckboxGroup.stories.tsx | 155 ++++++++++++++++++ 20 files changed, 824 insertions(+) create mode 100644 packages/react-components/src/components/Checkbox/Checkbox.css create mode 100644 packages/react-components/src/components/Checkbox/Checkbox.test.tsx create mode 100644 packages/react-components/src/components/Checkbox/Checkbox.tsx create mode 100644 packages/react-components/src/components/Checkbox/index.ts create mode 100644 packages/react-components/src/components/CheckboxGroup/CheckboxGroup.css create mode 100644 packages/react-components/src/components/CheckboxGroup/CheckboxGroup.test.tsx create mode 100644 packages/react-components/src/components/CheckboxGroup/CheckboxGroup.tsx create mode 100644 packages/react-components/src/components/CheckboxGroup/index.ts create mode 100644 packages/react-components/src/components/Icons/SvgCheckIcon/SvgCheckIcon.tsx create mode 100644 packages/react-components/src/components/Icons/SvgCheckIcon/index.ts create mode 100644 packages/react-components/src/components/Icons/SvgDashIcon/SvgDashIcon.tsx create mode 100644 packages/react-components/src/components/Icons/SvgDashIcon/index.ts create mode 100644 packages/react-components/src/pages/CheckboxGroup/CheckboxGroup.tsx create mode 100644 packages/react-components/src/pages/CheckboxGroup/index.ts create mode 100644 packages/react-components/src/stories/Checkbox.stories.tsx create mode 100644 packages/react-components/src/stories/CheckboxGroup.mdx create mode 100644 packages/react-components/src/stories/CheckboxGroup.stories.tsx diff --git a/packages/react-components/src/App.tsx b/packages/react-components/src/App.tsx index e97d86dd..0b38099e 100644 --- a/packages/react-components/src/App.tsx +++ b/packages/react-components/src/App.tsx @@ -9,6 +9,7 @@ import useWindowDimensions from "@/hooks/useWindowDimensions"; import { ButtonPage, ButtonGroupPage, + CheckboxGroupPage, InlineAlertPage, SelectPage, TagGroupPage, @@ -150,6 +151,7 @@ function App() {

Components

+ diff --git a/packages/react-components/src/components/Checkbox/Checkbox.css b/packages/react-components/src/components/Checkbox/Checkbox.css new file mode 100644 index 00000000..68fa032c --- /dev/null +++ b/packages/react-components/src/components/Checkbox/Checkbox.css @@ -0,0 +1,118 @@ +.bcds-react-aria-Checkbox { + cursor: pointer; + display: flex; + flex-direction: row; + gap: 10px; + align-items: center; +} + +/* Checkbox */ +.bcds-react-aria-Checkbox > .checkbox { + align-self: first baseline; + display: flex; + margin-top: var(--layout-margin-hair); + justify-content: space-around; + width: var(--icons-size-small); + height: var(--icons-size-small); + min-width: var(--icons-size-small); + background-color: var(--surface-color-secondary-default); + border-color: var(--surface-color-border-medium); + border-style: solid; + border-width: var(--layout-border-width-small); + border-radius: var(--layout-border-radius-small); +} +.bcds-react-aria-Checkbox > .checkbox > svg { + align-self: center; +} + +/* Label */ +.bcds-react-aria-Checkbox > .label { + font: var(--typography-regular-small-body); + color: var(--typography-color-secondary); +} + +/* Focus */ +.bcds-react-aria-Checkbox[data-focused] > .checkbox { + outline: solid var(--layout-border-width-medium) + var(--surface-color-border-active); + outline-offset: var(--layout-margin-hair); +} + +/* Hover */ +.bcds-react-aria-Checkbox[data-hovered] > .checkbox { + border-color: var(--surface-color-border-dark); +} +.bcds-react-aria-Checkbox[data-selected][data-hovered] > .checkbox { + background-color: var(--surface-color-primary-hover); + border-color: var(--surface-color-primary-hover); +} + +/* Pressed */ +.bcds-react-aria-Checkbox[data-pressed] > .checkbox { + background-color: var(--surface-color-primary-default); +} + +/* Selected */ +.bcds-react-aria-Checkbox[data-selected] > .checkbox { + background-color: var(--surface-color-primary-default); + border-color: var(--surface-color-primary-default); +} +.bcds-react-aria-Checkbox[data-selected] > .checkbox > svg { + color: var(--icons-color-primary-invert); +} + +/* Disabled */ +.bcds-react-aria-Checkbox[data-disabled] { + cursor: not-allowed; +} +.bcds-react-aria-Checkbox[data-disabled] > .checkbox { + border-color: var(--surface-color-border-medium); + background-color: var(--surface-color-primary-disabled); +} +.bcds-react-aria-Checkbox[data-disabled] > .label { + color: var(--typography-color-disabled); +} +.bcds-react-aria-Checkbox[data-selected][data-disabled] > .checkbox { + border-color: var(--surface-color-primary-disabled); + background-color: var(--surface-color-primary-disabled); +} +.bcds-react-aria-Checkbox[data-selected][data-disabled] > .checkbox > svg { + color: var(--icons-color-disabled); +} + +/* Indeterminate */ +.bcds-react-aria-Checkbox[data-indeterminate] > .checkbox { + background-color: var(--surface-color-primary-default); + border-color: var(--surface-color-primary-default); +} +.bcds-react-aria-Checkbox[data-indeterminate][data-disabled] > .checkbox { + background-color: var(--surface-color-primary-disabled); + border-color: var(--surface-color-primary-disabled); +} +.bcds-react-aria-Checkbox[data-indeterminate][data-invalid] > .checkbox { + background-color: var(--surface-color-primary-danger-button-default); +} +.bcds-react-aria-Checkbox[data-indeterminate] > .checkbox > svg { + color: var(--icons-color-primary-invert); + align-self: center; + vertical-align: middle; +} +.bcds-react-aria-Checkbox[data-indeterminate][data-disabled] > .checkbox > svg { + color: var(--icons-color-disabled); +} + +/* Read-only */ +.bcds-react-aria-Checkbox[data-readonly] { + cursor: not-allowed; +} + +/* Invalid */ +.bcds-react-aria-Checkbox[data-invalid] > .checkbox { + border-color: var(--support-border-color-danger); +} +.bcds-react-aria-Checkbox[data-invalid] > .checkbox > svg { + color: var(--icons-color-primary-invert); +} +.bcds-react-aria-Checkbox[data-selected][data-invalid] > .checkbox { + background-color: var(--surface-color-primary-danger-button-default); +} diff --git a/packages/react-components/src/components/Checkbox/Checkbox.test.tsx b/packages/react-components/src/components/Checkbox/Checkbox.test.tsx new file mode 100644 index 00000000..6c81dc81 --- /dev/null +++ b/packages/react-components/src/components/Checkbox/Checkbox.test.tsx @@ -0,0 +1,22 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; // for matchers like toBeChecked + +import Checkbox from "./Checkbox"; + +describe("Checkbox component", () => { + test("Checkbox renders unchecked, user clicks it, checkbox is checked", () => { + render(I agree); + const checkbox = screen.getByLabelText(/i agree/i); + expect(checkbox).not.toBeChecked(); + fireEvent.click(checkbox); + expect(checkbox).toBeChecked(); + }); + + test("Checkbox renders checked, user clicks it, checkbox is unchecked", () => { + render(Email me my results); + const checkbox = screen.getByLabelText(/email me my results/i); + expect(checkbox).toBeChecked(); + fireEvent.click(checkbox); + expect(checkbox).not.toBeChecked(); + }); +}); diff --git a/packages/react-components/src/components/Checkbox/Checkbox.tsx b/packages/react-components/src/components/Checkbox/Checkbox.tsx new file mode 100644 index 00000000..5a7ac1ef --- /dev/null +++ b/packages/react-components/src/components/Checkbox/Checkbox.tsx @@ -0,0 +1,32 @@ +import { + Checkbox as ReactAriaCheckbox, + CheckboxProps, + CheckboxRenderProps, +} from "react-aria-components"; + +import SvgCheckIcon from "../Icons/SvgCheckIcon"; +import SvgDashIcon from "../Icons/SvgDashIcon"; + +import "./Checkbox.css"; + +export default function Checkbox({ value, children, ...props }: CheckboxProps) { + return ( + + {({ isRequired, isSelected, isIndeterminate }: CheckboxRenderProps) => ( + <> +
+ {isSelected && !isIndeterminate && } + {isIndeterminate && } +
+ + <>{children} {isRequired && "(required)"} + + + )} +
+ ); +} diff --git a/packages/react-components/src/components/Checkbox/index.ts b/packages/react-components/src/components/Checkbox/index.ts new file mode 100644 index 00000000..c391828b --- /dev/null +++ b/packages/react-components/src/components/Checkbox/index.ts @@ -0,0 +1,2 @@ +export { default } from "./Checkbox"; +export type { CheckboxProps } from "./Checkbox"; diff --git a/packages/react-components/src/components/CheckboxGroup/CheckboxGroup.css b/packages/react-components/src/components/CheckboxGroup/CheckboxGroup.css new file mode 100644 index 00000000..4f23aa53 --- /dev/null +++ b/packages/react-components/src/components/CheckboxGroup/CheckboxGroup.css @@ -0,0 +1,48 @@ +.bcds-react-aria-CheckboxGroup { + display: flex; + flex-direction: column; + gap: var(--layout-margin-small); +} + +/* Orientation */ +.bcds-react-aria-CheckboxGroup--options { + display: flex; +} + +.bcds-react-aria-CheckboxGroup--options.vertical { + flex-direction: column; + gap: var(--layout-margin-small); +} + +.bcds-react-aria-CheckboxGroup--options.horizontal { + flex-direction: row; + gap: var(--layout-margin-medium); +} +.bcds-react-aria-CheckboxGroup--options.horizontal.flex-wrap-nowrap { + flex-wrap: nowrap; +} +.bcds-react-aria-CheckboxGroup--options.horizontal.flex-wrap-wrap { + flex-wrap: wrap; +} +.bcds-react-aria-CheckboxGroup--options.horizontal.flex-wrap-wrap-reverse { + flex-wrap: wrap-reverse; +} + +/* Parts */ +.bcds-react-aria-CheckboxGroup--label { + font: var(--typography-regular-small-body); + color: var(--typography-color-primary); +} + +.bcds-react-aria-CheckboxGroup--description { + font: var(--typography-regular-small-body); + color: var(--typography-color-secondary); +} + +.bcds-react-aria-CheckboxGroup--error { + display: inline-flex; + flex-direction: row; + gap: var(--layout-margin-xsmall); + font: var(--typography-regular-small-body); + color: var(--typography-color-danger); +} diff --git a/packages/react-components/src/components/CheckboxGroup/CheckboxGroup.test.tsx b/packages/react-components/src/components/CheckboxGroup/CheckboxGroup.test.tsx new file mode 100644 index 00000000..41cb4796 --- /dev/null +++ b/packages/react-components/src/components/CheckboxGroup/CheckboxGroup.test.tsx @@ -0,0 +1,32 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; // for matchers like toBeChecked + +import CheckboxGroup from "./CheckboxGroup"; +import Checkbox from "../Checkbox/Checkbox"; + +describe("CheckboxGroup component", () => { + beforeEach(() => { + render( + + Apple + Orange + Banana + + ); + }); + + test("renders 3 checkboxes with middle one checked", () => { + const checkboxes = screen.getAllByRole("checkbox"); + expect(checkboxes).toHaveLength(3); + expect(checkboxes[0]).not.toBeChecked(); + expect(checkboxes[1]).toBeChecked(); + expect(checkboxes[2]).not.toBeChecked(); + }); + + test("user clicks 'orange' option, it is no longer checked", () => { + const checkboxOrange = screen.getByLabelText(/orange/i); + expect(checkboxOrange).toBeChecked(); + fireEvent.click(checkboxOrange); + expect(checkboxOrange).not.toBeChecked(); + }); +}); diff --git a/packages/react-components/src/components/CheckboxGroup/CheckboxGroup.tsx b/packages/react-components/src/components/CheckboxGroup/CheckboxGroup.tsx new file mode 100644 index 00000000..aa403c22 --- /dev/null +++ b/packages/react-components/src/components/CheckboxGroup/CheckboxGroup.tsx @@ -0,0 +1,74 @@ +import { + CheckboxGroup as ReactAriaCheckboxGroup, + CheckboxGroupRenderProps, + FieldError, + Label, + Text, +} from "react-aria-components"; + +import type { + CheckboxGroupProps as ReactAriaCheckboxGroupProps, + ValidationResult, +} from "react-aria-components"; + +import "./CheckboxGroup.css"; +import SvgExclamationIcon from "../Icons/SvgExclamationIcon"; + +export interface CheckboxGroupProps extends ReactAriaCheckboxGroupProps { + /* Group orientation, defaults to `vertical` */ + orientation?: "horizontal" | "vertical"; + /* Group label */ + label?: string; + /* Group description */ + description?: string; + /* Error message */ + errorMessage?: string | ((validation: ValidationResult) => string); + /** `flex-wrap` style property, defaults to `nowrap` */ + flexWrap?: "nowrap" | "wrap" | "wrap-reverse"; +} + +export default function CheckboxGroup({ + orientation = "vertical", + label, + description, + errorMessage, + flexWrap = "nowrap", + children, + ...props +}: CheckboxGroupProps) { + return ( + + {({ isRequired, isInvalid }: CheckboxGroupRenderProps) => ( + <> + {label && ( + + )} +
+ <>{children} +
+ {description && ( + + {description} + + )} + {isInvalid && ( +
+ + {errorMessage} +
+ )} + + )} +
+ ); +} diff --git a/packages/react-components/src/components/CheckboxGroup/index.ts b/packages/react-components/src/components/CheckboxGroup/index.ts new file mode 100644 index 00000000..59d8af28 --- /dev/null +++ b/packages/react-components/src/components/CheckboxGroup/index.ts @@ -0,0 +1,2 @@ +export { default } from "./CheckboxGroup"; +export type { CheckboxGroupProps } from "./CheckboxGroup"; diff --git a/packages/react-components/src/components/Icons/SvgCheckIcon/SvgCheckIcon.tsx b/packages/react-components/src/components/Icons/SvgCheckIcon/SvgCheckIcon.tsx new file mode 100644 index 00000000..b50ad880 --- /dev/null +++ b/packages/react-components/src/components/Icons/SvgCheckIcon/SvgCheckIcon.tsx @@ -0,0 +1,18 @@ +export default function SvgCheckIcon({ id = "check-icon" }) { + return ( + + + + + + ); +} diff --git a/packages/react-components/src/components/Icons/SvgCheckIcon/index.ts b/packages/react-components/src/components/Icons/SvgCheckIcon/index.ts new file mode 100644 index 00000000..5726084b --- /dev/null +++ b/packages/react-components/src/components/Icons/SvgCheckIcon/index.ts @@ -0,0 +1 @@ +export { default } from "./SvgCheckIcon"; diff --git a/packages/react-components/src/components/Icons/SvgDashIcon/SvgDashIcon.tsx b/packages/react-components/src/components/Icons/SvgDashIcon/SvgDashIcon.tsx new file mode 100644 index 00000000..23a4ced6 --- /dev/null +++ b/packages/react-components/src/components/Icons/SvgDashIcon/SvgDashIcon.tsx @@ -0,0 +1,16 @@ +export default function SvgDashIcon({ id = "dash-icon" }) { + return ( + + + + ); +} diff --git a/packages/react-components/src/components/Icons/SvgDashIcon/index.ts b/packages/react-components/src/components/Icons/SvgDashIcon/index.ts new file mode 100644 index 00000000..1c3a68d0 --- /dev/null +++ b/packages/react-components/src/components/Icons/SvgDashIcon/index.ts @@ -0,0 +1 @@ +export { default } from "./SvgDashIcon"; diff --git a/packages/react-components/src/components/index.ts b/packages/react-components/src/components/index.ts index a2efef14..7ad249f0 100644 --- a/packages/react-components/src/components/index.ts +++ b/packages/react-components/src/components/index.ts @@ -3,12 +3,16 @@ import "@bcgov/design-tokens/css/variables.css"; export { default as InlineAlert } from "./InlineAlert"; export { default as Button } from "./Button"; export { default as ButtonGroup } from "./ButtonGroup"; +export { default as Checkbox } from "./Checkbox"; +export { default as CheckboxGroup } from "./CheckboxGroup"; export { default as Header } from "./Header"; export { default as Footer, FooterLinks } from "./Footer"; export { default as Form } from "./Form"; export { default as Select } from "./Select"; export { default as SvgBcLogo } from "./Icons/SvgBcLogo"; +export { default as SvgCheckIcon } from "./Icons/SvgCheckIcon"; export { default as SvgCheckCircleIcon } from "./Icons/SvgCheckCircleIcon"; +export { default as SvgDashIcon } from "./Icons/SvgDashIcon"; export { default as SvgExclamationIcon } from "./Icons/SvgExclamationIcon"; export { default as SvgExclamationCircleIcon } from "./Icons/SvgExclamationCircleIcon"; export { default as SvgChevronUpIcon } from "./Icons/SvgChevronUpIcon"; diff --git a/packages/react-components/src/pages/CheckboxGroup/CheckboxGroup.tsx b/packages/react-components/src/pages/CheckboxGroup/CheckboxGroup.tsx new file mode 100644 index 00000000..7879bd29 --- /dev/null +++ b/packages/react-components/src/pages/CheckboxGroup/CheckboxGroup.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; + +import { Checkbox, CheckboxGroup } from "@/components"; + +export default function CheckboxGroupPage() { + const [isSelected, setIsSelected] = useState(false); + + return ( + <> +

Checkboxes

+ + Checkbox 1 + Checkbox 2 + + Checkbox 3 is indeterminate + + + Checkbox 4 is disabled + + Checkbox 5 (same value as 6) + Checkbox 6 (same value as 5) + +

Horizontal checkboxes

+ + Checkbox 1 + Checkbox 2 + Checkbox 3 + + Checkbox 4 + + +

Checkbox group with errors

+ + Checkbox 1 + + Checkbox 2 is invalid + + Checkbox 3 + + Checkbox 4 + + +

Controlled checkbox

+ + setIsSelected(isSelected)} + > + Controlled + + + + ); +} diff --git a/packages/react-components/src/pages/CheckboxGroup/index.ts b/packages/react-components/src/pages/CheckboxGroup/index.ts new file mode 100644 index 00000000..2fe15e42 --- /dev/null +++ b/packages/react-components/src/pages/CheckboxGroup/index.ts @@ -0,0 +1,3 @@ +import CheckboxGroupPage from "./CheckboxGroup"; + +export default CheckboxGroupPage; diff --git a/packages/react-components/src/pages/index.ts b/packages/react-components/src/pages/index.ts index 1cc2919d..dc7037da 100644 --- a/packages/react-components/src/pages/index.ts +++ b/packages/react-components/src/pages/index.ts @@ -1,5 +1,6 @@ import ButtonPage from "./Button"; import ButtonGroupPage from "./ButtonGroup"; +import CheckboxGroupPage from "./CheckboxGroup"; import InlineAlertPage from "./InlineAlert"; import SelectPage from "./Select"; import TagGroupPage from "./TagGroup"; @@ -11,6 +12,7 @@ import TooltipPage from "./Tooltip"; export { ButtonPage, ButtonGroupPage, + CheckboxGroupPage, InlineAlertPage, SelectPage, TagGroupPage, diff --git a/packages/react-components/src/stories/Checkbox.stories.tsx b/packages/react-components/src/stories/Checkbox.stories.tsx new file mode 100644 index 00000000..9028631e --- /dev/null +++ b/packages/react-components/src/stories/Checkbox.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Checkbox } from "../components"; +import { CheckboxProps } from "../components/Checkbox"; + +const meta = { + title: "Components/CheckboxGroup/Checkbox", + component: Checkbox, + parameters: { + layout: "centered", + }, + argTypes: { + value: { + control: { type: "text" }, + description: "Unique identifier used for validation and data submission", + }, + children: { + control: { type: "object" }, + description: "Text label", + }, + defaultSelected: { + control: { type: "boolean" }, + description: "Whether a checkbox is selected by default", + }, + isRequired: { + control: { type: "boolean" }, + description: "Whether a checkbox is mandatory", + }, + isIndeterminate: { + control: { type: "boolean" }, + description: "Locks a checkbox to an 'indeterminate' state", + }, + isInvalid: { + control: { type: "boolean" }, + description: "Whether a checkbox is invalid", + }, + isDisabled: { + control: { type: "boolean" }, + description: "Disables a checkbox", + }, + isReadOnly: { + control: { type: "boolean" }, + description: "Locks a checkbox to its current state", + }, + isSelected: { + control: { type: "boolean" }, + description: + "For a controlled component, whether the checkbox is selected", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const CheckboxTemplate: Story = { + args: { children: "This is a checkbox label" }, + render: ({ ...args }: CheckboxProps) => , +}; + +export const DefaultSelectedCheckbox: Story = { + args: { + children: "This checkbox is selected by default", + defaultSelected: true, + }, +}; + +export const RequiredCheckbox: Story = { + args: { + children: "This checkbox is mandatory", + isRequired: true, + }, +}; + +export const DisabledCheckbox: Story = { + args: { + children: "This checkbox is disabled", + isDisabled: true, + }, +}; + +export const ReadOnlyCheckbox: Story = { + args: { + children: "This checkbox is set to read-only", + isSelected: true, + isReadOnly: true, + }, +}; + +export const IndeterminateCheckbox: Story = { + args: { + children: "This checkbox is neither selected nor deselected", + isIndeterminate: true, + }, +}; diff --git a/packages/react-components/src/stories/CheckboxGroup.mdx b/packages/react-components/src/stories/CheckboxGroup.mdx new file mode 100644 index 00000000..3c9cec17 --- /dev/null +++ b/packages/react-components/src/stories/CheckboxGroup.mdx @@ -0,0 +1,127 @@ +{/* CheckboxGroup.mdx */} + +import { + Canvas, + Controls, + Meta, + Primary, + Source, + Story, + Subtitle, +} from "@storybook/blocks"; + +import * as CheckboxGroupStories from "./CheckboxGroup.stories"; +import * as CheckboxStories from "./Checkbox.stories"; + + + +# CheckboxGroup + + + Checkboxes enable the user to select one or more options from a list. + + + + +## Usage and resources + +Learn more about working with the CheckboxGroup and Checkbox components: + +- [Usage and best practice guidance](https://www2.gov.bc.ca/gov/content?id=074FAD78863442AEAF2453BC18B28D2A) +- [View the select component in Figma](https://www2.gov.bc.ca/gov/content?id=8E36BE1D10E04A17B0CD4D913FA7AC43#designers) + +This component is based on the React Aria [Checkbox](https://react-spectrum.adobe.com/react-aria/Checkbox.html) and [CheckboxGroup](https://react-spectrum.adobe.com/react-aria/CheckboxGroup.html) components. Consult the React Aria documentation for additional technical information. + +### Validation + +Default and custom data validation support is built in, using the [React Aria FieldError subcomponent](https://react-spectrum.adobe.com/react-aria/CheckboxGroup.html#validation). + +## Configuration + +A checkbox group comprises two components: + +- [Checkbox](#checkbox) defines an individual list item +- [CheckboxGroup](#checkboxgroup-1) provides a wrapper for multiple `Checkbox` components, and handles state management and data validation + +Each component has its own set of supported props. + +## Controls + +### CheckboxGroup controls + + + + +### Checkbox controls + + + + +## Checkbox + +`Checkbox` renders a focusable and selectable checkbox, along with a text label (set via the `children` slot). + +Each checkbox should also have a unique identifier, set using the `value` prop. This is used for data validation and submission. If two checkboxes have the same `value`, selecting one will set both to `isSelected`. + +Use `defaultSelected` to make a checkbox selected by default: + + + +Use `isRequired` to mark a checkbox as mandatory and display a "(required)" label: + + + +### States + +Disable a checkbox using `isDisabled`. A disabled checkbox cannot be focused or selected: + + + +Set `isReadOnly` to lock a checkbox to its current value. A read-only checkbox can be focused, but cannot be selected: + + + +A checkbox can be put in [an 'indeterminate' (mixed/uncertain) state](https://react-spectrum.adobe.com/react-aria/Checkbox.html#indeterminate) using `isIndeterminate`: + + + +`isIndeterminate` overrides the checkbox's appearance, until it is set to `false` (for example, conditionally based on other selections.) + +## CheckboxGroup + +`CheckboxGroup` wraps multiple checkboxes into a group. It expects an array of `Checkbox` components passed as `children`. + +Use the `label` and `description` props to explain the purpose of a checkbox group: + + + +If you do not provide a visible `label`, you must instead pass an `aria-label` prop for use by assistive technologies. + +You can toggle the layout of a checkbox group between `vertical` (default) and `horizontal` using the `orientation` prop: + + + +### Required input + +Pass `isRequired` to make input mandatory, and display an additional "(required)" label: + + + +### Disabled state + +You can disable an entire checkbox group using `isDisabled`: + + + +### Validation and error handling + +You can [configure validation behaviour](https://react-spectrum.adobe.com/react-aria/CheckboxGroup.html#validation) at the group or individual checkbox level. Set the `isInvalid` prop to display error messages based on your app's validation logic. + +You can also use a [Form wrapper](/docs/utility-form-wrapper--docs) to configure validation and submission behaviour for multiple input components. + + + +`CheckboxGroup` uses the FieldError subcomponent to render error messages. You can pass custom or dynamically-generated content to the `errorMessage` slot. diff --git a/packages/react-components/src/stories/CheckboxGroup.stories.tsx b/packages/react-components/src/stories/CheckboxGroup.stories.tsx new file mode 100644 index 00000000..8b573507 --- /dev/null +++ b/packages/react-components/src/stories/CheckboxGroup.stories.tsx @@ -0,0 +1,155 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Checkbox, CheckboxGroup } from "../components"; +import { CheckboxGroupProps } from "../components/CheckboxGroup"; + +const meta = { + title: "Components/CheckboxGroup", + component: CheckboxGroup, + parameters: { + layout: "centered", + }, + argTypes: { + label: { + control: { type: "text" }, + description: "Text label for checkbox group", + }, + description: { + control: { type: "text" }, + description: "Additional description or helper text", + }, + orientation: { + control: { type: "radio" }, + options: ["horizontal", "vertical"], + description: "Sets layout of checkboxes", + }, + defaultValue: { + control: { type: "object" }, + description: + "Array of values which should be selected by default (uncontrolled)", + }, + errorMessage: { + control: { type: "object" }, + description: "Error message displayed when input is invalid", + }, + children: { + control: { type: "object" }, + description: "Expects an array of `Checkbox` components", + }, + isRequired: { + control: { type: "boolean" }, + description: "Whether an input is mandatory or optional", + }, + isInvalid: { + control: { type: "boolean" }, + description: "Set when input values are invalid", + }, + isDisabled: { + control: { type: "boolean" }, + description: "Disables the entire checkbox group", + }, + flexWrap: { + control: { type: "radio" }, + options: ["nowrap", "wrap", "wrap-reverse"], + description: "Sets `flex-wrap` property on checkbox items", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const CheckboxGroupTemplate: Story = { + args: { + label: "This is a checkbox group", + orientation: "vertical", + children: [ + + Checkbox 1 + , + Checkbox 2, + Checkbox 3, + + Checkbox 4 is disabled + , + + Checkbox 5 is indeterminate + , + Checkboxes 6 and 7 are synced, + Checkboxes 6 and 7 are synced, + ], + }, + render: ({ ...args }: CheckboxGroupProps) => , +}; + +export const CheckboxGroupWithLabelAndDescription: Story = { + args: { + label: "This is the primary label", + description: "This is an additional description field.", + children: [ + Option 1, + Option 2, + Option 3, + ], + }, +}; + +export const HorizontalCheckboxGroup: Story = { + args: { + orientation: "horizontal", + label: "This checkbox group is laid out horizontally", + children: [ + Option 1, + Option 2, + Option 3, + Option 4, + ], + }, +}; + +export const RequiredCheckboxGroup: Story = { + args: { + label: "This checkbox group requires an input", + isRequired: true, + children: [ + Option 1, + Option 2, + Option 3, + Option 4, + ], + }, +}; + +export const DisabledCheckboxGroup: Story = { + args: { + label: "This checkbox group has been disabled", + description: "None of the options can be focused or selected", + isDisabled: true, + children: [ + Option 1, + Option 2, + Option 3, + Option 4, + ], + }, +}; + +export const CheckboxGroupWithErrors: Story = { + args: { + orientation: "vertical", + label: "This checkbox group has errors", + description: "Description and/or helper text", + errorMessage: "Error messages can be customised or passed programmatically", + children: [ + + Option 1 + , + Option 2, + Option 3, + Option 4, + ], + isInvalid: true, + isRequired: true, + }, + render: ({ ...args }: CheckboxGroupProps) => , +};