Skip to content
Open
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
40 changes: 40 additions & 0 deletions app/components/shipwright/checkbox_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<%= content_tag :label,
class: label_classes,
for: id,
data: { indeterminate: indeterminate? ? "true" : nil } do %>

<span class="relative inline-flex items-center justify-center shrink-0">
<%= check_box_tag name, value, checked?,
id: id,
disabled: disabled?,
class: "peer sr-only",
**html_attrs %>

<span class="<%= visual_classes %>">
<%# Check icon — shown when the label (group) contains a checked input,
and the group is NOT in the indeterminate state. %>
<svg data-check-icon
class="hidden group-has-checked:block group-data-[indeterminate=true]:hidden w-3 h-3 text-text-inverse"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12"/>
</svg>
<%# Dash icon — shown when the label carries data-indeterminate="true". %>
<svg data-dash-icon
class="hidden group-data-[indeterminate=true]:block w-3 h-3 text-text-inverse"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</span>
</span>

<% if label_text %>
<span class="flex flex-col gap-0.5">
<span data-checkbox-label><%= label_text %></span>
<% if caption %>
<span data-checkbox-caption class="text-xs text-text-disabled leading-4"><%= caption %></span>
<% end %>
</span>
<% end %>
<% end %>
116 changes: 116 additions & 0 deletions app/components/shipwright/checkbox_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
module Shipwright
# Checkbox form control with optional label + caption. Tri-state support
# (unchecked / checked / indeterminate) and destructive variant matching
# Shipwright Pro's Checkbox atom.
#
# Usage:
# <%= render Shipwright::CheckboxComponent.new(name: "terms", label: "Accept") %>
# <%= render Shipwright::CheckboxComponent.new(name: "x", label: "Yes", caption: "Opt-in") %>
# <%= render Shipwright::CheckboxComponent.new(name: "danger", label: "Delete", variant: :destructive) %>
#
# Styling strategy: the <label> is a Tailwind `.group` and carries
# `data-indeterminate="true"` when applicable. Descendants react to state
# via `group-has-checked:` and `group-data-[indeterminate=true]:` arbitrary
# variants. Using literal class strings (not interpolated) so Tailwind's
# scanner picks them up.
#
# Indeterminate state: the component emits data-indeterminate="true" on the
# label wrapper for CSS targeting. The native `input.indeterminate` DOM
# property is JS-only; consuming apps can bootstrap it with a small script
# if they need the `:indeterminate` pseudo-class to apply. The visual
# treatment works via the data-indeterminate attribute without JS.
class CheckboxComponent < BaseComponent
VARIANTS = %i[default destructive].freeze
LABEL_POSITIONS = %i[right left].freeze

# `class:` is a Ruby reserved word used as a keyword arg — requires
# binding.local_variable_get. Shipwright convention.
def initialize(
name: nil,
value: "1",
label: nil,
caption: nil,
checked: false,
indeterminate: false,
disabled: false,
variant: :default,
label_position: :right,
id: nil,
class: nil,
**html_attrs
)
unless VARIANTS.include?(variant)
raise ArgumentError, "#{self.class}: unknown variant :#{variant}. Valid: #{VARIANTS.join(', ')}"
end
unless LABEL_POSITIONS.include?(label_position)
raise ArgumentError, "#{self.class}: unknown label_position :#{label_position}. Valid: #{LABEL_POSITIONS.join(', ')}"
end

@name = name
@value = value
@label_text = label
@caption = caption
@checked = checked
@indeterminate = indeterminate
@disabled = disabled
@variant = variant
@label_position = label_position
# Rails form convention: auto-derive id as "name_value" so multiple
# checkboxes sharing a name (e.g., an array of options) still get
# unique ids. Explicit id: always wins.
@id = id || (name ? "#{name}_#{value}" : nil)
@class = binding.local_variable_get(:class)
@html_attrs = html_attrs
end

attr_reader :name, :value, :label_text, :caption, :id, :html_attrs, :label_position

def checked?
@checked
end

def disabled?
@disabled
end

def indeterminate?
@indeterminate
end

def destructive?
@variant == :destructive
end

def label_classes
classes(
"group inline-flex items-start gap-2 font-sans text-sm leading-5 text-text-primary",
@label_position == :left ? "flex-row-reverse justify-end" : nil,
@disabled ? "cursor-not-allowed opacity-70" : "cursor-pointer",
consumer: @class
)
end

# Classes on the visual "box". Two complete class strings per variant —
# literal (no Ruby interpolation) so Tailwind's source scanner picks them
# up and generates the corresponding CSS rules.
def visual_classes
base = "relative flex items-center justify-center shrink-0 w-5 h-5 rounded-md border-2 bg-transparent transition-colors " \
"peer-focus-visible:ring-2 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background-primary " \
"peer-disabled:bg-interactive-disable peer-disabled:border-interactive-disable"

variant_styles = if destructive?
"border-utility-negative-default " \
"group-has-checked:bg-utility-negative-default group-has-checked:border-utility-negative-default " \
"group-data-[indeterminate=true]:bg-utility-negative-default group-data-[indeterminate=true]:border-utility-negative-default " \
"peer-focus-visible:ring-utility-negative-default"
else
"border-interactive-primary-default " \
"group-has-checked:bg-interactive-primary-default group-has-checked:border-interactive-primary-default " \
"group-data-[indeterminate=true]:bg-interactive-primary-default group-data-[indeterminate=true]:border-interactive-primary-default " \
"peer-focus-visible:ring-interactive-primary-default"
end

"#{base} #{variant_styles}"
end
end
end
43 changes: 43 additions & 0 deletions test/components/previews/shipwright/checkbox_component_preview.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
module Shipwright
class CheckboxComponentPreview < Lookbook::Preview
# @label Default
def default
render Shipwright::CheckboxComponent.new(name: "default", label: "Label")
end

# @label Checked
def checked
render Shipwright::CheckboxComponent.new(name: "checked", label: "Label", checked: true)
end

# @label With caption
def with_caption
render Shipwright::CheckboxComponent.new(
name: "captioned",
label: "Email me updates",
caption: "No more than once a week.",
checked: true
)
end

# @label States
def states
render_with_template
end

# @label Variants
def variants
render_with_template
end

# @label Label position
def label_positions
render_with_template
end

# @label Group
def group
render_with_template
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<div style="padding: 2rem; display: flex; flex-direction: column; gap: 2rem;">
<fieldset style="border: 0; padding: 0;">
<legend style="font-weight: 600; margin-bottom: 0.5rem; font-family: Manrope, system-ui;">
Group Label <span style="color: #595d6a; font-weight: 400;">(required)</span>
</legend>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<%= render Shipwright::CheckboxComponent.new(name: "g1", value: "option1", label: "Option one", checked: true) %>
<%= render Shipwright::CheckboxComponent.new(name: "g1", value: "option2", label: "Option two", checked: true) %>
<%= render Shipwright::CheckboxComponent.new(name: "g1", value: "option3", label: "Option three", checked: true) %>
</div>
<p style="font-size: 0.75rem; color: #595d6a; margin-top: 0.5rem;">
When sharing a <code>name:</code> across checkboxes, each still gets a unique
<code>id</code> auto-derived as <code>name_value</code>. Click each label
independently — it toggles only its matching input.
</p>
</fieldset>

<div data-controller="select-all-demo">
<%= render Shipwright::CheckboxComponent.new(name: "parent", label: "Select all", data: { select_all_demo_target: "parent" }) %>
<div style="margin-left: 1.75rem; display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem;">
<%= render Shipwright::CheckboxComponent.new(name: "children", value: "one", label: "Child option 1", checked: true, data: { select_all_demo_target: "child" }) %>
<%= render Shipwright::CheckboxComponent.new(name: "children", value: "two", label: "Child option 2", data: { select_all_demo_target: "child" }) %>
<%= render Shipwright::CheckboxComponent.new(name: "children", value: "three", label: "Child option 3", checked: true, data: { select_all_demo_target: "child" }) %>
</div>
<p style="font-size: 0.75rem; color: #595d6a; margin-top: 0.5rem; max-width: 480px;">
Parent/child coordination (select-all + mixed state → indeterminate) is a
consumer-app concern — requires JS. The inline script below wires up this demo.
Use Stimulus / hotwire / any other controller in a real app.
</p>
</div>

<script>
(function () {
const root = document.querySelector('[data-controller="select-all-demo"]');
if (!root || root.dataset.selectAllWired === "true") return;
root.dataset.selectAllWired = "true";

// CheckboxComponent forwards **html_attrs (including `data-*`) onto the
// hidden <input>, not the outer <label>. The component's CSS key-selector
// for indeterminate state is `.group[data-indeterminate=true]` on the
// label — so we need to reach UP from the input to its parent <label>
// and set the attribute there for the visual to flip.
const parentInput = root.querySelector('input[data-select-all-demo-target="parent"]');
const parentLabel = parentInput.closest('label');
const childInputs = Array.from(root.querySelectorAll('input[data-select-all-demo-target="child"]'));

function syncParent() {
const checkedCount = childInputs.filter(i => i.checked).length;
if (checkedCount === 0) {
parentInput.checked = false;
parentInput.indeterminate = false;
parentLabel.dataset.indeterminate = "false";
} else if (checkedCount === childInputs.length) {
parentInput.checked = true;
parentInput.indeterminate = false;
parentLabel.dataset.indeterminate = "false";
} else {
parentInput.checked = false;
parentInput.indeterminate = true;
parentLabel.dataset.indeterminate = "true";
}
}

parentInput.addEventListener("change", () => {
const shouldCheck = parentInput.checked;
childInputs.forEach(i => { i.checked = shouldCheck; });
parentLabel.dataset.indeterminate = "false";
});

childInputs.forEach(input => input.addEventListener("change", syncParent));

syncParent(); // initial state
})();
</script>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div style="padding: 2rem; display: flex; flex-direction: column; gap: 1rem;">
<%= render Shipwright::CheckboxComponent.new(name: "lp1", label: "Label on right (default)") %>
<%= render Shipwright::CheckboxComponent.new(name: "lp2", label: "Label on left", label_position: :left) %>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<div style="padding: 2rem; display: flex; flex-direction: column; gap: 1rem;">
<%= render Shipwright::CheckboxComponent.new(name: "s1", label: "Unchecked", caption: "Caption") %>
<%= render Shipwright::CheckboxComponent.new(name: "s2", label: "Checked", caption: "Caption", checked: true) %>
<%= render Shipwright::CheckboxComponent.new(name: "s3", label: "Indeterminate", caption: "Caption", indeterminate: true) %>
<%= render Shipwright::CheckboxComponent.new(name: "s4", label: "Disabled", caption: "Caption", disabled: true) %>
<%= render Shipwright::CheckboxComponent.new(name: "s5", label: "Disabled + checked", caption: "Caption", disabled: true, checked: true) %>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div style="padding: 2rem; display: flex; flex-direction: column; gap: 1rem;">
<%= render Shipwright::CheckboxComponent.new(name: "v1", label: "Default", checked: true) %>
<%= render Shipwright::CheckboxComponent.new(name: "v2", label: "Destructive", variant: :destructive, checked: true) %>
<%= render Shipwright::CheckboxComponent.new(name: "v3", label: "Destructive unchecked", variant: :destructive) %>
</div>
Loading