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,72 +2,314 @@
2
2
 
3
3
  module NitroKit
4
4
  class Component < Phlex::HTML
5
- def initialize(attrs = {}, **defaults)
6
- @attrs = merge_attrs(attrs, **defaults)
5
+ DeferredContent = Data.define(:text, :block)
6
+ private_constant :DeferredContent
7
+ # Data keys a component sets for itself through `attributes:`.
8
+ COMPONENT_OWNED_DATA_ATTRIBUTES = %w[
9
+ state disabled required orientation presentation placement layout side field-type
10
+ ].freeze
11
+ # Every data key Nitro owns. Applications may not pass any of them through `data:`.
12
+ RESERVED_DATA_ATTRIBUTES = (
13
+ %w[nk slot variant size nk-escape enhanced] + COMPONENT_OWNED_DATA_ATTRIBUTES
14
+ ).freeze
15
+ ADDITIVE_DATA_ATTRIBUTES = %w[action controller].freeze
16
+ FORBIDDEN_ATTRIBUTES = %w[class style].freeze
17
+
18
+ def initialize(
19
+ component:,
20
+ attributes: {},
21
+ html: {},
22
+ aria: {},
23
+ data: {},
24
+ variant: nil,
25
+ size: nil,
26
+ desperately_need_a_class: nil
27
+ )
28
+ @component_name = normalize_identity(component, name: "component")
29
+ @attrs = owned_attributes(
30
+ attributes:,
31
+ html:,
32
+ aria:,
33
+ data:,
34
+ owned_data: {
35
+ nk: @component_name,
36
+ variant: variant && normalize_identity(variant, name: "variant"),
37
+ size: size && normalize_identity(size, name: "size")
38
+ }.compact,
39
+ desperately_need_a_class:
40
+ )
7
41
  end
8
42
 
9
- attr_reader :attrs
43
+ private
10
44
 
11
- def self.from_template(*args, **attrs, &block)
12
- new(*args, **attrs, &block).tap { |instance| instance.instance_variable_set(:@_nk_from_template, true) }
45
+ def root_attributes
46
+ @attrs
13
47
  end
14
48
 
15
- def builder(&block)
16
- @_nk_from_template && !self.is_a?(NitroKit::Component) ? capture(&block) : yield
49
+ def slot_attributes(
50
+ slot,
51
+ attributes: {},
52
+ html: {},
53
+ aria: {},
54
+ data: {},
55
+ variant: nil,
56
+ desperately_need_a_class: nil
57
+ )
58
+ raise ArgumentError, "Slots require a component identity" unless @component_name
59
+
60
+ owned_attributes(
61
+ attributes:,
62
+ html:,
63
+ aria:,
64
+ data:,
65
+ owned_data: {
66
+ slot: qualified_slot(slot),
67
+ variant: variant && normalize_identity(variant, name: "variant")
68
+ }.compact,
69
+ desperately_need_a_class:
70
+ )
17
71
  end
18
72
 
19
- private
73
+ def render_in_slot(component, slot, &block)
74
+ unless component.is_a?(NitroKit::Component)
75
+ raise ArgumentError, "Slots can only contain NitroKit::Component instances"
76
+ end
20
77
 
21
- # Class-level helper method for builder style components.
22
- # When called from erb or templates, we need to wrap the return value
23
- # in capture so we don't write to the output buffer immediately.
24
- # However when called from other components, we don't.
25
- def self.builder_method(method_name)
26
- warn(
27
- "[DEPRECATION] builder_method is deprecated. Please migrate to using the builder(&) pattern. See https://github.com/mikker/nitro_kit/issues/35 for details."
28
- )
78
+ component.__send__(:attach_to_slot!, qualified_slot(slot))
79
+ render(component, &block)
80
+ end
81
+
82
+ def validate_choice!(name, value, choices)
83
+ return value if choices.include?(value)
29
84
 
30
- nil
85
+ raise ArgumentError, "Unknown #{name} #{value.inspect}; expected one of: #{choices.map(&:inspect).join(", ")}"
31
86
  end
32
87
 
33
- # Merge attributes with some special cases for matching keys
34
- def merge_attrs(*hashes, **defaults)
35
- defaults
36
- .merge(*hashes) do |key, old_value, new_value|
37
- case key
38
- when :class
39
- # Use TailwindMerge to merge class names
40
- merge_class(old_value, new_value)
41
- when :data
42
- # Merge data hashes with some special cases for Stimulus
43
- merge_data(old_value, new_value)
44
- else
45
- new_value
46
- end
88
+ def validate_boolean!(name, value, allow_nil: false)
89
+ return value if value == true || value == false
90
+ return value if allow_nil && value.nil?
91
+
92
+ expected = allow_nil ? "true, false, or nil" : "true or false"
93
+ raise ArgumentError, "#{name} must be #{expected}; received #{value.inspect}"
94
+ end
95
+
96
+ def owned_attributes(attributes:, html:, aria:, data:, owned_data:, desperately_need_a_class:)
97
+ attributes = attribute_hash(attributes, name: "attributes")
98
+ html = attribute_hash(html, name: "html")
99
+ aria = attribute_hash(aria, name: "aria")
100
+ data = attribute_hash(data, name: "data")
101
+
102
+ internal_aria = extract_nested_attributes!(attributes, :aria)
103
+ internal_data = extract_nested_attributes!(attributes, :data)
104
+ internal_aria = normalize_aria_values(internal_aria)
105
+ aria = normalize_aria_values(aria)
106
+
107
+ validate_html_attributes!(attributes, source: "attributes")
108
+ validate_html_attributes!(html, source: "html")
109
+ validate_public_data!(data)
110
+ validate_internal_data!(internal_data)
111
+
112
+ merged = merge_distinct_attributes(attributes, html)
113
+ merged_aria = merge_distinct_attributes(internal_aria, aria, name: "ARIA")
114
+ merged_data = merge_owned_data(owned_data, internal_data, data)
115
+
116
+ merged[:aria] = merged_aria if merged_aria.any?
117
+ merged[:data] = merged_data if merged_data.any?
118
+
119
+ unless desperately_need_a_class.nil?
120
+ validate_class_escape!(desperately_need_a_class)
121
+ merged[:class] = desperately_need_a_class
122
+ merged[:data] = merge_owned_data(merged.fetch(:data, {}), { nk_escape: "class" })
123
+ end
124
+
125
+ merged
126
+ end
127
+
128
+ def attribute_hash(value, name:)
129
+ return value.dup if value.is_a?(Hash)
130
+
131
+ raise ArgumentError, "#{name} must be a Hash"
132
+ end
133
+
134
+ def extract_nested_attributes!(attributes, name)
135
+ key = attributes.keys.find { |candidate| normalized_attribute(candidate) == name.to_s }
136
+ return {} unless key
137
+
138
+ attribute_hash(attributes.delete(key), name:)
139
+ end
140
+
141
+ def validate_html_attributes!(attributes, source:)
142
+ attributes.each_key do |key|
143
+ normalized = normalized_attribute(key)
144
+
145
+ if FORBIDDEN_ATTRIBUTES.include?(normalized)
146
+ raise ArgumentError, "#{normalized}: is not allowed; use desperately_need_a_class: for classes"
147
+ end
148
+
149
+ if normalized == "data" || normalized == "aria" || normalized.start_with?("data-") || normalized.start_with?("aria-")
150
+ boundary = normalized.start_with?("aria") ? "aria" : "data"
151
+ raise ArgumentError, "Pass #{normalized} through #{boundary}:, not #{source}:"
47
152
  end
153
+ end
48
154
  end
49
155
 
50
- alias :mattr :merge_attrs
156
+ def validate_public_data!(data)
157
+ data.each_key do |key|
158
+ normalized = normalized_data_attribute(key)
159
+ next unless RESERVED_DATA_ATTRIBUTES.include?(normalized)
51
160
 
52
- def merge_class(*args)
53
- @@merger ||= TailwindMerge::Merger.new
54
- @@merger.merge(args)
161
+ raise ArgumentError, "data-#{normalized} is reserved by Nitro Kit"
162
+ end
55
163
  end
56
164
 
57
- def merge_data(*hashes)
58
- hashes
59
- .compact
60
- .reduce({}) do |acc, hash|
61
- acc.deep_merge(hash) do |key, old_val, new_val|
62
- # Concat Stimulus actions
63
- case key
64
- when :action, :controller
65
- [ new_val, old_val ].compact.join(" ")
66
- else
67
- new_val
165
+ def validate_internal_data!(data)
166
+ base_owned = RESERVED_DATA_ATTRIBUTES - COMPONENT_OWNED_DATA_ATTRIBUTES
167
+
168
+ data.each_key do |key|
169
+ normalized = normalized_data_attribute(key)
170
+ next unless base_owned.include?(normalized)
171
+
172
+ raise ArgumentError, "Use the component or slot API for data-#{normalized}"
173
+ end
174
+ end
175
+
176
+ def merge_distinct_attributes(*hashes, name: "HTML")
177
+ hashes.compact.reduce({}) do |merged, attributes|
178
+ attributes.each do |key, value|
179
+ existing_key = merged.keys.find do |candidate|
180
+ normalized_attribute(candidate) == normalized_attribute(key)
181
+ end
182
+
183
+ if existing_key
184
+ raise ArgumentError, "Duplicate #{name} attribute #{normalized_attribute(key)}"
185
+ end
186
+
187
+ merged[key] = value
188
+ end
189
+
190
+ merged
191
+ end
192
+ end
193
+
194
+ def merge_owned_data(*hashes)
195
+ hashes.compact.reduce({}) do |merged, attributes|
196
+ attributes.each do |key, value|
197
+ existing_key = merged.keys.find do |candidate|
198
+ normalized_data_attribute(candidate) == normalized_data_attribute(key)
199
+ end
200
+
201
+ if existing_key
202
+ normalized = normalized_data_attribute(key)
203
+
204
+ unless ADDITIVE_DATA_ATTRIBUTES.include?(normalized)
205
+ raise ArgumentError, "Duplicate data-#{normalized} attribute"
68
206
  end
207
+
208
+ merged[existing_key] = [ merged[existing_key], value ].flatten.compact.join(" ")
209
+ else
210
+ merged[key] = value
69
211
  end
70
212
  end
213
+
214
+ merged
215
+ end
216
+ end
217
+
218
+ def normalize_identity(value, name:)
219
+ normalized = value.to_s.strip.tr("_", "-")
220
+ raise ArgumentError, "#{name} cannot be blank" if normalized.empty?
221
+ unless normalized.match?(/\A[a-z0-9-]+\z/)
222
+ raise ArgumentError, "#{name} must contain only lowercase letters, numbers, and hyphens"
223
+ end
224
+
225
+ normalized
226
+ end
227
+
228
+ def qualified_slot(slot)
229
+ raise ArgumentError, "Slots require a component identity" unless @component_name
230
+
231
+ slot_name = normalize_identity(slot, name: "slot")
232
+ slot_name.start_with?("#{@component_name}-") ? slot_name : "#{@component_name}-#{slot_name}"
233
+ end
234
+
235
+ def attach_to_slot!(slot)
236
+ raise ArgumentError, "Nested slots require a component identity" unless @component_name
237
+
238
+ data = @attrs.fetch(:data, {})
239
+ if data.keys.any? { |key| normalized_data_attribute(key) == "slot" }
240
+ raise ArgumentError, "Component is already attached to a slot"
241
+ end
242
+
243
+ @attrs = @attrs.merge(data: merge_owned_data(data, { slot: }))
244
+ end
245
+
246
+ def normalized_attribute(key)
247
+ key.to_s.downcase.tr("_", "-")
248
+ end
249
+
250
+ def normalized_data_attribute(key)
251
+ normalized_attribute(key).delete_prefix("data-")
252
+ end
253
+
254
+ def normalized_aria_attribute(key)
255
+ normalized_attribute(key).delete_prefix("aria-")
256
+ end
257
+
258
+ # Conventions shared by the labelled control components. They read the
259
+ # `@label`, `@description`, `@id`, `@control_aria`, and `@invalid` state
260
+ # those components already keep.
261
+
262
+ def description_id
263
+ "#{@id}-description" if @id && @description
264
+ end
265
+
266
+ def control_aria
267
+ attributes = @control_aria.dup
268
+ key = attributes.keys.find { |candidate| normalized_aria_attribute(candidate) == "describedby" }
269
+ attributes[:describedby] = [ attributes.delete(key), description_id ].compact.join(" ").presence
270
+ attributes[:invalid] = "true" if @invalid
271
+ attributes
272
+ end
273
+
274
+ def require_accessible_name!(&block)
275
+ labelled = !@label.nil? || !block.nil?
276
+
277
+ unless labelled || accessible_name?
278
+ raise ArgumentError, "#{component_words} requires a label, block, or accessible control name"
279
+ end
280
+
281
+ if @description && !labelled
282
+ raise ArgumentError, "#{component_words} description requires label text or block content"
283
+ end
284
+ end
285
+
286
+ def accessible_name?
287
+ @control_aria.any? do |key, value|
288
+ %w[label labelledby].include?(normalized_aria_attribute(key)) && value.to_s.present?
289
+ end
290
+ end
291
+
292
+ def component_words
293
+ @component_name.tr("-", " ")
294
+ end
295
+
296
+ def validate_optional_text!(name, text)
297
+ return if text.nil?
298
+ return text if text.is_a?(String) && !text.strip.empty?
299
+
300
+ raise ArgumentError, "#{name} must be a non-blank String or nil"
301
+ end
302
+
303
+ def normalize_aria_values(attributes)
304
+ attributes.transform_values do |value|
305
+ value == true || value == false ? value.to_s : value
306
+ end
307
+ end
308
+
309
+ def validate_class_escape!(value)
310
+ return if value.is_a?(String) && !value.strip.empty?
311
+
312
+ raise ArgumentError, "desperately_need_a_class: must be a non-blank String"
71
313
  end
72
314
 
73
315
  def text_or_block(text = nil, &block)
@@ -81,5 +323,39 @@ module NitroKit
81
323
  nil
82
324
  end
83
325
  end
326
+
327
+ def content_from_keyword(name, text)
328
+ return if text.nil?
329
+
330
+ validate_content_text!(name, text)
331
+ DeferredContent.new(text:, block: nil)
332
+ end
333
+
334
+ def declare_content(name, current, text = nil, &block)
335
+ raise ArgumentError, "#{name} has already been provided" if current
336
+ raise ArgumentError, "#{name} accepts text or a block, not both" if !text.nil? && block
337
+
338
+ unless block
339
+ validate_content_text!(name, text)
340
+ end
341
+
342
+ DeferredContent.new(text:, block:)
343
+ end
344
+
345
+ def require_content!(component, name, content)
346
+ return content if content
347
+
348
+ raise ArgumentError, "#{component} requires #{name} through the constructor or compound method"
349
+ end
350
+
351
+ def render_deferred_content(content)
352
+ text_or_block(content.text, &content.block)
353
+ end
354
+
355
+ def validate_content_text!(name, text)
356
+ return text if text.is_a?(String) && !text.strip.empty?
357
+
358
+ raise ArgumentError, "#{name} must be a non-blank String or a block"
359
+ end
84
360
  end
85
361
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class Container < Component
5
+ SIZES = %i[sm md lg xl].freeze
6
+
7
+ def initialize(
8
+ size:,
9
+ id: nil,
10
+ html: {},
11
+ aria: {},
12
+ data: {},
13
+ desperately_need_a_class: nil
14
+ )
15
+ size = validate_choice!(:size, size, SIZES)
16
+
17
+ super(
18
+ component: :container,
19
+ attributes: { id: }.compact,
20
+ html:,
21
+ aria:,
22
+ data:,
23
+ size:,
24
+ desperately_need_a_class:
25
+ )
26
+ end
27
+
28
+ def view_template
29
+ div(**root_attributes) { yield if block_given? }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class ControlGroup < Component
5
+ def initialize(label: nil, id: nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
6
+ unless label.nil? || (label.is_a?(String) && label.present?)
7
+ raise ArgumentError, "ControlGroup label: must be nil or a non-blank String"
8
+ end
9
+
10
+ super(
11
+ component: :control_group,
12
+ attributes: {
13
+ id:,
14
+ role: label ? "group" : nil,
15
+ aria: { label: }.compact
16
+ },
17
+ html:,
18
+ aria:,
19
+ data:,
20
+ desperately_need_a_class:
21
+ )
22
+ end
23
+
24
+ def view_template
25
+ raise ArgumentError, "ControlGroup requires a block" unless block_given?
26
+
27
+ @rendering = true
28
+ div(**root_attributes) { yield(self) }
29
+ ensure
30
+ @rendering = false
31
+ end
32
+
33
+ def addon(text = nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
34
+ raise ArgumentError, "ControlGroup addon must be declared inside the render block" unless @rendering
35
+ raise ArgumentError, "ControlGroup addon accepts text or a block, not both" if text && block
36
+ unless block || (text.is_a?(String) && text.present?)
37
+ raise ArgumentError, "ControlGroup addon requires non-blank text or a block"
38
+ end
39
+
40
+ span(**slot_attributes(:addon, html:, aria:, data:, desperately_need_a_class:)) do
41
+ text_or_block(text, &block)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class DangerZone < Component
5
+ Child = Data.define(:component, :content)
6
+
7
+ def initialize(
8
+ title: nil,
9
+ description: nil,
10
+ id: nil,
11
+ html: {},
12
+ aria: {},
13
+ data: {},
14
+ desperately_need_a_class: nil
15
+ )
16
+ @title_content = content_from_keyword(:title, title)
17
+ @description_content = content_from_keyword(:description, description)
18
+ @confirmation = nil
19
+ @escape = nil
20
+
21
+ super(
22
+ component: :danger_zone,
23
+ attributes: { id: }.compact,
24
+ html:,
25
+ aria:,
26
+ data:,
27
+ desperately_need_a_class:
28
+ )
29
+ end
30
+
31
+ def view_template
32
+ yield self if block_given?
33
+ require_content!("DangerZone", :title, @title_content)
34
+ require_content!("DangerZone", :description, @description_content)
35
+ raise ArgumentError, "DangerZone requires exactly one confirmation" unless @confirmation
36
+
37
+ section(**root_attributes) do
38
+ header(**slot_attributes(:header)) do
39
+ h2(**slot_attributes(:title)) { render_deferred_content(@title_content) }
40
+ p(**slot_attributes(:description)) { render_deferred_content(@description_content) }
41
+ end
42
+ div(**slot_attributes(:confirmation), &@confirmation)
43
+ render_in_slot(@escape.component, :escape, &@escape.content) if @escape
44
+ end
45
+ end
46
+
47
+ def title(text = nil, &block)
48
+ @title_content = declare_content(:title, @title_content, text, &block)
49
+ nil
50
+ end
51
+
52
+ def description(text = nil, &block)
53
+ @description_content = declare_content(:description, @description_content, text, &block)
54
+ nil
55
+ end
56
+
57
+ def confirmation(&content)
58
+ raise ArgumentError, "DangerZone confirmation requires a block" unless content
59
+ raise ArgumentError, "DangerZone accepts exactly one confirmation" if @confirmation
60
+
61
+ @confirmation = content
62
+ nil
63
+ end
64
+
65
+ def escape(component, &content)
66
+ unless component.is_a?(NitroKit::Button)
67
+ raise ArgumentError, "DangerZone escape must be a NitroKit::Button"
68
+ end
69
+ if component.variant == :destructive
70
+ raise ArgumentError, "DangerZone escape cannot use the destructive Button variant"
71
+ end
72
+ raise ArgumentError, "DangerZone accepts at most one safe escape action" if @escape
73
+
74
+ @escape = Child.new(component:, content:)
75
+ nil
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class DataSection < Component
5
+ Child = Data.define(:component, :content)
6
+
7
+ def initialize(
8
+ title: nil,
9
+ description: nil,
10
+ id: nil,
11
+ html: {},
12
+ aria: {},
13
+ data: {},
14
+ desperately_need_a_class: nil
15
+ )
16
+ @title_content = content_from_keyword(:title, title)
17
+ @description_content = content_from_keyword(:description, description)
18
+ @actions = nil
19
+ @content = nil
20
+
21
+ super(
22
+ component: :data_section,
23
+ attributes: { id: }.compact,
24
+ html:,
25
+ aria:,
26
+ data:,
27
+ desperately_need_a_class:
28
+ )
29
+ end
30
+
31
+ def view_template
32
+ yield self if block_given?
33
+ require_content!("DataSection", :title, @title_content)
34
+ raise ArgumentError, "DataSection requires exactly one table or EmptyState" unless @content
35
+
36
+ section(**root_attributes) do
37
+ header(**slot_attributes(:header)) do
38
+ div(**slot_attributes(:heading)) do
39
+ h2(**slot_attributes(:title)) { render_deferred_content(@title_content) }
40
+ if @description_content
41
+ p(**slot_attributes(:description)) { render_deferred_content(@description_content) }
42
+ end
43
+ end
44
+ render_in_slot(@actions.component, :actions, &@actions.content) if @actions
45
+ end
46
+ render_in_slot(@content.component, content_slot, &@content.content)
47
+ end
48
+ end
49
+
50
+ def title(text = nil, &block)
51
+ @title_content = declare_content(:title, @title_content, text, &block)
52
+ nil
53
+ end
54
+
55
+ def description(text = nil, &block)
56
+ @description_content = declare_content(:description, @description_content, text, &block)
57
+ nil
58
+ end
59
+
60
+ alias :html_table :table
61
+
62
+ def actions(component, &content)
63
+ unless component.is_a?(NitroKit::ButtonGroup) || component.is_a?(NitroKit::Button)
64
+ raise ArgumentError, "DataSection actions must be a NitroKit::ButtonGroup or NitroKit::Button"
65
+ end
66
+ raise ArgumentError, "DataSection accepts at most one actions group" if @actions
67
+
68
+ @actions = Child.new(component:, content:)
69
+ nil
70
+ end
71
+
72
+ def table(component, &content)
73
+ unless table_component?(component)
74
+ raise ArgumentError, "DataSection table must be a NitroKit::Table or NitroKit::DetailsTable"
75
+ end
76
+
77
+ assign_content(component, &content)
78
+ end
79
+
80
+ def empty_state(component, &content)
81
+ unless component.is_a?(NitroKit::EmptyState)
82
+ raise ArgumentError, "DataSection empty_state must be a NitroKit::EmptyState"
83
+ end
84
+ raise ArgumentError, "DataSection EmptyState must use level: 3" if component.level != 3
85
+
86
+ assign_content(component, &content)
87
+ end
88
+
89
+ private
90
+
91
+ def table_component?(component)
92
+ component.is_a?(NitroKit::Table) || component.is_a?(NitroKit::DetailsTable)
93
+ end
94
+
95
+ def assign_content(component, &content)
96
+ raise ArgumentError, "DataSection accepts exactly one table or EmptyState" if @content
97
+
98
+ @content = Child.new(component:, content:)
99
+ nil
100
+ end
101
+
102
+ def content_slot
103
+ table_component?(@content.component) ? :table : :empty_state
104
+ end
105
+ end
106
+ end