Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bordered-choice-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/kumo": minor
---

Add bordered appearances and horizontal orientation support to radio and checkbox groups.
109 changes: 108 additions & 1 deletion packages/kumo-docs-astro/src/components/demos/CheckboxDemo.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -78,6 +79,112 @@ export function CheckboxGroupDemo() {
);
}

export function CheckboxBorderedGroupDemo() {
const [notifications, setNotifications] = useState<string[]>(["email"]);
const [alertCategories, setAlertCategories] = useState<string[]>([
"security",
"performance",
]);
const [exportContents, setExportContents] = useState<string[]>([
"configuration",
"analytics",
]);
const [permissions, setPermissions] = useState<string[]>(["read", "edit"]);

return (
<div className="flex flex-col gap-8">
<Checkbox.Group
legend="Notifications"
appearance="bordered"
orientation="horizontal"
value={notifications}
onValueChange={setNotifications}
>
<Checkbox.Item
value="email"
label={
<span className="flex items-center gap-2">
<EnvelopeIcon
size={18}
className="text-kumo-subtle"
aria-hidden
/>
Email
</span>
}
/>
<Checkbox.Item
value="sms"
label={
<span className="flex items-center gap-2">
<ChatCircleTextIcon
size={18}
className="text-kumo-subtle"
aria-hidden
/>
SMS
</span>
}
/>
</Checkbox.Group>
<Checkbox.Group
legend="Alert categories (control first)"
appearance="bordered"
controlFirst
value={alertCategories}
onValueChange={setAlertCategories}
>
<Checkbox.Item value="security" label="Security" />
<Checkbox.Item value="performance" label="Performance" />
<Checkbox.Item
value="reliability"
label={
<span className="flex flex-wrap items-center gap-2">
Reliability
<Badge variant="neutral">Beta</Badge>
</span>
}
/>
<Checkbox.Item value="billing" label="Billing (unavailable)" disabled />
</Checkbox.Group>
<Checkbox.Group
legend="Export contents"
description="Long labels should wrap without moving or shrinking the controls."
appearance="bordered"
orientation="horizontal"
value={exportContents}
onValueChange={setExportContents}
>
<Checkbox.Item
value="configuration"
label="Account and zone configuration settings"
/>
<Checkbox.Item
value="analytics"
label="Historical analytics and event data"
/>
<Checkbox.Item
value="members"
label="Member profiles, roles, and access policies"
/>
</Checkbox.Group>
<Checkbox.Group
legend="Team permissions"
appearance="bordered"
controlFirst
value={permissions}
onValueChange={setPermissions}
>
<Checkbox.Item value="read" label="View resources" />
<Checkbox.Item value="edit" label="Create and edit resources" />
<Checkbox.Item value="deploy" label="Deploy to production" />
<Checkbox.Item value="members" label="Manage team members" />
<Checkbox.Item value="billing" label="Manage billing" disabled />
</Checkbox.Group>
</div>
);
}

/** 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<string[]>(["email"]);
Expand Down
102 changes: 102 additions & 0 deletions packages/kumo-docs-astro/src/components/demos/RadioDemo.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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 (
<div className="flex flex-col gap-8">
<Radio.Group
legend="Scope"
appearance="bordered"
orientation="horizontal"
value={scope}
onValueChange={setScope}
>
<Radio.Item
label={
<span className="flex items-center gap-2">
<GlobeIcon size={18} className="text-kumo-subtle" />
All traffic
</span>
}
value="all"
/>
<Radio.Item
label={
<span className="flex items-center gap-2">
<GitBranchIcon size={18} className="text-kumo-subtle" />
Previews only
</span>
}
value="previews"
/>
</Radio.Group>
<Radio.Group
legend="Environment (control first)"
appearance="bordered"
controlPosition="start"
value={environment}
onValueChange={setEnvironment}
>
<Radio.Item label="Production" value="production" />
<Radio.Item label="Staging" value="staging" />
<Radio.Item label="Development" value="development" />
<Radio.Item
label="Local preview (unavailable)"
value="local"
disabled
/>
</Radio.Group>
<Radio.Group
legend="Request routing"
description="Long labels should wrap without moving or shrinking the controls."
appearance="bordered"
orientation="horizontal"
value={routingMode}
onValueChange={setRoutingMode}
>
<Radio.Item
label="Route to the nearest healthy origin"
value="nearest"
/>
<Radio.Item
label="Automatically balance latency and availability"
value="smart"
/>
<Radio.Item
label="Always use the designated primary origin"
value="primary"
/>
</Radio.Group>
<Radio.Group
legend="Security level"
appearance="bordered"
controlPosition="start"
value={securityLevel}
onValueChange={setSecurityLevel}
>
<Radio.Item label="Essential" value="essential" />
<Radio.Item
label={
<span className="flex items-center gap-2">
<ShieldCheckIcon size={18} className="text-kumo-subtle" />
Balanced (recommended)
<Badge variant="primary">Default</Badge>
</span>
}
value="balanced"
/>
<Radio.Item label="Strict" value="strict" />
<Radio.Item label="Maximum" value="maximum" />
</Radio.Group>
</div>
);
}

/** Shows a radio group with helper description text */
export function RadioDescriptionDemo() {
const [value, setValue] = useState("standard");
Expand Down
14 changes: 14 additions & 0 deletions packages/kumo-docs-astro/src/pages/components/checkbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
CheckboxDisabledDemo,
CheckboxErrorDemo,
CheckboxGroupDemo,
CheckboxBorderedGroupDemo,
CheckboxGroupErrorDemo,
CheckboxLegendSrOnlyDemo,
CheckboxLegendCustomDemo,
Expand Down Expand Up @@ -128,6 +129,19 @@ export default function Example() {
<CheckboxGroupDemo client:visible />
</ComponentExample>

### Bordered Checkbox Group

<p>
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.
</p>
<ComponentExample demo="CheckboxBorderedGroupDemo">
<CheckboxBorderedGroupDemo client:visible />
</ComponentExample>

### Checkbox Group with Error

<p>
Expand Down
14 changes: 14 additions & 0 deletions packages/kumo-docs-astro/src/pages/components/radio.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
RadioBasicDemo,
RadioDefaultDemo,
RadioHorizontalDemo,
RadioBorderedDemo,
RadioDescriptionDemo,
RadioErrorDemo,
RadioDisabledDemo,
Expand Down Expand Up @@ -101,6 +102,19 @@ export default function Example() {
<RadioHorizontalDemo client:visible />
</ComponentExample>

### Bordered Group

<p>
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.
</p>
<ComponentExample demo="RadioBorderedDemo">
<RadioBorderedDemo client:visible />
</ComponentExample>

### With Description

<p>Add helper text below the radio items using the `description` prop.</p>
Expand Down
81 changes: 81 additions & 0 deletions packages/kumo/src/components/checkbox/checkbox.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Checkbox.Group
legend="Notifications"
appearance="bordered"
orientation={orientation}
defaultValue={["email"]}
>
<Checkbox.Item label="Email" value="email" />
<Checkbox.Item label="SMS" value="sms" />
</Checkbox.Group>,
);

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(
<Checkbox.Group appearance="bordered" controlFirst>
<Checkbox.Item label="Email" value="email" />
</Checkbox.Group>,
);

expect(
container.querySelector('[data-kumo-part="item-label"]')?.className,
).not.toContain("flex-row-reverse");
});

it("renders rich label content", () => {
const { getByText } = render(
<Checkbox.Group appearance="bordered">
<Checkbox.Item
label={
<span>
Security <strong>Recommended</strong>
</span>
}
value="security"
/>
</Checkbox.Group>,
);

expect(getByText("Recommended").tagName).toBe("STRONG");
});

it("dims only content for disabled bordered items", () => {
const { container } = render(
<Checkbox.Group appearance="bordered">
<Checkbox.Item label="Unavailable" value="unavailable" disabled />
</Checkbox.Group>,
);

const itemClassName = container.querySelector(
'[data-kumo-part="item-label"]',
)?.className;

expect(itemClassName).toContain("[&>*]:opacity-50");
expect(itemClassName?.split(" ")).not.toContain("opacity-50");
});
});
Loading
Loading