diff --git a/app/components/shipwright/checkbox_component.html.erb b/app/components/shipwright/checkbox_component.html.erb new file mode 100644 index 0000000..0805e54 --- /dev/null +++ b/app/components/shipwright/checkbox_component.html.erb @@ -0,0 +1,40 @@ +<%= content_tag :label, + class: label_classes, + for: id, + data: { indeterminate: indeterminate? ? "true" : nil } do %> + + + <%= check_box_tag name, value, checked?, + id: id, + disabled: disabled?, + class: "peer sr-only", + **html_attrs %> + + + <%# Check icon — shown when the label (group) contains a checked input, + and the group is NOT in the indeterminate state. %> + + + + <%# Dash icon — shown when the label carries data-indeterminate="true". %> + + + + + + + <% if label_text %> + + <%= label_text %> + <% if caption %> + <%= caption %> + <% end %> + + <% end %> +<% end %> diff --git a/app/components/shipwright/checkbox_component.rb b/app/components/shipwright/checkbox_component.rb new file mode 100644 index 0000000..5181b86 --- /dev/null +++ b/app/components/shipwright/checkbox_component.rb @@ -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 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 diff --git a/test/components/previews/shipwright/checkbox_component_preview.rb b/test/components/previews/shipwright/checkbox_component_preview.rb new file mode 100644 index 0000000..0c69004 --- /dev/null +++ b/test/components/previews/shipwright/checkbox_component_preview.rb @@ -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 diff --git a/test/components/previews/shipwright/checkbox_component_preview/group.html.erb b/test/components/previews/shipwright/checkbox_component_preview/group.html.erb new file mode 100644 index 0000000..babc068 --- /dev/null +++ b/test/components/previews/shipwright/checkbox_component_preview/group.html.erb @@ -0,0 +1,75 @@ + + + + Group Label (required) + + + <%= 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) %> + + + When sharing a name: across checkboxes, each still gets a unique + id auto-derived as name_value. Click each label + independently — it toggles only its matching input. + + + + + <%= render Shipwright::CheckboxComponent.new(name: "parent", label: "Select all", data: { select_all_demo_target: "parent" }) %> + + <%= 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" }) %> + + + 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. + + + + + diff --git a/test/components/previews/shipwright/checkbox_component_preview/label_positions.html.erb b/test/components/previews/shipwright/checkbox_component_preview/label_positions.html.erb new file mode 100644 index 0000000..ed41f0e --- /dev/null +++ b/test/components/previews/shipwright/checkbox_component_preview/label_positions.html.erb @@ -0,0 +1,4 @@ + + <%= render Shipwright::CheckboxComponent.new(name: "lp1", label: "Label on right (default)") %> + <%= render Shipwright::CheckboxComponent.new(name: "lp2", label: "Label on left", label_position: :left) %> + diff --git a/test/components/previews/shipwright/checkbox_component_preview/states.html.erb b/test/components/previews/shipwright/checkbox_component_preview/states.html.erb new file mode 100644 index 0000000..4a42ae7 --- /dev/null +++ b/test/components/previews/shipwright/checkbox_component_preview/states.html.erb @@ -0,0 +1,7 @@ + + <%= 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) %> + diff --git a/test/components/previews/shipwright/checkbox_component_preview/variants.html.erb b/test/components/previews/shipwright/checkbox_component_preview/variants.html.erb new file mode 100644 index 0000000..231ac92 --- /dev/null +++ b/test/components/previews/shipwright/checkbox_component_preview/variants.html.erb @@ -0,0 +1,5 @@ + + <%= 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) %> + diff --git a/test/components/shipwright/checkbox_component_test.rb b/test/components/shipwright/checkbox_component_test.rb new file mode 100644 index 0000000..afafc3c --- /dev/null +++ b/test/components/shipwright/checkbox_component_test.rb @@ -0,0 +1,156 @@ +require "test_helper" + +class Shipwright::CheckboxComponentTest < ViewComponent::TestCase + def test_renders_input_with_type_checkbox + render_inline(Shipwright::CheckboxComponent.new(name: "terms", label: "Accept terms")) + + assert_selector "input[type='checkbox'][name='terms']" + end + + def test_renders_label_text + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "My label")) + + assert_selector "label", text: "My label" + end + + def test_renders_caption_when_given + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L", caption: "Caption text")) + + assert_text "Caption text" + end + + def test_no_caption_element_when_nil + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L")) + + assert_no_selector "[data-checkbox-caption]" + end + + def test_checked_attribute + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L", checked: true)) + + assert_selector "input[type='checkbox'][checked]" + end + + def test_unchecked_by_default + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L")) + + assert_no_selector "input[type='checkbox'][checked]" + end + + def test_disabled_attribute + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L", disabled: true)) + + assert_selector "input[type='checkbox'][disabled]" + assert_selector "label.cursor-not-allowed" + end + + def test_indeterminate_sets_data_attribute + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L", indeterminate: true)) + + # Data attribute for CSS targeting + JS bootstrapping to set input.indeterminate + assert_selector "label[data-indeterminate='true']" + end + + def test_destructive_variant_applies_negative_color + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L", variant: :destructive, checked: true)) + + html = rendered_content + assert_includes html, "utility-negative-default" + end + + def test_default_variant_uses_primary_color + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L", checked: true)) + + html = rendered_content + assert_includes html, "interactive-primary-default" + end + + def test_label_position_right_by_default + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L")) + + assert_no_selector "label.flex-row-reverse" + end + + def test_label_position_left_reverses_order + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L", label_position: :left)) + + assert_selector "label.flex-row-reverse" + end + + def test_value_attribute_defaults_to_1 + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L")) + + assert_selector "input[value='1']" + end + + def test_custom_value + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L", value: "yes")) + + assert_selector "input[value='yes']" + end + + def test_auto_generated_id_combines_name_and_value + render_inline(Shipwright::CheckboxComponent.new(name: "accept_terms", label: "L")) + + # Default value is "1", so id auto-derives to "accept_terms_1" + assert_selector "input[id='accept_terms_1']" + assert_selector "label[for='accept_terms_1']" + end + + def test_shared_name_with_distinct_values_gets_unique_ids + render_inline(Shipwright::CheckboxComponent.new(name: "opts", value: "a", label: "A")) + assert_selector "input[id='opts_a']" + assert_selector "label[for='opts_a']" + end + + def test_custom_id + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L", id: "my-check")) + + assert_selector "input[id='my-check']" + assert_selector "label[for='my-check']" + end + + def test_check_icon_rendered_in_visual + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L")) + + assert_selector "svg[data-check-icon]" + end + + def test_dash_icon_rendered_for_indeterminate + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L")) + + assert_selector "svg[data-dash-icon]" + end + + def test_consumer_class_merges_onto_label + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L", class: "my-2")) + + assert_selector "label.my-2" + end + + def test_raises_on_invalid_variant + assert_raises(ArgumentError) do + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L", variant: :weird)) + end + end + + def test_raises_on_invalid_label_position + assert_raises(ArgumentError) do + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L", label_position: :top)) + end + end + + def test_renders_without_label + render_inline(Shipwright::CheckboxComponent.new(name: "x")) + + assert_selector "input[type='checkbox']" + # sr-only label for accessibility when no visible label + assert_no_selector "[data-checkbox-label]" + end + + def test_passes_html_attributes_to_input + render_inline(Shipwright::CheckboxComponent.new(name: "x", label: "L", data: { turbo: "false" })) + + assert_selector "input[data-turbo='false']" + end +end diff --git a/test/dummy/app/assets/builds/tailwind.css b/test/dummy/app/assets/builds/tailwind.css index ca8b6d6..249d823 100644 --- a/test/dummy/app/assets/builds/tailwind.css +++ b/test/dummy/app/assets/builds/tailwind.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:"Manrope", ui-sans-serif, system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-white:#fff;--spacing:.25rem;--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--font-weight-normal:400;--font-weight-medium:500;--radius-md:6px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-interactive-primary-default:#14161c;--color-interactive-primary-hover:#595d6a;--color-interactive-primary-pressed:#333747;--color-interactive-secondary-default:#fdfdfd;--color-interactive-disable:#eaeaea;--color-border-interactive:#14161c;--color-utility-negative-default:#b62e2e;--color-text-primary:#050507;--color-text-inverse:#fdfdfd;--color-text-disabled:#595d6a;--color-background-primary:#fdfdfd;--leading-2xs:1.25rem;--spacing-2xs:8px;--spacing-sm:16px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.inline-flex{display:inline-flex}.h-8{height:calc(var(--spacing) * 8)}.h-11{height:calc(var(--spacing) * 11)}.h-13{height:calc(var(--spacing) * 13)}.w-8{width:calc(var(--spacing) * 8)}.w-11{width:calc(var(--spacing) * 11)}.w-13{width:calc(var(--spacing) * 13)}.w-full{width:100%}.cursor-not-allowed{cursor:not-allowed}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.gap-2xs{gap:var(--spacing-2xs)}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-border-interactive{border-color:var(--color-border-interactive)}.bg-interactive-disable{background-color:var(--color-interactive-disable)}.bg-interactive-primary-default{background-color:var(--color-interactive-primary-default)}.bg-interactive-secondary-default{background-color:var(--color-interactive-secondary-default)}.bg-transparent{background-color:#0000}.bg-utility-negative-default{background-color:var(--color-utility-negative-default)}.p-0{padding:calc(var(--spacing) * 0)}.px-sm{padding-inline:var(--spacing-sm)}.font-sans{font-family:var(--font-sans)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.leading-2xs{--tw-leading:var(--leading-2xs);line-height:var(--leading-2xs)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.text-text-disabled{color:var(--color-text-disabled)}.text-text-inverse{color:var(--color-text-inverse)}.text-text-primary{color:var(--color-text-primary)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:bg-interactive-disable:hover{background-color:var(--color-interactive-disable)}.hover\:bg-interactive-primary-hover:hover{background-color:var(--color-interactive-primary-hover)}.hover\:bg-utility-negative-default\/90:hover{background-color:#b62e2ee6}@supports (color:color-mix(in lab, red, red)){.hover\:bg-utility-negative-default\/90:hover{background-color:color-mix(in oklab, var(--color-utility-negative-default) 90%, transparent)}}.hover\:text-text-disabled:hover{color:var(--color-text-disabled)}.hover\:text-text-inverse:hover{color:var(--color-text-inverse)}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-interactive-primary-default:focus-visible{--tw-ring-color:var(--color-interactive-primary-default)}.focus-visible\:ring-utility-negative-default:focus-visible{--tw-ring-color:var(--color-utility-negative-default)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:ring-offset-background-primary:focus-visible{--tw-ring-offset-color:var(--color-background-primary)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.active\:bg-interactive-disable:active{background-color:var(--color-interactive-disable)}.active\:bg-interactive-primary-pressed:active{background-color:var(--color-interactive-primary-pressed)}.active\:bg-utility-negative-default\/80:active{background-color:#b62e2ecc}@supports (color:color-mix(in lab, red, red)){.active\:bg-utility-negative-default\/80:active{background-color:color-mix(in oklab, var(--color-utility-negative-default) 80%, transparent)}}.active\:text-text-inverse:active{color:var(--color-text-inverse)}}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:"Manrope", ui-sans-serif, system-ui, sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--font-weight-normal:400;--radius-md:6px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-interactive-primary-default:#14161c;--color-interactive-primary-hover:#595d6a;--color-interactive-primary-pressed:#333747;--color-interactive-secondary-default:#fdfdfd;--color-interactive-disable:#eaeaea;--color-border-interactive:#14161c;--color-utility-negative-default:#b62e2e;--color-text-primary:#050507;--color-text-inverse:#fdfdfd;--color-text-disabled:#595d6a;--color-background-primary:#fdfdfd;--leading-2xs:1.25rem;--spacing-2xs:8px;--spacing-sm:16px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.relative{position:relative}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.flex{display:flex}.hidden{display:none}.inline-flex{display:inline-flex}.h-3{height:calc(var(--spacing) * 3)}.h-5{height:calc(var(--spacing) * 5)}.h-8{height:calc(var(--spacing) * 8)}.h-11{height:calc(var(--spacing) * 11)}.h-13{height:calc(var(--spacing) * 13)}.w-3{width:calc(var(--spacing) * 3)}.w-5{width:calc(var(--spacing) * 5)}.w-8{width:calc(var(--spacing) * 8)}.w-11{width:calc(var(--spacing) * 11)}.w-13{width:calc(var(--spacing) * 13)}.w-full{width:100%}.shrink-0{flex-shrink:0}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.flex-col{flex-direction:column}.flex-row-reverse{flex-direction:row-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2xs{gap:var(--spacing-2xs)}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-border-interactive{border-color:var(--color-border-interactive)}.border-interactive-primary-default{border-color:var(--color-interactive-primary-default)}.border-utility-negative-default{border-color:var(--color-utility-negative-default)}.bg-interactive-disable{background-color:var(--color-interactive-disable)}.bg-interactive-primary-default{background-color:var(--color-interactive-primary-default)}.bg-interactive-secondary-default{background-color:var(--color-interactive-secondary-default)}.bg-transparent{background-color:#0000}.bg-utility-negative-default{background-color:var(--color-utility-negative-default)}.p-0{padding:calc(var(--spacing) * 0)}.px-sm{padding-inline:var(--spacing-sm)}.font-sans{font-family:var(--font-sans)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-2xs{--tw-leading:var(--leading-2xs);line-height:var(--leading-2xs)}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.text-text-disabled{color:var(--color-text-disabled)}.text-text-inverse{color:var(--color-text-inverse)}.text-text-primary{color:var(--color-text-primary)}.opacity-70{opacity:.7}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.group-has-checked\:block:is(:where(.group):has(:checked) *){display:block}.group-has-checked\:border-interactive-primary-default:is(:where(.group):has(:checked) *){border-color:var(--color-interactive-primary-default)}.group-has-checked\:border-utility-negative-default:is(:where(.group):has(:checked) *){border-color:var(--color-utility-negative-default)}.group-has-checked\:bg-interactive-primary-default:is(:where(.group):has(:checked) *){background-color:var(--color-interactive-primary-default)}.group-has-checked\:bg-utility-negative-default:is(:where(.group):has(:checked) *){background-color:var(--color-utility-negative-default)}.group-data-\[indeterminate\=true\]\:block:is(:where(.group)[data-indeterminate=true] *){display:block}.group-data-\[indeterminate\=true\]\:hidden:is(:where(.group)[data-indeterminate=true] *){display:none}.group-data-\[indeterminate\=true\]\:border-interactive-primary-default:is(:where(.group)[data-indeterminate=true] *){border-color:var(--color-interactive-primary-default)}.group-data-\[indeterminate\=true\]\:border-utility-negative-default:is(:where(.group)[data-indeterminate=true] *){border-color:var(--color-utility-negative-default)}.group-data-\[indeterminate\=true\]\:bg-interactive-primary-default:is(:where(.group)[data-indeterminate=true] *){background-color:var(--color-interactive-primary-default)}.group-data-\[indeterminate\=true\]\:bg-utility-negative-default:is(:where(.group)[data-indeterminate=true] *){background-color:var(--color-utility-negative-default)}.peer-focus-visible\:ring-2:is(:where(.peer):focus-visible~*){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.peer-focus-visible\:ring-interactive-primary-default:is(:where(.peer):focus-visible~*){--tw-ring-color:var(--color-interactive-primary-default)}.peer-focus-visible\:ring-utility-negative-default:is(:where(.peer):focus-visible~*){--tw-ring-color:var(--color-utility-negative-default)}.peer-focus-visible\:ring-offset-2:is(:where(.peer):focus-visible~*){--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.peer-focus-visible\:ring-offset-background-primary:is(:where(.peer):focus-visible~*){--tw-ring-offset-color:var(--color-background-primary)}.peer-disabled\:border-interactive-disable:is(:where(.peer):disabled~*){border-color:var(--color-interactive-disable)}.peer-disabled\:bg-interactive-disable:is(:where(.peer):disabled~*){background-color:var(--color-interactive-disable)}@media (hover:hover){.hover\:bg-interactive-disable:hover{background-color:var(--color-interactive-disable)}.hover\:bg-interactive-primary-hover:hover{background-color:var(--color-interactive-primary-hover)}.hover\:bg-utility-negative-default\/90:hover{background-color:#b62e2ee6}@supports (color:color-mix(in lab, red, red)){.hover\:bg-utility-negative-default\/90:hover{background-color:color-mix(in oklab, var(--color-utility-negative-default) 90%, transparent)}}.hover\:text-text-disabled:hover{color:var(--color-text-disabled)}.hover\:text-text-inverse:hover{color:var(--color-text-inverse)}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-interactive-primary-default:focus-visible{--tw-ring-color:var(--color-interactive-primary-default)}.focus-visible\:ring-utility-negative-default:focus-visible{--tw-ring-color:var(--color-utility-negative-default)}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:ring-offset-background-primary:focus-visible{--tw-ring-offset-color:var(--color-background-primary)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.active\:bg-interactive-disable:active{background-color:var(--color-interactive-disable)}.active\:bg-interactive-primary-pressed:active{background-color:var(--color-interactive-primary-pressed)}.active\:bg-utility-negative-default\/80:active{background-color:#b62e2ecc}@supports (color:color-mix(in lab, red, red)){.active\:bg-utility-negative-default\/80:active{background-color:color-mix(in oklab, var(--color-utility-negative-default) 80%, transparent)}}.active\:text-text-inverse:active{color:var(--color-text-inverse)}}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} \ No newline at end of file diff --git a/test/dummy/app/views/pages/components.html.erb b/test/dummy/app/views/pages/components.html.erb index de3e4fc..126caeb 100644 --- a/test/dummy/app/views/pages/components.html.erb +++ b/test/dummy/app/views/pages/components.html.erb @@ -49,4 +49,14 @@ <%= render Shipwright::ButtonComponent.new(class: "w-full") do %>Full Width Override<% end %> + + Checkbox + + <%= render Shipwright::CheckboxComponent.new(name: "c1", label: "Unchecked") %> + <%= render Shipwright::CheckboxComponent.new(name: "c2", label: "Checked", checked: true) %> + <%= render Shipwright::CheckboxComponent.new(name: "c3", label: "With caption", caption: "Some help text", checked: true) %> + <%= render Shipwright::CheckboxComponent.new(name: "c4", label: "Disabled", disabled: true, checked: true) %> + <%= render Shipwright::CheckboxComponent.new(name: "c5", label: "Indeterminate", indeterminate: true) %> + <%= render Shipwright::CheckboxComponent.new(name: "c6", label: "Destructive", variant: :destructive, checked: true) %> +
+ When sharing a name: across checkboxes, each still gets a unique + id auto-derived as name_value. Click each label + independently — it toggles only its matching input. +
name:
id
name_value
+ 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. +