Skip to content

Add CheckboxComponent - #10

Open
PendragonDevelopment wants to merge 5 commits into
fix/lookbook-button-previewsfrom
feat/checkbox-component
Open

Add CheckboxComponent#10
PendragonDevelopment wants to merge 5 commits into
fix/lookbook-button-previewsfrom
feat/checkbox-component

Conversation

@PendragonDevelopment

Copy link
Copy Markdown
Collaborator

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) %>
```

Param Values Default
`name` Form field name `nil`
`value` Form field value `"1"`
`label` Optional visible label `nil`
`caption` Optional secondary text beneath label `nil`
`checked` `true`/`false` `false`
`indeterminate` `true`/`false` `false`
`disabled` `true`/`false` `false`
`variant` `:default` (black), `:destructive` (red) `:default`
`label_position` `:right`, `:left` `:right`
`id` Auto-derived from `name` if omitted `nil`
`class` Any Tailwind classes `nil`

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

  • 23 new tests pass (tri-state, variants, disabled, label position, caption, auto-derived id, consumer class, error cases, HTML attrs)
  • Full suite: 48 tests, 89 assertions, 0 failures
  • `rake tailwindcss:build` compiles peer-* selectors and utility classes
  • `/components` renders all 6 states inline
  • Lookbook at `/lookbook/preview/shipwright/checkbox/*` with 7 scenarios including a nested parent/child group

No new tokens needed.

🤖 Generated with Claude Code

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.
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