forty-cdk
llms.txt

Primitives

Toggle

A two-state button that stays pressed or unpressed.

forty-cdk/toggle WAI-ARIA APG

Press it with the pointer, Space or Enter. The button reflects aria-pressed and data-state, so the pressed and unpressed looks come from one rule.

Two related primitives in a single folder: [forToggle] is a standalone two-state button (<button aria-pressed>); [forToggleGroup] + [forToggleGroupItem] compose a group of toggle buttons with single / multiple selection, roving tabindex, and arrow-key navigation. For exclusive selection where one option is always required, use [forRadioGroup] instead, or set deselectable="false" on a single-mode group. Radio guarantees one-of-N and moves the selection with the arrow keys, whereas ToggleGroup in single mode lets the user clear the selection unless deselectable is false, and its arrows move focus only.

Anatomy

<!-- Standalone two-state button -->
<button forToggle [(checked)]="bold">B</button>

<!-- Group of toggle buttons (single or multiple selection) -->
<div forToggleGroup [(value)]="format" multiple>
  <button forToggleGroupItem value="bold">B</button>
  <button forToggleGroupItem value="italic">I</button>
  <button forToggleGroupItem value="underline">U</button>
</div>

Examples

States

One class and one directive, three states. Both disabled and readonly stay focusable (per APG). They reflect aria-disabled / data-disabled and aria-readonly / data-readonly rather than the native disabled attribute, so assistive tech still announces the button while interaction is a no-op.

Default
Disabled
Read-only

ToggleGroup

A group of toggles with roving tabindex. In multiple mode each item toggles independently, and arrows only move focus: selection needs Space / Enter or click. value is always a readonly string[], carrying 0 or 1 entries in single mode, so flipping multiple needs no change to your state.

Signal Forms

ForToggleGroup implements FormValueControl<readonly string[]>, so [formField] binds the pressed-values array to a form field. This single-select alignment group is required: clearing the choice and blurring marks the group data-invalid / data-touched.

Text alignment

Signal Forms (single Toggle)

ForToggle implements FormCheckboxControl, so a single aria-pressed toggle (the natural home for bold / italic, mute, or favourite buttons) auto-wires with [formField] from @angular/forms/signals. The schema's disabled, readonly, required, invalid, pending, dirty, errors, and touched flow into the matching inputs without consumer glue.

import { Component, signal } from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';
import { ForToggle } from 'forty-cdk/toggle';

@Component({
  selector: 'demo-bold',
  imports: [ForToggle, FormField],
  template: ` <button forToggle [formField]="prefs.bold" aria-label="Bold">B</button> `,
})
export class DemoBold {
  readonly model = signal({ bold: false });
  readonly prefs = form(this.model, (s) => required(s.bold));
}

When [name] is set (typically through [formField]), the directive mounts an <input type="hidden" value="on"> sibling while checked so the surrounding <form> picks it up during native submission.

For a set of related toggles, ForToggleGroup implements FormValueControl<readonly string[]> instead: its value is readonly string[], and a single-mode group simply carries [] or [selected].

Signal Forms (ToggleGroup)

ForToggleGroup implements FormValueControl<readonly string[]>, so it auto-wires with [formField] from @angular/forms/signals. The schema's disabled, readonly, required, invalid, pending, dirty, errors, touched, and name flow into the matching inputs without consumer glue.

import { Component, signal } from '@angular/core';
import { form, required, requiredError, validate } from '@angular/forms/signals';
import { ForToggleGroup, ForToggleGroupItem } from 'forty-cdk/toggle';

@Component({
  selector: 'demo-formats',
  imports: [ForToggleGroup, ForToggleGroupItem /* , FormField from @angular/forms */],
  template: `
    <div forToggleGroup multiple [formField]="prefs.formats" aria-label="Formats">
      <button forToggleGroupItem value="bold">B</button>
      <button forToggleGroupItem value="italic">I</button>
      <button forToggleGroupItem value="underline">U</button>
    </div>
  `,
})
export class DemoFormats {
  readonly model = signal({ formats: [] as string[] });
  readonly prefs = form(this.model, (s) => {
    required(s.formats);
    validate(s.formats, ({ value }) =>
      value().length === 0 ? requiredError({ message: 'Pick at least one format' }) : undefined,
    );
  });
}

Requiring a non-empty selection. ForToggleGroup's value is a readonly string[], and Angular's required() treats only '', false, null, and NaN as empty. An empty array [] counts as present, so required(s.formats) reflects data-required but never makes the form invalid on its own. Enforce "at least one" with the explicit validate(...) length rule above, or with Angular's minLength(s.formats, 1) (which emits a minLengthError instead of a requiredError). The single-boolean ForToggle above is unaffected: required(s.bold) works because false is treated as empty.

When [name] is set (typically through [formField]), the directive mounts one <input type="hidden"> sibling per selected value so the surrounding <form> picks the values up during native submission.

Choose between the two form-control shapes by the value you need: ForToggle (FormCheckboxControl, a single boolean) for one independent on/off button, ForToggleGroup (FormValueControl<readonly string[]>) for a set of related toggles whose selection is a list.

API

ForToggle

PropertyTypeDescription
checked
model
Two-way bindable on/off state. Required by FormCheckboxControl; what [formField] binds. The host reflects it via aria-pressed and data-state.
Default: false
disabled
input
When true, click is ignored; reflects aria-disabled="true" + data-disabled. Stays focusable (per APG) and sets no native disabled.
Default: false
readonly
input
When true, click is ignored but the host stays focusable. Reflected as data-readonly only, because aria-readonly is not supported on role="button".
Default: false
required
input
Reflected as data-required only, because aria-required is not supported on role="button".
Default: false
invalid
input
Reflected as aria-invalid and data-invalid.
Default: false
pending
input
Reflected as aria-busy and data-pending.
Default: false
dirty
input
Reflected as data-dirty.
Default: false
name
input
When non-empty, a hidden input is mounted for native form submission (name=on while checked).
Default: ''
errors
input
Validation errors surfaced by Signal Forms.
Default: []
touched
model
Two-way bindable. Set to true on blur.
Default: false
Data attributeValues
data-statechecked | unchecked
data-disabledpresent | absent
data-readonlypresent | absent
data-requiredpresent | absent

ForToggleGroup

PropertyTypeDescription
value
model
Two-way bindable. Selected values, in arbitrary order. Required by FormValueControl<readonly string[]>.
Default: []
multiple
input
When true, items toggle independently. When false, single mode (clicking the pressed item clears, unless deselectable is false).
Default: false
deselectable
input
In single mode, whether pressing the pressed item clears the selection. When false, that press is a no-op and the item keeps aria-pressed="true"; a group with no value still starts empty. Ignored in multiple mode. The default is read from provideForToggleDefaults.
Default: true
disabled
input
Disables every item regardless of per-item state.
Default: false
readonly
input
Click is ignored, items remain focusable. Reflected as data-readonly on the root only, because aria-readonly is supported on neither role="group" nor the items' role="button".
Default: false
required
input
Reflected as data-required on the root only, because aria-required is supported on neither role="group" nor the items' role="button".
Default: false
invalid
input
Reflected as aria-invalid and data-invalid.
Default: false
pending
input
Reflected as aria-busy and data-pending.
Default: false
dirty
input
Reflected as data-dirty.
Default: false
name
input
When non-empty, hidden inputs are mounted for native form submission (one per selected value).
Default: ''
errors
input
Validation errors surfaced by Signal Forms.
Default: []
touched
model
Two-way bindable. Set on focusout outside the group.
Default: false
orientation
input
Layout direction for keyboard navigation.
Default: 'horizontal'
dir
input
Reading direction. RTL swaps ArrowLeft / ArrowRight.
Default: 'ltr'
loop
input
When true, arrow nav wraps at the ends. The default is read from provideForToggleDefaults.
Default: true
Data attributeValues
data-orientationhorizontal | vertical
data-disabledpresent | absent
data-readonlypresent | absent

ForToggleGroupItem

PropertyTypeDescription
value
input.required
Identifier added to / removed from the group's value.
Default: —
disabled
input
Per-item disabled, in addition to the group's disabled.
Default: false
Data attributeValues
data-statechecked | unchecked
data-disabledpresent | absent
data-orientationhorizontal | vertical

Keyboard

  • Enter / Space on the focused item toggles it (native button behavior).
  • ArrowLeft / ArrowRight moves focus in horizontal mode (or vertical, swapped via orientation). Disabled items are skipped.
  • ArrowUp / ArrowDown moves focus in vertical mode.
  • Home / End jump to the first / last enabled item.
  • Tab enters and exits the group at the entry-point item. Before any interaction that is the first selected item, or the first enabled item when no value is selected; once you move focus with the arrows (or Home / End), the tab stop follows the last focused item, so Shift+Tab back into the group restores it.

Arrow keys move focus only. Selection requires an explicit click or Space / Enter. There is no selection-on-focus, unlike [forRadioGroup].

Accessibility

Implements the WAI-ARIA Button pattern (toggle-button variant).

  • [forToggle] emits role="button" with aria-pressed="true|false", which screen readers announce as "toggle button".
  • [forToggleGroup] emits role="group" and reflects data-orientation for CSS (not aria-orientation). Provide a label via aria-label or aria-labelledby.
  • Roving tabindex manages focus within the group. The consumer never sets tabindex manually.
  • A disabled item stays focusable (per APG): it reflects aria-disabled="true" + data-disabled="" rather than native disabled, so assistive tech still announces it while interaction is a no-op.
  • readonly has no ARIA channel here. WAI-ARIA supports aria-readonly on neither role="button" nor role="group", so a read-only toggle / group reflects the boolean data-readonly styling hook only and interaction is blocked in the handler.

Styling

forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under API, not off the for* selectors (Styling forty-cdk explains why).

.toggle {
  background: var(--surface);
}

.toggle[data-state='checked'] {
  background: var(--accent);
}

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

Behavior notes

  • data-state uses the form-control vocabulary "checked" | "unchecked" (per CLAUDE.md cross-primitive convention), even though ARIA uses aria-pressed. The data attribute mirrors the logical "is this option active" state, not the ARIA term.
  • Single mode lets the user reach the [] state by clicking the currently pressed item again. Set deselectable="false" to keep the pressed item pressed instead, for a segmented button whose arrows move focus only, or use [forRadioGroup] if you need to enforce one-of-N with the selection following the arrow keys.
  • Roving tabindex follows focus: once any item is focused, that item becomes the tab stop so re-entry restores it. Before any focus, the entry point is computed from the group's value: the first selected item when there is at least one selection, otherwise the first enabled item in DOM order. The consumer never sets tabindex manually.

Wrapping in a design system

Wrapping form primitives documents both supported wrapper patterns: hostDirectives with the exported FOR_TOGGLE_HOST_DIRECTIVE_INPUTS / FOR_TOGGLE_HOST_DIRECTIVE_OUTPUTS and FOR_TOGGLE_GROUP_HOST_DIRECTIVE_INPUTS / FOR_TOGGLE_GROUP_HOST_DIRECTIVE_OUTPUTS name tuples, and subclassing.