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,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NitroKit
4
+ class DetailsTable < Component
5
+ include Phlex::Rails::Helpers::Routes
6
+
7
+ UNSET = Object.new.freeze
8
+ private_constant :UNSET
9
+ Field = Data.define(:attribute, :label, :value, :content)
10
+
11
+ def initialize(
12
+ record,
13
+ caption: nil,
14
+ label: nil,
15
+ empty_text: nil,
16
+ boolean_labels: nil,
17
+ route_base: nil,
18
+ id: nil,
19
+ html: {},
20
+ aria: {},
21
+ data: {},
22
+ desperately_need_a_class: nil
23
+ )
24
+ @record = record
25
+ @route_base = route_base
26
+ @caption = caption.nil? ? nil : validate_text!("caption", caption)
27
+ @empty_text = validate_text!("empty_text", empty_text || I18n.t("nitro_kit.details_table.empty"))
28
+ @boolean_labels = validate_boolean_labels!(boolean_labels || default_boolean_labels)
29
+ @fields = []
30
+ @table = Table.new(
31
+ table_aria: label.nil? ? {} : { label: validate_text!("label", label) }
32
+ )
33
+
34
+ super(
35
+ component: :details_table,
36
+ attributes: { id: }.compact,
37
+ html:,
38
+ aria:,
39
+ data:,
40
+ desperately_need_a_class:
41
+ )
42
+ end
43
+
44
+ attr_reader :record, :route_base
45
+
46
+ def view_template
47
+ yield self if block_given?
48
+ raise ArgumentError, "DetailsTable requires at least one field" if @fields.empty?
49
+
50
+ div(**root_attributes) do
51
+ render_in_slot(@table, :table) do
52
+ @table.caption(@caption) if @caption
53
+ @table.tbody do
54
+ @fields.each { |field| render_field(field) }
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ def fields(*attributes)
61
+ raise ArgumentError, "DetailsTable fields requires at least one attribute" if attributes.empty?
62
+
63
+ attributes.each { |attribute| field(attribute) }
64
+ nil
65
+ end
66
+
67
+ def field(attribute, label: nil, value: UNSET, &content)
68
+ attribute = validate_attribute!(attribute)
69
+ if @fields.any? { |field| field.attribute.to_s == attribute.to_s }
70
+ raise ArgumentError, "DetailsTable field keys must be unique: #{attribute.inspect}"
71
+ end
72
+ label = label.nil? ? attribute.to_s.humanize : validate_text!("field label", label)
73
+ resolved_value = value.equal?(UNSET) ? resolve_attribute(attribute) : value
74
+
75
+ @fields << Field.new(attribute:, label:, value: resolved_value, content:)
76
+ nil
77
+ end
78
+
79
+ private
80
+
81
+ def render_field(field)
82
+ @table.tr do
83
+ @table.th(field.label, scope: :row)
84
+ @table.td do
85
+ if field.content
86
+ field.content.call(field.value)
87
+ else
88
+ render_value(field.value)
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ def render_value(value)
95
+ case value
96
+ when nil
97
+ em(**slot_attributes(:empty)) { plain(@empty_text) }
98
+ when true, false
99
+ span(**slot_attributes(:boolean)) { plain(@boolean_labels.fetch(value)) }
100
+ when Date, Time, ActiveSupport::TimeWithZone
101
+ time(**slot_attributes(:time, attributes: { datetime: value.iso8601 })) do
102
+ plain(I18n.localize(value, format: :long).to_s)
103
+ end
104
+ when Numeric
105
+ span(**slot_attributes(:number)) { plain(value.to_s) }
106
+ when Symbol
107
+ plain(value.to_s.humanize)
108
+ when String
109
+ render_string(value)
110
+ else
111
+ render_special_value(value)
112
+ end
113
+ end
114
+
115
+ def render_string(value)
116
+ if value.start_with?("https://", "http://")
117
+ a(
118
+ **slot_attributes(
119
+ :link,
120
+ attributes: { href: value, target: "_blank", rel: "noopener noreferrer" }
121
+ )
122
+ ) { plain(value) }
123
+ else
124
+ plain(value)
125
+ end
126
+ end
127
+
128
+ def render_special_value(value)
129
+ if active_storage_attachment?(value)
130
+ render_attachment(value)
131
+ elsif active_record?(value)
132
+ render_record(value)
133
+ elsif collection?(value)
134
+ render_collection(value)
135
+ else
136
+ plain(value.to_s)
137
+ end
138
+ end
139
+
140
+ def render_attachment(attachment)
141
+ return render_value(nil) unless attachment.attached?
142
+
143
+ if attachment.blob.image? && attachment.blob.variable?
144
+ render_in_slot(
145
+ ProgressiveImage.new(
146
+ attachment:,
147
+ alt: attachment.filename.to_s,
148
+ size: :sm
149
+ ),
150
+ :attachment
151
+ )
152
+ else
153
+ code(**slot_attributes(:file)) { plain(attachment.filename.to_s) }
154
+ end
155
+ end
156
+
157
+ def render_record(value)
158
+ route = route_base ? [ *Array(route_base), value ] : value
159
+ a(**slot_attributes(:record, attributes: { href: url_for(route) })) do
160
+ plain(record_name(value))
161
+ end
162
+ end
163
+
164
+ def render_collection(values)
165
+ span(**slot_attributes(:list)) do
166
+ values.each_with_index do |value, index|
167
+ plain(", ") if index.positive?
168
+ span(**slot_attributes(:list_item)) { render_value(value) }
169
+ end
170
+ end
171
+ end
172
+
173
+ def record_name(value)
174
+ label = value.to_label if value.respond_to?(:to_label)
175
+ label = value.name if label.blank? && value.respond_to?(:name)
176
+ label.presence || "#{value.class.model_name.human} ##{value.id}"
177
+ end
178
+
179
+ def active_storage_attachment?(value)
180
+ defined?(ActiveStorage::Attached::One) && value.is_a?(ActiveStorage::Attached::One)
181
+ end
182
+
183
+ def active_record?(value)
184
+ defined?(ActiveRecord::Base) && value.is_a?(ActiveRecord::Base)
185
+ end
186
+
187
+ def collection?(value)
188
+ value.is_a?(Array) || (
189
+ defined?(ActiveRecord::Associations::CollectionProxy) &&
190
+ value.is_a?(ActiveRecord::Associations::CollectionProxy)
191
+ )
192
+ end
193
+
194
+ def resolve_attribute(attribute)
195
+ return record.public_send(attribute) if record.respond_to?(attribute)
196
+
197
+ raise ArgumentError, "#{record.class} does not expose #{attribute.inspect}"
198
+ end
199
+
200
+ def validate_attribute!(attribute)
201
+ return attribute if attribute.is_a?(Symbol)
202
+ return attribute if attribute.is_a?(String) && !attribute.strip.empty?
203
+
204
+ raise ArgumentError, "DetailsTable field attribute must be a Symbol or non-blank String"
205
+ end
206
+
207
+ def validate_text!(name, value)
208
+ return value if value.is_a?(String) && !value.strip.empty?
209
+
210
+ raise ArgumentError, "DetailsTable #{name} must be a non-blank String"
211
+ end
212
+
213
+ def default_boolean_labels
214
+ {
215
+ true => I18n.t("nitro_kit.details_table.boolean_true"),
216
+ false => I18n.t("nitro_kit.details_table.boolean_false")
217
+ }
218
+ end
219
+
220
+ def validate_boolean_labels!(labels)
221
+ unless labels.is_a?(Hash) && labels.keys.sort_by(&:to_s) == [ false, true ]
222
+ raise ArgumentError, "DetailsTable boolean_labels must be a Hash with true and false keys"
223
+ end
224
+
225
+ labels.each { |key, value| validate_text!("boolean_labels[#{key}]", value) }
226
+ labels
227
+ end
228
+ end
229
+ end
@@ -2,117 +2,291 @@
2
2
 
3
3
  module NitroKit
4
4
  class Dialog < Component
5
- def initialize(identifier: nil, **attrs)
6
- @identifier = identifier || SecureRandom.hex(6)
5
+ Trigger = ::Data.define(:text, :variant, :size, :disabled, :html, :aria, :data, :css_class, :content)
6
+ private_constant :Trigger
7
+
8
+ Panel = ::Data.define(:title, :description, :nonmodal, :html, :aria, :data, :css_class, :content)
9
+ private_constant :Panel
10
+
11
+ CloseButton = ::Data.define(:label, :html, :aria, :data, :css_class)
12
+ private_constant :CloseButton
13
+
14
+ def initialize(
15
+ id:,
16
+ dismissible: true,
17
+ html: {},
18
+ aria: {},
19
+ data: {},
20
+ desperately_need_a_class: nil
21
+ )
22
+ unless id.is_a?(String) && id.present? && !id.match?(/\s/)
23
+ raise ArgumentError, "Dialog id: must be a non-blank String without whitespace"
24
+ end
25
+
26
+ @id = id
27
+ @dismissible = validate_boolean!(:dismissible, dismissible)
7
28
 
8
29
  super(
9
- attrs,
10
- data: { controller: "nk--dialog", action: "click->nk--dialog#clickOutside" }
30
+ component: :dialog,
31
+ attributes: {
32
+ id:,
33
+ data: {
34
+ controller: "nk--dialog",
35
+ nk__dialog_dismissible_value: @dismissible
36
+ }
37
+ },
38
+ html:,
39
+ aria:,
40
+ data:,
41
+ desperately_need_a_class:
11
42
  )
12
43
  end
13
44
 
14
- attr_reader :identifier
45
+ attr_reader :id
15
46
 
16
- def view_template
17
- div(**attrs) do
18
- yield
47
+ def view_template(&block)
48
+ collect_declarations(&block)
49
+
50
+ div(**root_attributes) do
51
+ render_trigger if @trigger
52
+ render_panel
19
53
  end
20
54
  end
21
55
 
22
- def trigger(text = nil, as: Button, **attrs, &block)
23
- builder do
24
- trigger_attrs = mattr(
25
- attrs,
26
- data: {
27
- nk__dialog_target: "trigger",
28
- action: "click->nk--dialog#open"
29
- }
30
- )
56
+ def trigger(
57
+ text = nil,
58
+ variant: :default,
59
+ size: :md,
60
+ disabled: false,
61
+ html: {},
62
+ aria: {},
63
+ data: {},
64
+ desperately_need_a_class: nil,
65
+ &content
66
+ )
67
+ ensure_collecting!
68
+ raise ArgumentError, "Dialog accepts at most one trigger" if @trigger
31
69
 
32
- case as
33
- when Symbol
34
- send(as, **trigger_attrs) do
35
- text_or_block(text, &block)
36
- end
37
- else
38
- render(as.new(**trigger_attrs)) do
39
- text_or_block(text, &block)
40
- end
41
- end
42
- end
70
+ @trigger = Trigger.new(
71
+ text:,
72
+ variant:,
73
+ size:,
74
+ disabled: validate_boolean!(:disabled, disabled),
75
+ html:,
76
+ aria:,
77
+ data:,
78
+ css_class: desperately_need_a_class,
79
+ content:
80
+ )
81
+ nil
43
82
  end
44
83
 
45
- alias :html_dialog :dialog
84
+ def panel(
85
+ title:,
86
+ description: nil,
87
+ nonmodal: false,
88
+ html: {},
89
+ aria: {},
90
+ data: {},
91
+ desperately_need_a_class: nil,
92
+ &content
93
+ )
94
+ ensure_collecting!
95
+ raise ArgumentError, "Dialog accepts exactly one panel" if @panel
46
96
 
47
- def dialog(**attrs)
48
- builder do
49
- html_dialog(
50
- **mattr(
51
- attrs,
52
- class: dialog_class,
53
- data: { nk__dialog_target: "dialog" },
54
- aria: {
55
- labelledby: id(:title),
56
- describedby: id(:description)
57
- }
58
- )
59
- ) do
60
- yield
61
- end
97
+ unless title.is_a?(String) && title.present?
98
+ raise ArgumentError, "Dialog title: must be a non-blank String"
62
99
  end
100
+ unless description.nil? || (description.is_a?(String) && description.present?)
101
+ raise ArgumentError, "Dialog description: must be nil or a non-blank String"
102
+ end
103
+
104
+ @panel = Panel.new(
105
+ title:,
106
+ description:,
107
+ nonmodal: validate_boolean!(:nonmodal, nonmodal),
108
+ html:,
109
+ aria:,
110
+ data:,
111
+ css_class: desperately_need_a_class,
112
+ content:
113
+ )
114
+ nil
63
115
  end
64
116
 
65
- def close_button(**attrs)
66
- builder do
67
- render(
68
- Button.new(
69
- **mattr(
70
- attrs,
71
- variant: :ghost,
72
- size: :sm,
73
- class: "absolute top-2 right-2",
74
- data: { action: "nk--dialog#close" }
75
- )
76
- )
77
- ) do
78
- render(Icon.new(:x))
79
- end
117
+ def close_button(
118
+ label: I18n.t("nitro_kit.dialog.close"),
119
+ html: {},
120
+ aria: {},
121
+ data: {},
122
+ desperately_need_a_class: nil
123
+ )
124
+ ensure_rendering_panel!
125
+ raise ArgumentError, "Dialog accepts at most one close button" if @close_button
126
+ unless @dismissible
127
+ raise ArgumentError, "Dialog dismissible: false renders no close button"
80
128
  end
129
+
130
+ @close_button = CloseButton.new(
131
+ label:,
132
+ html:,
133
+ aria:,
134
+ data:,
135
+ css_class: desperately_need_a_class
136
+ )
137
+ nil
81
138
  end
82
139
 
83
- def title(text = nil, **attrs, &block)
84
- builder do
85
- h2(**mattr(attrs, id: id(:title), class: "text-lg font-semibold mb-2")) do
86
- text_or_block(text, &block)
87
- end
140
+ private
141
+
142
+ def collect_declarations
143
+ raise ArgumentError, "Dialog requires a declaration block" unless block_given?
144
+
145
+ @collecting = true
146
+ yield(self)
147
+ raise ArgumentError, "Dialog requires exactly one panel" unless @panel
148
+
149
+ if @panel.nonmodal && @trigger
150
+ raise ArgumentError,
151
+ "Dialog nonmodal: true cannot be combined with a trigger; the trigger opens the panel modally"
88
152
  end
153
+ ensure
154
+ @collecting = false
89
155
  end
90
156
 
91
- def description(text = nil, **attrs, &block)
92
- builder do
93
- div(
94
- **mattr(
95
- attrs,
96
- id: id(:description),
97
- class: "text-muted-content mb-6 text-sm leading-relaxed"
98
- )
99
- ) do
100
- text_or_block(text, &block)
101
- end
157
+ def render_trigger
158
+ component = Button.new(
159
+ @trigger.text,
160
+ variant: @trigger.variant,
161
+ size: @trigger.size,
162
+ disabled: @trigger.disabled,
163
+ html: with_command(@trigger.html, "show-modal", disabled: @trigger.disabled),
164
+ aria: @trigger.aria.merge(haspopup: "dialog"),
165
+ data: @trigger.data,
166
+ desperately_need_a_class: @trigger.css_class
167
+ )
168
+
169
+ if @trigger.content
170
+ render_in_slot(component, :trigger) { render(@trigger.content) }
171
+ else
172
+ render_in_slot(component, :trigger)
102
173
  end
103
174
  end
104
175
 
105
- private
176
+ def render_panel
177
+ owned_aria = {
178
+ labelledby: element_id(:title),
179
+ describedby: @panel.description.nil? ? nil : element_id(:description)
180
+ }.compact
181
+
182
+ content = capture_panel_content
183
+
184
+ dialog(
185
+ **slot_attributes(
186
+ :panel,
187
+ attributes: {
188
+ id: element_id(:panel),
189
+ open: @panel.nonmodal,
190
+ closedby: @dismissible ? "any" : "none",
191
+ aria: owned_aria,
192
+ data: {
193
+ nk__dialog_target: "panel",
194
+ action: "click->nk--dialog#dismiss cancel->nk--dialog#cancel"
195
+ }
196
+ },
197
+ html: @panel.html,
198
+ aria: @panel.aria,
199
+ data: @panel.data,
200
+ desperately_need_a_class: @panel.css_class
201
+ )
202
+ ) do
203
+ render_close_button if @dismissible
204
+ render_title
205
+ render_description if @panel.description
206
+ raw(safe(content))
207
+ end
208
+ end
209
+
210
+ # The panel content is captured first so Nitro owns the rendered order:
211
+ # close button, title, description, then application content. A sticky
212
+ # close button must precede scrolling content in the DOM.
213
+ def capture_panel_content
214
+ return "" unless @panel.content
215
+
216
+ @rendering_panel = true
217
+ capture { render(@panel.content) }
218
+ ensure
219
+ @rendering_panel = false
220
+ end
221
+
222
+ def render_close_button
223
+ declaration = @close_button || CloseButton.new(
224
+ label: I18n.t("nitro_kit.dialog.close"),
225
+ html: {},
226
+ aria: {},
227
+ data: {},
228
+ css_class: nil
229
+ )
230
+
231
+ render_in_slot(
232
+ Button.new(
233
+ icon: :x,
234
+ variant: :ghost,
235
+ size: :sm,
236
+ html: with_command(declaration.html, "close"),
237
+ aria: declaration.aria.merge(label: declaration.label),
238
+ data: declaration.data,
239
+ desperately_need_a_class: declaration.css_class
240
+ ),
241
+ :close
242
+ )
243
+ end
244
+
245
+ def render_title
246
+ h2(
247
+ **slot_attributes(
248
+ :title,
249
+ attributes: { id: element_id(:title) }
250
+ )
251
+ ) { @panel.title }
252
+ end
253
+
254
+ def render_description
255
+ div(
256
+ **slot_attributes(
257
+ :description,
258
+ attributes: { id: element_id(:description) }
259
+ )
260
+ ) { @panel.description }
261
+ end
262
+
263
+ def element_id(suffix)
264
+ "#{id}-#{suffix}"
265
+ end
266
+
267
+ def ensure_collecting!
268
+ return if @collecting
106
269
 
107
- def id(suffix = nil)
108
- "nk-#{identifier}#{suffix ? "-#{suffix}" : ""}"
270
+ raise ArgumentError, "Dialog declarations must be inside the render block"
109
271
  end
110
272
 
111
- def dialog_class
112
- [
113
- "border rounded-xl max-w-lg w-full bg-background text-foreground shadow-lg m-auto p-6",
114
- "dark:backdrop:bg-black/50"
115
- ]
273
+ def ensure_rendering_panel!
274
+ return if @rendering_panel
275
+
276
+ raise ArgumentError, "Dialog close button must be declared inside the panel block"
277
+ end
278
+
279
+ def with_command(html, command, disabled: false)
280
+ unless html.is_a?(Hash)
281
+ raise ArgumentError, "html must be a Hash"
282
+ end
283
+
284
+ collision = html.keys.find { |key| %w[command commandfor].include?(key.to_s.downcase) }
285
+ if collision
286
+ raise ArgumentError, "#{collision}: is owned by Dialog"
287
+ end
288
+
289
+ disabled ? html : html.merge(command:, commandfor: element_id(:panel))
116
290
  end
117
291
  end
118
292
  end