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
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class Dropzone < Component
5
+ # Strings the Stimulus controller needs at runtime. Nitro translates them
6
+ # here and hands them to the controller as Stimulus values so no user-facing
7
+ # English lives in JavaScript.
8
+ CONTROLLER_MESSAGE_KEYS = %w[
9
+ progress_for
10
+ remove_file
11
+ uploading
12
+ uploading_percent
13
+ uploaded
14
+ upload_failed
15
+ ready
16
+ status.empty
17
+ status.selected.one
18
+ status.selected.other
19
+ status.uploading.one
20
+ status.uploading.other
21
+ status.attention.one
22
+ status.attention.other
23
+ status.uploaded.one
24
+ status.uploaded.other
25
+ status.ready.one
26
+ status.ready.other
27
+ errors.upload_failed
28
+ errors.upload_failed_detail
29
+ errors.uploads_in_progress
30
+ errors.failed_files
31
+ errors.too_large
32
+ errors.not_accepted
33
+ errors.max_files.one
34
+ errors.max_files.other
35
+ ].freeze
36
+
37
+ # `:input` keeps the native file input visible beside the drop target.
38
+ # `:minimal` leaves the drop target as the only visible affordance; the
39
+ # input stays in the accessibility tree and keeps its own focus ring.
40
+ PRESENTATIONS = %i[input minimal].freeze
41
+
42
+ def initialize(
43
+ id:,
44
+ name:,
45
+ label: I18n.t("nitro_kit.dropzone.label"),
46
+ description: nil,
47
+ presentation: :input,
48
+ direct_upload: true,
49
+ multiple: false,
50
+ accept: nil,
51
+ max_files: 1,
52
+ max_bytes: nil,
53
+ disabled: false,
54
+ required: false,
55
+ html: {},
56
+ aria: {},
57
+ data: {},
58
+ desperately_need_a_class: nil
59
+ )
60
+ @identifier = component_id(id)
61
+ @name = form_name(name)
62
+ @label = required_text(:label, label)
63
+ @description = optional_text(:description, description)
64
+ @presentation = validate_choice!(:presentation, presentation, PRESENTATIONS)
65
+ @direct_upload = validate_boolean!(:direct_upload, direct_upload)
66
+ @multiple = validate_boolean!(:multiple, multiple)
67
+ @accept = optional_text(:accept, accept)
68
+ @max_files = positive_integer(:max_files, max_files)
69
+ @max_bytes = positive_integer(:max_bytes, max_bytes, allow_nil: true)
70
+ @disabled = validate_boolean!(:disabled, disabled)
71
+ @required = validate_boolean!(:required, required)
72
+
73
+ if !@multiple && @max_files != 1
74
+ raise ArgumentError, "max_files must be 1 when multiple is false"
75
+ end
76
+
77
+ super(
78
+ component: :dropzone,
79
+ attributes: {
80
+ id: @identifier,
81
+ aria: { disabled: @disabled ? true : nil },
82
+ data: {
83
+ presentation: @presentation,
84
+ controller: @disabled ? nil : "nk--dropzone",
85
+ state: @disabled ? "disabled" : "idle",
86
+ action: @disabled ? nil : dropzone_actions,
87
+ nk__dropzone_direct_upload_value: @direct_upload,
88
+ nk__dropzone_max_files_value: @max_files,
89
+ nk__dropzone_max_bytes_value: @max_bytes,
90
+ nk__dropzone_accept_value: @accept
91
+ }.compact.merge(@disabled ? {} : controller_message_values)
92
+ },
93
+ html:,
94
+ aria:,
95
+ data:,
96
+ desperately_need_a_class:
97
+ )
98
+ end
99
+
100
+ # `label:` shadows the Phlex element, which the message region still needs.
101
+ alias :html_label :label
102
+
103
+ attr_reader :identifier, :name, :label, :description, :presentation, :max_files, :max_bytes
104
+
105
+ def view_template
106
+ div(**root_attributes) do
107
+ render_message
108
+ render_input
109
+ render_status
110
+ render_error
111
+ render_preview_list
112
+ render_preview_template
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ # The `label` element owns the accessible name through `for`, so the input
119
+ # carries no competing `aria-labelledby` and its description is not
120
+ # announced twice.
121
+ def render_message
122
+ html_label(
123
+ **slot_attributes(
124
+ :message,
125
+ attributes: { for: input_id }
126
+ )
127
+ ) do
128
+ strong(**slot_attributes(:title, attributes: { id: title_id })) { plain(@label) }
129
+ span(**slot_attributes(:instruction)) { plain(I18n.t("nitro_kit.dropzone.prompt")) }
130
+
131
+ if description
132
+ span(**slot_attributes(:description, attributes: { id: description_id })) { plain(description) }
133
+ end
134
+ end
135
+ end
136
+
137
+ def render_input
138
+ input(
139
+ **slot_attributes(
140
+ :input,
141
+ attributes: {
142
+ id: input_id,
143
+ type: "file",
144
+ name:,
145
+ accept: @accept,
146
+ multiple: @multiple,
147
+ disabled: @disabled,
148
+ required: @required,
149
+ aria: {
150
+ describedby: describedby,
151
+ errormessage: error_id
152
+ },
153
+ data: {
154
+ direct_upload_url: @direct_upload ? direct_upload_path : nil,
155
+ nk__dropzone_target: "input",
156
+ action: @disabled ? nil : "change->nk--dropzone#select"
157
+ }.compact
158
+ }
159
+ )
160
+ )
161
+ end
162
+
163
+ def render_status
164
+ p(
165
+ **slot_attributes(
166
+ :status,
167
+ attributes: {
168
+ id: status_id,
169
+ role: "status",
170
+ aria: { live: "polite", atomic: true },
171
+ data: { nk__dropzone_target: "status" }
172
+ }
173
+ )
174
+ ) do
175
+ plain(
176
+ @disabled ?
177
+ I18n.t("nitro_kit.dropzone.status.disabled") :
178
+ I18n.t("nitro_kit.dropzone.status.empty")
179
+ )
180
+ end
181
+ end
182
+
183
+ def render_error
184
+ p(
185
+ **slot_attributes(
186
+ :error,
187
+ attributes: {
188
+ id: error_id,
189
+ role: "alert",
190
+ hidden: true,
191
+ data: { nk__dropzone_target: "error" }
192
+ }
193
+ )
194
+ )
195
+ end
196
+
197
+ def render_preview_list
198
+ ul(
199
+ **slot_attributes(
200
+ :preview_list,
201
+ attributes: {
202
+ hidden: true,
203
+ aria: { label: I18n.t("nitro_kit.dropzone.preview_list") },
204
+ data: { nk__dropzone_target: "previewList" }
205
+ }
206
+ )
207
+ )
208
+ end
209
+
210
+ def render_preview_template
211
+ template(
212
+ **slot_attributes(
213
+ :preview_template,
214
+ data: { nk__dropzone_target: "previewTemplate" }
215
+ )
216
+ ) do
217
+ li(**slot_attributes(:preview, attributes: { data: { state: "queued" } })) do
218
+ img(**slot_attributes(:preview_image, attributes: { alt: "", hidden: true }))
219
+
220
+ span(**slot_attributes(:file)) do
221
+ strong(**slot_attributes(:file_name))
222
+ small(**slot_attributes(:file_size))
223
+ end
224
+
225
+ progress(
226
+ **slot_attributes(
227
+ :progress,
228
+ attributes: {
229
+ max: 100,
230
+ value: 0,
231
+ aria: { label: I18n.t("nitro_kit.dropzone.progress") }
232
+ }
233
+ )
234
+ )
235
+ span(**slot_attributes(:file_status)) { plain(I18n.t("nitro_kit.dropzone.queued")) }
236
+ button(
237
+ **slot_attributes(
238
+ :remove_control,
239
+ attributes: {
240
+ type: "button",
241
+ data: { action: "click->nk--dropzone#remove" }
242
+ }
243
+ )
244
+ ) { plain(I18n.t("nitro_kit.dropzone.remove")) }
245
+ end
246
+ end
247
+ end
248
+
249
+ def controller_message_values
250
+ CONTROLLER_MESSAGE_KEYS.to_h do |key|
251
+ [
252
+ :"nk__dropzone_#{key.tr('.', '_')}_value",
253
+ I18n.t("nitro_kit.dropzone.#{key}")
254
+ ]
255
+ end
256
+ end
257
+
258
+ def dropzone_actions
259
+ [
260
+ "dragenter->nk--dropzone#dragEnter",
261
+ "dragover->nk--dropzone#dragOver",
262
+ "dragleave->nk--dropzone#dragLeave",
263
+ "drop->nk--dropzone#drop",
264
+ "submit@document->nk--dropzone#submit",
265
+ "turbo:before-cache@document->nk--dropzone#teardown"
266
+ ].join(" ")
267
+ end
268
+
269
+ def describedby
270
+ [ status_id, error_id ].join(" ")
271
+ end
272
+
273
+ def input_id = "#{identifier}-input"
274
+ def title_id = "#{identifier}-title"
275
+ def description_id
276
+ "#{identifier}-description" if description
277
+ end
278
+ def status_id = "#{identifier}-status"
279
+ def error_id = "#{identifier}-error"
280
+
281
+ def direct_upload_path
282
+ routes = direct_upload_routes
283
+ unless routes.respond_to?(:rails_direct_uploads_path)
284
+ raise ArgumentError, "direct_upload: true requires Active Storage routes"
285
+ end
286
+
287
+ routes.rails_direct_uploads_path
288
+ end
289
+
290
+ def direct_upload_routes
291
+ Rails.application.routes.url_helpers
292
+ end
293
+
294
+ def component_id(value)
295
+ return value if value.is_a?(String) && value.present? && !value.match?(/\s/)
296
+
297
+ raise ArgumentError, "Dropzone id must be a non-blank String without whitespace"
298
+ end
299
+
300
+ def form_name(value)
301
+ return value if value.is_a?(String) && value.present?
302
+
303
+ raise ArgumentError, "Dropzone name must be a non-blank String"
304
+ end
305
+
306
+ def required_text(name, value)
307
+ return value if value.is_a?(String) && value.present?
308
+
309
+ raise ArgumentError, "#{name} must be a non-blank String"
310
+ end
311
+
312
+ def optional_text(name, value)
313
+ return value if value.nil? || (value.is_a?(String) && value.present?)
314
+
315
+ raise ArgumentError, "#{name} must be nil or a non-blank String"
316
+ end
317
+
318
+ def positive_integer(name, value, allow_nil: false)
319
+ return value if allow_nil && value.nil?
320
+ return value if value.is_a?(Integer) && value.positive?
321
+
322
+ expected = allow_nil ? "nil or a positive Integer" : "a positive Integer"
323
+ raise ArgumentError, "#{name} must be #{expected}"
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class EmptyState < Component
5
+ Child = ::Data.define(:component, :content)
6
+ private_constant :Child
7
+
8
+ TITLE_LEVELS = (2..6).freeze
9
+ VARIANTS = %i[default borderless].freeze
10
+
11
+ def initialize(
12
+ title: nil,
13
+ description: nil,
14
+ variant: :default,
15
+ level: 2,
16
+ id: nil,
17
+ html: {},
18
+ aria: {},
19
+ data: {},
20
+ desperately_need_a_class: nil
21
+ )
22
+ @title_content = content_from_keyword(:title, title)
23
+ @description_content = content_from_keyword(:description, description)
24
+ @level = validate_choice!(:level, level, TITLE_LEVELS)
25
+ @variant = validate_choice!(:variant, variant, VARIANTS)
26
+ @icon = nil
27
+ @actions = []
28
+
29
+ super(
30
+ component: :empty_state,
31
+ attributes: { id: }.compact,
32
+ html:,
33
+ aria:,
34
+ data:,
35
+ variant:,
36
+ desperately_need_a_class:
37
+ )
38
+ end
39
+
40
+ attr_reader :level, :variant
41
+
42
+ def view_template
43
+ yield self if block_given?
44
+ require_content!("EmptyState", :title, @title_content)
45
+
46
+ section(**root_attributes) do
47
+ render_in_slot(@icon.component, :icon, &@icon.content) if @icon
48
+ public_send(:"h#{level}", **slot_attributes(:title)) { render_deferred_content(@title_content) }
49
+ if @description_content
50
+ p(**slot_attributes(:description)) { render_deferred_content(@description_content) }
51
+ end
52
+ render_actions if @actions.any?
53
+ end
54
+ end
55
+
56
+ def title(text = nil, &block)
57
+ @title_content = declare_content(:title, @title_content, text, &block)
58
+ nil
59
+ end
60
+
61
+ def description(text = nil, &block)
62
+ @description_content = declare_content(:description, @description_content, text, &block)
63
+ nil
64
+ end
65
+
66
+ def icon(component, &content)
67
+ unless component.is_a?(NitroKit::Icon)
68
+ raise ArgumentError, "EmptyState icon must be a NitroKit::Icon"
69
+ end
70
+ raise ArgumentError, "EmptyState accepts at most one icon" if @icon
71
+
72
+ @icon = Child.new(component:, content:)
73
+ nil
74
+ end
75
+
76
+ def action(component, &content)
77
+ unless component.is_a?(NitroKit::Button)
78
+ raise ArgumentError, "EmptyState actions must be NitroKit::Button instances"
79
+ end
80
+ if @actions.any? { |action| action.component.equal?(component) }
81
+ raise ArgumentError, "EmptyState cannot contain the same Button twice"
82
+ end
83
+ raise ArgumentError, "EmptyState accepts at most two actions" if @actions.size == 2
84
+
85
+ @actions << Child.new(component:, content:)
86
+ nil
87
+ end
88
+
89
+ private
90
+
91
+ def render_actions
92
+ div(**slot_attributes(:actions)) do
93
+ @actions.each do |action|
94
+ render_in_slot(action.component, :action, &action.content)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end