phlex-forms 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74bd32547d30535c35060ea6c93f9cfd199614a9ced2cf1a95a7c32fe62eb3e6
4
- data.tar.gz: cf0087d9bee7cb13366ef489fb8764c82867b406881934eb92cdb7dbc271d0f8
3
+ metadata.gz: fd321ccbe2bec3d9b8966d0eda0e1036e4143af38b4aa55dec702ce8c6d23137
4
+ data.tar.gz: 2abe25e3c63dd5d689a6c6d762df3ed6204c0dca0ef6b7d569f6e8c3da4ee1d7
5
5
  SHA512:
6
- metadata.gz: 33980ae64283b549a82ab25bd445df927616ac7bc5d0929c41aba13c10439d795c631268e6154bd64a5c0b9783baee4573e7e4efdeef7ecd3e8ac4633791b1bd
7
- data.tar.gz: d4d4efd19dd0dbde90311922d91040052925a87948cdcd95d42b50049028186a6cd1b7b80a8b7027154cadd67e8801ad242777817a1ab214d13088e40a5558b3
6
+ metadata.gz: fb8b8768817fef4b18e0dacadbfb966cf7356339badfce5e96ea04ccc7e0d5bcc60686e65288413376bbe7873e17993746b35dceefb917a58132505ef88f9094
7
+ data.tar.gz: e7f775c0c15db6e595f9ac5c93449fb49b12d032f1a615f231b5e23f1aa2ac188fb2eb21a6afccf073758227a3e100a69def8f24185baadd63ddc19e923a48e0
data/CHANGELOG.md CHANGED
@@ -16,6 +16,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
  Shares one array-valued field name with a leading empty-array hidden field,
17
17
  derives the checked set from the model's current value, and renders under both
18
18
  themes. `variant:` (`:stack`/`:inline`/`:pill`) is layout-only, no JS.
19
+ - **`checkbox_group` accessible name** (issue #17): the `div[role="group"]` can
20
+ now carry an accessible name/description. HTML/ARIA attributes pass straight
21
+ through to the group, so the bare verb is named with plain `aria:`
22
+ (`aria: { label: "Tags" }` or `aria: { labelledby: "id" }`); through `f.field`
23
+ the Control's own visible `label:` / `hint:` get stable ids and the field wires
24
+ `aria-labelledby` / `aria-describedby` at them (no duplicate markup). Absent a
25
+ name, output is unchanged.
26
+ - **`checkbox_group` `item_label:`** — the per-item text accessor, so a
27
+ `f.field(:tags, as: :checkbox_group, label: "Tags", item_label: ->(t){…})`
28
+ renders a visible group heading (`label:`, via the Control) AND custom item
29
+ labels at once — previously `label:` on the `f.field` path was eaten by the
30
+ heading and items fell back to `to_s`. `item_label:` wins over `label:` for the
31
+ item text; on the bare verb `label:` stays the item accessor and `item_label:`
32
+ is an alias. Absent it, behavior is unchanged.
19
33
 
20
34
  ### Changed
21
35
 
data/README.md CHANGED
@@ -366,9 +366,15 @@ f.checkbox_group(:tag_ids, Tag.all, value: :id, label: :name)
366
366
  f.checkbox_group(:tag_ids, Tag.all, value: :id,
367
367
  label: ->(t) { t.name.presence || t.slug }, # Symbol method or Proc
368
368
  variant: :pill, # :stack (default) | :inline | :pill
369
- size: :sm) # daisyUI checkbox size
370
- # ...or through field inference:
371
- f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value: :id, label: :name
369
+ size: :sm, # daisyUI checkbox size
370
+ aria: { label: "Tags" }) # names the group for screen readers
371
+ # ...or through field inference. Here `label:`/`hint:` are the field's VISIBLE
372
+ # heading + description (rendered by the Control, which also names the group);
373
+ # `item_label:` gives the per-item text, so you get a heading AND custom item
374
+ # labels at once:
375
+ f.field :tag_ids, as: :checkbox_group, collection: Tag.all, value: :id,
376
+ label: "Tags", hint: "Pick any",
377
+ item_label: ->(t) { t.name.presence || t.slug }, variant: :pill
372
378
 
373
379
  f.collection_select(:country_id, Country.all, :id, :name, prompt: "Select…")
374
380
  ```
@@ -379,6 +385,27 @@ set comes from the model's current value matched by each item's resolved
379
385
  `value:` — re-rendering an edit form pre-checks the right boxes. The `:pill`
380
386
  variant styles the active chip with Tailwind's `has-[:checked]:` (no JS).
381
387
 
388
+ **Two labels, no collision.** Through `f.field`, `label:` is the field's visible
389
+ group heading (the Control renders it); the per-item text comes from `item_label:`
390
+ (a Symbol method or Proc). On the bare `f.checkbox_group` verb there is no Control
391
+ heading, so `label:` *is* the per-item accessor (and `item_label:` is accepted as
392
+ an alias). Either way `value:` is the submitted value; `item_label:` wins over
393
+ `label:` for the item text when both are present.
394
+
395
+ A `role="group"` needs an **accessible name** for assistive tech. The verb has
396
+ no bespoke naming option — HTML/ARIA attributes pass straight through to the
397
+ group, so name it with plain `aria:` (`aria: { label: "Tags" }` for a literal
398
+ name, or `aria: { labelledby: "some_id" }` to point at an existing element).
399
+ Through `f.field`, the Control's own visible `label:` / `hint:` name the group
400
+ automatically (the field wires `aria-labelledby` / `aria-describedby` at them).
401
+ Without a name the group renders as before — naming is the caller's call, the
402
+ same posture as Rails' derived form markup.
403
+
404
+ Field ids derive from scope + name (+ value for group items), exactly like
405
+ Rails' `form_with`; the gem does not guarantee page-wide id uniqueness across
406
+ multiple forms for the same model — scope one form (`Form(model:, scope: …)`) to
407
+ disambiguate, as you would in Rails.
408
+
382
409
  `Form(model: @item, scope: false)` emits **bare** field names
383
410
  (`name="quantity"`) — the shape phlex-reactive row editors and
384
411
  `<template>`-cloned rows need. External widgets bind through the public
@@ -8,13 +8,28 @@ module Forms
8
8
  # resolved value: of its item against the model's current set.
9
9
  #
10
10
  # f.checkbox_group(:tag_ids, Tag.all, value: :id,
11
- # label: ->(t) { t.name.presence || t.slug }, variant: :pill, size: :sm)
11
+ # item_label: ->(t) { t.name.presence || t.slug }, variant: :pill, size: :sm)
12
12
  #
13
- # value: method or proc -> the submitted value of each item (default :id)
14
- # label: method or proc -> the visible text of each item (default :to_s)
13
+ # The item value/text accessors (value:/label:/item_label:) live on the BUILDER
14
+ # (Forms::Field#checkbox_group), which pre-resolves each item to
15
+ # { value:, label:, checked:, id: } before this leaf renders. This leaf is
16
+ # presentation-only — it receives the resolved options: array, never the raw
17
+ # accessors.
15
18
  # variant: :stack (default) | :inline | :pill — layout only, zero JS
16
19
  # size: daisyUI checkbox size modifier (:xs :sm :md :lg :xl)
17
20
  #
21
+ # Accessible name (issue #17): `role="group"` needs one so a screen reader
22
+ # announces the group when focus enters a checkbox. The leaf does NOT invent
23
+ # its own naming API — extra attributes pass straight through to the group
24
+ # `div`, so the caller names it with plain HTML/ARIA:
25
+ #
26
+ # f.checkbox_group(:tag_ids, Tag.all, value: :id, aria: { label: "Tags" })
27
+ # f.checkbox_group(:tag_ids, Tag.all, value: :id, aria: { labelledby: "hdr" })
28
+ #
29
+ # Through `f.field`, the builder points the group at the Control's own visible
30
+ # <label>/hint via `aria: { labelledby:, describedby: }` (so the accessible
31
+ # name matches what sighted users see) — same passthrough, no special API.
32
+ #
18
33
  # The checked set is passed in pre-resolved by the builder (Field#checkbox_group
19
34
  # matches the model's current value by each item's resolved value:), so the
20
35
  # component itself stays presentation-only. Each checkbox's markup is delegated
@@ -44,7 +59,11 @@ module Forms
44
59
  # convention as collection_check_boxes).
45
60
  input(type: "hidden", name: @name, value: "")
46
61
 
47
- div(class: group_classes, role: "group", "aria-invalid": @error || nil) do
62
+ # class is the per-checkbox styling seam (see render_checkbox), not a group
63
+ # attribute — everything else the caller passed lands on the group so aria:,
64
+ # data:, id: etc. pass straight through.
65
+ div(class: group_classes, role: "group", "aria-invalid": @error || nil,
66
+ **@attributes.except(:class)) do
48
67
  @options.each { |option| item(option) }
49
68
  end
50
69
  end
data/lib/forms/field.rb CHANGED
@@ -112,8 +112,20 @@ module Forms
112
112
  #
113
113
  # field.checkbox_group(Tag.all, value: :id, label: ->(t) { t.name })
114
114
  #
115
- # value:/label: are a method name (Symbol) or a proc taking the item.
116
- def checkbox_group(collection, value: :id, label: :to_s, **)
115
+ # value: is a method name (Symbol) or a proc taking the item -> its submitted
116
+ # value. The per-item visible text comes from item_label: (Symbol/Proc/String)
117
+ # if given, else label:; when NEITHER is given each item is labelled by the
118
+ # first of name/title/label/to_s it responds to (the same LABEL_METHODS chain
119
+ # Inference uses for association choices) — so a plain
120
+ # `f.field(:tags, as: :checkbox_group, label: "Tags")` shows readable item
121
+ # text without an explicit accessor.
122
+ #
123
+ # item_label: exists so the `f.field` path can pass a visible group heading as
124
+ # `label:` (consumed by the Control) AND still customize the per-item text
125
+ # here — the two no longer collide. item_label: is consumed here; it never
126
+ # leaks to the group div.
127
+ def checkbox_group(collection, value: :id, label: nil, item_label: nil, **)
128
+ text = item_label || label
117
129
  # The model's current value is already the raw values (e.g. record.tag_ids
118
130
  # => [1, 3]), so compare against them directly — don't re-resolve value:.
119
131
  selected = Array(field_value)
@@ -121,7 +133,7 @@ module Forms
121
133
  item_value = resolve_item(item, value)
122
134
  {
123
135
  value: item_value,
124
- label: resolve_item(item, label),
136
+ label: text ? resolve_item(item, text) : infer_item_label(item),
125
137
  checked: selected.include?(item_value),
126
138
  id: "#{field_id}_#{item_value}"
127
139
  }
@@ -256,9 +268,25 @@ module Forms
256
268
  validator.options.key?(:if) || validator.options.key?(:unless) || validator.options.key?(:on)
257
269
  end
258
270
 
259
- # value:/label: for checkbox_group: a Proc taking the item, or a method name.
271
+ # value:/label:/item_label: for checkbox_group. A Proc is called with the
272
+ # item; a String is literal text (the same for every item — no method
273
+ # dispatch, so a stray string can't NoMethodError); anything else (a Symbol)
274
+ # is sent to the item as a method name.
260
275
  def resolve_item(item, accessor)
261
- accessor.respond_to?(:call) ? accessor.call(item) : item.public_send(accessor)
276
+ case accessor
277
+ when Proc then accessor.call(item)
278
+ when String then accessor
279
+ else item.public_send(accessor)
280
+ end
281
+ end
282
+
283
+ # Default per-item label when no label:/item_label: was given: the first of
284
+ # name/title/label/to_s the item responds to (mirrors PhlexForms::Inference's
285
+ # LABEL_METHODS for association choices, so option text is picked the same way
286
+ # across the gem).
287
+ def infer_item_label(item)
288
+ method = PhlexForms::Inference::LABEL_METHODS.find { |m| item.respond_to?(m) }
289
+ item.public_send(method || :to_s)
262
290
  end
263
291
 
264
292
  def field_attributes
@@ -12,7 +12,7 @@ module Forms
12
12
  def view_template
13
13
  return unless @text
14
14
 
15
- p(class: classes) { @text }
15
+ p(class: classes, **@options.except(:class)) { @text }
16
16
  end
17
17
 
18
18
  private
@@ -6,27 +6,33 @@ module Forms
6
6
  # workhorse behind the Control-first `f.field` API and the explicit `f.Control`
7
7
  # escape hatch.
8
8
  class FormControl < Phlex::HTML
9
- def initialize(*modifiers, label: nil, hint: nil, error: nil, for: nil, required: false, **options)
9
+ # label_id:/hint_id: give the label and hint stable ids so a group control
10
+ # (checkbox_group's div[role="group"], which a plain `for`/`id` can't name)
11
+ # can reference them via aria-labelledby / aria-describedby (issue #17).
12
+ def initialize(*modifiers, label: nil, hint: nil, error: nil, for: nil, required: false,
13
+ label_id: nil, hint_id: nil, **options)
10
14
  @modifiers = modifiers
11
15
  @label = label
12
16
  @hint = hint
13
17
  @error = error
14
18
  @field_id = grab(for:)
15
19
  @required = required
20
+ @label_id = label_id
21
+ @hint_id = hint_id
16
22
  @options = options
17
23
  super()
18
24
  end
19
25
 
20
26
  def view_template(&)
21
27
  div(class: control_classes, **@options.except(:class)) do
22
- render Forms::Label.new(text: @label, for: @field_id, required: @required) if @label
28
+ render Forms::Label.new(text: @label, for: @field_id, required: @required, id: @label_id) if @label
23
29
 
24
30
  yield if block_given?
25
31
 
26
32
  if @error
27
33
  render Forms::FieldError.new(message: @error)
28
34
  elsif @hint
29
- render Forms::FieldHint.new(text: @hint)
35
+ render Forms::FieldHint.new(text: @hint, id: @hint_id)
30
36
  end
31
37
  end
32
38
  end
@@ -7,14 +7,14 @@ module Forms
7
7
  class Control < Forms::FormControl
8
8
  def view_template
9
9
  div(**@options.except(:class), class: @options[:class]) do
10
- render Label.new(text: @label, for: @field_id, required: @required) if @label
10
+ render Label.new(text: @label, for: @field_id, required: @required, id: @label_id) if @label
11
11
 
12
12
  yield if block_given?
13
13
 
14
14
  if @error
15
15
  render FieldError.new(message: @error)
16
16
  elsif @hint
17
- render FieldHint.new(text: @hint)
17
+ render FieldHint.new(text: @hint, id: @hint_id)
18
18
  end
19
19
  end
20
20
  end
@@ -81,11 +81,39 @@ module PhlexForms
81
81
  options = fo.apply_validations(options)
82
82
  choices ||= materialize_choices(inferred.choices)
83
83
 
84
- render fo.control(label: label_text, hint:, required: req) do
84
+ # A checkbox_group renders div[role="group"], which a plain <label for> can't
85
+ # name. Give the Control's label/hint stable ids (control_opts) and point the
86
+ # group at them with a passed-through aria: { labelledby:, describedby: }
87
+ # (group_opts), reusing the Control's own visible chrome (issue #17).
88
+ control_opts, group_opts = group_aria(fo, inferred.as, label_text, hint)
89
+ options = options.merge(group_opts)
90
+
91
+ render fo.control(label: label_text, hint:, required: req, **control_opts) do
85
92
  render_field_input(fo, inferred.name, inferred.as, modifiers, choices:, required: req, **options)
86
93
  end
87
94
  end
88
95
 
96
+ # For a checkbox_group field: the stable ids to stamp on the Control's
97
+ # label/hint (control_opts), and a group `aria:` hash pointing back at them
98
+ # (group_opts) — passed through to the group div, no special leaf API. Returns
99
+ # [{}, {}] for every other field type (a label associates via for/id, no aria
100
+ # needed) and when neither label nor hint is present.
101
+ def group_aria(fo, as, label_text, hint)
102
+ return [{}, {}] unless as == :checkbox_group
103
+
104
+ control_opts = {}
105
+ aria = {}
106
+ if label_text
107
+ control_opts[:label_id] = "#{fo.field_id}_label"
108
+ aria[:labelledby] = control_opts[:label_id]
109
+ end
110
+ if hint
111
+ control_opts[:hint_id] = "#{fo.field_id}_hint"
112
+ aria[:describedby] = control_opts[:hint_id]
113
+ end
114
+ [control_opts, aria.empty? ? {} : { aria: }]
115
+ end
116
+
89
117
  # ------------------------------------------------------------------
90
118
  # Layout helpers
91
119
  # ------------------------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PhlexForms
4
- VERSION = "0.2.4"
4
+ VERSION = "0.2.6"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-forms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson