From 58d2edce077739e3660f235b87684fca9218c21a Mon Sep 17 00:00:00 2001 From: Brattin11 <64220304+Brattin11@users.noreply.github.com> Date: Tue, 10 Dec 2024 10:52:37 -0600 Subject: [PATCH] feat: RadioButtonGroup component (#1517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ✅ 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 --- .changeset/itchy-bags-provide.md | 5 + documentation/specs/RadioButtonGroup.md | 7 +- .../src/RadioButtonGroup/RadioButtonGroup.mdx | 56 +++++++ .../RadioButtonGroup.module.scss | 48 ++++++ .../RadioButtonGroup.stories.tsx | 105 ++++++++++++ .../RadioButtonGroup.test.tsx | 113 +++++++++++++ .../src/RadioButtonGroup/RadioButtonGroup.tsx | 152 ++++++++++++++++++ easy-ui-react/src/RadioButtonGroup/index.ts | 1 + 8 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 .changeset/itchy-bags-provide.md create mode 100644 easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.mdx create mode 100644 easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.module.scss create mode 100644 easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.stories.tsx create mode 100644 easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.test.tsx create mode 100644 easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.tsx create mode 100644 easy-ui-react/src/RadioButtonGroup/index.ts diff --git a/.changeset/itchy-bags-provide.md b/.changeset/itchy-bags-provide.md new file mode 100644 index 00000000..98639d1b --- /dev/null +++ b/.changeset/itchy-bags-provide.md @@ -0,0 +1,5 @@ +--- +"@easypost/easy-ui": minor +--- + +Add RadioButtonGroup component diff --git a/documentation/specs/RadioButtonGroup.md b/documentation/specs/RadioButtonGroup.md index c78a951b..961d7bdc 100644 --- a/documentation/specs/RadioButtonGroup.md +++ b/documentation/specs/RadioButtonGroup.md @@ -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 ( - + in cm diff --git a/easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.mdx b/easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.mdx new file mode 100644 index 00000000..3295ee16 --- /dev/null +++ b/easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.mdx @@ -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"; + + + +# RadioButtonGroup + +A `` is a group of connected buttons that act as a radio group. + + + +## Selection Mode + +A `` can be used in `single` or `multiple` selection mode. + + + + +## Disabled + +A `` can be disabled. + + + + +Individual `` elements can be disabled. + + + +## Disallow Empty Selection + +A `` can disallow empty selection. + + + + +## Properties + +### RadioButtonGroup + + + +### RadioButtonGroup.Button + + diff --git a/easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.module.scss b/easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.module.scss new file mode 100644 index 00000000..41055f83 --- /dev/null +++ b/easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.module.scss @@ -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"); + } +} diff --git a/easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.stories.tsx b/easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.stories.tsx new file mode 100644 index 00000000..bfc13c55 --- /dev/null +++ b/easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.stories.tsx @@ -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; + +const meta: Meta = { + 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 {children}; +}; + +export default meta; + +export const Default: Story = { + render: Template.bind({}), + args: { + children: ( + <> + in + cm + + ), + defaultSelectedKeys: ["in"], + }, +}; + +export const SelectionMode: Story = { + render: Template.bind({}), + args: { + children: ( + <> + Bold + Italic + + Underline + + + ), + selectionMode: "multiple", + }, +}; + +export const EnabledDisabled: Story = { + render: Template.bind({}), + args: { + children: ( + <> + in + cm + + ), + isDisabled: true, + defaultSelectedKeys: ["in"], + }, +}; + +export const EnabledDisabledButton: Story = { + render: Template.bind({}), + args: { + children: ( + <> + in + + cm + + ft + + mi + + + ), + defaultSelectedKeys: ["in"], + }, +}; + +export const DisallowEmptySelection: Story = { + render: Template.bind({}), + args: { + children: ( + <> + in + cm + + ), + disallowEmptySelection: true, + defaultSelectedKeys: ["in"], + }, +}; diff --git a/easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.test.tsx b/easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.test.tsx new file mode 100644 index 00000000..b558510f --- /dev/null +++ b/easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.test.tsx @@ -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("", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should render a radio group", () => { + render( + + + First Button + + + Second Button + + , + ); + 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( + + First item + + Second item + + , + ); + 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( + + First item + + Second item + + , + ); + 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( + + First item + + Second item + + , + ); + 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( + + First item + + Second item + + , + ); + + const radios = screen.getAllByRole("radio"); + await selectCheckbox(user, radios[0]); + expect(handleChange).toBeCalled(); + expect(screen.getAllByRole("radio")[0]).not.toBeChecked(); + + rerender( + + First item + + Second item + + , + ); + + expect(screen.getAllByRole("radio")[1]).toBeChecked(); + }); +}); diff --git a/easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.tsx b/easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.tsx new file mode 100644 index 00000000..870f5a2b --- /dev/null +++ b/easy-ui-react/src/RadioButtonGroup/RadioButtonGroup.tsx @@ -0,0 +1,152 @@ +import React, { ReactNode } from "react"; +import { Key } from "react-aria"; +import { AriaLabelingProps } from "@react-types/shared"; +import { ToggleButtonGroup, ToggleButton } from "react-aria-components"; +import { classNames, getComponentThemeToken } from "../utilities/css"; +import { ThemeColorAliases } from "../types"; +import { Text } from "../Text"; + +import styles from "./RadioButtonGroup.module.scss"; + +export type RadioButtonGroupButtonProps = { + /** + * The label for the radio button. + */ + children: ReactNode; + /** + * Whether the radio button is disabled or not. + */ + isDisabled?: boolean; + /** + * An identifier for the item in selectedKeys. + */ + id: string; +}; + +/** + * Represents an button in a ``. + */ + +function RadioButtonGroupButton(props: RadioButtonGroupButtonProps) { + const { children, isDisabled, id } = props; + + return ( + + {children} + + ); +} + +export type RadioButtonGroupProps = AriaLabelingProps & { + /** + * Color for the selected button in the group. + * @default "primary.500" + */ + color?: ThemeColorAliases; + /** + * Whether single or multiple selection is enabled. + * @default "single" + */ + selectionMode?: "single" | "multiple"; + /** + * Whether the collection allows empty selection. + */ + disallowEmptySelection?: boolean; + /** + * The currently selected keys in the collection (controlled). + */ + selectedKeys?: Iterable; + /** + * The initial selected keys in the collection (uncontrolled). + */ + defaultSelectedKeys?: Iterable; + /** + * Whether all items are disabled. + */ + isDisabled?: boolean; + /** + * RadioButtonGroup Buttons + */ + children: ReactNode; + /** + * Handler that is called when the selection changes. + */ + onSelectionChange?: (keys: Set) => void; +}; + +/** + * A group of connected buttons that act as a radio group. + * + * @remarks + * Use a radio button group to toggle between two or more + * values for a given attribute. + * + * @example + * ```tsx + * + * First item + * + * Second item + * + * + * ``` + * + * @example + * _Default selected keys:_ + * ```tsx + * + * First item + * + * Second item + * + * + * ``` + * + * @example + * _Controlled value:_ + * ```tsx + * ()}> + * First item + * + * Second item + * + * + * ``` + */ + +export function RadioButtonGroup(props: RadioButtonGroupProps) { + const { + color = "primary.500", + children, + onSelectionChange, + selectedKeys, + selectionMode = "single", + disallowEmptySelection, + defaultSelectedKeys, + isDisabled, + } = props; + + const style = { + ...getComponentThemeToken("radio-button-group", "color", "color", color), + }; + return ( + + {children} + + ); +} + +RadioButtonGroup.Button = RadioButtonGroupButton; diff --git a/easy-ui-react/src/RadioButtonGroup/index.ts b/easy-ui-react/src/RadioButtonGroup/index.ts new file mode 100644 index 00000000..944127d0 --- /dev/null +++ b/easy-ui-react/src/RadioButtonGroup/index.ts @@ -0,0 +1 @@ +export * from "./RadioButtonGroup";