Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .changeset/radio-group-atom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@code-sherpas/pharos-react': minor
---

Add `RadioGroup` + `RadioGroupItem`, a set of mutually exclusive options (the
shadcn RadioGroup contract) over Base UI's `RadioGroup` + `Radio` —
`role="radiogroup"` / `role="radio"`, arrow-key navigation, single selection,
and the shared Button / Input / Checkbox / Switch focus ring. Completes the
boolean/choice form-control family (Checkbox → Switch → Radio). Compound
(group + item) because the selection semantics live on the group. Label-less by
design (D11): pair the group with a `<label>` / `aria-labelledby` and each item
with a `<label htmlFor>`; error via `aria-invalid` on the group. The selected
dot is a CSS `<span>` — no icon dependency.
53 changes: 53 additions & 0 deletions NAMING-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1718,3 +1718,56 @@ Maps to the various boolean toggles / on-off settings across Alexandria's
forms. Like the other form atoms, a like-for-like swap (prop remap +
`<label>` wiring) is incremental; toggles wired into complex local state are
case-by-case (Fase 6).

## RadioGroup (D22, 2026-07-15)

A set of mutually exclusive options. Wraps Base UI's `RadioGroup` + `Radio` —
a `role="radiogroup"` container plus `role="radio"` buttons with a hidden form
input, single selection, roving-tabindex arrow-key navigation, and the shared
focus ring. The **third and final** boolean/choice control, completing the
family opened by Checkbox (D20): Checkbox → Switch → **Radio**.

### Compound, unlike Checkbox / Switch

Checkbox and Switch are single self-contained atoms. RadioGroup is **compound**
(`RadioGroup` + `RadioGroupItem`) because a radio is meaningless alone — the
selection semantics (single choice, arrow navigation, the submitted value, the
`name`) live on the **group**, not the individual control. Splitting them
matches shadcn and the ARIA APG radiogroup pattern; forcing a flat single-atom
API here would break the contract (Rule #0). This is the same reasoning that
made Select / Combobox / DropdownMenu compound.

### Canonical naming (`RadioGroup` + `RadioGroupItem`)

Canonical order shadcn > Base UI > ARIA APG. shadcn names the parts
`RadioGroup` and `RadioGroupItem`; Base UI names them `RadioGroup` and `Radio`.
We take shadcn's names (the item is `RadioGroupItem`, not `Radio`) so the
public contract reads like the rest of the ecosystem, and re-author the styles
as CSS Modules over the Base UI primitive. `data-pharos-slot` is `radio-group`
/ `radio-group-item`, mirroring the shadcn `data-slot` values.

### Shared API, error at the group

Inherits the form-control conventions: **label-less** (Escuela 1, D11) — the
consumer pairs the group with a `<label>` / `aria-labelledby` and each item
with a `<label htmlFor>`; **no `size` axis** in v1 (shadcn ships none). The one
structural difference from Checkbox / Switch: **error is a group concern**, so
`aria-invalid` goes on the `RadioGroup` and every item shows the error ring
(`.group[aria-invalid] .item`), rather than per-control.

### Ring-with-dot, token-derived, no icon

The item mirrors the Checkbox's 20 px (`spacing-5`) control on the token grid,
but circular (`radius-full`): the ring stays visible and an inner dot
(`spacing-2`, `neutral-900`) appears on selection — the state-of-the-art radio
look, deliberately distinct from the Checkbox's filled square. The ring
darkens to `neutral-900` when selected, keeping one "active control" color
across the family. The dot is a plain `<span>` (Base UI mounts the Indicator
only while selected, `keepMounted={false}`) — no icon dependency.

### Mapping from Alexandria

Maps to the various native/`@headlessui` radio groups across Alexandria's forms
(single-choice settings, filters). Like the other form atoms, a like-for-like
swap (prop remap + `<label>` wiring) is incremental; groups wired into complex
local state are case-by-case (Fase 6).
22 changes: 22 additions & 0 deletions examples/saas-demo/e2e/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,26 @@ test.describe('Settings form', () => {
await page.getByText('Send me a weekly digest').click();
await expect(toggle).toHaveAttribute('aria-checked', 'false');
});

test('picks a single theme in the radio group', async ({ page }) => {
const group = page.getByRole('radiogroup', { name: 'Theme' });
const system = group.getByRole('radio', { name: 'Match system' });
const light = group.getByRole('radio', { name: 'Light' });
// Seeded selection is "system".
await expect(system).toHaveAttribute('aria-checked', 'true');
await expect(light).toHaveAttribute('aria-checked', 'false');

// Selecting another option enforces single selection.
await light.click();
await expect(light).toHaveAttribute('aria-checked', 'true');
await expect(system).toHaveAttribute('aria-checked', 'false');

// Arrow keys move the selection (roving radiogroup semantics).
await light.press('ArrowDown');
await expect(group.getByRole('radio', { name: 'Dark' })).toHaveAttribute(
'aria-checked',
'true',
);
await expect(light).toHaveAttribute('aria-checked', 'false');
});
});
37 changes: 37 additions & 0 deletions examples/saas-demo/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
ComboboxList,
ComboboxTrigger,
Input,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
Expand All @@ -40,6 +42,7 @@ export function SettingsPage() {
const [skills, setSkills] = useState<string[]>(['React']);
const [notify, setNotify] = useState(true);
const [weeklyDigest, setWeeklyDigest] = useState(false);
const [theme, setTheme] = useState('system');
const [nameError, setNameError] = useState(false);
const [saved, setSaved] = useState(false);

Expand Down Expand Up @@ -197,6 +200,40 @@ export function SettingsPage() {
</label>
</div>

<div className="field">
<label id="theme-label">Theme</label>
<RadioGroup
aria-labelledby="theme-label"
value={theme}
onValueChange={(value) => {
setTheme(value as string);
touch();
}}
>
<label
htmlFor="theme-light"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
>
<RadioGroupItem id="theme-light" value="light" />
Light
</label>
<label
htmlFor="theme-dark"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
>
<RadioGroupItem id="theme-dark" value="dark" />
Dark
</label>
<label
htmlFor="theme-system"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
>
<RadioGroupItem id="theme-system" value="system" />
Match system
</label>
</RadioGroup>
</div>

<Separator />
<div className="settings-actions">
<Button type="submit">Save changes</Button>
Expand Down
81 changes: 81 additions & 0 deletions src/components/RadioGroup.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/* RadioGroup.module.css
*
* Per-component CSS Modules definition (Decision D9). Hashed into
* `dist/styles.css`. Styles the shadcn "RadioGroup" contract over Base UI's
* `RadioGroup` + `Radio`. Item state is driven by Base UI's `data-checked` /
* `data-disabled` attributes.
*
* The atom owns no labels (Escuela 1, D11): the consumer pairs the group with a
* `<label>` / `aria-labelledby` and each item with a `<label htmlFor>`. Error
* state is conveyed with `aria-invalid` on the group. The selected dot is a
* plain `<span>` — no icon dependency (Base UI mounts it only when checked).
*
* The item mirrors Checkbox's 20px (spacing-5) control on the token grid, but
* circular: a ring that stays visible with an inner dot on selection, the
* state-of-the-art radio look (distinct from Checkbox's filled square).
*/

.group {
display: flex;
flex-direction: column;
gap: var(--pharos-spacing-2);
}

.item {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: var(--pharos-spacing-5);
height: var(--pharos-spacing-5);
padding: 0;

border: 1px solid var(--pharos-color-neutral-500);
border-radius: var(--pharos-radius-full);
background-color: var(--pharos-color-base-white);

cursor: pointer;
transition-property: background-color, border-color, box-shadow;
transition-duration: var(--pharos-duration-fast);
transition-timing-function: var(--pharos-easing-out);
}

.item:hover:not([data-disabled]):not([data-checked]) {
border-color: var(--pharos-color-neutral-700);
}

/* Selected — the ring darkens to match the Checkbox / Switch "active" color and
* the inner dot appears. */
.item[data-checked] {
border-color: var(--pharos-color-neutral-900);
}

/* Error state via aria-invalid on the group (radio error is a group concern,
* D11) — every item shows the error ring. */
.group[aria-invalid='true'] .item {
border-color: var(--pharos-color-semantic-error-fg);
}

.item:focus-visible {
outline: none;
/* Same two-stop ring as Button / Input / Checkbox / Switch so the system has
* one focus look. */
box-shadow:
0 0 0 2px var(--pharos-color-base-white),
0 0 0 4px var(--pharos-color-primary-600);
}

.item[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
}

/* The inner dot — Base UI mounts the Indicator only while selected. */
.indicator {
display: inline-flex;
width: var(--pharos-spacing-2);
height: var(--pharos-spacing-2);
border-radius: var(--pharos-radius-full);
background-color: var(--pharos-color-neutral-900);
}
94 changes: 94 additions & 0 deletions src/components/RadioGroup.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { RadioGroup, RadioGroupItem } from './RadioGroup';

/**
* `RadioGroup` is a set of mutually exclusive options (Decision D22). It wraps
* Base UI's `RadioGroup` + `Radio` — `role="radiogroup"` / `role="radio"`,
* arrow-key navigation, focus ring shared with Button / Input / Checkbox /
* Switch. Compound (group + item) because the selection semantics live on the
* group. The atom owns no labels (Escuela 1, D11): pair the group with a
* `<label>` / `aria-labelledby` and each item with a `<label htmlFor>`. Error
* state via `aria-invalid` on the group.
*/
const meta: Meta<typeof RadioGroup> = {
title: 'Components/RadioGroup',
component: RadioGroup,
};

export default meta;
type Story = StoryObj<typeof meta>;

const row: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 8 };

/** The label pattern: a labelled group with a `<label htmlFor>` per item. */
export const Default: Story = {
render: () => (
<RadioGroup defaultValue="card" aria-labelledby="pay-label">
<span id="pay-label">Payment method</span>
<label style={row} htmlFor="pay-card">
<RadioGroupItem id="pay-card" value="card" />
Credit card
</label>
<label style={row} htmlFor="pay-cash">
<RadioGroupItem id="pay-cash" value="cash" />
Cash
</label>
<label style={row} htmlFor="pay-transfer">
<RadioGroupItem id="pay-transfer" value="transfer" />
Bank transfer
</label>
</RadioGroup>
),
};

/** A disabled individual item sits next to selectable ones. */
export const WithDisabledItem: Story = {
render: () => (
<RadioGroup defaultValue="standard" aria-label="Shipping speed">
<label style={row} htmlFor="ship-standard">
<RadioGroupItem id="ship-standard" value="standard" />
Standard
</label>
<label style={row} htmlFor="ship-express">
<RadioGroupItem id="ship-express" value="express" />
Express
</label>
<label style={row} htmlFor="ship-overnight">
<RadioGroupItem id="ship-overnight" value="overnight" disabled />
Overnight (unavailable)
</label>
</RadioGroup>
),
};

/** The whole group disabled. */
export const Disabled: Story = {
render: () => (
<RadioGroup defaultValue="a" disabled aria-label="Disabled group">
<label style={row} htmlFor="dis-a">
<RadioGroupItem id="dis-a" value="a" />
Option A
</label>
<label style={row} htmlFor="dis-b">
<RadioGroupItem id="dis-b" value="b" />
Option B
</label>
</RadioGroup>
),
};

/** Error state via the standard `aria-invalid` attribute on the group. */
export const Invalid: Story = {
render: () => (
<RadioGroup aria-invalid aria-label="Required choice">
<label style={row} htmlFor="inv-yes">
<RadioGroupItem id="inv-yes" value="yes" />
Yes
</label>
<label style={row} htmlFor="inv-no">
<RadioGroupItem id="inv-no" value="no" />
No
</label>
</RadioGroup>
),
};
Loading
Loading