In a row, with a hint
orientation="horizontal" lays the options on one line for a short list; label and hint name the whole group, which is what a screen reader announces first.
Billed yearly.
A group of mutually exclusive options, where the group is the form control and the options are its values.
@pacit/components/radiopct-radio-group, pct-radioImplements the W3C ARIA APG Radio Group pattern ↗
Chosen: pro
import { Component, signal } from '@angular/core';
import { PctRadio, PctRadioGroup } from '@pacit/components/radio';
/** One choice of a few, all visible — arrows move both the focus and the value. */
@Component({
selector: 'demo-radio',
imports: [PctRadio, PctRadioGroup],
template: `
<pct-radio-group ariaLabel="Plan" [(value)]="plan">
<pct-radio value="free">Free</pct-radio>
<pct-radio value="pro">Pro</pct-radio>
<pct-radio value="enterprise" disabled>Enterprise</pct-radio>
</pct-radio-group>
<p>Chosen: {{ plan() ?? 'nothing yet' }}</p>
`,
})
export class RadioDemo {
readonly plan = signal<string | null>('pro');
}radio.demo.ts — the same file runs here and is highlighted under Code
One import per component, and the shortest possible use — the package itself is installed once, on the start page.
import { PctRadioGroup, PctRadio } from '@pacit/components/radio';| Exported | What it is |
|---|---|
PctRadioOptioninterface | What the group needs to know about an option, and nothing more: the value it stands for and a name to call it by in a message. Declared HERE, beside the group, so that the option can provide it while importing the group — the other direction (contentChildren(PctRadio)) would close the import cycle. |
PCT_RADIO_OPTIONtoken | The channel through which pct-radio announces itself to the group. |
PctRadioGroupcomponentpct-radio-group | A single-choice group. The group is the form control (FormValueControl), not the individual options — from the form's point of view a radiogroup edits one value (req-api-signal-forms). |
PctRadiocomponentpct-radio | A single option inside pct-radio-group. Not a form control of its own — the group holds the state (req-api-signal-forms). It stands on a native <input type="radio"> with a shared name, so arrow navigation and Tab behaviour come from the browser rather than from a roving-tabindex implementation of ours. |
<pct-radio-group ariaLabel="Plan" [(value)]="plan">
<pct-radio value="free">Free</pct-radio>
<pct-radio value="pro">Pro</pct-radio>
</pct-radio-group>Each example is the component itself, not a screenshot. Open the code under any of them.
orientation="horizontal" lays the options on one line for a short list; label and hint name the whole group, which is what a screen reader announces first.
Billed yearly.
Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.
unreleased marks 1 of them: on main, and not in @pacit/components@0.2.0 — the next release names the version.
PctRadioGroupcomponent · pct-radio-groupA single-choice group. The group is the form control (FormValueControl), not the individual options — from the form's point of view a radiogroup edits one value (req-api-signal-forms).
| Name | Type | What it does |
|---|---|---|
valuetwo-waydefault null | T | null | The selected value — a required field of the FormValueControl contract. null means "no option is selected": the state the group is born in. |
disableddefault false | boolean | Disables every option at once — each native radio takes it, so the group leaves the tab order. With [formField] the directive writes it, as it writes every input of the FormUiControl contract. |
readonlydefault false | boolean | Holds the choice while the options stay focusable; aria-readonly goes on the group, because the radio role has none. |
invaliddefault false | boolean | The form's verdict; shown only once touched, so an empty form does not open red. |
toucheddefault false | boolean | Whether the user has left the field once; with invalid it gates the error face. |
requireddefault false | boolean | Marks the label with the required sign; with [formField] it follows the schema's required(). |
errorsdefault [] | readonly ValidationError.WithOptionalFieldTree[] | The form's validation errors; the first one's message takes the hint's place once the field is touched. |
warningsunreleaseddefault undefined | readonly ValidationError.WithOptionalFieldTree[] | undefined | A verdict without a veto (0087): shown after the error and before the hint once the field is touched, in the warning tone, with aria-invalid untouched. Left unbound, the control reads what pctWarn() wrote for its own [formField]; bound — [] included — the list given stands. |
namedefault '' | string | The shared name attribute of the native radios; generated by default. |
labeldefault '' | string | The visible label, rendered by the control itself when it stands outside a pct-field; inside one, the field's label is the name. |
hintdefault '' | string | A line of help under the control, outside a pct-field; the first error message takes its place while the field is invalid and touched. |
orientationdefault 'vertical' | 'vertical' | 'horizontal' | Which axis the options run along; written to aria-orientation and to the state attribute the stylesheet lays the group out by. |
compareWithdefault pctSameValue | PctCompareWith<T> | Value equality — as in pct-select; entities are compared by key. |
emptyValuedeprecatedsince 0.3.0 — reset the form with the value it should hold instead: form().reset({ plan: null }). Removed no earlier than 0.5.0.default null | T | null | The "no choice" value. It was what a form's reset wrote into the model, and a reset writes nothing there any more, so nothing reads it (0092). |
| Name | Payload | When it fires |
|---|---|---|
touch | void | Emitted on blur — lets the form mark the field as touched. |
The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.
| Attribute | Bound to | Note |
|---|---|---|
class | pct-radio-group | |
role | radiogroup | |
aria-labelledby | labelledBy() | |
aria-describedby | describedBy() | |
aria-invalid | showInvalid() ? "true" : null | |
aria-required | required() ? "true" : null | |
aria-readonly | readonly() ? "true" : null | aria-readonly belongs to the GROUP, not to an option: the radio role does not support it (radiogroup does), and an attribute disallowed for a role is a critical violation, not a cosmetic one (lesson-33). |
aria-orientation | orientation() | |
data-pct-orientation | orientation() | |
data-pct-invalid | showInvalid() ? "" : null | |
data-pct-warning | warningLit() ? "" : null | |
data-pct-disabled | disabled() ? "" : null | |
data-pct-in-field | inField ? "" : null |
PctRadiocomponent · pct-radioA single option inside pct-radio-group. Not a form control of its own — the group holds the state (req-api-signal-forms). It stands on a native <input type="radio"> with a shared name, so arrow navigation and Tab behaviour come from the browser rather than from a roving-tabindex implementation of ours.
| Name | Type | What it does |
|---|---|---|
valuerequired | T | The value this option stands for. |
disableddefault false | boolean | Disables a single option; the group can disable them all. |
ariaLabeldefault '' | string | The accessible name of an option whose projected content is not text — an icon, a colour swatch. An INPUT rather than an aria-label written on the tag, because the tag cannot carry one: role="radio" sits on the <input> inside, the host has no role at all, and an ARIA name on a roleless element is ignored (req-a11y-built-in). |
ariaLabelledbydefault '' | string | As ariaLabel, for a name that already stands elsewhere on the page. It wins over both. |
(click), (focus), the form's submit — are the platform's, and two-way models above carry the rest. The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.
| Attribute | Bound to | Note |
|---|---|---|
class | pct-radio | |
data-pct-checked | checked() ? "" : null | |
data-pct-disabled | isDisabled() ? "" : null | |
data-pct-invalid | group.showInvalid() ? "" : null |
Two ways in, both stable. No class names to depend on.
In a test, PctRadioGroupHarness, PctRadioHarness from @pacit/components/testing read a part by the name below and a state by its attribute — the harness gate holds the names to the package.
| Part | Selector | What it is |
|---|---|---|
circle | [data-pct-part="circle"] | the ring drawn over it |
control | [data-pct-part="control"] | the native input of an option |
dot | [data-pct-part="dot"] | the mark inside the chosen ring |
label | [data-pct-part="label"] | the text of an option |
group-error | [data-pct-part="group-error"] | the message when the group is invalid |
group-hint | [data-pct-part="group-hint"] | the hint under the group |
group-label | [data-pct-part="group-label"] | the label of the whole group |
group-options | [data-pct-part="group-options"] | the list of options |
group-warning | [data-pct-part="group-warning"] | the message when the choice is allowed and suspect — after the error, before the hint (0087) |
Every value the component reads, with the light and the dark default. Override a token at any scope and it cascades like any custom property.
| Token | Styles |
|---|---|
--pct-radio-bg{pct.surface}#ffffff · #0f172a | Background of the radio. |
--pct-radio-bg-disabled{pct.surface-disabled}#f1f5f9 · #1e293b | Background of the radio while disabled. |
--pct-radio-border{pct.border-strong}#64748b | Edge colour of the radio. |
--pct-radio-border-checked{pct.primary}#2563eb · #60a5fa | Edge colour of the radio when checked. |
--pct-radio-border-disabled{pct.border}#e2e8f0 · #334155 | Edge colour of the radio while disabled. |
--pct-radio-border-hover{pct.text-muted}#334155 · #e2e8f0 | Edge colour of the radio on hover. |
--pct-radio-border-invalid{pct.danger}#b91c1c · #f87171 | Edge colour of the radio when invalid. |
--pct-radio-dot-bg{pct.primary}#2563eb · #60a5fa | Background of the dot. |
--pct-radio-dot-size8px | Size of the dot. |
--pct-radio-fg-disabled{pct.text-disabled}#64748b | Colour of the dot while disabled. |
--pct-radio-fg-invalid{pct.danger}#b91c1c · #f87171 | Colour of the dot when invalid. |
--pct-radio-fg-warning{pct.warning}#b45309 · #fbbf24 | Text colour of the warning line (0087); the ring keeps its colour, a warning being no fault of the control. |
--pct-radio-gap{pct.space.3}8px | Gap between the radio and its label. |
--pct-radio-group-label-fg{pct.text}#0f172a · #ffffff | Text colour of the group label. |
--pct-radio-hint-fg{pct.text-muted}#334155 · #e2e8f0 | Text colour of the hint. |
--pct-radio-label-fg{pct.text}#0f172a · #ffffff | Text colour of the label. |
--pct-radio-option-gap{pct.space.3}8px | Gap between the options of a group. |
--pct-radio-size18px | Size of the radio. |
--pct-radio-target-min{pct.target.min}24px | Touch-target floor of the radio. |
A brand override in a few declarations. Scope it to a theme attribute, a page or one container — the component never notices.
[data-theme='brand'] {
--pct-radio-border-checked: #0f766e;
--pct-radio-dot-bg: #0f766e;
}Chosen: pro
Pattern: Radio Group — role="radiogroup", aria-labelledby, aria-orientation
| key | effect | source |
|---|---|---|
↑ ↓ ← → | move between options, wrapping around | native <input type="radio"> sharing a name |
Tab | one stop in the Tab order for the whole group | native |
This is the model implementation of req-api-platform: no roving tabindex of our own. Tested in apps/sandbox-e2e/src/radio.spec.ts.
Every line is a test in three engines or a gate on every commit. The gaps are listed the same way, each with the requirement that will close it.
apps/sandbox-e2e/src/radio.spec.tsapps/sandbox-e2e/src/a11y.spec.ts — including the /states view with a read-only group, which found aria-readonly on the radio role (lesson-33)apps/sandbox-e2e/src/visual.spec.tsforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts — this is where a regression surfaced: the dot was a plain <div> with a background, so a selected option looked identical to an empty one (lesson-40). The second one no browser here shows: the disabled circle kept a theme surface, and tools/check-styles.mjs (point 7) is what says so (lesson-70)prefers-reduced-motionapps/sandbox-e2e/src/preferences.spec.tsapps/sandbox-e2e/src/radio.spec.tsbare varianttools/check-styles.mjs (point 5, req-token-logical) over the stylesheet — one justified pct-exception, the symmetric left: 50% of the hit zone — and the radio-in-wrapper-rtl baseline in apps/sandbox-e2e/src/visual.spec.ts. Not yet: with orientation="horizontal" the Left/Right arrows have to swap, and no test presses them under dir="rtl"apps/sandbox-e2e/src/hydration.spec.tslibs/components/radio/src/radio.spec.ts, apps/sandbox-e2e/src/forms.spec.ts; a form's reset — reset() keeping the value, reset(value) showing it, the form pristine: libs/components/radio/src/radio.spec.ts › signal formslibs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts). Note: label collided with the options' labels — hence the group- prefix (lesson-15), which every part of the group carries, group-options included (point 6 of the gate)contrast.policy.jsonlibs/tokens/src/contrast.policy.jsonPCT_TEXTStools/check-texts.mjs — no strings of its own; the dev-mode report of a duplicated value is addressed to the developer, so it is permanently in English and outside PCT_TEXTS (req-api-texts)libs/components/size.snapshot.md — the ./radio rowdocs/acr/at/ — three readers, whole sandbox. Orca and VoiceOver give the whole shape: Plan · panel · Free · not selected radio button. and Free radio button, 1 of 3 Plan You can change it at any time required radio group — group, option, state, and for VoiceOver the position, the hint and required. NVDA announces the group and stops: Horizontal layout, grouping, with no option and no state, at every radio stop of this walk. Whether that is NVDA in browse mode or the group itself is the next question, and it is a specific one. VoiceOver left this view after one stop, so its reading covers the first option/components/radio on the published site — prerendered, the demo's own source is the code tabRead at build time from the tracked snapshots, not typed here.
The library's first composite component. The form control is the group, not the options (req-api-container).
ControlValueAccessorNoInferviewChildren$event from (valueChange) is not checked in the template. The group has no options input (they are projected content), so the only source of T is value itself — and then NoInfer has nothing to protect. That is a limitation of Angular, not of the API (0010).viewChildren, because they are projected. focus() therefore queries the host's DOM (lesson-16) — the native controls are what the browser checks. What the options MEAN travels the other road: a token (PCT_RADIO_OPTION) the option provides, which leaves the import pointing one way only where contentChildren(PctRadio) would have closed the cycle. The DOM could not have carried it — [attr.value] is absent for a non-primitive T.value attribute disappears for non-primitive T — [object Object] in the DOM would look like a value while identifying nothing.