facebook / facebook/astryx

[RFC] Standalone radio control — the RadioInput/RadioControl primitive (spec for #4724)

Open
#4,755 3 comments 0 reactions 0 assignees View on GitHub
enhancement
Dominant language
TypeScript
Stars
13.1k
Forks
1.1k
Avg merge
1d 14h
Merged PRs (30d)
669

Description

## Problem Statement

The radio family has no standalone control. `RadioList` + `RadioListItem` cover the grouped-field case, but `RadioListItem` reads `RadioListContext` and **throws** if rendered outside a `RadioList`:

```
RadioListItem must be used within an RadioList
```

So there is no way to render a single radio in a bespoke layout — a comparison cell, a pricing card header, a custom two-column arrangement — without standing up a whole `RadioList` (its `Field` chrome, label, status, group semantics). The checkbox family already solved this: `CheckboxInput` is the standalone labeled control, and `CheckboxList` / `CheckboxListItem` compose it. The radio family is missing that bottom layer.

This RFC specifies that missing primitive and, importantly, asks the maintainers to settle its **shape** and **name** before the implementation lands — the current draft PR (#4724) added it as `RadioControl` but diverges from sibling conventions (`name`/`checked`/optional-`label`/`keepFocusableWhenDisabled`), which a review correctly flagged. The convergence work is straightforward; the design questions below are not, and they are what this issue is for.

## Evidence of Demand

Product-neutral cases where a radio must render outside a `RadioList` field:

- Pricing cards: each plan card is a large selectable surface with a radio in the corner; the cards are laid out in a `Grid`, not a vertical `RadioList`.
- Comparison tables: a radio per column header to pick "the active plan," inside `Table` cells.
- Segmented custom layouts: a radio embedded in a bespoke row that already owns its own label, description, and end content.
- Settings rows composed from `Item`/`ListItem` where the row — not a `RadioList` — owns the layout.

In every case the consumer wants the *control*, not `RadioList`'s field wrapper. Today they either misuse `RadioListItem` (throws) or hand-roll a native `` (loses theming, the `astryx-radio` targets, focus ring, forced-colors handling, and hover treatment).

## Why Existing Components Don't Cover This

- `RadioList` is the group field (label + status + `Field` chrome + roving-tabindex management). It is the wrong layer for a single control in a custom surface.
- `RadioListItem` is a *row* (start content + label + description + end content) and hard-requires `RadioListContext`.
- There is no `RadioInput` analogous to `CheckboxInput`.

## Prior Art (internal + external)

- **In-system sibling:** `CheckboxInput` — the standalone labeled boolean control. `CheckboxListItem` composes it with `isLabelHidden` and delegates row clicks via `Item`/`ListItem` `interactiveRef`. This is the pattern the radio family should mirror.
- **External:** Radix `RadioGroup.Item`, React Aria `Radio`, MUI `Radio`, Ant `Radio` — all expose a single radio control that must live inside a group/provider for selection semantics. None expose a truly standalone single radio that toggles on *and* off, because a lone radio can't be deselected by clicking it (native behavior). That constraint is central to the shape question below.

## Use Cases the API Must Handle

| # | Case | Scenario |
|---|---|---|
| 1 | Simple/default | One radio in a custom layout, controlled, with an accessible name. |
| 2 | Configured | `size`, `isDisabled`, `isRequired`, a disabled-reason. |
| 3 | Controlled group | Several controls sharing a group name, one selected, selecting one deselects the rest. |
| 4 | Composed | A radio inside a `Card` / `Table` cell / bespoke `Item` row that owns the visible label. |
| 5 | Edge | Disabled-with-reason must stay keyboard- and AT-discoverable; forced-colors; RTL. |
| 6 | Migration | `RadioListItem` recomposed on top of the primitive with no behavior change for existing `RadioList` consumers. |

## Candidate API Shapes (arbitration)

### Option A — bare `RadioControl` (control only)

Renders just the circle + dot; the accessible name is required and applied as `aria-label`; no visible label text.

```tsx
setValue(v)}
/>
```

- Converges naming to siblings: `htmlName?`, `isChecked`, `label` **required**, `onChange(value, e)`.
- Disabled-reason via `disabledMessage?` (mirrors `CheckboxInput`), replacing the internal `keepFocusableWhenDisabled` boolean.
- Pro: smallest surface; a true low-level primitive. Con: `label` renders nothing visible, so "labelled" means "aria-label only" — a subtle mismatch with `CheckboxInput`, where `label` is visible unless `isLabelHidden`.

### Option B — full `RadioInput` (the `CheckboxInput` twin)

A fully labeled control: circle + visible label + description + status + `disabledMessage`, `isLabelHidden` to hide the label. `RadioListItem` composes it exactly as `CheckboxListItem` composes `CheckboxInput` (hidden label + row click delegation).

```tsx
setValue(v)}
/>
```

- Pro: perfect family symmetry; `label`/`isLabelHidden`/`description`/`status`/`disabledMessage` all mean exactly what they mean on `CheckboxInput`; resolves the "same word, opposite behavior" objection cleanly.
- Con: larger surface; and recomposing `RadioListItem` on it **changes `RadioListItem`'s rendered output** (drops the external `` in favor of a hidden label on the control + click delegation). That's a deliberate, reviewable change, not a byte-identical refactor.

### Option C — relax `RadioListItem`'s throw (no new export)

Give `RadioListItem` a standalone mode (like `CheckboxListItem`'s `isChecked`/`onCheck`) so it renders outside a `RadioList` instead of throwing. No new component.

```tsx
setValue('pro')} />
```

- Pro: zero new public component; smallest API-surface delta. Con: `RadioListItem` is a *row* abstraction — it still carries row layout you may not want in a `Card`/cell; and it doesn't give you the bare control.

## Recommendation

Lead with **Option B (`RadioInput`)** — it is the most convergent with the existing checkbox family and makes every shared prop mean the same thing across the two families, which is the strongest signal from the review. Fall back to **Option A** if the maintainers prefer to keep the primitive bare and not expand `RadioListItem`'s output. **Option C** is viable only if the team decides a standalone *bare control* isn't wanted at all.

This should be **vibe-tested** per [API Arbitration] with naive prompts across use cases 1–6 (single radio in a card; a controlled group; a radio in a table cell; disabled-with-reason) before the shape is locked. I'm happy to run that and post results here.

## Convergence Requirements (apply to whichever shape wins)

A prior review of the draft PR enumerated fixes that any final shape must satisfy:

1. **Naming** → `htmlName?` (not `name`), `isChecked` (not `checked`), required `label` (+ `isLabelHidden` for Options B), `disabledMessage?` (not `keepFocusableWhenDisabled`). No `eslint-disable` on `@astryx/boolean-prop-naming`.
2. **Always has an accessible name** — `label` required so a standalone control can't ship unnamed.
3. **`xstyle`/`className`/`style` land on the root wrapper**, not the zero-opacity ``.
4. **`radioScope` on the control's own wrapper** so a standalone control has a hover state (today it only hovers inside a `RadioListItem` row).
5. **New public surface → spec'd + vibe-tested + maintainer sign-off** (this issue).
6. **Focus ring on the disabled-with-reason path** (`!isDisabled || keepsFocusable`).
7. **`{...rest}` precedence** — contract props (`type`, `role`, computed `id`, `aria-*`) set after `{...rest}`; owned handlers composed via `composeEventHandlers`, not clobbered.
8. **`onChange(value, e)`** — hand back the event (matches `CheckboxInput`).
9. **Docs** — a `theming` block for `astryx-radio`/`astryx-radio-dot` discoverable from the component's own doc, tokens for the raw dimensions, and `aria-describedby` documented only where actually supported.

## Tracking

Implementation lands in PR #4724 once the shape is settled here. Author: freddymeta.

Contributor guide

Open the contributing guide

Research direction

Start by comparing the CheckboxInput, RadioList, and RadioListItem entry points, then review draft PR #4724 against the three candidate API shapes. Run the proposed naive prompts for use cases 1–6 and check the convergence requirements. Done means a documented API shape, vibe-test results, and maintainer sign-off before implementation proceeds.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
frontend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.