Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Shares one array-valued field name with a leading empty-array hidden field,
derives the checked set from the model's current value, and renders under both
themes. `variant:` (`:stack`/`:inline`/`:pill`) is layout-only, no JS.
- **`checkbox_group` accessible name** (issue #17): the `div[role="group"]` can
now carry an accessible name/description. HTML/ARIA attributes pass straight
through to the group, so the bare verb is named with plain `aria:`
(`aria: { label: "Tags" }` or `aria: { labelledby: "id" }`); through `f.field`
the Control's own visible `label:` / `hint:` get stable ids and the field wires
`aria-labelledby` / `aria-describedby` at them (no duplicate markup). Absent a
name, output is unchanged.

### Changed

Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,9 +366,11 @@ f.checkbox_group(:tag_ids, Tag.all, value: :id, label: :name)
f.checkbox_group(:tag_ids, Tag.all, value: :id,
label: ->(t) { t.name.presence || t.slug }, # Symbol method or Proc
variant: :pill, # :stack (default) | :inline | :pill
size: :sm) # daisyUI checkbox size
# ...or through field inference:
f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value: :id, label: :name
size: :sm, # daisyUI checkbox size
aria: { label: "Tags" }) # names the group for screen readers
# ...or through field inference (the field's label/hint name the group):
f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value: :id,
label: "Tags", hint: "Pick any"

f.collection_select(:country_id, Country.all, :id, :name, prompt: "Select…")
```
Expand All @@ -379,6 +381,20 @@ set comes from the model's current value matched by each item's resolved
`value:` — re-rendering an edit form pre-checks the right boxes. The `:pill`
variant styles the active chip with Tailwind's `has-[:checked]:` (no JS).

A `role="group"` needs an **accessible name** for assistive tech. The verb has
no bespoke naming option — HTML/ARIA attributes pass straight through to the
group, so name it with plain `aria:` (`aria: { label: "Tags" }` for a literal
name, or `aria: { labelledby: "some_id" }` to point at an existing element).
Through `f.field`, the Control's own visible `label:` / `hint:` name the group
automatically (the field wires `aria-labelledby` / `aria-describedby` at them).
Without a name the group renders as before — naming is the caller's call, the
same posture as Rails' derived form markup.

Field ids derive from scope + name (+ value for group items), exactly like
Rails' `form_with`; the gem does not guarantee page-wide id uniqueness across
multiple forms for the same model — scope one form (`Form(model:, scope: …)`) to
disambiguate, as you would in Rails.

`Form(model: @item, scope: false)` emits **bare** field names
(`name="quantity"`) — the shape phlex-reactive row editors and
`<template>`-cloned rows need. External widgets bind through the public
Expand Down
18 changes: 17 additions & 1 deletion lib/forms/checkbox_group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ module Forms
# variant: :stack (default) | :inline | :pill — layout only, zero JS
# size: daisyUI checkbox size modifier (:xs :sm :md :lg :xl)
#
# Accessible name (issue #17): `role="group"` needs one so a screen reader
# announces the group when focus enters a checkbox. The leaf does NOT invent
# its own naming API — extra attributes pass straight through to the group
# `div`, so the caller names it with plain HTML/ARIA:
#
# f.checkbox_group(:tag_ids, Tag.all, value: :id, aria: { label: "Tags" })
# f.checkbox_group(:tag_ids, Tag.all, value: :id, aria: { labelledby: "hdr" })
#
# Through `f.field`, the builder points the group at the Control's own visible
# <label>/hint via `aria: { labelledby:, describedby: }` (so the accessible
# name matches what sighted users see) — same passthrough, no special API.
#
# The checked set is passed in pre-resolved by the builder (Field#checkbox_group
# matches the model's current value by each item's resolved value:), so the
# component itself stays presentation-only. Each checkbox's markup is delegated
Expand Down Expand Up @@ -44,7 +56,11 @@ def view_template
# convention as collection_check_boxes).
input(type: "hidden", name: @name, value: "")

div(class: group_classes, role: "group", "aria-invalid": @error || nil) do
# class is the per-checkbox styling seam (see render_checkbox), not a group
# attribute — everything else the caller passed lands on the group so aria:,
# data:, id: etc. pass straight through.
div(class: group_classes, role: "group", "aria-invalid": @error || nil,
**@attributes.except(:class)) do
@options.each { |option| item(option) }
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/forms/field_hint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def initialize(text: nil, **options)
def view_template
return unless @text

p(class: classes) { @text }
p(class: classes, **@options.except(:class)) { @text }
end

private
Expand Down
12 changes: 9 additions & 3 deletions lib/forms/form_control.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,33 @@ module Forms
# workhorse behind the Control-first `f.field` API and the explicit `f.Control`
# escape hatch.
class FormControl < Phlex::HTML
def initialize(*modifiers, label: nil, hint: nil, error: nil, for: nil, required: false, **options)
# label_id:/hint_id: give the label and hint stable ids so a group control
# (checkbox_group's div[role="group"], which a plain `for`/`id` can't name)
# can reference them via aria-labelledby / aria-describedby (issue #17).
def initialize(*modifiers, label: nil, hint: nil, error: nil, for: nil, required: false,
label_id: nil, hint_id: nil, **options)
@modifiers = modifiers
@label = label
@hint = hint
@error = error
@field_id = grab(for:)
@required = required
@label_id = label_id
@hint_id = hint_id
@options = options
super()
end

def view_template(&)
div(class: control_classes, **@options.except(:class)) do
render Forms::Label.new(text: @label, for: @field_id, required: @required) if @label
render Forms::Label.new(text: @label, for: @field_id, required: @required, id: @label_id) if @label

yield if block_given?

if @error
render Forms::FieldError.new(message: @error)
elsif @hint
render Forms::FieldHint.new(text: @hint)
render Forms::FieldHint.new(text: @hint, id: @hint_id)
end
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/forms/plain/control.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ module Plain
class Control < Forms::FormControl
def view_template
div(**@options.except(:class), class: @options[:class]) do
render Label.new(text: @label, for: @field_id, required: @required) if @label
render Label.new(text: @label, for: @field_id, required: @required, id: @label_id) if @label

yield if block_given?

if @error
render FieldError.new(message: @error)
elsif @hint
render FieldHint.new(text: @hint)
render FieldHint.new(text: @hint, id: @hint_id)
end
end
end
Expand Down
30 changes: 29 additions & 1 deletion lib/phlex_forms/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,39 @@ def field(name, *modifiers, label: nil, hint: nil, as: nil, required: nil,
options = fo.apply_validations(options)
choices ||= materialize_choices(inferred.choices)

render fo.control(label: label_text, hint:, required: req) do
# A checkbox_group renders div[role="group"], which a plain <label for> can't
# name. Give the Control's label/hint stable ids (control_opts) and point the
# group at them with a passed-through aria: { labelledby:, describedby: }
# (group_opts), reusing the Control's own visible chrome (issue #17).
control_opts, group_opts = group_aria(fo, inferred.as, label_text, hint)
options = options.merge(group_opts)

render fo.control(label: label_text, hint:, required: req, **control_opts) do
render_field_input(fo, inferred.name, inferred.as, modifiers, choices:, required: req, **options)
end
end

# For a checkbox_group field: the stable ids to stamp on the Control's
# label/hint (control_opts), and a group `aria:` hash pointing back at them
# (group_opts) — passed through to the group div, no special leaf API. Returns
# [{}, {}] for every other field type (a label associates via for/id, no aria
# needed) and when neither label nor hint is present.
def group_aria(fo, as, label_text, hint)
return [{}, {}] unless as == :checkbox_group

control_opts = {}
aria = {}
if label_text
control_opts[:label_id] = "#{fo.field_id}_label"
aria[:labelledby] = control_opts[:label_id]
end
if hint
control_opts[:hint_id] = "#{fo.field_id}_hint"
aria[:describedby] = control_opts[:hint_id]
end
[control_opts, aria.empty? ? {} : { aria: }]
end

# ------------------------------------------------------------------
# Layout helpers
# ------------------------------------------------------------------
Expand Down
114 changes: 114 additions & 0 deletions spec/forms/checkbox_group_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,118 @@
expect(PhlexForms::Theme.plain[:checkbox_group]).to eq(Forms::Plain::CheckboxGroup)
end
end

# Issue #17: role="group" needs an accessible name (and optional description)
# so assistive tech announces the group's purpose when focus enters a checkbox.
describe "accessible name (issue #17)" do
describe "the bare verb" do
# No bespoke naming API — HTML/ARIA attributes pass straight through to the
# group div, so the caller names it with plain aria: {}.
it "names the group via a passed-through aria: { label: }" do
output = render_form(user) do |f|
f.checkbox_group(:tag_ids, collection, value: :id, label: :name, aria: { label: "Tags" })
end

expect(output).to match(/role="group"[^>]*aria-label="Tags"/)
end

it "names the group via a passed-through aria: { labelledby: } id" do
output = render_form(user) do |f|
f.checkbox_group(:tag_ids, collection, value: :id, label: :name,
aria: { labelledby: "external_heading", describedby: "external_hint" })
end

expect(output).to match(/role="group"[^>]*aria-labelledby="external_heading"/)
expect(output).to include('aria-describedby="external_hint"')
end

it "passes arbitrary attributes (data:) through to the group" do
output = render_form(user) do |f|
f.checkbox_group(:tag_ids, collection, value: :id, label: :name,
data: { controller: "chips" })
end

expect(output).to match(/role="group"[^>]*data-controller="chips"/)
end

it "escapes the attribute delimiter in a passed-through aria-label (no breakout)" do
# Phlex escapes the quote delimiter (&quot;) so a value can't break out of
# the attribute; < / > are inert inside a quoted attribute value, so the
# <script> text stays trapped as an attribute value, never a new element.
output = render_form(user) do |f|
f.checkbox_group(:tag_ids, collection, value: :id, label: :name,
aria: { label: '"><script>x' })
end

expect(output).to include('aria-label="&quot;><script>x"')
# The dangerous form — an unescaped quote that closes the attribute and
# opens a real <script> element — must NOT appear.
expect(output).not_to include('aria-label=""><script>x')
end

it "emits only role + aria-invalid on the group when no aria given" do
# Backward compatible with the #9 output (no accessible name is the
# caller's responsibility — same posture as Rails' derived markup).
output = render_form(user) do |f|
f.checkbox_group(:tag_ids, collection, value: :id, label: :name)
end

expect(output).not_to include("aria-label")
expect(output).not_to include("aria-labelledby")
expect(output).not_to include("aria-describedby")
end
end

describe "the f.field(as: :checkbox_group) path" do
it "names the group via the Control's own label (no duplicate heading)" do
output = render_form(user) do |f|
f.field(:tag_ids, as: :checkbox_group, collection:, value: :id, label: "Tags")
end

# The Control's <label> carries a stable id...
expect(output).to include('<label for="user_tag_ids" id="user_tag_ids_label">')
expect(output).to include(">Tags</span>")
# ...and the group points aria-labelledby at it.
expect(output).to match(/role="group"[^>]*aria-labelledby="user_tag_ids_label"/)
# Exactly one "Tags" text node — no duplicate heading inside the group.
expect(output.scan(">Tags<").size).to eq(1)
end

it "describes the group via the Control's hint" do
output = render_form(user) do |f|
f.field(:tag_ids, as: :checkbox_group, collection:, value: :id,
label: "Tags", hint: "Pick any")
end

expect(output).to match(/id="user_tag_ids_hint"[^>]*>Pick any/)
expect(output).to match(/role="group"[^>]*aria-describedby="user_tag_ids_hint"/)
end
end

describe "theme parity" do
it "passes aria through under the plain theme with zero styling classes" do
output = render_form(user, theme: :plain) do |f|
f.checkbox_group(:tag_ids, collection, value: :id, label: :name,
aria: { label: "Tags" })
end

expect(output).to match(/role="group"[^>]*aria-label="Tags"/)
expect(output).not_to include('class="')
end

it "the plain f.field path stamps the Control's label/hint ids (no dangling aria)" do
# The plain Control overrides view_template; it must thread label_id/hint_id
# the same way, or the group's aria-* would point at ids that don't exist.
output = render_form(user, theme: :plain) do |f|
f.field(:tag_ids, as: :checkbox_group, collection:, value: :id,
label: "Tags", hint: "Pick any")
end

expect(output).to include('id="user_tag_ids_label"')
expect(output).to include('id="user_tag_ids_hint"')
expect(output).to match(/role="group"[^>]*aria-labelledby="user_tag_ids_label"/)
expect(output).to include('aria-describedby="user_tag_ids_hint"')
end
end
end
end
Loading