Skip to content

Commit

Permalink
feat: RadioButtonGroup component (#1517)
Browse files Browse the repository at this point in the history
## ✅ 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~
- [X] Specs are up-to-date
- [X] Console is free from warnings
- [X] No accessibility violations are reported
- [X] Cross-browser check is performed (Chrome, Safari, Firefox)
- [X] Changeset is added
  • Loading branch information
Brattin11 authored Dec 10, 2024
1 parent 72a5119 commit 58d2edc
Show file tree
Hide file tree
Showing 8 changed files with 485 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/itchy-bags-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@easypost/easy-ui": minor
---

Add RadioButtonGroup component
7 changes: 5 additions & 2 deletions documentation/specs/RadioButtonGroup.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,12 @@ import { RadioButtonGroup } from "@easypost/easy-ui/RadioButtonGroup";
import SettingsIcon from "@easypost/easy-ui-icons/Settings";

function Component() {
const [selected, setSelected] = React.useState("in");
const [selected, setSelected] = React.useState(new Set(["in"]));
return (
<RadioButtonGroup onSelectionChange={setSelected}>
<RadioButtonGroup
selectedKeys={new Set(["in"])}
onSelectionChange={setSelected}
>
<RadioButtonGroup.Button id="in">in</RadioButtonGroup.Button>
<RadioButtonGroup.Button id="cm">cm</RadioButtonGroup.Button>
</RadioButtonGroup>
Expand Down
56 changes: 56 additions & 0 deletions easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from "react";
import { ArgTypes, Canvas, Meta, Controls } from "@storybook/blocks";
import { RadioButtonGroup } from "./RadioButtonGroup";
import * as RadioButtonGroupStories from "./RadioButtonGroup.stories";

<Meta of={RadioButtonGroupStories} />

# RadioButtonGroup

A `<RadioButtonGroup />` is a group of connected buttons that act as a radio group.

<Canvas of={RadioButtonGroupStories.Default} />

## Selection Mode

A `<RadioButtonGroup />` can be used in `single` or `multiple` selection mode.

<Canvas of={RadioButtonGroupStories.SelectionMode} />
<Controls
of={RadioButtonGroupStories.SelectionMode}
include={["selectionMode"]}
/>

## Disabled

A `<RadioButtonGroup />` can be disabled.

<Canvas of={RadioButtonGroupStories.EnabledDisabled} />
<Controls
of={RadioButtonGroupStories.EnabledDisabled}
include={["isDisabled"]}
/>

Individual `<RadioButtonGroup.Button />` elements can be disabled.

<Canvas of={RadioButtonGroupStories.EnabledDisabledButton} />

## Disallow Empty Selection

A `<RadioButtonGroup />` can disallow empty selection.

<Canvas of={RadioButtonGroupStories.DisallowEmptySelection} />
<Controls
of={RadioButtonGroupStories.DisallowEmptySelection}
include={["disallowEmptySelection"]}
/>

## Properties

### RadioButtonGroup

<ArgTypes of={RadioButtonGroup} />

### RadioButtonGroup.Button

<ArgTypes of={RadioButtonGroup.Button} />
48 changes: 48 additions & 0 deletions easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
@use "../styles/common" as *;
@use "../Button/mixins" as Button;
@use "../styles/unstyled";

.RadioButtonGroupButton {
@include unstyled.button;
min-width: 32px;
padding: calc(#{design-token("space.0.5")} - 1px) // subracting 1 for border
design-token("space.1");
border: design-token("shape.border_width.1") solid
design-token("color.neutral.300");
outline: none;
color: design-token("color.primary.800");
margin-inline-start: calc(#{design-token("shape.border_width.1")} * -1);
position: relative;
display: inline-flex;
justify-content: center;
align-items: center;

&:first-child {
border-radius: design-token("shape.border_radius.md") 0 0
design-token("shape.border_radius.md");
margin-inline-start: 0;
}

&:last-child {
border-radius: 0 design-token("shape.border_radius.md")
design-token("shape.border_radius.md") 0;
}

&:disabled {
color: design-token("color.neutral.300");
cursor: not-allowed;
}

&[data-selected] {
background-color: component-token("radio-button-group", "color");
border-color: component-token("radio-button-group", "color");
color: design-token("color.neutral.000");
z-index: 2;
}

&[data-selected]:disabled {
background-color: design-token("color.neutral.100");
color: design-token("color.neutral.600");
border-color: design-token("color.neutral.300");
}
}
105 changes: 105 additions & 0 deletions easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { createColorTokensControl } from "../utilities/storybook";
import { RadioButtonGroup, RadioButtonGroupProps } from "./RadioButtonGroup";

type Story = StoryObj<typeof RadioButtonGroup>;

const meta: Meta<typeof RadioButtonGroup> = {
title: "Components/RadioButtonGroup",
component: RadioButtonGroup,
args: {
color: "primary.500",
},
argTypes: {
color: {
...createColorTokensControl(),
table: {
type: { summary: null },
},
},
},
};

const Template = (args: RadioButtonGroupProps) => {
const { children, ...restArgs } = args;
return <RadioButtonGroup {...restArgs}>{children}</RadioButtonGroup>;
};

export default meta;

export const Default: Story = {
render: Template.bind({}),
args: {
children: (
<>
<RadioButtonGroup.Button id="in">in</RadioButtonGroup.Button>
<RadioButtonGroup.Button id="cm">cm</RadioButtonGroup.Button>
</>
),
defaultSelectedKeys: ["in"],
},
};

export const SelectionMode: Story = {
render: Template.bind({}),
args: {
children: (
<>
<RadioButtonGroup.Button id="bold">Bold</RadioButtonGroup.Button>
<RadioButtonGroup.Button id="italic">Italic</RadioButtonGroup.Button>
<RadioButtonGroup.Button id="underline">
Underline
</RadioButtonGroup.Button>
</>
),
selectionMode: "multiple",
},
};

export const EnabledDisabled: Story = {
render: Template.bind({}),
args: {
children: (
<>
<RadioButtonGroup.Button id="in">in</RadioButtonGroup.Button>
<RadioButtonGroup.Button id="cm">cm</RadioButtonGroup.Button>
</>
),
isDisabled: true,
defaultSelectedKeys: ["in"],
},
};

export const EnabledDisabledButton: Story = {
render: Template.bind({}),
args: {
children: (
<>
<RadioButtonGroup.Button id="in">in</RadioButtonGroup.Button>
<RadioButtonGroup.Button isDisabled id="cm">
cm
</RadioButtonGroup.Button>
<RadioButtonGroup.Button id="ft">ft</RadioButtonGroup.Button>
<RadioButtonGroup.Button isDisabled id="mi">
mi
</RadioButtonGroup.Button>
</>
),
defaultSelectedKeys: ["in"],
},
};

export const DisallowEmptySelection: Story = {
render: Template.bind({}),
args: {
children: (
<>
<RadioButtonGroup.Button id="in">in</RadioButtonGroup.Button>
<RadioButtonGroup.Button id="cm">cm</RadioButtonGroup.Button>
</>
),
disallowEmptySelection: true,
defaultSelectedKeys: ["in"],
},
};
113 changes: 113 additions & 0 deletions easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { screen } from "@testing-library/react";
import React from "react";
import { vi } from "vitest";
import { render, selectCheckbox } from "../utilities/test";
import { RadioButtonGroup } from "./RadioButtonGroup";

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

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

it("should render a radio group", () => {
render(
<RadioButtonGroup>
<RadioButtonGroup.Button id="first">
First Button
</RadioButtonGroup.Button>
<RadioButtonGroup.Button id="second">
Second Button
</RadioButtonGroup.Button>
</RadioButtonGroup>,
);
expect(screen.getByRole("radiogroup")).toBeInTheDocument();
expect(screen.getAllByRole("radio").length).toBe(2);
expect(screen.getAllByRole("radio")[0]).toHaveAttribute("aria-checked");
});

it("should update when a button is clicked", async () => {
const { user } = render(
<RadioButtonGroup defaultSelectedKeys={["first"]}>
<RadioButtonGroup.Button id="first">First item</RadioButtonGroup.Button>
<RadioButtonGroup.Button id="second">
Second item
</RadioButtonGroup.Button>
</RadioButtonGroup>,
);
const radios = screen.getAllByRole("radio");
expect(radios[0]).toBeChecked();

await selectCheckbox(user, radios[1]);
expect(radios[0]).not.toBeChecked();
expect(radios[1]).toBeChecked();
});

it("should support disabling the group", async () => {
const { user } = render(
<RadioButtonGroup defaultSelectedKeys={["first"]} isDisabled>
<RadioButtonGroup.Button id="first">First item</RadioButtonGroup.Button>
<RadioButtonGroup.Button id="second">
Second item
</RadioButtonGroup.Button>
</RadioButtonGroup>,
);
const radios = screen.getAllByRole("radio");
expect(radios[0]).toBeChecked();

await selectCheckbox(user, radios[1]);
expect(radios[0]).toBeChecked();
expect(radios[1]).not.toBeChecked();
});

it("should support disabling individual buttons", async () => {
const { user } = render(
<RadioButtonGroup defaultSelectedKeys={["first"]} isDisabled>
<RadioButtonGroup.Button id="first">First item</RadioButtonGroup.Button>
<RadioButtonGroup.Button id="second" isDisabled>
Second item
</RadioButtonGroup.Button>
</RadioButtonGroup>,
);
const radios = screen.getAllByRole("radio");
expect(radios[0]).toBeChecked();

await selectCheckbox(user, radios[1]);
expect(radios[0]).toBeChecked();
expect(radios[1]).not.toBeChecked();
});

it("should support controlled use", async () => {
const handleChange = vi.fn();
const { user, rerender } = render(
<RadioButtonGroup
selectedKeys={["second"]}
onSelectionChange={handleChange}
>
<RadioButtonGroup.Button id="first">First item</RadioButtonGroup.Button>
<RadioButtonGroup.Button id="second">
Second item
</RadioButtonGroup.Button>
</RadioButtonGroup>,
);

const radios = screen.getAllByRole("radio");
await selectCheckbox(user, radios[0]);
expect(handleChange).toBeCalled();
expect(screen.getAllByRole("radio")[0]).not.toBeChecked();

rerender(
<RadioButtonGroup selectedKeys={["second"]}>
<RadioButtonGroup.Button id="first">First item</RadioButtonGroup.Button>
<RadioButtonGroup.Button id="second">
Second item
</RadioButtonGroup.Button>
</RadioButtonGroup>,
);

expect(screen.getAllByRole("radio")[1]).toBeChecked();
});
});
Loading

0 comments on commit 58d2edc

Please sign in to comment.