Skip to content

Commit

Permalink
Add Checkbox and CheckboxGroup components (#463)
Browse files Browse the repository at this point in the history
* 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 60c564b.

* 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 <[email protected]>
  • Loading branch information
mkernohanbc and ty2k authored Sep 11, 2024
1 parent 22ac1f3 commit f859877
Show file tree
Hide file tree
Showing 20 changed files with 824 additions and 0 deletions.
2 changes: 2 additions & 0 deletions packages/react-components/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import useWindowDimensions from "@/hooks/useWindowDimensions";
import {
ButtonPage,
ButtonGroupPage,
CheckboxGroupPage,
InlineAlertPage,
SelectPage,
TagGroupPage,
Expand Down Expand Up @@ -150,6 +151,7 @@ function App() {
<h1>Components</h1>
<ButtonPage />
<ButtonGroupPage />
<CheckboxGroupPage />
<SwitchPage />
<InlineAlertPage />
<SelectPage />
Expand Down
118 changes: 118 additions & 0 deletions packages/react-components/src/components/Checkbox/Checkbox.css
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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(<Checkbox>I agree</Checkbox>);
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(<Checkbox defaultSelected={true}>Email me my results</Checkbox>);
const checkbox = screen.getByLabelText(/email me my results/i);
expect(checkbox).toBeChecked();
fireEvent.click(checkbox);
expect(checkbox).not.toBeChecked();
});
});
32 changes: 32 additions & 0 deletions packages/react-components/src/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ReactAriaCheckbox
className="bcds-react-aria-Checkbox"
value={value}
{...props}
>
{({ isRequired, isSelected, isIndeterminate }: CheckboxRenderProps) => (
<>
<div className="checkbox">
{isSelected && !isIndeterminate && <SvgCheckIcon />}
{isIndeterminate && <SvgDashIcon />}
</div>
<span className="label">
<>{children}</> {isRequired && "(required)"}
</span>
</>
)}
</ReactAriaCheckbox>
);
}
2 changes: 2 additions & 0 deletions packages/react-components/src/components/Checkbox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from "./Checkbox";
export type { CheckboxProps } from "./Checkbox";
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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(
<CheckboxGroup label="Fruits" defaultValue={["orange"]}>
<Checkbox value="apple">Apple</Checkbox>
<Checkbox value="orange">Orange</Checkbox>
<Checkbox value="banana">Banana</Checkbox>
</CheckboxGroup>
);
});

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();
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<ReactAriaCheckboxGroup
className={`bcds-react-aria-CheckboxGroup`}
{...props}
>
{({ isRequired, isInvalid }: CheckboxGroupRenderProps) => (
<>
{label && (
<Label className="bcds-react-aria-CheckboxGroup--label">
{label} {isRequired && "(required)"}
</Label>
)}
<div
className={`bcds-react-aria-CheckboxGroup--options ${orientation} flex-wrap-${flexWrap}`}
>
<>{children}</>
</div>
{description && (
<Text
slot="description"
className="bcds-react-aria-CheckboxGroup--description"
>
{description}
</Text>
)}
{isInvalid && (
<div className="bcds-react-aria-CheckboxGroup--error">
<SvgExclamationIcon />
<FieldError>{errorMessage}</FieldError>
</div>
)}
</>
)}
</ReactAriaCheckboxGroup>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from "./CheckboxGroup";
export type { CheckboxGroupProps } from "./CheckboxGroup";
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export default function SvgCheckIcon({ id = "check-icon" }) {
return (
<svg
id={id}
width="10"
height="10"
viewBox="0 0 10 10"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
d="M9.09447 1.16176C8.67766 0.834916 8.07489 0.907409 7.74747 1.32376L3.63547 6.55676L2.21847 4.85676C1.87495 4.46651 1.2832 4.42064 0.883639 4.75329C0.484082 5.08594 0.421942 5.6762 0.743468 6.08476L2.92047 8.69776C2.94047 8.72176 2.96947 8.72976 2.99047 8.75176C3.0133 8.78196 3.03802 8.81068 3.06447 8.83776C3.10707 8.86542 3.15189 8.8895 3.19847 8.90976C3.23068 8.92977 3.26407 8.9478 3.29847 8.96376C3.41159 9.01295 3.53316 9.03977 3.65647 9.04276H3.65747C3.78585 9.04006 3.91238 9.01149 4.02947 8.95876C4.06502 8.9413 4.09942 8.92159 4.13247 8.89976C4.18141 8.87729 4.22826 8.85052 4.27247 8.81976C4.29837 8.79134 4.32242 8.76128 4.34447 8.72976C4.36447 8.70976 4.39347 8.69976 4.41247 8.67576L9.25747 2.50976C9.58477 2.09253 9.51178 1.48896 9.09447 1.16176V1.16176Z"
fill="currentColor"
/>
</g>
</svg>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./SvgCheckIcon";
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export default function SvgDashIcon({ id = "dash-icon" }) {
return (
<svg
id={id}
width="8"
height="10"
viewBox="0 0 8 2"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.99005 1.96004H1.01005C0.479855 1.96004 0.0500488 1.53023 0.0500488 1.00004C0.0500488 0.469846 0.479855 0.0400391 1.01005 0.0400391H6.99005C7.52024 0.0400391 7.95005 0.469846 7.95005 1.00004C7.95005 1.53023 7.52024 1.96004 6.99005 1.96004H6.99005Z"
fill="currentColor"
/>
</svg>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./SvgDashIcon";
Loading

0 comments on commit f859877

Please sign in to comment.