Skip to content

feat: checkbox_group carries an accessible name (a11y, #17) - #18

Merged
mhenrixon merged 1 commit into
mainfrom
issue-17-checkbox-group-accessible-name
Jul 11, 2026
Merged

feat: checkbox_group carries an accessible name (a11y, #17)#18
mhenrixon merged 1 commit into
mainfrom
issue-17-checkbox-group-accessible-name

Conversation

@mhenrixon

@mhenrixon mhenrixon commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #17. Follow-up to #9checkbox_group's div[role="group"] had no
accessible name, so a screen reader announced "checkbox, Ruby" with no clue the
boxes belong to a "Tags" group. Now the group can carry an accessible name.

Approach (revised after review): no bespoke naming API — attributes pass
through.
The leaf forwards extra HTML/ARIA attributes straight to the group
div, so the caller names it with plain aria:. This is smaller and more
flexible than an invented heading:/hint: option.

Two entry points

Bare verb — name it with aria: (a literal string, or an id reference):

f.checkbox_group(:tag_ids, Tag.all, value: :id, aria: { label: "Tags" })
f.checkbox_group(:tag_ids, Tag.all, value: :id, aria: { labelledby: "some_id" })
# → <div role="group" aria-label="Tags">…</div>

Field path — reuses the Control's OWN visible <label>/hint (best practice:
the accessible name matches what sighted users see, no duplicate markup):

f.field(:tag_ids, as: :checkbox_group, collection: Tag.all, value: :id,
        label: "Tags", hint: "Pick any")
<label for="user_tag_ids" id="user_tag_ids_label"><span class="label">Tags</span></label>
<div role="group" aria-labelledby="user_tag_ids_label" aria-describedby="user_tag_ids_hint"></div>
<p id="user_tag_ids_hint" >Pick any</p>

The builder stamps stable ids on the Control's label/hint (threaded through
FormControl and Forms::Plain::Control) and passes
aria: { labelledby:, describedby: } through to the group. When no name is
supplied, output is byte-identical to #9 (Phlex omits nil attributes).

Id uniqueness (your Q3)

Field ids derive from scope + name (+ value) exactly like Rails' form_with,
which likewise does not guarantee page-wide uniqueness across multiple forms for
the same model. Documented in the README (scope one form to disambiguate) — not
enforced, matching the Rails contract. No code.

Test coverage

  • Bare verb: aria: { label: }aria-label; aria: { labelledby:/describedby: }
    passthrough; arbitrary data: passthrough; attribute-delimiter escaping (a
    " in the value is &quot;-escaped so it can't break out — </> are inert
    inside a quoted attribute); only role+aria-invalid when no aria given.
  • Field path: Control's label/hint get ids, group aria-* point at them, exactly
    one "Tags" text node (no duplicate).
  • Theme parity: bare verb under plain (zero styling classes); plain field path
    stamps the Control label/hint ids.

Full suite: 153 examples, 0 failures · rubocop lib spec clean.

Deviations & judgment calls

  • Pivoted from an invented API to attribute passthrough (reviewer's steer).
    The first cut added heading:/hint: options that rendered internal <span id>
    elements. On review we agreed plain aria: passthrough is simpler and more
    flexible — the leaf now forwards @attributes.except(:class) to the group and
    owns no naming vocabulary. Dropped the heading/hint spans, their id generation,
    and the heading_classes/hint_classes seams.
  • aria-label (string) for the bare verb, aria-labelledby (id) for the field
    path.
    The field path has a visible label, so pointing at it keeps the
    accessible name in sync with what's on screen; the bare verb has no such
    element, so a literal aria-label string is the pragmatic default (the caller
    can still pass aria: { labelledby: } to reference their own heading).
  • Daisy/plain drift, caught mid-implementation: Forms::Plain::Control
    overrides view_template, so it needed the same label_id:/hint_id:
    threading as FormControl — without it the plain field path pointed
    aria-labelledby at a label with no id (a dangling reference). Fixed, with a
    dedicated plain-field-path regression spec.
  • Group id: is not settable — the leaf's id: keyword seeds the checkbox
    ids, so a caller can't override the group div's id via id:. Out of scope for
    checkbox_group: group has no accessible name (no legend/aria-labelledby) #17 (naming); noted here so it isn't mistaken for a passthrough gap.
  • Id uniqueness: documented, not enforced — matches Rails (your call).

div[role="group"] had no accessible name, so a screen reader announced
"checkbox, Ruby" with no indication the boxes belong to a "Tags" group (#17).

The leaf invents NO naming API — extra HTML/ARIA attributes pass straight
through to the group div, so the caller names it with plain aria:
- Bare verb: `f.checkbox_group(..., aria: { label: "Tags" })` (a literal name)
  or `aria: { labelledby: "some_id" }` (point at an existing element).
- Field path: `f.field(:tags, as: :checkbox_group, label:, hint:)` reuses the
  Control's OWN visible <label>/hint — they get stable ids (label_id:/hint_id:
  threaded through FormControl AND Plain::Control) and the builder passes
  aria: { labelledby:, describedby: } through to the group, so the accessible
  name matches what sighted users see and nothing is duplicated.

Absent a name, output is byte-identical to #9 (nil attrs are omitted by Phlex).
Id uniqueness follows Rails' form_with contract (derived from scope+name+value,
host owns cross-form uniqueness) — documented, not enforced.

## Test Coverage
- bare verb: aria: { label: } -> aria-label; aria: { labelledby:/describedby: }
  passthrough; arbitrary data: passthrough; delimiter-escaping (no breakout);
  only role+aria-invalid when no aria given (back-compat)
- field path: Control's label/hint get ids, group aria-* point at them, exactly
  one "Tags" text node (no duplicate)
- theme parity: bare verb under plain (zero classes); plain field path stamps
  the Control label/hint ids (guards the daisy/plain drift where Plain::Control
  overrides view_template)

## Verification
- [x] bundle exec rubocop lib spec passes
- [x] bundle exec rspec passes (153 examples)

Closes #17
@mhenrixon
mhenrixon force-pushed the issue-17-checkbox-group-accessible-name branch from ff03a6a to 67377b7 Compare July 11, 2026 09:43
@mhenrixon
mhenrixon merged commit 171f7de into main Jul 11, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

checkbox_group: group has no accessible name (no legend/aria-labelledby)

1 participant