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
11 changes: 11 additions & 0 deletions .changeset/switch-atom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@code-sherpas/pharos-react': minor
---

Add `Switch`, a single on/off toggle (the shadcn Switch contract) over Base
UI's `Switch` — `role="switch"`, Space to toggle, and the shared Button / Input
/ Checkbox focus ring. Sibling to `Checkbox` in the boolean form-control
family: same `checked` / `onCheckedChange` / `disabled` API, no third
`indeterminate` state (a toggle is binary). Label-less by design (D11): pair
with a `<label htmlFor>`; error via `aria-invalid`. Geometry is fully
token-derived (40×24 track, 16px thumb, spacing-4 travel); no icon dependency.
54 changes: 54 additions & 0 deletions NAMING-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1664,3 +1664,57 @@ Maps to the various native/`@headlessui` checkboxes across the forms
(settings, filters, consent). Like the other form atoms, a like-for-like
swap (prop remap + `<label>` wiring) is incremental; checkbox groups with
custom layout/state are case-by-case.

## Switch (D21, 2026-07-15)

A single on/off toggle. Wraps Base UI's `Switch` — a `role="switch"` button
plus a hidden form input, Space to toggle, and the shared focus ring. The
second of the boolean form controls, completing the family opened by Checkbox
(D20): Checkbox → **Switch** → Radio to follow.

### Why a separate atom from Checkbox (canonical, not cosmetic)

shadcn ships `Switch` and `Checkbox` as distinct components, and the split is
semantic, not visual: a **Switch** takes effect immediately (a setting you
flip — notifications on/off), while a **Checkbox** is a selection usually
submitted with a surrounding form (accept terms, pick options). ARIA backs the
distinction with separate roles (`switch` vs `checkbox`). Merging them into one
configurable control would blur both contracts (Rule #0: don't force it).

The API is deliberately the same shape as Checkbox — `checked` /
`onCheckedChange` / `disabled`, label-less (Escuela 1, D11) with error via
`aria-invalid`, no `size` axis in v1 (shadcn ships none). The one intentional
difference: **no `indeterminate`**. A toggle is binary; there is no third
state (Base UI's `Switch` exposes none either).

### `role="switch"`, not a styled checkbox

Base UI's `Switch` renders `role="switch"` with `aria-checked`, the canonical
ARIA APG toggle semantics — not a `role="checkbox"` restyled to look like a
toggle. Screen readers announce "switch, on/off", which is the correct mental
model for an immediate setting.

### Token-derived geometry (no hardcoded pixels)

The track is 40×24 (`spacing-10` × `spacing-6`) with `spacing-1` (4px) padding
around a 16px (`spacing-4`) thumb, so the thumb travels exactly `spacing-4`
(32px inner − 16px thumb) — every dimension is a `--pharos-*` token
(NON-NEGOTIABLE #1). The track is intentionally larger than the 20px Checkbox
box: a toggle is a bigger hit target and reads as a distinct control shape.

The **on** track uses `neutral-900` — the same fill as the Checkbox's checked
state, keeping one "active control" color across the family. The **off** track
is `neutral-300`; the perceivable state indicator is the thumb (white with a
`neutral-500` hairline, ≈5.4:1), which stays distinguishable on both the light
off track and the dark on track — satisfying WCAG 1.4.11 without a track
border (which would break the clean token travel). Error state is an inset
1px ring (`semantic.error`) so the track keeps its geometry. No icon
dependency — the thumb is a plain `<span>` (unlike Checkbox's inline SVG
marks).

### Mapping from Alexandria

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).
10 changes: 10 additions & 0 deletions examples/saas-demo/e2e/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,14 @@ test.describe('Settings form', () => {
await page.getByText('Email me product updates').click();
await expect(box).toHaveAttribute('aria-checked', 'true');
});

test('toggles the weekly-digest switch', async ({ page }) => {
const toggle = page.getByRole('switch', { name: 'Send me a weekly digest' });
await expect(toggle).toHaveAttribute('aria-checked', 'false');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-checked', 'true');
// Label click toggles it back (label/control association).
await page.getByText('Send me a weekly digest').click();
await expect(toggle).toHaveAttribute('aria-checked', 'false');
});
});
16 changes: 16 additions & 0 deletions examples/saas-demo/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
SelectTrigger,
SelectValue,
Separator,
Switch,
Textarea,
} from '@code-sherpas/pharos-react';
import { SKILLS, TIMEZONES } from '../data';
Expand All @@ -38,6 +39,7 @@ export function SettingsPage() {
const [visibility, setVisibility] = useState('organization');
const [skills, setSkills] = useState<string[]>(['React']);
const [notify, setNotify] = useState(true);
const [weeklyDigest, setWeeklyDigest] = useState(false);
const [nameError, setNameError] = useState(false);
const [saved, setSaved] = useState(false);

Expand Down Expand Up @@ -181,6 +183,20 @@ export function SettingsPage() {
</label>
</div>

<div className="field">
<label htmlFor="digest" style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<Switch
id="digest"
checked={weeklyDigest}
onCheckedChange={(checked) => {
setWeeklyDigest(checked);
touch();
}}
/>
Send me a weekly digest
</label>
</div>

<Separator />
<div className="settings-actions">
<Button type="submit">Save changes</Button>
Expand Down
88 changes: 88 additions & 0 deletions src/components/Switch.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/* Switch.module.css
*
* Per-component CSS Modules definition (Decision D9). Hashed into
* `dist/styles.css`. Styles a single on/off toggle (the shadcn "Switch"
* contract) over Base UI's `Switch`. State is driven by Base UI's
* `data-checked` / `data-disabled` attributes.
*
* The atom owns no label (Escuela 1, D11): the consumer pairs it with a
* `<label htmlFor>`. Error state is conveyed with `aria-invalid`, like Input /
* Textarea / Checkbox. No icon dependency — the thumb is a plain `<span>`.
*
* Geometry is fully token-derived: a 40×24 track (spacing-10 × spacing-6) with
* spacing-1 (4px) padding houses a 16px (spacing-4) thumb, so the thumb travels
* exactly spacing-4 (32px inner − 16px thumb) with no hardcoded pixel value.
*/

.root {
box-sizing: border-box;
display: inline-flex;
align-items: center;
flex-shrink: 0;
width: var(--pharos-spacing-10);
height: var(--pharos-spacing-6);
padding: var(--pharos-spacing-1);

border: none;
border-radius: var(--pharos-radius-full);
/* Off track. The perceivable state indicator is the thumb (white with a
* neutral-500 hairline, ≈5.4:1) — it stays distinguishable on both the light
* off track and the dark on track, satisfying WCAG 1.4.11. */
background-color: var(--pharos-color-neutral-300);

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

.root:hover:not([data-disabled]):not([data-checked]) {
background-color: var(--pharos-color-neutral-400);
}

/* On track — the neutral-900 fill, matching the Checkbox's checked look. */
.root[data-checked] {
background-color: var(--pharos-color-neutral-900);
}

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

/* Error state via the standard aria-invalid attribute (D11). An inset ring so
* the track keeps its geometry (no border-driven layout shift on the thumb). */
.root[aria-invalid='true'] {
box-shadow: inset 0 0 0 1px var(--pharos-color-semantic-error-fg);
}

.root:focus-visible {
outline: none;
/* Same two-stop ring as Button / Input / Checkbox 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);
}

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

.thumb {
box-sizing: border-box;
width: var(--pharos-spacing-4);
height: var(--pharos-spacing-4);
border: 1px solid var(--pharos-color-neutral-500);
border-radius: var(--pharos-radius-full);
background-color: var(--pharos-color-base-white);

transition-property: transform;
transition-duration: var(--pharos-duration-fast);
transition-timing-function: var(--pharos-easing-out);
}

/* Slide to the far edge: inner width (32px) − thumb (16px) = spacing-4. */
.root[data-checked] .thumb {
transform: translateX(var(--pharos-spacing-4));
}
47 changes: 47 additions & 0 deletions src/components/Switch.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Switch } from './Switch';

/**
* `Switch` is a single on/off toggle (Decision D21). It wraps Base UI's
* `Switch` — `role="switch"`, Space to toggle, focus ring shared with Button /
* Input / Checkbox. Sibling to `Checkbox` in the boolean form-control family;
* a Switch takes effect immediately (a setting you flip). The atom owns no
* label (Escuela 1, D11): pair it with a `<label htmlFor>`. Error state via
* `aria-invalid`.
*/
const meta: Meta<typeof Switch> = {
title: 'Components/Switch',
component: Switch,
// The raw <label> text inherits the DS base typography from the story canvas
// (#storybook-root, set in preview.css after #151) — no decorator needed.
};

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

/** The label pattern: a `<label htmlFor>` paired with the control. */
export const WithLabel: Story = {
render: () => (
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }} htmlFor="notify">
<Switch id="notify" defaultChecked />
Enable notifications
</label>
),
};

/** All resting states side by side (each labelled for a11y). */
export const States: Story = {
render: () => (
<div style={{ display: 'flex', gap: 20, alignItems: 'center' }}>
<Switch aria-label="Off" />
<Switch aria-label="On" defaultChecked />
<Switch aria-label="Disabled" disabled />
<Switch aria-label="Disabled on" disabled defaultChecked />
</div>
),
};

/** Error state via the standard `aria-invalid` attribute. */
export const Invalid: Story = {
render: () => <Switch aria-label="Required toggle" aria-invalid />,
};
38 changes: 38 additions & 0 deletions src/components/Switch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { ComponentProps } from 'react';
import { Switch as BaseSwitch } from '@base-ui/react/switch';
import { cn } from '../lib/cn';
import styles from './Switch.module.css';

/**
* A single on/off toggle control (the shadcn **Switch** contract). Wraps Base
* UI's `Switch`, which renders a `role="switch"` button plus a hidden form
* input, and carries the ARIA state (`aria-checked`), keyboard toggle (Space)
* and focus handling.
*
* Sibling to `Checkbox` (D20) in the boolean form-control family: same
* `checked` / `onCheckedChange` / `disabled` API, same shared focus ring. The
* difference is semantic — a Switch takes effect immediately (a setting you
* flip), a Checkbox is a selection that is usually submitted with a form. It
* has no third `indeterminate` state (a toggle is binary).
*
* Like `Input` / `Textarea` / `Checkbox` (Escuela 1, D11), the atom owns no
* label: pair it with a `<label htmlFor>`. Error state is conveyed via
* `aria-invalid`, not a custom prop.
*
* @example
* <label htmlFor="notify" className="flex items-center gap-2">
* <Switch id="notify" checked={enabled} onCheckedChange={setEnabled} />
* Enable notifications
* </label>
*/
export interface SwitchProps extends ComponentProps<typeof BaseSwitch.Root> {}

export function Switch({ className, ...rest }: SwitchProps) {
return (
<BaseSwitch.Root data-pharos-slot="switch" className={cn(styles.root, className)} {...rest}>
<BaseSwitch.Thumb className={styles.thumb} />
</BaseSwitch.Root>
);
}

Switch.displayName = 'Switch';
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,6 @@ export type {
} from './components/Dialog';
export { Checkbox } from './components/Checkbox';
export type { CheckboxProps } from './components/Checkbox';
export { Switch } from './components/Switch';
export type { SwitchProps } from './components/Switch';
export { cn } from './lib/cn';
81 changes: 81 additions & 0 deletions tests/Switch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Switch } from '../src/components/Switch';

describe('Switch', () => {
it('renders a switch, off by default', () => {
render(<Switch aria-label="Notifications" />);
const toggle = screen.getByRole('switch', { name: 'Notifications' });
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveAttribute('aria-checked', 'false');
});

it('toggles checked state on click', async () => {
const user = userEvent.setup();
render(<Switch aria-label="Notifications" />);
const toggle = screen.getByRole('switch', { name: 'Notifications' });

await user.click(toggle);
expect(toggle).toHaveAttribute('aria-checked', 'true');
await user.click(toggle);
expect(toggle).toHaveAttribute('aria-checked', 'false');
});

it('calls onCheckedChange when toggled (controlled)', async () => {
const user = userEvent.setup();
const onCheckedChange = vi.fn();
render(<Switch aria-label="Notifications" checked={false} onCheckedChange={onCheckedChange} />);

await user.click(screen.getByRole('switch', { name: 'Notifications' }));
expect(onCheckedChange).toHaveBeenCalledWith(true, expect.anything());
});

it('toggles with the Space key', async () => {
const user = userEvent.setup();
render(<Switch aria-label="Notifications" />);
const toggle = screen.getByRole('switch', { name: 'Notifications' });

toggle.focus();
await user.keyboard(' ');
expect(toggle).toHaveAttribute('aria-checked', 'true');
});

it('does not toggle when disabled', async () => {
const user = userEvent.setup();
const onCheckedChange = vi.fn();
render(<Switch aria-label="Notifications" disabled onCheckedChange={onCheckedChange} />);

await user.click(screen.getByRole('switch', { name: 'Notifications' }));
expect(onCheckedChange).not.toHaveBeenCalled();
});

it('associates with a label via htmlFor/id and toggles on label click', async () => {
const user = userEvent.setup();
render(
<label htmlFor="notify">
<Switch id="notify" />
Enable notifications
</label>,
);
const toggle = screen.getByRole('switch', { name: 'Enable notifications' });
await user.click(screen.getByText('Enable notifications'));
expect(toggle).toHaveAttribute('aria-checked', 'true');
});

it('reflects aria-invalid for the error state', () => {
render(<Switch aria-label="Required toggle" aria-invalid />);
expect(screen.getByRole('switch', { name: 'Required toggle' })).toHaveAttribute(
'aria-invalid',
'true',
);
});

it('tags the control via data-pharos-slot', () => {
render(<Switch aria-label="Notifications" />);
expect(screen.getByRole('switch', { name: 'Notifications' })).toHaveAttribute(
'data-pharos-slot',
'switch',
);
});
});
Loading