diff --git a/.changeset/bordered-choice-groups.md b/.changeset/bordered-choice-groups.md new file mode 100644 index 0000000000..784a40a496 --- /dev/null +++ b/.changeset/bordered-choice-groups.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": minor +--- + +Add bordered appearances and horizontal orientation support to radio and checkbox groups. diff --git a/packages/kumo-docs-astro/src/components/demos/CheckboxDemo.tsx b/packages/kumo-docs-astro/src/components/demos/CheckboxDemo.tsx index b50844c2d6..c8227d81a6 100644 --- a/packages/kumo-docs-astro/src/components/demos/CheckboxDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/CheckboxDemo.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; -import { Checkbox } from "@cloudflare/kumo"; +import { Badge, Checkbox } from "@cloudflare/kumo"; +import { ChatCircleTextIcon, EnvelopeIcon } from "@phosphor-icons/react"; export function CheckboxBasicDemo() { const [checked, setChecked] = useState(false); @@ -78,6 +79,112 @@ export function CheckboxGroupDemo() { ); } +export function CheckboxBorderedGroupDemo() { + const [notifications, setNotifications] = useState(["email"]); + const [alertCategories, setAlertCategories] = useState([ + "security", + "performance", + ]); + const [exportContents, setExportContents] = useState([ + "configuration", + "analytics", + ]); + const [permissions, setPermissions] = useState(["read", "edit"]); + + return ( +
+ + + + Email + + } + /> + + + SMS + + } + /> + + + + + + Reliability + Beta + + } + /> + + + + + + + + + + + + + + +
+ ); +} + /** Shows Checkbox.Legend with sr-only to visually hide the legend while keeping it accessible, useful when a parent Field already provides a visible label */ export function CheckboxLegendSrOnlyDemo() { const [preferences, setPreferences] = useState(["email"]); diff --git a/packages/kumo-docs-astro/src/components/demos/RadioDemo.tsx b/packages/kumo-docs-astro/src/components/demos/RadioDemo.tsx index d6c0c4f0d3..172a48a568 100644 --- a/packages/kumo-docs-astro/src/components/demos/RadioDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/RadioDemo.tsx @@ -1,5 +1,10 @@ import { useState } from "react"; import { Badge, Radio } from "@cloudflare/kumo"; +import { + GitBranchIcon, + GlobeIcon, + ShieldCheckIcon, +} from "@phosphor-icons/react"; /** Shows a basic controlled radio group */ export function RadioBasicDemo() { @@ -46,6 +51,103 @@ export function RadioHorizontalDemo() { ); } +/** Shows bordered radio groups in horizontal and vertical layouts */ +export function RadioBorderedDemo() { + const [scope, setScope] = useState("all"); + const [environment, setEnvironment] = useState("production"); + const [routingMode, setRoutingMode] = useState("smart"); + const [securityLevel, setSecurityLevel] = useState("balanced"); + + return ( +
+ + + + All traffic + + } + value="all" + /> + + + Previews only + + } + value="previews" + /> + + + + + + + + + + + + + + + + + Balanced (recommended) + Default + + } + value="balanced" + /> + + + +
+ ); +} + /** Shows a radio group with helper description text */ export function RadioDescriptionDemo() { const [value, setValue] = useState("standard"); diff --git a/packages/kumo-docs-astro/src/pages/components/checkbox.mdx b/packages/kumo-docs-astro/src/pages/components/checkbox.mdx index a0065e323f..cb8d33f849 100644 --- a/packages/kumo-docs-astro/src/pages/components/checkbox.mdx +++ b/packages/kumo-docs-astro/src/pages/components/checkbox.mdx @@ -19,6 +19,7 @@ import { CheckboxDisabledDemo, CheckboxErrorDemo, CheckboxGroupDemo, + CheckboxBorderedGroupDemo, CheckboxGroupErrorDemo, CheckboxLegendSrOnlyDemo, CheckboxLegendCustomDemo, @@ -128,6 +129,19 @@ export default function Example() { +### Bordered Checkbox Group + +

+ Use `appearance="bordered"` to place checkbox options in one contiguous + surface with shared dividers. Set `orientation="horizontal"` for a + side-by-side layout; groups are vertical by default. Bordered groups place + controls at the end unless `controlFirst` is set. The examples below also + cover long wrapping text, disabled options, and larger option sets. +

+ + + + ### Checkbox Group with Error

diff --git a/packages/kumo-docs-astro/src/pages/components/radio.mdx b/packages/kumo-docs-astro/src/pages/components/radio.mdx index 2c59c705dd..b809de29cb 100644 --- a/packages/kumo-docs-astro/src/pages/components/radio.mdx +++ b/packages/kumo-docs-astro/src/pages/components/radio.mdx @@ -13,6 +13,7 @@ import { RadioBasicDemo, RadioDefaultDemo, RadioHorizontalDemo, + RadioBorderedDemo, RadioDescriptionDemo, RadioErrorDemo, RadioDisabledDemo, @@ -101,6 +102,19 @@ export default function Example() { +### Bordered Group + +

+ Use `appearance="bordered"` to place options in one contiguous surface with + shared dividers. It supports both vertical and horizontal orientations and + places controls at the end by default. Set `controlPosition="start"` to place + controls before their labels. The examples below also cover rich labels, long + wrapping text, disabled options, and larger option sets. +

+ + + + ### With Description

Add helper text below the radio items using the `description` prop.

diff --git a/packages/kumo/src/components/checkbox/checkbox.test.tsx b/packages/kumo/src/components/checkbox/checkbox.test.tsx new file mode 100644 index 0000000000..a2eb4f0825 --- /dev/null +++ b/packages/kumo/src/components/checkbox/checkbox.test.tsx @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vite-plus/test"; +import { render } from "@testing-library/react"; +import { Checkbox } from "./checkbox"; + +describe("Checkbox.Group", () => { + it.each([ + ["vertical", "border-t"], + ["horizontal", "border-l"], + ] as const)( + "renders a bordered %s group with shared dividers", + (orientation, dividerClass) => { + const { container } = render( + + + + , + ); + + const group = container.querySelector('[data-kumo-part="group-items"]'); + const item = container.querySelector('[data-kumo-part="item-label"]'); + + expect(group?.className).toContain("rounded-lg"); + expect(group?.className).toContain(dividerClass); + expect(item?.className).toContain("flex-1"); + expect(item?.className).toContain("flex-row-reverse"); + expect(item?.className).toContain("bg-kumo-elevated"); + expect( + item?.querySelector('[data-kumo-part="item-content"]')?.className, + ).toContain("leading-5"); + }, + ); + + it("preserves control-first layout when explicitly requested", () => { + const { container } = render( + + + , + ); + + expect( + container.querySelector('[data-kumo-part="item-label"]')?.className, + ).not.toContain("flex-row-reverse"); + }); + + it("renders rich label content", () => { + const { getByText } = render( + + + Security Recommended + + } + value="security" + /> + , + ); + + expect(getByText("Recommended").tagName).toBe("STRONG"); + }); + + it("dims only content for disabled bordered items", () => { + const { container } = render( + + + , + ); + + const itemClassName = container.querySelector( + '[data-kumo-part="item-label"]', + )?.className; + + expect(itemClassName).toContain("[&>*]:opacity-50"); + expect(itemClassName?.split(" ")).not.toContain("opacity-50"); + }); +}); diff --git a/packages/kumo/src/components/checkbox/checkbox.tsx b/packages/kumo/src/components/checkbox/checkbox.tsx index 2aa42fa6ce..c801799bed 100644 --- a/packages/kumo/src/components/checkbox/checkbox.tsx +++ b/packages/kumo/src/components/checkbox/checkbox.tsx @@ -60,9 +60,19 @@ export function checkboxVariants({ // Legacy type alias for backwards compatibility export type CheckboxVariant = KumoCheckboxVariant; -// Context for passing controlFirst from Group to Items -const CheckboxGroupContext = createContext<{ controlFirst: boolean }>({ +/** Visual treatment for items within a checkbox group. */ +export type CheckboxGroupAppearance = "default" | "bordered"; + +/** Layout direction for items within a checkbox group. */ +export type CheckboxGroupOrientation = "vertical" | "horizontal"; + +// Context for passing group layout options to Items. +const CheckboxGroupContext = createContext<{ + controlFirst: boolean; + appearance: CheckboxGroupAppearance; +}>({ controlFirst: true, + appearance: "default", }); /** @@ -196,7 +206,11 @@ export interface CheckboxGroupProps { allValues?: string[]; /** Whether all checkboxes in the group are disabled */ disabled?: boolean; - /** When true (default), checkbox appears before label. When false, label appears before checkbox. */ + /** Layout direction of the checkbox items. */ + orientation?: CheckboxGroupOrientation; + /** Visual treatment applied to the group. */ + appearance?: CheckboxGroupAppearance; + /** When true, checkbox appears before label. When false, label appears before checkbox. Defaults to true for default appearance and false for bordered appearance. */ controlFirst?: boolean; /** Additional CSS classes */ className?: string; @@ -208,8 +222,8 @@ export interface CheckboxGroupProps { export type CheckboxItemProps = { /** Visual variant: "default" or "error" for validation failures */ variant?: CheckboxVariant; - /** Label text displayed next to checkbox */ - label: string; + /** Label content displayed next to checkbox */ + label: ReactNode; /** Value of the checkbox (required when used in Checkbox.Group) */ value?: string; /** Additional CSS classes for the label wrapper */ @@ -343,7 +357,8 @@ const CheckboxItem = forwardRef( }, ref, ) => { - const { controlFirst } = useContext(CheckboxGroupContext); + const { controlFirst, appearance } = useContext(CheckboxGroupContext); + const isBordered = appearance === "bordered"; return ( ); }, @@ -422,11 +451,17 @@ function CheckboxGroup({ onValueChange, allValues, disabled, - controlFirst = true, + orientation = "vertical", + appearance = "default", + controlFirst, className, }: CheckboxGroupProps) { + const effectiveControlFirst = controlFirst ?? appearance === "default"; + return ( - + )} -
{children}
+
[data-kumo-part=item-label]+[data-kumo-part=item-label]]:border-t [&>[data-kumo-part=item-label]+[data-kumo-part=item-label]]:border-kumo-line" + : "flex flex-row [&>[data-kumo-part=item-label]+[data-kumo-part=item-label]]:border-l [&>[data-kumo-part=item-label]+[data-kumo-part=item-label]]:border-kumo-line", + ) + : orientation === "vertical" + ? "flex flex-col gap-2" + : "flex flex-row flex-wrap gap-2", + )} + > + {children} +
{error &&

{error}

} {description && (

{description}

diff --git a/packages/kumo/src/components/checkbox/index.ts b/packages/kumo/src/components/checkbox/index.ts index 0fc6d2f42d..c076819eb2 100644 --- a/packages/kumo/src/components/checkbox/index.ts +++ b/packages/kumo/src/components/checkbox/index.ts @@ -6,6 +6,8 @@ export { type CheckboxProps, type CheckboxLegendProps, type CheckboxGroupProps, + type CheckboxGroupAppearance, + type CheckboxGroupOrientation, type CheckboxItemProps, type KumoCheckboxVariant, type CheckboxVariant, diff --git a/packages/kumo/src/components/radio/radio.test.tsx b/packages/kumo/src/components/radio/radio.test.tsx index f85f6952a7..184145621d 100644 --- a/packages/kumo/src/components/radio/radio.test.tsx +++ b/packages/kumo/src/components/radio/radio.test.tsx @@ -101,6 +101,66 @@ describe("Radio", () => { it("exports KUMO_RADIO_VARIANTS with appearance axis", () => { expect(KUMO_RADIO_VARIANTS.appearance.default).toBeDefined(); expect(KUMO_RADIO_VARIANTS.appearance.card).toBeDefined(); + expect(KUMO_RADIO_VARIANTS.appearance.bordered).toBeDefined(); expect(KUMO_RADIO_DEFAULT_VARIANTS.appearance).toBe("default"); }); + + it.each([ + ["vertical", "border-t"], + ["horizontal", "border-l"], + ] as const)( + "renders a bordered %s group with shared dividers", + (orientation, dividerClass) => { + const { container } = render( + + + + , + ); + + const group = container.querySelector('[data-kumo-part="group-items"]'); + const item = container.querySelector('[data-kumo-part="item-label"]'); + + expect(group?.className).toContain("rounded-lg"); + expect(group?.className).toContain(dividerClass); + expect(item?.className).toContain("flex-1"); + expect(item?.className).toContain("flex-row-reverse"); + expect(item?.className).toContain("bg-kumo-elevated"); + expect( + item?.querySelector('[data-kumo-part="item-content"]')?.className, + ).toContain("leading-5"); + }, + ); + + it("supports controlPosition='start' on bordered appearance", () => { + const { container } = render( + + + , + ); + + expect( + container.querySelector('[data-kumo-part="item-label"]')?.className, + ).not.toContain("flex-row-reverse"); + }); + + it("dims only content for disabled bordered items", () => { + const { container } = render( + + + , + ); + + const itemClassName = container.querySelector( + '[data-kumo-part="item-label"]', + )?.className; + + expect(itemClassName).toContain("[&>*]:opacity-50"); + expect(itemClassName?.split(" ")).not.toContain("opacity-50"); + }); }); diff --git a/packages/kumo/src/components/radio/radio.tsx b/packages/kumo/src/components/radio/radio.tsx index 071b62dffe..bd21d5edad 100644 --- a/packages/kumo/src/components/radio/radio.tsx +++ b/packages/kumo/src/components/radio/radio.tsx @@ -45,6 +45,10 @@ export const KUMO_RADIO_VARIANTS = { description: "Choice card appearance with border, padding, and highlighted selection state", }, + bordered: { + classes: "p-3 transition-colors hover:bg-kumo-elevated", + description: "Option within a bordered group with shared dividers", + }, }, } as const; @@ -69,6 +73,7 @@ export interface KumoRadioVariantsProps { * Visual appearance. * - `"default"` — Standard inline radio item * - `"card"` — Choice card with border, padding, and highlighted selection state + * - `"bordered"` — Option within a bordered group with shared dividers * @default "default" */ appearance?: KumoRadioAppearance; @@ -100,7 +105,7 @@ export type RadioControlPosition = "start" | "end"; // Context for passing controlPosition and appearance from Group to Items. // `controlPosition` may be undefined so each item can fall back to an -// appearance-appropriate default (start for default, end for card). +// appearance-appropriate default (start for default, end for card/bordered). const RadioGroupContext = createContext<{ controlPosition: RadioControlPosition | undefined; appearance: KumoRadioAppearance; @@ -220,6 +225,7 @@ export interface RadioGroupProps { * Visual appearance applied to all Radio.Item children. * - `"default"` — Standard inline radio items * - `"card"` — Choice card with border, padding, and highlighted selection state + * - `"bordered"` — Contiguous bordered group with dividers between items * * Individual items can override this with their own `appearance` prop. * @default "default" @@ -243,7 +249,7 @@ export interface RadioGroupProps { ) => void; /** Whether all radios in the group are disabled */ disabled?: boolean; - /** Position of radio control relative to label: "start" puts radio before label, "end" puts label before radio. Defaults to "start" for default appearance and "end" for card appearance. */ + /** Position of radio control relative to label: "start" puts radio before label, "end" puts label before radio. Defaults to "start" for default appearance and "end" for card or bordered appearance. */ controlPosition?: RadioControlPosition; /** Form submission name for the radio group */ name?: string; @@ -279,6 +285,7 @@ export type RadioItemProps = { * Visual appearance of the radio item. * - `"default"` — Standard inline radio item * - `"card"` — Choice card with border, padding, and highlighted selection state + * - `"bordered"` — Option within a contiguous bordered group * * When set on an individual item, overrides the group-level `appearance`. * @default "default" @@ -313,12 +320,13 @@ function _RadioItem( useContext(RadioGroupContext); const appearance = appearanceProp ?? groupAppearance; const isCard = appearance === "card"; + const isBordered = appearance === "bordered"; // Fall back to an appearance-appropriate default when controlPosition is - // not provided: card defaults to "end" (radio on the right), default - // appearance defaults to "start" (radio on the left). + // not provided: card and bordered default to "end" (radio on the right), + // while default appearance puts the radio on the left. const effectiveControlPosition: RadioControlPosition = - controlPosition ?? (isCard ? "end" : "start"); + controlPosition ?? (isCard || isBordered ? "end" : "start"); if (isCard) { const controlAtStart = effectiveControlPosition === "start"; @@ -384,10 +392,16 @@ function _RadioItem( data-kumo-part="item-label" className={cn( "group relative m-0 inline-flex items-start gap-2", + isBordered && "w-full flex-1 p-3 transition-colors", // "start" (default): radio before label // "end": label before radio using flex-row-reverse effectiveControlPosition === "end" && "flex-row-reverse justify-end", - disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer", + disabled + ? cn( + "cursor-not-allowed", + isBordered ? "[&>*]:opacity-50" : "opacity-50", + ) + : cn("cursor-pointer", isBordered && "hover:bg-kumo-elevated"), className, )} > @@ -416,7 +430,15 @@ function _RadioItem( - {label} + + {label} + ); } @@ -480,12 +502,24 @@ function RadioGroup({ )}
[data-kumo-part=item-label]+[data-kumo-part=item-label]]:border-t [&>[data-kumo-part=item-label]+[data-kumo-part=item-label]]:border-kumo-line" + : "flex flex-row [&>[data-kumo-part=item-label]+[data-kumo-part=item-label]]:border-l [&>[data-kumo-part=item-label]+[data-kumo-part=item-label]]:border-kumo-line", + ) + : orientation === "vertical" + ? cn( + "flex flex-col", + appearance === "card" ? "gap-3" : "gap-2", + ) + : appearance === "card" + ? "grid grid-cols-2 gap-3" + : "flex flex-row flex-wrap gap-2", )} > {children}