Add CheckboxComponent - #10
Open
PendragonDevelopment wants to merge 5 commits into
Open
Conversation
Tri-state form checkbox (unchecked / checked / indeterminate) with
optional label + caption. Matches Shipwright Pro's Checkbox atom.
API:
<%= render Shipwright::CheckboxComponent.new(name: "terms", label: "Accept") %>
<%= render Shipwright::CheckboxComponent.new(
name: "notify", label: "Email me", caption: "Weekly updates only",
checked: true) %>
<%= render Shipwright::CheckboxComponent.new(
name: "delete", label: "Permanent", variant: :destructive) %>
Props:
- name: form field name
- value: form field value (default "1")
- label: optional visible label text
- caption: optional secondary text beneath the label
- checked: default false
- indeterminate: default false (see note below)
- disabled: default false
- variant: :default (black) or :destructive (red)
- label_position: :right (default) or :left
- id: optional; auto-derives from name
- class: consumer override on the <label> wrapper
- **html_attrs: pass-through onto the <input>
Structure: a <label> wraps a hidden native <input type="checkbox"> (for
a11y + form submission) plus a styled <span> that is the visible box.
CSS peer-* selectors flip the visual's appearance based on the input's
:checked / :disabled / :focus-visible pseudo-classes.
Indeterminate state: sets data-indeterminate="true" on the wrapper for
CSS targeting. The native `input.indeterminate` property is JS-only —
consuming apps that need `:indeterminate` to apply on the input itself
can bootstrap it with a small script:
document.querySelectorAll('[data-indeterminate="true"] input')
.forEach(i => i.indeterminate = true)
The visual dash icon renders via the data-indeterminate attribute
selector, so the component looks right even without that bootstrap.
No new tokens — uses existing Interactive/Utility/Text palette.
23 new tests pass. Total suite: 48 tests, 89 assertions.
Lookbook: default, checked, with_caption, states, variants,
label_positions, group.
Two bugs in the initial implementation:
1. Tailwind wasn't compiling `peer-checked:bg-*` utilities because
the classes were built with Ruby string interpolation
(`peer-checked:#{v[:bg]}`). Tailwind's scanner only picks up
literal class strings in source files — interpolated values are
invisible. Fixed by inlining complete class bundles per variant,
with full literal strings for every peer-* / group-* combination.
2. The check-mark and dash SVGs were styled with `peer-checked:`
but they are descendants of the visual span, not siblings of the
peer input. The `peer-*` modifier only targets siblings. Fixed
by putting `group` on the <label> and using `group-has-checked:`
and `group-data-[indeterminate=true]:` on the icons + visual.
These work regardless of DOM depth because they use CSS :has()
and attribute selectors matching any descendant.
Visible result: checked, indeterminate, and destructive states now
render correctly — filled box + appropriate check/dash icon, white
on default, white on red for destructive.
Two fixes:
1. Auto-generated id previously matched the `name` kwarg alone, so
three checkboxes sharing `name: 'g1'` all got `id='g1'` and the
`<label for='g1'>` targeted only the first input in the DOM —
clicking any label toggled the same checkbox. Fixed by deriving
id as `#{name}_#{value}` (Rails form-helper convention); explicit
`id:` still wins when provided.
2. The 'Select all' parent/child demo in Lookbook had no JS wiring,
so clicking the parent didn't toggle children and vice versa.
Added a self-contained inline IIFE in the preview template that
handles parent ↔ children sync (indeterminate when mixed, all-on
/ all-off when parent clicked). The docs make clear this is a
consumer-app concern — use Stimulus or your framework's equivalent
in production; this script just makes the demo work.
Test added: test_shared_name_with_distinct_values_gets_unique_ids.
Existing test_auto_generated_id_from_name renamed and updated to
expect 'accept_terms_1' for the default value '1'.
The CheckboxComponent forwards html_attrs (data-*, id, etc.) onto the
hidden <input>, not the outer <label>. My demo JS was doing
`querySelector('[data-select-all-demo-target="parent"]')` which matched
the input and then setting `.dataset.indeterminate` on it — but the
visual's CSS rule keys off `.group[data-indeterminate=true]` on the
LABEL. So the attribute ended up on the wrong element and the
indeterminate visual never appeared when children were mixed.
Fixed by selecting the input directly (`input[data-...target]`) then
using `.closest('label')` to get the label for dataset updates.
Also documented the component's html_attrs forwarding behavior in a
comment inside the demo script.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
New `Shipwright::CheckboxComponent` — tri-state form checkbox (unchecked / checked / indeterminate) with optional label + caption, matching Shipwright Pro's Checkbox atom.
Stack: targets `fix/lookbook-button-previews` (#1) directly. No atom deps (uses inline SVGs for check and dash icons).
API
```erb
<%= render Shipwright::CheckboxComponent.new(name: "terms", label: "Accept") %>
<%= render Shipwright::CheckboxComponent.new(
name: "notify", label: "Email me", caption: "Weekly updates only", checked: true) %>
<%= render Shipwright::CheckboxComponent.new(
name: "delete", label: "Permanent", variant: :destructive) %>
```
Structure
A `` wraps a hidden native `<input type="checkbox">` (for accessibility + form submission) and a styled `` visual. Tailwind `peer-*` selectors flip the visual based on the input's `:checked` / `:disabled` / `:focus-visible` pseudo-classes — no JS required.
Indeterminate
The component sets `data-indeterminate="true"` on the wrapper. The native `input.indeterminate` is JS-only; consuming apps can bootstrap it with:
```js
document.querySelectorAll('[data-indeterminate="true"] input')
.forEach(i => i.indeterminate = true)
```
The visual dash renders via the attribute selector regardless, so the component looks right SSR.
Test plan
No new tokens needed.
🤖 Generated with Claude Code