nitro_kit 0.8.0 → 2.0.0.alpha.1

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 +151 -0
  4. data/LICENSE +28 -0
  5. data/README.md +21 -16
  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 +7225 -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 +128 -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 +336 -119
  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 +104 -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/implementation_plan.md +279 -0
  95. data/docs/initialization_prompt.md +36 -0
  96. data/docs/migration_1_to_2.md +318 -0
  97. data/docs/new_app_strategy.md +24 -0
  98. data/docs/patterns/application_foundation.md +136 -0
  99. data/docs/patterns/crud_resource.md +144 -0
  100. data/docs/patterns/destructive_action.md +105 -0
  101. data/docs/patterns/flash_and_toast.md +57 -0
  102. data/docs/patterns/inline_edit.md +59 -0
  103. data/docs/patterns/queryable_collection.md +187 -0
  104. data/docs/patterns/resource_form.md +126 -0
  105. data/docs/rails_conventions.md +95 -0
  106. data/docs/rails_integration.md +514 -0
  107. data/lib/generators/nitro_kit/install_generator.rb +48 -0
  108. data/lib/generators/nitro_kit/upgrade_smoke_tests_generator.rb +67 -0
  109. data/lib/nitro_kit/engine.rb +20 -0
  110. data/lib/nitro_kit/installation.rb +671 -0
  111. data/lib/nitro_kit/migration_inventory.rb +345 -0
  112. data/lib/nitro_kit/upgrade_smoke_test.rb +403 -0
  113. data/lib/nitro_kit/version.rb +1 -1
  114. data/lib/nitro_kit.rb +5 -42
  115. data/lib/rails/commands/nitro_kit/nitro_kit_command.rb +42 -0
  116. data/lib/tasks/nitro_kit_tasks.rake +79 -4
  117. data/plugins/nitro-kit/.codex-plugin/plugin.json +28 -0
  118. data/plugins/nitro-kit/skills/nitro-kit-hotwire/SKILL.md +67 -0
  119. data/plugins/nitro-kit/skills/nitro-kit-hotwire/agents/openai.yaml +4 -0
  120. data/plugins/nitro-kit/skills/nitro-kit-rails/SKILL.md +60 -0
  121. data/plugins/nitro-kit/skills/nitro-kit-rails/agents/openai.yaml +4 -0
  122. data/plugins/nitro-kit/skills/nitro-kit-ui/SKILL.md +62 -0
  123. data/plugins/nitro-kit/skills/nitro-kit-ui/agents/openai.yaml +4 -0
  124. data/src/stylesheets/nitro_kit/components/accordion.css +119 -0
  125. data/src/stylesheets/nitro_kit/components/alert.css +82 -0
  126. data/src/stylesheets/nitro_kit/components/app_navigation.css +156 -0
  127. data/src/stylesheets/nitro_kit/components/app_shell.css +605 -0
  128. data/src/stylesheets/nitro_kit/components/appearance_picker.css +117 -0
  129. data/src/stylesheets/nitro_kit/components/auth_shell.css +8 -0
  130. data/src/stylesheets/nitro_kit/components/avatar.css +81 -0
  131. data/src/stylesheets/nitro_kit/components/avatar_stack.css +80 -0
  132. data/src/stylesheets/nitro_kit/components/badge.css +76 -0
  133. data/src/stylesheets/nitro_kit/components/button.css +283 -0
  134. data/src/stylesheets/nitro_kit/components/button_group.css +53 -0
  135. data/src/stylesheets/nitro_kit/components/button_to.css +6 -0
  136. data/src/stylesheets/nitro_kit/components/card.css +70 -0
  137. data/src/stylesheets/nitro_kit/components/checkbox.css +160 -0
  138. data/src/stylesheets/nitro_kit/components/checkbox_group.css +61 -0
  139. data/src/stylesheets/nitro_kit/components/combobox.css +148 -0
  140. data/src/stylesheets/nitro_kit/components/command_palette.css +255 -0
  141. data/src/stylesheets/nitro_kit/components/container.css +25 -0
  142. data/src/stylesheets/nitro_kit/components/control_group.css +149 -0
  143. data/src/stylesheets/nitro_kit/components/danger_zone.css +54 -0
  144. data/src/stylesheets/nitro_kit/components/data_section.css +40 -0
  145. data/src/stylesheets/nitro_kit/components/details_table.css +53 -0
  146. data/src/stylesheets/nitro_kit/components/dialog.css +80 -0
  147. data/src/stylesheets/nitro_kit/components/dropdown.css +152 -0
  148. data/src/stylesheets/nitro_kit/components/dropzone.css +189 -0
  149. data/src/stylesheets/nitro_kit/components/empty_state.css +53 -0
  150. data/src/stylesheets/nitro_kit/components/field.css +79 -0
  151. data/src/stylesheets/nitro_kit/components/field_group.css +6 -0
  152. data/src/stylesheets/nitro_kit/components/fieldset.css +28 -0
  153. data/src/stylesheets/nitro_kit/components/flex.css +455 -0
  154. data/src/stylesheets/nitro_kit/components/form_section.css +71 -0
  155. data/src/stylesheets/nitro_kit/components/grid.css +308 -0
  156. data/src/stylesheets/nitro_kit/components/icon.css +26 -0
  157. data/src/stylesheets/nitro_kit/components/input.css +80 -0
  158. data/src/stylesheets/nitro_kit/components/label.css +9 -0
  159. data/src/stylesheets/nitro_kit/components/layout.css +440 -0
  160. data/src/stylesheets/nitro_kit/components/page_header.css +62 -0
  161. data/src/stylesheets/nitro_kit/components/pagination.css +119 -0
  162. data/src/stylesheets/nitro_kit/components/pagination_bar.css +41 -0
  163. data/src/stylesheets/nitro_kit/components/palette.css +113 -0
  164. data/src/stylesheets/nitro_kit/components/progressive_image.css +139 -0
  165. data/src/stylesheets/nitro_kit/components/radio_button.css +127 -0
  166. data/src/stylesheets/nitro_kit/components/radio_button_group.css +143 -0
  167. data/src/stylesheets/nitro_kit/components/rich_text_area.css +21 -0
  168. data/src/stylesheets/nitro_kit/components/select.css +70 -0
  169. data/src/stylesheets/nitro_kit/components/settings_layout.css +100 -0
  170. data/src/stylesheets/nitro_kit/components/sheet.css +100 -0
  171. data/src/stylesheets/nitro_kit/components/stat_grid.css +51 -0
  172. data/src/stylesheets/nitro_kit/components/switch.css +152 -0
  173. data/src/stylesheets/nitro_kit/components/table.css +110 -0
  174. data/src/stylesheets/nitro_kit/components/tabs.css +138 -0
  175. data/src/stylesheets/nitro_kit/components/textarea.css +39 -0
  176. data/src/stylesheets/nitro_kit/components/toast.css +131 -0
  177. data/src/stylesheets/nitro_kit/components/toolbar.css +78 -0
  178. data/src/stylesheets/nitro_kit/components/tooltip.css +124 -0
  179. data/src/stylesheets/nitro_kit/components/typeset.css +191 -0
  180. data/src/stylesheets/nitro_kit/layers.css +7 -0
  181. data/src/stylesheets/nitro_kit/reset.css +192 -0
  182. data/src/stylesheets/nitro_kit/tokens.css +198 -0
  183. metadata +183 -50
  184. data/Rakefile +0 -8
  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 -42
  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 -121
  219. data/lib/nitro_kit/variants.rb +0 -21
@@ -2,173 +2,390 @@
2
2
 
3
3
  module NitroKit
4
4
  class Dropdown < Component
5
- ITEM_VARIANTS = %i[default destructive]
5
+ PLACEMENTS = %i[bottom_start bottom_end top_start top_end].freeze
6
+ ITEM_VARIANTS = %i[default destructive].freeze
7
+ ITEM_TYPES = %i[button submit reset].freeze
6
8
 
7
- include Phlex::Rails::Helpers::LinkTo
9
+ Trigger = ::Data.define(
10
+ :text,
11
+ :variant,
12
+ :size,
13
+ :icon,
14
+ :icon_end,
15
+ :label,
16
+ :disabled,
17
+ :html,
18
+ :aria,
19
+ :data,
20
+ :css_class,
21
+ :content
22
+ )
23
+ Entry = ::Data.define(
24
+ :kind,
25
+ :text,
26
+ :href,
27
+ :icon,
28
+ :variant,
29
+ :type,
30
+ :disabled,
31
+ :html,
32
+ :aria,
33
+ :data,
34
+ :css_class,
35
+ :content
36
+ )
8
37
 
9
- def initialize(placement: nil, **attrs)
10
- @placement = placement
38
+ def initialize(
39
+ id: nil,
40
+ placement: :bottom_start,
41
+ html: {},
42
+ aria: {},
43
+ data: {},
44
+ desperately_need_a_class: nil
45
+ )
46
+ @identifier = component_id(id)
47
+ @placement = validate_choice!(:placement, placement, PLACEMENTS)
48
+ @entries = []
11
49
 
12
50
  super(
13
- attrs,
14
- data: {
15
- controller: "nk--dropdown",
16
- nk__dropdown_placement_value: placement
17
- }
51
+ component: :dropdown,
52
+ attributes: {
53
+ id: @identifier,
54
+ data: {
55
+ controller: "nk--dropdown",
56
+ placement: placement_value
57
+ }
58
+ },
59
+ html:,
60
+ aria:,
61
+ data:,
62
+ desperately_need_a_class:
18
63
  )
19
64
  end
20
65
 
21
- attr_reader :placement
66
+ attr_reader :identifier, :placement
22
67
 
23
- def view_template
24
- div(**mattr(attrs)) do
25
- yield
68
+ def view_template(&block)
69
+ collect_entries(&block)
70
+
71
+ div(**root_attributes) do
72
+ render_trigger
73
+ render_content
26
74
  end
27
75
  end
28
76
 
29
- def trigger(text = nil, as: NitroKit::Button, **attrs, &block)
30
- builder do
31
- trigger_attrs = mattr(
32
- attrs,
33
- aria: { haspopup: "true", expanded: "false" },
34
- data: { nk__dropdown_target: "trigger", action: "click->nk--dropdown#toggle" }
35
- )
77
+ def trigger(
78
+ text = nil,
79
+ variant: :default,
80
+ size: :md,
81
+ icon: nil,
82
+ icon_end: nil,
83
+ label: nil,
84
+ disabled: false,
85
+ html: {},
86
+ aria: {},
87
+ data: {},
88
+ desperately_need_a_class: nil,
89
+ &content
90
+ )
91
+ ensure_collecting!
92
+ raise ArgumentError, "Dropdown accepts exactly one trigger" if @trigger
93
+ if text.nil? && !content && icon.nil? && icon_end.nil?
94
+ raise ArgumentError, "Dropdown trigger requires text, a block, or an icon"
95
+ end
36
96
 
37
- case as
38
- when Symbol
39
- send(as, **trigger_attrs) do
40
- text_or_block(text, &block)
41
- end
42
- else
43
- render(as.new(**trigger_attrs)) do
44
- text_or_block(text, &block)
45
- end
46
- end
97
+ @trigger = Trigger.new(
98
+ text:,
99
+ variant: validate_trigger_choice!(:variant, variant, Button::VARIANTS),
100
+ size: validate_trigger_choice!(:size, size, Button::SIZES),
101
+ icon:,
102
+ icon_end:,
103
+ label:,
104
+ disabled: validate_boolean!(:disabled, disabled),
105
+ html:,
106
+ aria:,
107
+ data:,
108
+ css_class: desperately_need_a_class,
109
+ content:
110
+ )
111
+ nil
112
+ end
113
+
114
+ def title(text = nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &content)
115
+ ensure_collecting!
116
+ validate_content!(:title, text, content)
117
+ add_entry(
118
+ kind: :title,
119
+ text:,
120
+ html:,
121
+ aria:,
122
+ data:,
123
+ css_class: desperately_need_a_class,
124
+ content:
125
+ )
126
+ end
127
+
128
+ def item(
129
+ text = nil,
130
+ href: nil,
131
+ icon: nil,
132
+ variant: :default,
133
+ type: :button,
134
+ disabled: false,
135
+ html: {},
136
+ aria: {},
137
+ data: {},
138
+ desperately_need_a_class: nil,
139
+ &content
140
+ )
141
+ ensure_collecting!
142
+ validate_content!(:item, text, content)
143
+ variant = validate_choice!(:variant, variant, ITEM_VARIANTS)
144
+ type = validate_choice!(:type, type.to_s.to_sym, ITEM_TYPES)
145
+ disabled = validate_boolean!(:disabled, disabled)
146
+ if !href.nil? && (!href.is_a?(String) || href.empty?)
147
+ raise ArgumentError, "Dropdown item href must be nil or a non-blank String"
148
+ end
149
+
150
+ add_entry(
151
+ kind: :item,
152
+ text:,
153
+ href:,
154
+ icon:,
155
+ variant:,
156
+ type:,
157
+ disabled:,
158
+ html:,
159
+ aria:,
160
+ data:,
161
+ css_class: desperately_need_a_class,
162
+ content:
163
+ )
164
+ end
165
+
166
+ def separator(html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
167
+ ensure_collecting!
168
+ add_entry(
169
+ kind: :separator,
170
+ html:,
171
+ aria:,
172
+ data:,
173
+ css_class: desperately_need_a_class
174
+ )
175
+ end
176
+
177
+ private
178
+
179
+ def collect_entries
180
+ raise ArgumentError, "Dropdown requires a declaration block" unless block_given?
181
+
182
+ @collecting = true
183
+ yield(self)
184
+ raise ArgumentError, "Dropdown requires one trigger" unless @trigger
185
+ raise ArgumentError, "Dropdown requires at least one item" unless @entries.any? { |entry| entry.kind == :item }
186
+ ensure
187
+ @collecting = false
188
+ end
189
+
190
+ def render_trigger
191
+ component = Button.new(
192
+ @trigger.text,
193
+ variant: @trigger.variant,
194
+ size: @trigger.size,
195
+ icon: @trigger.icon,
196
+ icon_end: @trigger.icon_end,
197
+ label: @trigger.label,
198
+ disabled: @trigger.disabled,
199
+ id: trigger_id,
200
+ html: @trigger.html.merge(popovertarget: @trigger.disabled ? nil : content_id),
201
+ aria: @trigger.aria.merge(haspopup: "menu"),
202
+ data: owned_data(
203
+ @trigger.data,
204
+ nk__dropdown_target: "trigger",
205
+ action: "keydown->nk--dropdown#openFromKeyboard"
206
+ ),
207
+ desperately_need_a_class: @trigger.css_class
208
+ )
209
+ if @trigger.content
210
+ render_in_slot(component, :trigger) { render(@trigger.content) }
211
+ else
212
+ render_in_slot(component, :trigger)
47
213
  end
48
214
  end
49
215
 
50
- def content(as: :div, **attrs)
51
- builder do
52
- div(
53
- **mattr(
54
- attrs,
216
+ def render_content
217
+ div(
218
+ **slot_attributes(
219
+ :content,
220
+ attributes: {
221
+ id: content_id,
55
222
  role: "menu",
56
- aria: { hidden: "true" },
57
- class: content_class,
58
- data: { nk__dropdown_target: "content" },
59
- popover: true
60
- )
61
- ) do
62
- yield
63
- end
223
+ popover: "auto",
224
+ tabindex: -1,
225
+ aria: { labelledby: trigger_id },
226
+ data: {
227
+ placement: placement_value,
228
+ nk__dropdown_target: "content",
229
+ action: "beforetoggle->nk--dropdown#rememberFocus " \
230
+ "toggle->nk--dropdown#focusOpened keydown->nk--dropdown#navigate"
231
+ }
232
+ }
233
+ )
234
+ ) do
235
+ @entries.each { |entry| render_entry(entry) }
64
236
  end
65
237
  end
66
238
 
67
- def title(text = nil, **attrs, &block)
68
- builder do
69
- div(**mattr(attrs, class: title_class)) do
70
- text_or_block(text, &block)
71
- end
239
+ def render_entry(entry)
240
+ case entry.kind
241
+ when :title then render_title(entry)
242
+ when :item then render_item(entry)
243
+ when :separator then render_separator(entry)
72
244
  end
73
245
  end
74
246
 
75
- def item(text = nil, href: nil, variant: :default, **attrs, &block)
76
- builder do
77
- common_attrs = mattr(
78
- attrs,
79
- role: "menuitem",
80
- tabindex: "-1",
81
- class: [ item_class, item_variant_class(variant) ]
247
+ def render_title(entry)
248
+ div(
249
+ **slot_attributes(
250
+ :title,
251
+ attributes: {
252
+ role: "presentation",
253
+ aria: { hidden: true }
254
+ },
255
+ html: entry.html,
256
+ aria: entry.aria,
257
+ data: entry.data,
258
+ desperately_need_a_class: entry.css_class
82
259
  )
260
+ ) { render_content_value(entry) }
261
+ end
262
+
263
+ def render_item(entry)
264
+ native_attributes = if entry.href
265
+ {
266
+ href: entry.disabled ? nil : entry.href,
267
+ tabindex: -1,
268
+ aria: entry.aria.merge(disabled: entry.disabled ? true : nil)
269
+ }
270
+ else
271
+ {
272
+ type: entry.type,
273
+ disabled: entry.disabled,
274
+ tabindex: -1,
275
+ aria: entry.aria
276
+ }
277
+ end
278
+
279
+ attributes = slot_attributes(
280
+ :item,
281
+ attributes: native_attributes.merge(
282
+ role: "menuitem",
283
+ data: {
284
+ nk__dropdown_target: "item",
285
+ action: entry.disabled ? nil : "click->nk--dropdown#select"
286
+ }
287
+ ),
288
+ html: entry.html,
289
+ data: entry.data,
290
+ variant: entry.variant,
291
+ desperately_need_a_class: entry.css_class
292
+ )
83
293
 
84
- if href
85
- link_to(href, **common_attrs) do
86
- text_or_block(text, &block)
87
- end
88
- else
89
- div(**common_attrs) do
90
- text_or_block(text, &block)
91
- end
294
+ public_send(entry.href ? :a : :button, **attributes) do
295
+ if entry.icon
296
+ span(**slot_attributes(:item_icon)) { render(Icon.new(entry.icon, size: :sm)) }
92
297
  end
298
+ render_content_value(entry)
93
299
  end
94
300
  end
95
301
 
96
- def item_to(
97
- text_or_href,
98
- href = nil,
99
- **attrs,
100
- &block
101
- )
102
- builder do
103
- if block_given?
104
- href = text_or_href
105
- text_or_href = nil
106
- end
302
+ def render_separator(entry)
303
+ hr(
304
+ **slot_attributes(
305
+ :separator,
306
+ attributes: { role: "separator" },
307
+ html: entry.html,
308
+ aria: entry.aria,
309
+ data: entry.data,
310
+ desperately_need_a_class: entry.css_class
311
+ )
312
+ )
313
+ end
107
314
 
108
- item(text_or_href, href: href, **attrs, &block)
109
- end
315
+ def render_content_value(entry)
316
+ entry.content ? render(entry.content) : plain(entry.text.to_s)
110
317
  end
111
318
 
112
- def destructive_item(*args, **attrs, &block)
113
- builder do
114
- item(*args, **attrs, variant: :destructive, &block)
115
- end
319
+ def add_entry(**attributes)
320
+ @entries << Entry.new(**{
321
+ kind: nil,
322
+ text: nil,
323
+ href: nil,
324
+ icon: nil,
325
+ variant: nil,
326
+ type: nil,
327
+ disabled: false,
328
+ html: {},
329
+ aria: {},
330
+ data: {},
331
+ css_class: nil,
332
+ content: nil
333
+ }.merge(attributes))
334
+ nil
116
335
  end
117
336
 
118
- def destructive_item_to(text_or_block, href = nil, **attrs, &block)
119
- builder do
120
- href = args.shift if block_given?
121
- destructive_item(text_or_block, href: href, **attrs, &block)
122
- end
337
+ def validate_content!(name, text, content)
338
+ return unless text.nil? && !content
339
+
340
+ raise ArgumentError, "Dropdown #{name} requires text or a block"
123
341
  end
124
342
 
125
- def separator
126
- builder do
127
- hr(class: separator_class)
128
- end
343
+ def owned_data(data, **owned)
344
+ action_key = data.keys.find { |key| key.to_s.tr("_", "-") == "action" }
345
+ app_action = action_key && data[action_key]
346
+ data.except(action_key).merge(
347
+ owned.merge(action: [ owned[:action], app_action ].compact.join(" "))
348
+ )
129
349
  end
130
350
 
131
- private
351
+ def placement_value
352
+ placement.to_s.tr("_", "-")
353
+ end
132
354
 
133
- def content_class
134
- [
135
- "isolate w-max-content absolute top-0 left-0",
136
- "p-1 bg-background text-foreground rounded-md border shadow-sm",
137
- "w-fit max-w-sm flex-col text-left",
138
- "[&[aria-hidden=true]]:hidden flex"
139
- ]
355
+ def trigger_id
356
+ "#{identifier}-trigger"
140
357
  end
141
358
 
142
- def trigger_class
143
- ""
359
+ def content_id
360
+ "#{identifier}-content"
144
361
  end
145
362
 
146
- def title_class
147
- "px-3 pt-2 pb-1.5 text-muted-content text-sm"
363
+ def ensure_collecting!
364
+ return if @collecting
365
+
366
+ raise ArgumentError, "Dropdown declarations must be inside the render block"
148
367
  end
149
368
 
150
- def item_class
151
- [
152
- "px-3 py-1.5 rounded",
153
- "font-medium truncate",
154
- "cursor-default",
155
- "flex gap-2 items-center [&>span]:truncate"
156
- ]
369
+ def validate_trigger_choice!(name, value, choices)
370
+ return value if choices.include?(value)
371
+
372
+ raise ArgumentError,
373
+ "Unknown Dropdown trigger #{name} #{value.inspect}; expected one of: #{choices.map(&:inspect).join(", ")}"
157
374
  end
158
375
 
159
- def item_variant_class(variant)
160
- case variant
161
- when :default
162
- "[&[href]]:hover:bg-muted"
163
- when :destructive
164
- "text-destructive-content [&[href]]:hover:bg-destructive [&[href]]:hover:text-white"
165
- else
166
- raise ArgumentError, "Unknown variant: #{variant.inspect}"
167
- end
376
+ # Private copy of the AppShell identifier contract. Extract a shared
377
+ # validator into Component once its owner lands.
378
+ def component_id(value)
379
+ return generated_id if value.nil?
380
+ return value if value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
381
+
382
+ raise ArgumentError,
383
+ "Dropdown id must be nil or a String starting with a letter or digit and containing " \
384
+ "only letters, digits, hyphens, and underscores"
168
385
  end
169
386
 
170
- def separator_class
171
- "border-t my-1 -mx-1"
387
+ def generated_id
388
+ "nk-dropdown-#{SecureRandom.hex(4)}"
172
389
  end
173
390
  end
174
391
  end