Skip to content

Commit

Permalink
feat: CheckableCard component (#1443)
Browse files Browse the repository at this point in the history
## 📝 Changes

Please provide a brief summary of the changes made and why they were
made.

Include any notes, screenshots, or videos that may be helpful for
developers reviewing this pull request.

## ✅ Checklist

Easy UI has certain UX standards that must be met. In general,
non-trivial changes should meet the following criteria:

- [x] Visuals match Design Specs in Figma
- [x] Stories accompany any component changes
- [x] Code is in accordance with our style guide
- [x] Design tokens are utilized
- [x] Unit tests accompany any component changes
- [x] TSDoc is written for any API surface area
- [ ] Specs are up-to-date
- [x] Console is free from warnings
- [ ] No accessibility violations are reported
- [ ] Cross-browser check is performed (Chrome, Safari, Firefox)
- [x] Changeset is added

~Strikethrough~ any items that are not applicable to this pull request.

---------

Co-authored-by: Stephen Watkins <[email protected]>
  • Loading branch information
austincraig09 and stephenjwatkins authored Feb 10, 2025
1 parent 380d1ad commit 4314cde
Show file tree
Hide file tree
Showing 15 changed files with 421 additions and 25 deletions.
5 changes: 5 additions & 0 deletions .changeset/two-rice-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@easypost/easy-ui": minor
---

feat: CheckableCard component
154 changes: 154 additions & 0 deletions documentation/specs/CheckableCard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# `CheckableCard` Component Specification

## Overview

A `CheckableCard` is a styled container with a `Checkbox` form element.

### Use Cases

- Use a CheckableCard to display a checkbox for users inside a styled container

### Features

- Supports use of `Checkbox` form element within a `Card` container
- Supports existing features of `Card` component
- Supports existing features of `Checkbox` component

### Prior Art

### Design

Design of `CheckableCard` is comprised of the `Checkbox` component wrapped by the `Card` container component.

### API

```ts
export type CheckableCardProps = CheckboxProps & {
/**
* Card children
*/
children: ReactNode;
};
```

### Example Usage

```tsx
import { CheckableCard } from "@easypost/easy-ui/CheckableCard";

function Component() {
return <CheckableCard>Checkbox item</CheckableCard>;
}
```

_Default value:_

```tsx
import { CheckableCard } from "@easypost/easy-ui/CheckableCard";

function Component() {
return <CheckableCard defaultSelected={true}>Checkbox item</CheckableCard>;
}
```

_Controlled:_

```tsx
import { CheckableCard } from "@easypost/easy-ui/CheckableCard";

function Component() {
const [isSelected, setIsSelected] = useState(false);
return (
<CheckableCard
isSelected={isSelected}
onChange={(isSelected) => setIsSelected(isSelected)}
>
Checkbox item
</CheckableCard>
);
}
```

_Disabled:_

```tsx
import { CheckableCard } from "@easypost/easy-ui/CheckableCard";

function Component() {
return <CheckableCard isDisabled={true}>Checkbox item</CheckableCard>;
}
```

_Read-only:_

```tsx
import { CheckableCard } from "@easypost/easy-ui/CheckableCard";

function Component() {
return (
<CheckableCard isSelected={true} isReadOnly={true}>
Checkbox item
</CheckableCard>
);
}
```

_Form submission data:_

```tsx
import { CheckableCard } from "@easypost/easy-ui/CheckableCard";

function Component() {
return (
<CheckableCard name="checkable-card-name" value="checkable-card-value">
Checkbox item
</CheckableCard>
);
}
```

_Error:_

```tsx
import { CheckableCard } from "@easypost/easy-ui/CheckableCard";

function Component() {
return (
<CheckableCard
validationState="invalid"
errorText="This is required to proceed"
>
Checkbox item
</CheckableCard>
);
}
```

### Anatomy

The `CheckableCard` component will render a `Card` component that wraps a `Checkbox` component.

```tsx
function CheckableCard() {
return (
<Card>
<Checkbox
isSelected={isSelected}
onChange={(isSelected) => setIsSelected(isSelected)}
>
Checkbox item
</Checkbox>
</Card>
);
}
```

---

## Behavior

### Accessibility

### Dependencies

- React Aria's `useCheckbox`, `useFocusRing`, `VisuallyHidden`, `useToggleState`, and `ValidationState`
10 changes: 10 additions & 0 deletions easy-ui-react/src/CheckableCard/CheckableCard.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from "react";
import { ArgTypes, Canvas, Meta, Controls } from "@storybook/blocks";
import { CheckableCard } from "./CheckableCard";
import * as CheckableCardStories from "./CheckableCard.stories";

<Meta of={CheckableCardStories} />

# CheckableCard

<Canvas of={CheckableCardStories.Default} />
5 changes: 5 additions & 0 deletions easy-ui-react/src/CheckableCard/CheckableCard.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@use "../styles/common" as *;

.textContainer {
padding-left: 36px;
}
22 changes: 22 additions & 0 deletions easy-ui-react/src/CheckableCard/CheckableCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { CheckableCardProps, CheckableCard } from "./CheckableCard";

type Story = StoryObj<typeof CheckableCard>;

const Template = (args: CheckableCardProps) => <CheckableCard {...args} />;

const meta: Meta<typeof CheckableCard> = {
title: "Components/Cards/CheckableCard",
component: CheckableCard,
};

export default meta;

export const Default: Story = {
render: Template.bind({}),
args: {
header: "Header",
children: "Content",
},
};
96 changes: 96 additions & 0 deletions easy-ui-react/src/CheckableCard/CheckableCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { screen } from "@testing-library/react";
import React from "react";
import { vi } from "vitest";
import { hoverOverTooltipTrigger } from "../Tooltip/Tooltip.test";
import { render, selectCheckbox } from "../utilities/test";
import { CheckableCard } from "./CheckableCard";

describe("<CheckableCard />", () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it("should render a CheckableCard", () => {
render(<CheckableCard header="Header">Content</CheckableCard>);
expect(screen.getByRole("checkbox")).toBeInTheDocument();
expect(screen.getByRole("checkbox")).not.toBeChecked();
});

it("should support uncontrolled", async () => {
const { user } = render(
<CheckableCard header="Header" defaultSelected={true}>
Checkbox item
</CheckableCard>,
);
expect(screen.getByRole("checkbox")).toBeChecked();
await selectCheckbox(user, screen.getByRole("checkbox"));
expect(screen.getByRole("checkbox")).not.toBeChecked();
});

it("should support controlled", async () => {
const handleChange = vi.fn();
const { user, rerender } = render(
<CheckableCard header="Header" isSelected={false} onChange={handleChange}>
Checkbox item
</CheckableCard>,
);
await selectCheckbox(user, screen.getByRole("checkbox"));
expect(handleChange).toBeCalled();
expect(screen.getByRole("checkbox")).not.toBeChecked();
rerender(
<CheckableCard header="Header" isSelected={true} onChange={handleChange}>
Checkbox item
</CheckableCard>,
);
expect(screen.getByRole("checkbox")).toBeChecked();
});

it("should support isDisabled", () => {
render(
<CheckableCard header="Header" isDisabled>
Checkbox item
</CheckableCard>,
);
expect(screen.getByRole("checkbox")).toBeDisabled();
});

it("should support isReadOnly", async () => {
const { user } = render(
<CheckableCard header="Header" isReadOnly>
Checkbox item
</CheckableCard>,
);
await selectCheckbox(user, screen.getByRole("checkbox"));
expect(screen.getByRole("checkbox")).not.toBeChecked();
});

it("should support isIndeterminate", () => {
render(
<CheckableCard header="Header" isIndeterminate>
Checkbox item
</CheckableCard>,
);
expect(
(screen.getByRole("checkbox") as HTMLInputElement).indeterminate,
).toBeTruthy();
});

it("should support errors", async () => {
const { user } = render(
<CheckableCard
header="Header"
validationState="invalid"
errorText="This field is required"
>
Checkbox item
</CheckableCard>,
);
await hoverOverTooltipTrigger(user, screen.getByText("Error"));
expect(screen.getByRole("checkbox")).toBeInvalid();
expect(screen.getByText("This field is required")).toBeInTheDocument();
});
});
58 changes: 58 additions & 0 deletions easy-ui-react/src/CheckableCard/CheckableCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React, { ReactNode } from "react";
import { Card } from "../Card";
import { Checkbox, CheckboxProps } from "../Checkbox";
import { HorizontalStack } from "../HorizontalStack";
import { Text } from "../Text";
import { VerticalStack } from "../VerticalStack";

import styles from "./CheckableCard.module.scss";

export type CheckableCardProps = Omit<CheckboxProps, "size"> & {
/**
* The header for the checkable card.
*/
header: ReactNode;

/**
* The content for the checkable card.
*/
children: ReactNode;
};

/**
* A styled container with a `Checkbox` form element.
*
* @example
* ```tsx
* <CheckableCard
* isSelected={isSelected}
* onChange={(isSelected) => setIsSelected(isSelected)}
* header="Header"
* >
* Content
* </CheckableCard>
* ```
*/
export function CheckableCard(props: CheckableCardProps) {
const { header, children, ...checkboxProps } = props;
return (
<Card>
<VerticalStack gap="2">
<HorizontalStack gap="1.5" blockAlign="center">
<VerticalStack gap="1">
<Checkbox {...checkboxProps} size="lg">
<Text weight="semibold" as="h3">
{header}
</Text>
</Checkbox>
<div className={styles.textContainer}>
<Text weight="medium" color="neutral.500">
{children}
</Text>
</div>
</VerticalStack>
</HorizontalStack>
</VerticalStack>
</Card>
);
}
1 change: 1 addition & 0 deletions easy-ui-react/src/CheckableCard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./CheckableCard";
Loading

0 comments on commit 4314cde

Please sign in to comment.