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

Add the Switch primitive — a binary on/off control following the WAI-ARIA APG
pattern, shipped as an agnostic core (`@dunky.dev/switch`) plus a React binding
(`@dunky.dev/react-switch`).

```tsx
import { Switch } from '@dunky.dev/react-switch'

function App() {
return (
<Switch onCheckedChange={console.log}>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.Label>Airplane mode</Switch.Label>
</Switch>
)
}
```
27 changes: 27 additions & 0 deletions packages/core/switch/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# @dunky.dev/switch

The framework-agnostic switch interaction, modeled as a state machine on
`@dunky.dev/state-machine`. Pure logic — no substrate, no framework. Consumers
pair it with a substrate driver rather than driving the machine directly.

The behavior contract — scenarios, guarantees, driver obligations — lives in
[SPEC.md](./SPEC.md).

## Install

```sh
npm install @dunky.dev/switch
```

## Usage

```ts
import { machine, connector } from '@dunky.dev/state-machine'
import { switchMachine, switchConnect } from '@dunky.dev/switch'

// `id` is substrate-minted (SSR-safe); the connect derives the per-part ids.
const service = machine(switchMachine({ ...options, id: 'my-switch' }))
connector(service, switchConnect, options) // wires the consumer callbacks
service.start()
service.send({ type: 'toggle' })
```
111 changes: 111 additions & 0 deletions packages/core/switch/SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# SPEC / Switch

## Reference

- **W3C pattern**: [APG Switch](https://www.w3.org/WAI/ARIA/apg/patterns/switch/),
over the normative
[WAI-ARIA 1.2 `switch`](https://www.w3.org/TR/wai-aria-1.2/#switch) role
definition.
- **State machine**: built on `@dunky.dev/state-machine`.
- **Prior art**: API shape modeled on the Radix, Base UI, and Ark switches.

## Overview

A switch is a binary control that turns a setting on or off, taking effect
immediately — airplane mode, notifications, dark theme. Unlike a checkbox it
never collects a choice for later submission and never has a mixed state: it
is always exactly on or off.

## Anatomy

```
<Switch> — root; owns checked state, renders nothing of its own
|_ <Control> — the interactive element: carries the switch role and checked state
| |_ <Thumb> — the knob; purely visual, styled off data-state
|_ <Label> — names the control; pressing it toggles
```

## Behavior

Using the switch is a walkthrough of intent, not a prop list:

- The **root** owns the checked state, exposed controlled and uncontrolled,
mirroring native patterns: an uncontrolled switch can be seeded checked,
while a controlled consumer drives it from outside — and every toggle intent
is reported back so the consumer stays in sync. Controlled is that sync
contract, not a hard gate: a toggle intent takes effect immediately and is
reported; the `checked` prop re-applies only when its value changes.
- The **control** is the single interactive element. Pressing it toggles; it
carries the `switch` role and the checked state to assistive tech.
- The **thumb** is purely visual — the knob consumers animate between the two
ends of the track. It carries no behavior, only the styling hooks.
- The **label** names the control. Pressing it toggles too, matching native
label ergonomics. The control's ARIA name follows what is actually rendered
— an omitted Label never leaves a dangling reference.
- **Disabled** blocks toggling — from the control and the label alike — and is
exposed to assistive tech and styling. It gates user intent only: a
controlled or programmatic change still applies while disabled, so the
consumer's state never desyncs.

## States

| State | Behavior |
| ----------- | ------------------------------------------------------------------------ |
| `unchecked` | The setting is off. A toggle intent moves to `checked` unless disabled. |
| `checked` | The setting is on. A toggle intent moves to `unchecked` unless disabled. |

### Disabled

Disabled is a flag over both states, not a third state: the switch keeps its
checked value while disabled and resumes toggling when re-enabled. The
decision to block lives in the machine, so every substrate inherits it — a
part's press binding never second-guesses it.

### Label presence

A Label can appear or disappear at any time — the ARIA name relationship on
the Control always follows what is actually rendered.

## Accessibility

Per APG Switch:

- **Role**: the control is `switch`, with `aria-checked` always present and
mirroring the state — `true` or `false`, never mixed (the switch is binary).
- **Name**: the control is labelled by the rendered Label, or by an accessible
label the consumer puts on the control in the no-label case. One of the two
must be present.
- **Keyboard**: the control is focusable and Space toggles (Enter as well on a
substrate whose native control activates on Enter). Substrates deliver this
by rendering a natively activatable element — activation stays a single
"press" intent.
- **Disabled**: exposed as `aria-disabled`; every part also carries
`data-disabled` for styling.
- **State styling**: every part carries `data-state="checked" | "unchecked"` —
the consumer's styling and animation hook.

## Constraints

- The control must always resolve an accessible name — from a rendered Label
or a consumer-supplied label — never neither.
- ARIA labelled-by must only reference elements that are actually rendered.
- `aria-checked` always mirrors the machine state; it is never `mixed`.
- Every checked ⇄ unchecked transition, whatever its cause, is reported to
the consumer.
- While disabled, no user intent changes the state; controlled and
programmatic changes still apply.
- Form integration (name/value, hidden input, form reset) is out of scope for
v0 — the switch is a pure setting control.

## Internals

- **The Label is not a substrate-native `<label>`.** A native label forwards
activation to its control, which would double-fire next to the part's own
press-to-toggle binding — and not every substrate has a label element. The
Label part instead carries its own press binding (toggling through the same
machine intent, so the disabled guard applies) and the name flows through
`aria-labelledby`.
- **Toggle is one event, gated in the machine.** Control press, label press,
and keyboard activation all send the same `toggle` intent; the disabled
guard decides. Programmatic changes (`check` / `uncheck`) bypass the guard
on purpose — see Disabled under States.
40 changes: 40 additions & 0 deletions packages/core/switch/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@dunky.dev/switch",
"version": "0.0.0",
"description": "Framework-agnostic switch interaction, modeled as a state machine.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/dunky-dev/ui.git",
"directory": "packages/core/switch"
},
"files": [
"dist"
],
"type": "module",
"sideEffects": false,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"access": "public"
},
"scripts": {
"build": "tsdown"
},
"dependencies": {
"@dunky.dev/state-machine": "^0.1.0",
"@dunky.dev/state-machine-bindings": "^0.1.0"
}
}
100 changes: 100 additions & 0 deletions packages/core/switch/src/connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { makeReaction, type Connect } from '@dunky.dev/state-machine'
import type { AttrBindings, EventBindings } from '@dunky.dev/state-machine-bindings'
import type {
SwitchContext,
SwitchIds,
SwitchMachineEvent,
SwitchOptions,
SwitchStateName,
} from './types'

// The bindings a part carries, drawn from the shared agnostic vocabulary; the
// index signature keeps parts assignable to the loose shape each substrate's
// normalize() accepts. `data-state` is the styling/animation hook;
// `data-disabled` is present (empty) while disabled, absent otherwise.
export type SwitchPartBindings = EventBindings &
AttrBindings & { 'data-state'?: SwitchStateName; 'data-disabled'?: string } & Record<
string,
unknown
>

// The cross-part ids derive from the one base id, so the Control's
// aria-labelledby and the Label's id always agree.
function switchIds(id: string): SwitchIds {
return { control: `${id}-control`, label: `${id}-label` }
}

/** The view-facing surface a driver reads from the running switch machine. */
export interface SwitchApi {
checked: boolean
disabled: boolean
ids: SwitchIds
setChecked: (checked: boolean) => void
parts: {
control: SwitchPartBindings
thumb: SwitchPartBindings
label: SwitchPartBindings
}
}

export const switchConnect: Connect<
SwitchStateName,
SwitchContext,
SwitchMachineEvent,
SwitchOptions,
SwitchApi
> = ({ state, context, send }) => {
const checked = state === 'checked'
const dataState: SwitchStateName = checked ? 'checked' : 'unchecked'
const dataDisabled = context.disabled ? '' : undefined
const ids = switchIds(context.id)

// Control press and label press are the same user intent; whether it flips
// the state is gated in the machine (disabled).
const toggle = (): void => send({ type: 'toggle' })

return {
checked,
disabled: context.disabled,
ids,
setChecked(next) {
if (checked === next) return
send({ type: next ? 'check' : 'uncheck' })
},
parts: {
control: {
role: 'switch',
id: ids.control,
// Always present, true or false — the switch is binary, never mixed.
checked,
disabled: context.disabled || undefined,
// A dangling aria-labelledby id is an a11y defect — only while rendered.
labelledBy: context.parts.label ? ids.label : undefined,
'data-state': dataState,
'data-disabled': dataDisabled,
onPress: toggle,
},
thumb: {
'data-state': dataState,
'data-disabled': dataDisabled,
},
label: {
id: ids.label,
'data-state': dataState,
'data-disabled': dataDisabled,
onPress: toggle,
},
},
}
}

const reaction = makeReaction<SwitchStateName, SwitchContext, SwitchMachineEvent, SwitchOptions>()

// One reaction per consumer callback. Reactions fire in registration order within
// a single setContext — that order is the callback-order contract. See SPEC.md.
switchConnect.reactions = [
reaction(
m => m.matches('checked'),
(checked, props) => props.onCheckedChange?.(checked),
),
]
11 changes: 11 additions & 0 deletions packages/core/switch/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export { switchMachine, type SwitchMachine } from './machine'
export { switchConnect, type SwitchApi, type SwitchPartBindings } from './connect'
export type {
SwitchCallbacks,
SwitchContext,
SwitchIds,
SwitchMachineEvent,
SwitchOptions,
SwitchPart,
SwitchStateName,
} from './types'
64 changes: 64 additions & 0 deletions packages/core/switch/src/machine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
setup,
type Action,
type Guard,
type Machine,
type TransitionConfig,
} from '@dunky.dev/state-machine'
import type { SwitchContext, SwitchMachineEvent, SwitchOptions, SwitchStateName } from './types'

/** The running switch machine — what a substrate holds and sends events to. */
export type SwitchMachine = Machine<SwitchStateName, SwitchContext, SwitchMachineEvent>

type SwitchAction = Action<SwitchContext, SwitchMachineEvent>
type SwitchGuard = Guard<SwitchContext, SwitchMachineEvent>

// Disabled gates user intent (`toggle`) only — programmatic `check`/`uncheck`
// stay open so a controlled consumer never desyncs while disabled.
const canToggle: SwitchGuard = ({ context }) => !context.disabled

const setDisabled: SwitchAction = ({ event, setContext }) => {
if (event.type !== 'set.disabled') return
setContext({ disabled: event.disabled })
}

const setPartPresence: SwitchAction = ({ event, context, setContext }) => {
if (event.type !== 'part.presence') return
setContext({ parts: { ...context.parts, [event.part]: event.present } })
}

export function switchMachine(
options: SwitchOptions,
): TransitionConfig<SwitchStateName, SwitchContext, SwitchMachineEvent> {
// Annotated so createMachine infers Context as SwitchContext, not the narrowed literal.
const context: SwitchContext = {
disabled: options.disabled ?? false,
// The substrate supplies a unique id; `switch` is only a bare fallback.
id: options.id ?? 'switch',
parts: { label: false },
}

return setup.as<SwitchContext, SwitchMachineEvent>().createMachine({
initial: (options.checked ?? options.defaultChecked) === true ? 'checked' : 'unchecked',
context,
// Top-level: the disabled flag and part presence are settable from any state.
on: {
'set.disabled': { actions: setDisabled },
'part.presence': { actions: setPartPresence },
},
states: {
unchecked: {
on: {
toggle: { target: 'checked', guard: canToggle },
check: { target: 'checked' },
},
},
checked: {
on: {
toggle: { target: 'unchecked', guard: canToggle },
uncheck: { target: 'unchecked' },
},
},
},
})
}
Loading
Loading