From 7dfb8183693563c9add680b26a6b3ae6e23025b5 Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 20 Jul 2026 16:01:46 +0800 Subject: [PATCH 01/12] feat(shared-ui): add RadioCardGroup card-style single-select primitive --- .../shared-ui/src/RadioCardGroup.test.tsx | 61 +++++++++ packages/shared-ui/src/RadioCardGroup.tsx | 123 ++++++++++++++++++ packages/shared-ui/src/index.ts | 1 + 3 files changed, 185 insertions(+) create mode 100644 packages/shared-ui/src/RadioCardGroup.test.tsx create mode 100644 packages/shared-ui/src/RadioCardGroup.tsx diff --git a/packages/shared-ui/src/RadioCardGroup.test.tsx b/packages/shared-ui/src/RadioCardGroup.test.tsx new file mode 100644 index 00000000..88f18d02 --- /dev/null +++ b/packages/shared-ui/src/RadioCardGroup.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, cleanup, fireEvent, screen } from "@testing-library/react"; +import { RadioCardGroup } from "./RadioCardGroup"; + +afterEach(cleanup); + +const OPTS = [ + { value: "own", title: "Bring your own", description: "You pay rates directly." }, + { value: "shared", title: "Managed shared", description: "No setup.", badge: "included" }, + { value: "dedicated", title: "Managed dedicated", description: "Your own number." }, +]; + +describe("RadioCardGroup", () => { + it("renders every option title, description and badge", () => { + render( {}} options={OPTS} />); + expect(screen.getByText("Bring your own")).toBeTruthy(); + expect(screen.getByText("You pay rates directly.")).toBeTruthy(); + expect(screen.getByText("included")).toBeTruthy(); + }); + + it("checks the option matching value and exposes radiogroup semantics", () => { + render( {}} options={OPTS} legend="Delivery" />); + expect(screen.getByRole("radiogroup")).toBeTruthy(); + const radios = screen.getAllByRole("radio") as HTMLInputElement[]; + expect(radios[0].checked).toBe(false); + expect(radios[1].checked).toBe(true); + // Selected card carries the canonical active token. + const selectedCard = radios[1].closest("label")!; + expect(selectedCard.className).toContain("border-ih-primary"); + expect(selectedCard.className).toContain("bg-ih-primary/5"); + }); + + it("calls onChange with the value when a card is selected", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByText("Managed dedicated")); + expect(onChange).toHaveBeenCalledWith("dedicated"); + }); + + it("moves selection with arrow keys", () => { + const onChange = vi.fn(); + render(); + const radios = screen.getAllByRole("radio"); + fireEvent.keyDown(radios[0], { key: "ArrowDown" }); + expect(onChange).toHaveBeenLastCalledWith("shared"); + }); + + it("does not select a disabled option", () => { + const onChange = vi.fn(); + const opts = [OPTS[0], { ...OPTS[1], disabled: true }]; + render(); + const radios = screen.getAllByRole("radio") as HTMLInputElement[]; + expect(radios[1].disabled).toBe(true); + }); + + it("renders error over hint when both are present", () => { + render( {}} options={OPTS} error="Required" hint="Pick one" />); + expect(screen.getByText("Required")).toBeTruthy(); + expect(screen.queryByText("Pick one")).toBeNull(); + }); +}); diff --git a/packages/shared-ui/src/RadioCardGroup.tsx b/packages/shared-ui/src/RadioCardGroup.tsx new file mode 100644 index 00000000..e440b09c --- /dev/null +++ b/packages/shared-ui/src/RadioCardGroup.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import { cn } from "./cn"; + +export interface RadioCardOption { + value: string; + title: React.ReactNode; + description?: React.ReactNode; + badge?: React.ReactNode; + disabled?: boolean; +} + +interface RadioCardGroupProps { + name: string; + value: string; + onChange: (value: string) => void; + options: RadioCardOption[]; + legend?: React.ReactNode; + error?: string; + hint?: string; + className?: string; +} + +/** + * Design System card-style single-select. A vertical stack of bordered cards, + * each a native