Skip to content

feat: checkbox_group item_label — visible heading + custom item text at once - #19

Merged
mhenrixon merged 1 commit into
mainfrom
feature/checkbox-group-block-api
Jul 11, 2026
Merged

feat: checkbox_group item_label — visible heading + custom item text at once#19
mhenrixon merged 1 commit into
mainfrom
feature/checkbox-group-block-api

Conversation

@mhenrixon

Copy link
Copy Markdown
Collaborator

Summary

Feedback follow-up to #9/#17. checkbox_group couldn't give both a visible
group heading and custom per-item labels in one call — label: was
overloaded (the field heading on f.field, the per-item accessor on the bare
verb), so on f.field the item text had nowhere to go and fell back to a struct
dump. This is what the app-local UI::CheckboxGroup existed to work around.

Add item_label: (Symbol / Proc / String) for the per-item text, so the
marketplace tag-picker is one gem call:

f.field(:tag_ids, as: :checkbox_group, collection: Tag.all, value: :id,
  label:      "Tags",                        # visible group heading (the Control)
  hint:       "Pick any",                    # visible description (the Control)
  item_label: ->(t) { t.name.presence || t.slug }, # per-item text
  variant:    :pill)
<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">
  <label class="badge badge-lg … has-[:checked]:badge-primary">
    <input type="checkbox" name="user[tag_ids][]" value="1" checked><span>Ruby</span>
  </label></div>
<p id="user_tag_ids_hint" >Pick any</p>

The Control already renders the visible heading and wires aria-labelledby at it
(#17) — this PR only unblocks the item accessor, so there is no second
heading code path.

Why not the block API

The original sketch was f.checkbox_group(:x, coll) { |g| g.heading …; g.item … }.
We compared it against a flat keyword and chose the keyword: the block would
re-implement the heading/hint the Control already renders (two heading code paths
to keep in sync) and diverge from the flat f.field shape the rest of the gem
uses. item_label: is a one-keyword fix, no new render context.

Behavior

  • item_label: wins over label: for item text; it's consumed in the builder
    and never leaks to the group div.
  • Bare verb unchangedf.checkbox_group(:x, coll, label: ->(t){…}) still
    uses label: as the item accessor; item_label: is accepted there as an alias.
  • New default — when neither label: nor item_label: is given, per-item
    text infers via PhlexForms::Inference::LABEL_METHODS (nametitlelabel
    to_s), the same chain used for association choices. So a plain
    f.field(:tags, as: :checkbox_group, label: "Tags") shows readable item text
    instead of #<struct …> (the previous foot-gun).
  • String item_label: is literal text (no public_send), so a stray string
    can't NoMethodError on an object lacking that reader.

Test coverage

  • f.field: visible heading (label:) + custom item labels (item_label: proc
    and symbol) together; item_label: wins over label:; bare-verb alias;
    explicit label: preserved.
  • Heading-only f.field infers item text — no #<struct …>.
  • String item_label: is literal (plain objects, no crash).

Full suite: 160 examples, 0 failures · rubocop lib spec clean. An
adversarial review pass independently confirmed no item_label: leak to the
group, byte-identical back-compat with no item_label:, and flagged the
struct-dump foot-gun (fixed here by the inference default).

Deviations & judgment calls

  • Rejected the block API (compared it with you first). item_label: reuses
    the Control heading that already works; the block would duplicate it.
  • Changed the default from label: :to_s to inference (name/title/label/to_s).
    An adversarial review and you both flagged the heading-only case dumping
    #<struct …>; inferring matches how the gem already labels association choices
    and makes the verb a genuine drop-in for UI::CheckboxGroup. A Struct/PORO with
    no name/title/label still falls to to_s, so nothing that worked before
    regresses.
  • String item_label: is now literal text, not a method name — the intuitive
    reading and it removes a NoMethodError foot-gun the review found.
  • Kept item_label || label precedence (any-falsy, not nil-only): true/
    false are nonsense for an accessor (the contract is Symbol/Proc/String), so a
    boolean is a caller error not worth a special case.
  • No Closes #N — this is feedback-driven, not a filed issue.

…ext at once

f.field(:x, as: :checkbox_group, label:) took `label:` as the visible Control
heading, so the per-item label proc had nowhere to go and items fell back to a
struct dump. Add `item_label:` (Symbol/Proc/String) for the per-item text, so a
single f.field call renders a visible heading AND custom item labels — the
marketplace tag-picker shape the app-local UI::CheckboxGroup existed for.

- item_label: wins over label: for the item text; consumed in the builder, never
  leaks to the group div.
- Bare verb keeps label: as the item accessor; item_label: is an alias there.
- NEW default: when neither is given, per-item text infers via
  PhlexForms::Inference::LABEL_METHODS (name/title/label/to_s) — the same chain
  used for association choices — so a plain f.field(label: "Tags") shows readable
  item text instead of #<struct ...>.
- resolve_item now treats a String as literal text (no method dispatch), so a
  stray string can't NoMethodError on objects lacking that reader.

## Test Coverage
- f.field: visible heading (label:) + custom item labels (item_label: proc/symbol)
  together; item_label: wins over label:; bare-verb alias; explicit label: kept
- heading-only f.field infers item text (no #<struct> dump)
- String item_label: is literal (plain objects, no crash)

## Verification
- [x] bundle exec rubocop lib spec passes
- [x] bundle exec rspec passes (160 examples)
- [x] adversarial review: no item_label leak, back-compat intact, footgun fixed
@mhenrixon
mhenrixon merged commit b69e4d8 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.

1 participant