nitro_kit 0.9.0 → 2.0.0.alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/plugins/marketplace.json +20 -0
  3. data/CHANGELOG.md +172 -0
  4. data/LICENSE +28 -0
  5. data/README.md +22 -15
  6. data/STYLE_GUIDE.md +425 -0
  7. data/app/assets/stylesheets/nitro_kit-tailwind-v4.css +38 -0
  8. data/app/assets/stylesheets/nitro_kit.css +7239 -0
  9. data/app/components/nitro_kit/accordion.rb +125 -65
  10. data/app/components/nitro_kit/alert.rb +80 -43
  11. data/app/components/nitro_kit/app_navigation.rb +307 -0
  12. data/app/components/nitro_kit/app_shell.rb +227 -0
  13. data/app/components/nitro_kit/appearance_bootstrap.rb +146 -0
  14. data/app/components/nitro_kit/appearance_picker.rb +156 -0
  15. data/app/components/nitro_kit/auth_shell.rb +34 -0
  16. data/app/components/nitro_kit/avatar.rb +85 -29
  17. data/app/components/nitro_kit/avatar_stack.rb +155 -9
  18. data/app/components/nitro_kit/badge.rb +43 -81
  19. data/app/components/nitro_kit/button.rb +180 -103
  20. data/app/components/nitro_kit/button_group.rb +63 -11
  21. data/app/components/nitro_kit/button_to.rb +69 -0
  22. data/app/components/nitro_kit/card.rb +42 -45
  23. data/app/components/nitro_kit/checkbox.rb +115 -62
  24. data/app/components/nitro_kit/checkbox_group.rb +131 -22
  25. data/app/components/nitro_kit/choice.rb +55 -0
  26. data/app/components/nitro_kit/combobox.rb +292 -105
  27. data/app/components/nitro_kit/command_palette.rb +412 -0
  28. data/app/components/nitro_kit/component.rb +322 -46
  29. data/app/components/nitro_kit/container.rb +32 -0
  30. data/app/components/nitro_kit/control_group.rb +45 -0
  31. data/app/components/nitro_kit/danger_zone.rb +78 -0
  32. data/app/components/nitro_kit/data_section.rb +106 -0
  33. data/app/components/nitro_kit/details_table.rb +229 -0
  34. data/app/components/nitro_kit/dialog.rb +257 -83
  35. data/app/components/nitro_kit/dropdown.rb +335 -122
  36. data/app/components/nitro_kit/dropzone.rb +326 -0
  37. data/app/components/nitro_kit/empty_state.rb +99 -0
  38. data/app/components/nitro_kit/field.rb +461 -236
  39. data/app/components/nitro_kit/field_group.rb +15 -8
  40. data/app/components/nitro_kit/fieldset.rb +37 -39
  41. data/app/components/nitro_kit/flex.rb +49 -0
  42. data/app/components/nitro_kit/form_builder.rb +313 -72
  43. data/app/components/nitro_kit/form_section.rb +79 -0
  44. data/app/components/nitro_kit/grid.rb +34 -0
  45. data/app/components/nitro_kit/icon.rb +54 -25
  46. data/app/components/nitro_kit/input.rb +97 -10
  47. data/app/components/nitro_kit/label.rb +22 -6
  48. data/app/components/nitro_kit/layout_options.rb +7 -0
  49. data/app/components/nitro_kit/page_header.rb +76 -0
  50. data/app/components/nitro_kit/pagination.rb +355 -69
  51. data/app/components/nitro_kit/pagination_bar.rb +90 -0
  52. data/app/components/nitro_kit/progressive_image.rb +181 -0
  53. data/app/components/nitro_kit/radio_button.rb +77 -55
  54. data/app/components/nitro_kit/radio_button_group.rb +107 -36
  55. data/app/components/nitro_kit/responsive_value.rb +94 -0
  56. data/app/components/nitro_kit/rich_text_area.rb +31 -0
  57. data/app/components/nitro_kit/select.rb +129 -64
  58. data/app/components/nitro_kit/settings_layout.rb +152 -0
  59. data/app/components/nitro_kit/sheet.rb +202 -0
  60. data/app/components/nitro_kit/stat_grid.rb +75 -0
  61. data/app/components/nitro_kit/switch.rb +108 -51
  62. data/app/components/nitro_kit/table.rb +237 -45
  63. data/app/components/nitro_kit/tabs.rb +168 -63
  64. data/app/components/nitro_kit/textarea.rb +81 -10
  65. data/app/components/nitro_kit/toast.rb +211 -80
  66. data/app/components/nitro_kit/toolbar.rb +75 -0
  67. data/app/components/nitro_kit/tooltip.rb +235 -34
  68. data/app/components/nitro_kit/typeset.rb +28 -0
  69. data/app/javascript/controllers/nk/app_shell_controller.js +224 -0
  70. data/app/javascript/controllers/nk/appearance_controller.js +73 -0
  71. data/app/javascript/controllers/nk/avatar_controller.js +15 -0
  72. data/app/javascript/controllers/nk/button_controller.js +56 -0
  73. data/app/javascript/controllers/nk/checkable_controller.js +41 -0
  74. data/app/javascript/controllers/nk/combobox_controller.js +314 -82
  75. data/app/javascript/controllers/nk/command_palette_controller.js +249 -0
  76. data/app/javascript/controllers/nk/dialog_controller.js +14 -10
  77. data/app/javascript/controllers/nk/dropdown_controller.js +106 -58
  78. data/app/javascript/controllers/nk/dropzone/direct_upload.js +61 -0
  79. data/app/javascript/controllers/nk/dropzone/file_rules.js +77 -0
  80. data/app/javascript/controllers/nk/dropzone/form_submit_lock.js +30 -0
  81. data/app/javascript/controllers/nk/dropzone_controller.js +482 -0
  82. data/app/javascript/controllers/nk/overlay_position.js +37 -0
  83. data/app/javascript/controllers/nk/progressive_image_controller.js +116 -0
  84. data/app/javascript/controllers/nk/tabs_controller.js +135 -23
  85. data/app/javascript/controllers/nk/toast_controller.js +112 -45
  86. data/app/javascript/controllers/nk/tooltip_controller.js +12 -47
  87. data/config/importmap.rb +2 -0
  88. data/config/locales/en.yml +98 -0
  89. data/docs/agent_guide.md +139 -0
  90. data/docs/agent_native_spec.md +422 -0
  91. data/docs/component_contracts.md +263 -0
  92. data/docs/customization.md +595 -0
  93. data/docs/hotwire.md +94 -0
  94. data/docs/initialization_prompt.md +36 -0
  95. data/docs/migration_1_to_2.md +318 -0
  96. data/docs/new_app_strategy.md +22 -0
  97. data/docs/patterns/application_foundation.md +136 -0
  98. data/docs/patterns/crud_resource.md +144 -0
  99. data/docs/patterns/destructive_action.md +105 -0
  100. data/docs/patterns/flash_and_toast.md +57 -0
  101. data/docs/patterns/inline_edit.md +59 -0
  102. data/docs/patterns/queryable_collection.md +187 -0
  103. data/docs/patterns/resource_form.md +126 -0
  104. data/docs/rails_conventions.md +95 -0
  105. data/docs/rails_integration.md +514 -0
  106. data/lib/generators/nitro_kit/install_generator.rb +48 -0
  107. data/lib/generators/nitro_kit/upgrade_smoke_tests_generator.rb +67 -0
  108. data/lib/nitro_kit/engine.rb +20 -0
  109. data/lib/nitro_kit/installation.rb +671 -0
  110. data/lib/nitro_kit/migration_inventory.rb +345 -0
  111. data/lib/nitro_kit/upgrade_smoke_test.rb +403 -0
  112. data/lib/nitro_kit/version.rb +1 -1
  113. data/lib/nitro_kit.rb +5 -42
  114. data/lib/rails/commands/nitro_kit/nitro_kit_command.rb +42 -0
  115. data/lib/tasks/nitro_kit_tasks.rake +79 -4
  116. data/plugins/nitro-kit/.codex-plugin/plugin.json +28 -0
  117. data/plugins/nitro-kit/skills/nitro-kit-hotwire/SKILL.md +67 -0
  118. data/plugins/nitro-kit/skills/nitro-kit-hotwire/agents/openai.yaml +4 -0
  119. data/plugins/nitro-kit/skills/nitro-kit-rails/SKILL.md +60 -0
  120. data/plugins/nitro-kit/skills/nitro-kit-rails/agents/openai.yaml +4 -0
  121. data/plugins/nitro-kit/skills/nitro-kit-ui/SKILL.md +62 -0
  122. data/plugins/nitro-kit/skills/nitro-kit-ui/agents/openai.yaml +4 -0
  123. data/src/stylesheets/nitro_kit/components/accordion.css +119 -0
  124. data/src/stylesheets/nitro_kit/components/alert.css +82 -0
  125. data/src/stylesheets/nitro_kit/components/app_navigation.css +156 -0
  126. data/src/stylesheets/nitro_kit/components/app_shell.css +605 -0
  127. data/src/stylesheets/nitro_kit/components/appearance_picker.css +117 -0
  128. data/src/stylesheets/nitro_kit/components/auth_shell.css +8 -0
  129. data/src/stylesheets/nitro_kit/components/avatar.css +81 -0
  130. data/src/stylesheets/nitro_kit/components/avatar_stack.css +80 -0
  131. data/src/stylesheets/nitro_kit/components/badge.css +76 -0
  132. data/src/stylesheets/nitro_kit/components/button.css +283 -0
  133. data/src/stylesheets/nitro_kit/components/button_group.css +53 -0
  134. data/src/stylesheets/nitro_kit/components/button_to.css +6 -0
  135. data/src/stylesheets/nitro_kit/components/card.css +70 -0
  136. data/src/stylesheets/nitro_kit/components/checkbox.css +160 -0
  137. data/src/stylesheets/nitro_kit/components/checkbox_group.css +61 -0
  138. data/src/stylesheets/nitro_kit/components/combobox.css +148 -0
  139. data/src/stylesheets/nitro_kit/components/command_palette.css +255 -0
  140. data/src/stylesheets/nitro_kit/components/container.css +25 -0
  141. data/src/stylesheets/nitro_kit/components/control_group.css +149 -0
  142. data/src/stylesheets/nitro_kit/components/danger_zone.css +54 -0
  143. data/src/stylesheets/nitro_kit/components/data_section.css +40 -0
  144. data/src/stylesheets/nitro_kit/components/details_table.css +53 -0
  145. data/src/stylesheets/nitro_kit/components/dialog.css +80 -0
  146. data/src/stylesheets/nitro_kit/components/dropdown.css +152 -0
  147. data/src/stylesheets/nitro_kit/components/dropzone.css +198 -0
  148. data/src/stylesheets/nitro_kit/components/empty_state.css +53 -0
  149. data/src/stylesheets/nitro_kit/components/field.css +79 -0
  150. data/src/stylesheets/nitro_kit/components/field_group.css +6 -0
  151. data/src/stylesheets/nitro_kit/components/fieldset.css +28 -0
  152. data/src/stylesheets/nitro_kit/components/flex.css +455 -0
  153. data/src/stylesheets/nitro_kit/components/form_section.css +71 -0
  154. data/src/stylesheets/nitro_kit/components/grid.css +308 -0
  155. data/src/stylesheets/nitro_kit/components/icon.css +26 -0
  156. data/src/stylesheets/nitro_kit/components/input.css +80 -0
  157. data/src/stylesheets/nitro_kit/components/label.css +9 -0
  158. data/src/stylesheets/nitro_kit/components/layout.css +440 -0
  159. data/src/stylesheets/nitro_kit/components/page_header.css +61 -0
  160. data/src/stylesheets/nitro_kit/components/pagination.css +119 -0
  161. data/src/stylesheets/nitro_kit/components/pagination_bar.css +41 -0
  162. data/src/stylesheets/nitro_kit/components/palette.css +113 -0
  163. data/src/stylesheets/nitro_kit/components/progressive_image.css +139 -0
  164. data/src/stylesheets/nitro_kit/components/radio_button.css +127 -0
  165. data/src/stylesheets/nitro_kit/components/radio_button_group.css +143 -0
  166. data/src/stylesheets/nitro_kit/components/rich_text_area.css +21 -0
  167. data/src/stylesheets/nitro_kit/components/select.css +70 -0
  168. data/src/stylesheets/nitro_kit/components/settings_layout.css +100 -0
  169. data/src/stylesheets/nitro_kit/components/sheet.css +100 -0
  170. data/src/stylesheets/nitro_kit/components/stat_grid.css +51 -0
  171. data/src/stylesheets/nitro_kit/components/switch.css +152 -0
  172. data/src/stylesheets/nitro_kit/components/table.css +116 -0
  173. data/src/stylesheets/nitro_kit/components/tabs.css +138 -0
  174. data/src/stylesheets/nitro_kit/components/textarea.css +39 -0
  175. data/src/stylesheets/nitro_kit/components/toast.css +131 -0
  176. data/src/stylesheets/nitro_kit/components/toolbar.css +78 -0
  177. data/src/stylesheets/nitro_kit/components/tooltip.css +124 -0
  178. data/src/stylesheets/nitro_kit/components/typeset.css +191 -0
  179. data/src/stylesheets/nitro_kit/layers.css +7 -0
  180. data/src/stylesheets/nitro_kit/reset.css +192 -0
  181. data/src/stylesheets/nitro_kit/tokens.css +198 -0
  182. metadata +182 -51
  183. data/Rakefile +0 -8
  184. data/app/assets/tailwind/application.css +0 -49
  185. data/app/components/nitro_kit/datepicker.rb +0 -9
  186. data/app/helpers/nitro_kit/accordion_helper.rb +0 -9
  187. data/app/helpers/nitro_kit/alert_helper.rb +0 -11
  188. data/app/helpers/nitro_kit/avatar_helper.rb +0 -13
  189. data/app/helpers/nitro_kit/badge_helper.rb +0 -13
  190. data/app/helpers/nitro_kit/button_group_helper.rb +0 -9
  191. data/app/helpers/nitro_kit/button_helper.rb +0 -49
  192. data/app/helpers/nitro_kit/card_helper.rb +0 -9
  193. data/app/helpers/nitro_kit/checkbox_helper.rb +0 -50
  194. data/app/helpers/nitro_kit/combobox_helper.rb +0 -9
  195. data/app/helpers/nitro_kit/datepicker_helper.rb +0 -9
  196. data/app/helpers/nitro_kit/dialog_helper.rb +0 -9
  197. data/app/helpers/nitro_kit/dropdown_helper.rb +0 -9
  198. data/app/helpers/nitro_kit/field_group_helper.rb +0 -9
  199. data/app/helpers/nitro_kit/field_helper.rb +0 -9
  200. data/app/helpers/nitro_kit/fieldset_helper.rb +0 -9
  201. data/app/helpers/nitro_kit/form_helper.rb +0 -13
  202. data/app/helpers/nitro_kit/icon_helper.rb +0 -9
  203. data/app/helpers/nitro_kit/input_helper.rb +0 -37
  204. data/app/helpers/nitro_kit/label_helper.rb +0 -19
  205. data/app/helpers/nitro_kit/pagination_helper.rb +0 -46
  206. data/app/helpers/nitro_kit/radio_button_helper.rb +0 -23
  207. data/app/helpers/nitro_kit/select_helper.rb +0 -24
  208. data/app/helpers/nitro_kit/switch_helper.rb +0 -9
  209. data/app/helpers/nitro_kit/table_helper.rb +0 -9
  210. data/app/helpers/nitro_kit/tabs_helper.rb +0 -9
  211. data/app/helpers/nitro_kit/textarea_helper.rb +0 -9
  212. data/app/helpers/nitro_kit/toast_helper.rb +0 -25
  213. data/app/helpers/nitro_kit/tooltip_helper.rb +0 -9
  214. data/app/javascript/controllers/nk/accordion_controller.js +0 -18
  215. data/app/javascript/controllers/nk/datepicker_controller.js +0 -5
  216. data/app/javascript/controllers/nk/switch_controller.js +0 -32
  217. data/lib/generators/nitro_kit/component_generator.rb +0 -84
  218. data/lib/nitro_kit/schema_builder.rb +0 -129
  219. data/lib/nitro_kit/variants.rb +0 -21
@@ -2,23 +2,75 @@
2
2
 
3
3
  module NitroKit
4
4
  class ButtonGroup < Component
5
- def initialize(**attrs)
5
+ Entry = Data.define(:button, :content)
6
+
7
+ def initialize(
8
+ buttons: [],
9
+ label: nil,
10
+ variant: nil,
11
+ size: nil,
12
+ id: nil,
13
+ html: {},
14
+ aria: {},
15
+ data: {},
16
+ desperately_need_a_class: nil
17
+ )
18
+ raise ArgumentError, "buttons must be an Array" unless buttons.is_a?(Array)
19
+ unless label.nil? || (label.is_a?(String) && !label.strip.empty?)
20
+ raise ArgumentError, "label must be a non-blank String or nil"
21
+ end
22
+ raise ArgumentError, "aria must be a Hash" unless aria.is_a?(Hash)
23
+
24
+ @variant = variant && validate_choice!(:variant, variant, Button::VARIANTS)
25
+ @size = size && validate_choice!(:size, size, Button::SIZES)
26
+ @entries = []
27
+ buttons.each { |button| add(button) }
28
+
6
29
  super(
7
- attrs,
8
- class: [
9
- "flex -space-x-px isolate",
10
- # Remove rounded corners from middle buttons
11
- "[&>*:not(:first-child):not(:last-child)]:rounded-none [&>*:first-child:not(:last-child)]:rounded-r-none [&>*:last-child:not(:first-child)]:rounded-l-none",
12
- # Put focused button on top
13
- "[&>*]:focus:z-10"
14
- ]
30
+ component: :button_group,
31
+ attributes: { id:, role: "group" },
32
+ html:,
33
+ aria: label ? aria.merge(label:) : aria,
34
+ data:,
35
+ desperately_need_a_class:
15
36
  )
16
37
  end
17
38
 
18
39
  def view_template
19
- div(**attrs) do
20
- yield
40
+ yield self if block_given?
41
+ raise ArgumentError, "ButtonGroup requires at least one Button" if @entries.empty?
42
+
43
+ div(**root_attributes) do
44
+ @entries.each do |entry|
45
+ render_in_slot(entry.button, :button, &entry.content)
46
+ end
47
+ end
48
+ end
49
+
50
+ def add(button, &content)
51
+ unless button.is_a?(NitroKit::Button)
52
+ raise ArgumentError, "ButtonGroup accepts only NitroKit::Button children"
21
53
  end
54
+ if @entries.any? { |entry| entry.button.equal?(button) }
55
+ raise ArgumentError, "ButtonGroup cannot contain the same Button twice"
56
+ end
57
+
58
+ @entries << Entry.new(button:, content:)
59
+ nil
60
+ end
61
+
62
+ # Declares a member with Button's own signature. Arguments are forwarded
63
+ # verbatim so the group can never restate a stale copy of Button's
64
+ # keywords; unknown keywords raise from Button itself. Group-level
65
+ # variant: and size: fill in only where the member stays silent.
66
+ def button(*arguments, **options, &content)
67
+ add(Button.new(*arguments, **member_defaults.merge(options)), &content)
68
+ end
69
+
70
+ private
71
+
72
+ def member_defaults
73
+ { variant: @variant, size: @size }.compact
22
74
  end
23
75
  end
24
76
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class ButtonTo < Component
5
+ include Phlex::Rails::Helpers::FormTag
6
+
7
+ METHODS = %i[get post patch put delete].freeze
8
+
9
+ def initialize(
10
+ text = nil,
11
+ href:,
12
+ method: :post,
13
+ variant: :default,
14
+ size: :md,
15
+ icon: nil,
16
+ icon_end: nil,
17
+ label: nil,
18
+ id: nil,
19
+ disabled: false,
20
+ loading: false,
21
+ html: {},
22
+ aria: {},
23
+ data: {},
24
+ button_html: {},
25
+ button_aria: {},
26
+ button_data: {},
27
+ desperately_need_a_class: nil
28
+ )
29
+ unless href.is_a?(String) && href.present?
30
+ raise ArgumentError, "ButtonTo href: must be a non-blank String"
31
+ end
32
+
33
+ @text = text
34
+ @href = href
35
+ @method = validate_choice!(:method, method.to_s.to_sym, METHODS)
36
+ @button = Button.new(
37
+ text,
38
+ variant:,
39
+ size:,
40
+ icon:,
41
+ icon_end:,
42
+ label:,
43
+ type: :submit,
44
+ disabled:,
45
+ loading:,
46
+ html: button_html,
47
+ aria: button_aria,
48
+ data: button_data
49
+ )
50
+
51
+ super(
52
+ component: :button_to,
53
+ attributes: { id: },
54
+ html:,
55
+ aria:,
56
+ data:,
57
+ desperately_need_a_class:
58
+ )
59
+ end
60
+
61
+ attr_reader :href, :method
62
+
63
+ def view_template(&block)
64
+ form_tag(href, method:, **root_attributes) do
65
+ render_in_slot(@button, :button, &block)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -2,72 +2,69 @@
2
2
 
3
3
  module NitroKit
4
4
  class Card < Component
5
- def initialize(**attrs)
5
+ TITLE_LEVELS = (1..6).freeze
6
+
7
+ def initialize(id: nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
6
8
  super(
7
- attrs,
8
- class: base_class
9
+ component: :card,
10
+ attributes: { id: }.compact,
11
+ html:,
12
+ aria:,
13
+ data:,
14
+ desperately_need_a_class:
9
15
  )
10
16
  end
11
17
 
12
18
  def view_template
13
- div(**attrs) do
14
- yield
15
- end
19
+ raise ArgumentError, "Card requires a block" unless block_given?
20
+
21
+ article(**root_attributes) { yield }
16
22
  end
17
23
 
18
- def title(text = nil, **attrs, &block)
19
- builder do
20
- h2(**mattr(attrs, class: "text-lg font-bold -mb-2")) do
21
- text_or_block(text, &block)
22
- end
23
- end
24
+ alias :html_title :title
25
+ alias :html_body :body
26
+ alias :html_footer :footer
27
+
28
+ def title(text = nil, level: 2, html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
29
+ validate_choice!(:level, level, TITLE_LEVELS)
30
+ require_region!(:title, text, block)
31
+ public_send(
32
+ :"h#{level}",
33
+ **slot_attributes(:title, html:, aria:, data:, desperately_need_a_class:)
34
+ ) { text_or_block(text, &block) }
24
35
  end
25
36
 
26
- def body(text = nil, **attrs, &block)
27
- builder do
28
- div(**mattr(attrs, class: "text-muted-content text-sm leading-relaxed")) do
29
- text_or_block(text, &block)
30
- end
37
+ def body(text = nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
38
+ require_region!(:body, text, block)
39
+ div(**slot_attributes(:body, html:, aria:, data:, desperately_need_a_class:)) do
40
+ text_or_block(text, &block)
31
41
  end
32
42
  end
33
43
 
34
- def footer(text = nil, **attrs, &block)
35
- builder do
36
- div(**mattr(attrs, class: "flex gap-2 items-center")) do
37
- text_or_block(text, &block)
38
- end
44
+ def footer(text = nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
45
+ require_region!(:footer, text, block)
46
+ html_footer(**slot_attributes(:footer, html:, aria:, data:, desperately_need_a_class:)) do
47
+ text_or_block(text, &block)
39
48
  end
40
49
  end
41
50
 
42
- def divider(**attrs)
43
- builder do
44
- full_width do
45
- hr(**attrs)
46
- end
47
- end
51
+ def divider(html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
52
+ hr(**slot_attributes(:divider, html:, aria:, data:, desperately_need_a_class:))
48
53
  end
49
54
 
50
- def full_width(**attrs)
51
- builder do
52
- div(**mattr(attrs, data: { slot: "full" }, class: "-mx-(--gap)")) do
53
- yield
54
- end
55
- end
55
+ def full(html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
56
+ raise ArgumentError, "Card full requires a block" unless block_given?
57
+
58
+ div(**slot_attributes(:full, html:, aria:, data:, desperately_need_a_class:)) { yield }
56
59
  end
57
60
 
58
61
  private
59
62
 
60
- def base_class
61
- [
62
- # Configure spacing with breakpoints
63
- "[--gap:calc(var(--spacing)*4)] sm:[--gap:calc(var(--spacing)*6)]",
64
- # Base styles
65
- "flex flex-col items-stretch rounded-lg bg-background border p-(--gap) gap-(--gap) overflow-hidden",
66
- # If a `data-slot=full` is the first thing, move it to the top
67
- "[&>[data-slot=full]:first-child]:-mt-(--gap)",
68
- # Group for hover, focus
69
- "group/card"
70
- ]
63
+ def require_region!(name, text, block)
64
+ return if block
65
+ return if text.respond_to?(:to_str) && !text.to_str.strip.empty?
66
+
67
+ raise ArgumentError, "Card #{name} requires non-blank text or a block"
71
68
  end
72
69
  end
73
70
  end
@@ -2,36 +2,86 @@
2
2
 
3
3
  module NitroKit
4
4
  class Checkbox < Component
5
- def initialize(label: nil, id: nil, wrapper: {}, **attrs)
6
- @id = id || "nk--" + SecureRandom.hex(4)
7
- @label = label
8
- @wrapper = wrapper
5
+ SIZES = %i[md lg].freeze
6
+
7
+ def initialize(
8
+ label: nil,
9
+ description: nil,
10
+ id: nil,
11
+ name: nil,
12
+ value: "1",
13
+ unchecked_value: "0",
14
+ include_hidden: true,
15
+ checked: false,
16
+ indeterminate: false,
17
+ disabled: false,
18
+ required: false,
19
+ invalid: false,
20
+ size: :md,
21
+ html: {},
22
+ aria: {},
23
+ data: {},
24
+ control_html: {},
25
+ control_aria: {},
26
+ control_data: {},
27
+ desperately_need_a_class: nil
28
+ )
29
+ @label = validate_optional_text!(:label, label)
30
+ @description = validate_optional_text!(:description, description)
31
+ @id = id
32
+ @name = name
33
+ @value = value
34
+ @unchecked_value = unchecked_value
35
+ @include_hidden = validate_boolean!(:include_hidden, include_hidden)
36
+ @checked = validate_boolean!(:checked, checked)
37
+ @indeterminate = validate_boolean!(:indeterminate, indeterminate)
38
+ @disabled = validate_boolean!(:disabled, disabled)
39
+ @required = validate_boolean!(:required, required)
40
+ @invalid = validate_boolean!(:invalid, invalid)
41
+ @size = validate_choice!(:size, size, SIZES)
42
+ @control_html = control_html
43
+ @control_aria = control_aria
44
+ @control_data = control_data
45
+
46
+ if include_hidden && unchecked_value.nil?
47
+ raise ArgumentError, "unchecked_value cannot be nil when include_hidden is true"
48
+ end
49
+ if description && (!id.is_a?(String) || id.strip.empty?)
50
+ raise ArgumentError, "checkbox requires a non-blank String id when description is present"
51
+ end
9
52
 
10
53
  super(
11
- attrs,
12
- id: @id,
13
- type: "checkbox",
14
- class: input_class
54
+ component: :checkbox,
55
+ size: @size,
56
+ attributes: {
57
+ data: indeterminate_data.merge(disabled: @disabled ? "true" : nil).compact
58
+ },
59
+ html:,
60
+ aria:,
61
+ data:,
62
+ desperately_need_a_class:
15
63
  )
16
64
  end
17
65
 
18
- alias :html_label :label
66
+ attr_reader :label, :description, :id, :name, :value, :unchecked_value, :size
19
67
 
20
- attr_reader :label, :id, :wrapper
68
+ def view_template(&block)
69
+ require_accessible_name!(&block)
21
70
 
22
- def view_template
23
- div(**mattr(wrapper, class: wrapper_class)) do
24
- html_label(
25
- class: "inline-grid *:[grid-area:1/1] shrink-0 place-items-center group/checkbox has-checked:not-has-indeterminate:[&>[data-check]]:visible has-indeterminate:[&>[data-indeterminate]]:visible"
26
- ) do
27
- input(**attrs)
28
- checkmark
29
- dash
30
- end
71
+ div(**root_attributes) do
72
+ render_hidden_control
31
73
 
32
- if label.present? || block_given?
33
- render(Label.new(for: id)) do
34
- label || (block_given? ? yield : nil)
74
+ if label.nil? && !block
75
+ render_control
76
+ span(**slot_attributes(:indicator), aria: { hidden: "true" })
77
+ else
78
+ render_in_slot(Label.new(for: id), :label) do
79
+ render_control
80
+ span(**slot_attributes(:indicator), aria: { hidden: "true" })
81
+ span(**slot_attributes(:content)) do
82
+ span(**slot_attributes(:label_text)) { text_or_block(label, &block) }
83
+ span(**slot_attributes(:description, attributes: { id: description_id })) { plain(description) } if description
84
+ end
35
85
  end
36
86
  end
37
87
  end
@@ -39,53 +89,56 @@ module NitroKit
39
89
 
40
90
  private
41
91
 
42
- def checkmark
43
- svg(
44
- class: merge_class(svg_class, "invisible"),
45
- viewbox: "0 0 16 16",
46
- fill: "none",
47
- stroke: "currentColor",
48
- stroke_linecap: "round",
49
- stroke_linejoin: "round",
50
- stroke_width: 3,
51
- data: { check: "" }
52
- ) do |svg|
53
- svg.path(d: "M 3 8 L 6 12 L 13 5")
54
- end
55
- end
92
+ # The native DOM property is the only checkbox state HTML cannot express, so
93
+ # it is also the only state the enhancer and the root attribute carry.
94
+ def indeterminate_data
95
+ return {} unless @indeterminate
56
96
 
57
- def dash
58
- svg(
59
- class: merge_class(svg_class, "invisible"),
60
- viewbox: "0 0 16 16",
61
- fill: "none",
62
- stroke: "currentColor",
63
- stroke_linecap: "round",
64
- stroke_width: 3,
65
- data: { indeterminate: "" }
66
- ) do |svg|
67
- svg.line(x1: "3", y1: "8", x2: "13", y2: "8")
68
- end
97
+ {
98
+ controller: "nk--checkable",
99
+ action: "change->nk--checkable#change",
100
+ state: "indeterminate",
101
+ nk__checkable_indeterminate_value: "true"
102
+ }
69
103
  end
70
104
 
71
- def input_class
72
- [
73
- "appearance-none shadow-sm size-4 rounded-sm border text-foreground",
74
- "checked:bg-primary checked:border-primary indeterminate:bg-primary indeterminate:border-primary",
75
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ring-offset-2 ring-offset-background",
76
- "disabled:pointer-events-none"
77
- ]
105
+ def render_hidden_control
106
+ return unless @include_hidden && name
107
+
108
+ render_in_slot(
109
+ Input.new(
110
+ type: :hidden,
111
+ name:,
112
+ value: unchecked_value,
113
+ disabled: @disabled,
114
+ autocomplete: "off"
115
+ ),
116
+ :unchecked
117
+ )
78
118
  end
79
119
 
80
- def svg_class
81
- "size-3 text-zinc-50 dark:text-zinc-950 pointer-events-none invisible"
120
+ def render_control
121
+ render_in_slot(
122
+ Input.new(
123
+ type: :checkbox,
124
+ id:,
125
+ name:,
126
+ value:,
127
+ checked: @checked,
128
+ disabled: @disabled,
129
+ required: @required,
130
+ html: @control_html,
131
+ aria: control_aria,
132
+ data: control_data
133
+ ),
134
+ :control
135
+ )
82
136
  end
83
137
 
84
- def wrapper_class
85
- [
86
- "isolate inline-flex items-center gap-2",
87
- "has-disabled:opacity-70"
88
- ]
138
+ def control_data
139
+ return @control_data unless @indeterminate
140
+
141
+ @control_data.merge(nk__checkable_target: "control")
89
142
  end
90
143
  end
91
144
  end
@@ -2,41 +2,150 @@
2
2
 
3
3
  module NitroKit
4
4
  class CheckboxGroup < Component
5
- def initialize(options = nil, **attrs)
6
- @options = options
5
+ ORIENTATIONS = %i[vertical horizontal].freeze
6
+ PRESENTATIONS = %i[list cards].freeze
7
+
8
+ alias :html_legend :legend
9
+
10
+ def initialize(
11
+ legend:,
12
+ options:,
13
+ name:,
14
+ value: [],
15
+ id: nil,
16
+ description: nil,
17
+ orientation: :vertical,
18
+ presentation: :list,
19
+ unchecked_value: "",
20
+ include_hidden: true,
21
+ disabled: false,
22
+ required: false,
23
+ size: :md,
24
+ html: {},
25
+ aria: {},
26
+ data: {},
27
+ desperately_need_a_class: nil
28
+ )
29
+ @legend = validate_text!(:legend, legend)
30
+ @options = Array(options).map { |choice| Choice.coerce(choice) }.freeze
31
+ @name = multiple_name(validate_text!(:name, name))
32
+ @value = Array(value).map(&:to_s).freeze
33
+ @id = id || deterministic_id(name)
34
+ @description = validate_optional_text!(:description, description)
35
+ @orientation = validate_choice!(:orientation, orientation, ORIENTATIONS)
36
+ @presentation = validate_choice!(:presentation, presentation, PRESENTATIONS)
37
+ @unchecked_value = unchecked_value
38
+ @include_hidden = validate_boolean!(:include_hidden, include_hidden)
39
+ @disabled = validate_boolean!(:disabled, disabled)
40
+ @required = validate_boolean!(:required, required)
41
+ @size = validate_choice!(:size, size, Checkbox::SIZES)
42
+
43
+ raise ArgumentError, "options cannot be empty" if @options.empty?
44
+ validate_unique_choices!
45
+ if include_hidden && unchecked_value.nil?
46
+ raise ArgumentError, "unchecked_value cannot be nil when include_hidden is true"
47
+ end
7
48
 
8
49
  super(
9
- attrs,
10
- class: "flex items-start flex-col gap-2"
50
+ component: :checkbox_group,
51
+ size: @size,
52
+ attributes: {
53
+ id: @id,
54
+ disabled: @disabled,
55
+ data: {
56
+ orientation: @orientation,
57
+ presentation: @presentation,
58
+ required: @required ? "true" : nil
59
+ }.compact
60
+ }.compact,
61
+ html:,
62
+ aria:,
63
+ data:,
64
+ desperately_need_a_class:
11
65
  )
12
66
  end
13
67
 
14
- attr_reader :options
68
+ attr_reader :legend, :options, :name, :value, :description, :id, :size, :orientation, :presentation
15
69
 
16
70
  def view_template
17
- div(**attrs) do
18
- if block_given?
19
- yield
20
- else
21
- options.map { |option| item(*option) }
71
+ fieldset(**root_attributes) do
72
+ html_legend(**slot_attributes(:legend)) { plain(legend) }
73
+ if description
74
+ p(**slot_attributes(:description, attributes: { id: description_id }.compact)) do
75
+ plain(description.to_s)
76
+ end
22
77
  end
23
- end
24
- end
25
78
 
26
- def title(text = nil, **attrs, &block)
27
- builder do
28
- render(Label.new(**attrs)) do
29
- text_or_block(text, &block)
79
+ render_hidden_control
80
+ div(**slot_attributes(:choices)) do
81
+ options.each_with_index { |choice, index| render_choice(choice, index) }
30
82
  end
31
83
  end
32
84
  end
33
85
 
34
- def item(text = nil, **attrs, &block)
35
- builder do
36
- render(Checkbox.new(**attrs)) do
37
- text_or_block(text, &block)
38
- end
39
- end
86
+ private
87
+
88
+ def render_hidden_control
89
+ return unless @include_hidden
90
+
91
+ render_in_slot(
92
+ Input.new(
93
+ type: :hidden,
94
+ name:,
95
+ value: @unchecked_value,
96
+ disabled: @disabled,
97
+ autocomplete: "off"
98
+ ),
99
+ :unchecked
100
+ )
101
+ end
102
+
103
+ # The fieldset and its legend already scope the group description, so a
104
+ # choice never repeats it through aria-describedby.
105
+ def render_choice(choice, index)
106
+ render_in_slot(
107
+ Checkbox.new(
108
+ label: choice.label,
109
+ description: choice.description,
110
+ id: choice.id || choice_id(index),
111
+ name:,
112
+ value: choice.value,
113
+ include_hidden: false,
114
+ checked: value.include?(choice.value.to_s),
115
+ disabled: @disabled || choice.disabled,
116
+ size:
117
+ ),
118
+ :choice
119
+ )
120
+ end
121
+
122
+ def multiple_name(name)
123
+ name.end_with?("[]") ? name : "#{name}[]"
124
+ end
125
+
126
+ def deterministic_id(name)
127
+ derived = name.to_s.gsub(/[^a-zA-Z0-9_-]+/, "_").gsub(/\A_+|_+\z/, "")
128
+ return derived unless derived.empty?
129
+
130
+ raise ArgumentError, "name #{name.inspect} cannot derive an id; pass id:"
131
+ end
132
+
133
+ def choice_id(index)
134
+ "#{id}-#{index}" if id
135
+ end
136
+
137
+ def validate_text!(name, text)
138
+ return text if text.is_a?(String) && !text.strip.empty?
139
+
140
+ raise ArgumentError, "#{name} must be a non-blank String"
141
+ end
142
+
143
+ def validate_unique_choices!
144
+ duplicate_value = options.group_by { |choice| choice.value.to_s }.find { |_value, matches| matches.many? }
145
+ raise ArgumentError, "choice values must be unique" if duplicate_value
146
+
147
+ ids = options.each_with_index.filter_map { |choice, index| choice.id || choice_id(index) }
148
+ raise ArgumentError, "choice ids must be unique" unless ids.uniq.length == ids.length
40
149
  end
41
150
  end
42
151
  end