nitro_kit 2.0.0.alpha.2 → 2.0.0.alpha.4

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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +229 -1
  3. data/README.md +25 -10
  4. data/STYLE_GUIDE.md +47 -5
  5. data/app/assets/stylesheets/nitro_kit-tailwind-v4.css +3 -3
  6. data/app/assets/stylesheets/nitro_kit.css +2820 -1816
  7. data/app/components/nitro_kit/accordion.rb +2 -26
  8. data/app/components/nitro_kit/alert.rb +1 -11
  9. data/app/components/nitro_kit/app_navigation.rb +45 -11
  10. data/app/components/nitro_kit/app_shell.rb +1 -7
  11. data/app/components/nitro_kit/appearance_picker.rb +11 -12
  12. data/app/components/nitro_kit/avatar.rb +6 -1
  13. data/app/components/nitro_kit/avatar_stack.rb +3 -0
  14. data/app/components/nitro_kit/badge.rb +13 -4
  15. data/app/components/nitro_kit/button_group.rb +2 -2
  16. data/app/components/nitro_kit/checkbox.rb +7 -4
  17. data/app/components/nitro_kit/checkbox_group.rb +0 -25
  18. data/app/components/nitro_kit/combobox.rb +4 -33
  19. data/app/components/nitro_kit/command_palette.rb +42 -59
  20. data/app/components/nitro_kit/component.rb +57 -5
  21. data/app/components/nitro_kit/danger_zone.rb +5 -1
  22. data/app/components/nitro_kit/data_section.rb +28 -4
  23. data/app/components/nitro_kit/details_table.rb +19 -8
  24. data/app/components/nitro_kit/dialog.rb +23 -19
  25. data/app/components/nitro_kit/dropdown.rb +1 -6
  26. data/app/components/nitro_kit/dropzone.rb +4 -15
  27. data/app/components/nitro_kit/empty_state.rb +21 -2
  28. data/app/components/nitro_kit/field.rb +35 -11
  29. data/app/components/nitro_kit/form_builder.rb +1 -2
  30. data/app/components/nitro_kit/progressive_image.rb +2 -2
  31. data/app/components/nitro_kit/radio_button.rb +7 -4
  32. data/app/components/nitro_kit/radio_button_group.rb +0 -25
  33. data/app/components/nitro_kit/select.rb +4 -17
  34. data/app/components/nitro_kit/settings_layout.rb +17 -4
  35. data/app/components/nitro_kit/{form_section.rb → settings_section.rb} +18 -11
  36. data/app/components/nitro_kit/sheet.rb +29 -20
  37. data/app/components/nitro_kit/stat_grid.rb +18 -2
  38. data/app/components/nitro_kit/table.rb +22 -3
  39. data/app/components/nitro_kit/tabs.rb +2 -13
  40. data/app/components/nitro_kit/toast.rb +4 -2
  41. data/app/components/nitro_kit/toolbar.rb +2 -0
  42. data/app/components/nitro_kit/tooltip.rb +7 -10
  43. data/app/javascript/controllers/nk/app_shell_controller.js +3 -3
  44. data/app/javascript/controllers/nk/appearance_controller.js +6 -0
  45. data/app/javascript/controllers/nk/combobox_controller.js +51 -1
  46. data/app/javascript/controllers/nk/command_palette_controller.js +25 -11
  47. data/app/javascript/controllers/nk/dialog_controller.js +54 -0
  48. data/app/javascript/controllers/nk/dropdown_controller.js +45 -0
  49. data/app/javascript/controllers/nk/dropzone_controller.js +2 -2
  50. data/app/javascript/controllers/nk/progressive_image_controller.js +11 -7
  51. data/app/javascript/controllers/nk/tabs_controller.js +21 -2
  52. data/app/javascript/controllers/nk/tooltip_controller.js +9 -0
  53. data/config/locales/en.yml +4 -1
  54. data/docs/agent_guide.md +63 -123
  55. data/docs/agent_native_spec.md +72 -383
  56. data/docs/browser_support.md +82 -0
  57. data/docs/component_contracts.md +108 -82
  58. data/docs/customization.md +689 -91
  59. data/docs/hotwire.md +50 -76
  60. data/docs/initialization_prompt.md +26 -31
  61. data/docs/migration_1_to_2.md +88 -30
  62. data/docs/patterns/application_foundation.md +45 -119
  63. data/docs/patterns/crud_resource.md +44 -126
  64. data/docs/patterns/destructive_action.md +45 -77
  65. data/docs/patterns/flash_and_toast.md +18 -36
  66. data/docs/patterns/inline_edit.md +21 -44
  67. data/docs/patterns/queryable_collection.md +65 -161
  68. data/docs/patterns/resource_form.md +33 -81
  69. data/docs/rails_conventions.md +38 -75
  70. data/docs/rails_integration.md +96 -446
  71. data/lib/generators/nitro_kit/upgrade_smoke_tests_generator.rb +12 -0
  72. data/lib/nitro_kit/installation.rb +20 -7
  73. data/lib/nitro_kit/migration_inventory.rb +275 -8
  74. data/lib/nitro_kit/upgrade_smoke_test.rb +10 -12
  75. data/lib/nitro_kit/version.rb +1 -1
  76. data/lib/rails/commands/nitro_kit/nitro_kit_command.rb +10 -3
  77. data/plugins/nitro-kit/skills/nitro-kit-hotwire/SKILL.md +4 -0
  78. data/plugins/nitro-kit/skills/nitro-kit-rails/SKILL.md +13 -1
  79. data/plugins/nitro-kit/skills/nitro-kit-ui/SKILL.md +32 -15
  80. data/src/stylesheets/nitro_kit/components/accordion.css +44 -44
  81. data/src/stylesheets/nitro_kit/components/alert.css +27 -36
  82. data/src/stylesheets/nitro_kit/components/app_navigation.css +92 -20
  83. data/src/stylesheets/nitro_kit/components/app_shell.css +199 -196
  84. data/src/stylesheets/nitro_kit/components/appearance_picker.css +61 -39
  85. data/src/stylesheets/nitro_kit/components/auth_shell.css +2 -2
  86. data/src/stylesheets/nitro_kit/components/avatar.css +15 -11
  87. data/src/stylesheets/nitro_kit/components/avatar_stack.css +48 -29
  88. data/src/stylesheets/nitro_kit/components/badge.css +13 -9
  89. data/src/stylesheets/nitro_kit/components/button.css +121 -79
  90. data/src/stylesheets/nitro_kit/components/button_group.css +19 -14
  91. data/src/stylesheets/nitro_kit/components/card.css +12 -6
  92. data/src/stylesheets/nitro_kit/components/checkbox.css +54 -67
  93. data/src/stylesheets/nitro_kit/components/checkbox_group.css +16 -16
  94. data/src/stylesheets/nitro_kit/components/combobox.css +39 -29
  95. data/src/stylesheets/nitro_kit/components/command_palette.css +110 -51
  96. data/src/stylesheets/nitro_kit/components/container.css +6 -6
  97. data/src/stylesheets/nitro_kit/components/control_group.css +61 -67
  98. data/src/stylesheets/nitro_kit/components/danger_zone.css +8 -8
  99. data/src/stylesheets/nitro_kit/components/data_section.css +4 -4
  100. data/src/stylesheets/nitro_kit/components/details_table.css +17 -15
  101. data/src/stylesheets/nitro_kit/components/dialog.css +8 -5
  102. data/src/stylesheets/nitro_kit/components/dropdown.css +36 -30
  103. data/src/stylesheets/nitro_kit/components/dropzone.css +78 -35
  104. data/src/stylesheets/nitro_kit/components/empty_state.css +7 -7
  105. data/src/stylesheets/nitro_kit/components/field.css +32 -28
  106. data/src/stylesheets/nitro_kit/components/field_group.css +13 -0
  107. data/src/stylesheets/nitro_kit/components/fieldset.css +8 -2
  108. data/src/stylesheets/nitro_kit/components/flex.css +1 -1
  109. data/src/stylesheets/nitro_kit/components/grid.css +1 -1
  110. data/src/stylesheets/nitro_kit/components/icon.css +5 -5
  111. data/src/stylesheets/nitro_kit/components/input.css +42 -8
  112. data/src/stylesheets/nitro_kit/components/layout.css +165 -165
  113. data/src/stylesheets/nitro_kit/components/page_header.css +7 -7
  114. data/src/stylesheets/nitro_kit/components/pagination.css +58 -49
  115. data/src/stylesheets/nitro_kit/components/pagination_bar.css +9 -9
  116. data/src/stylesheets/nitro_kit/components/palette.css +145 -77
  117. data/src/stylesheets/nitro_kit/components/progressive_image.css +45 -55
  118. data/src/stylesheets/nitro_kit/components/radio_button.css +48 -46
  119. data/src/stylesheets/nitro_kit/components/radio_button_group.css +49 -49
  120. data/src/stylesheets/nitro_kit/components/rich_text_area.css +16 -1
  121. data/src/stylesheets/nitro_kit/components/select.css +24 -15
  122. data/src/stylesheets/nitro_kit/components/settings_layout.css +26 -23
  123. data/src/stylesheets/nitro_kit/components/settings_section.css +84 -0
  124. data/src/stylesheets/nitro_kit/components/sheet.css +30 -10
  125. data/src/stylesheets/nitro_kit/components/stat_grid.css +11 -10
  126. data/src/stylesheets/nitro_kit/components/switch.css +60 -62
  127. data/src/stylesheets/nitro_kit/components/table.css +43 -35
  128. data/src/stylesheets/nitro_kit/components/tabs.css +48 -40
  129. data/src/stylesheets/nitro_kit/components/textarea.css +20 -5
  130. data/src/stylesheets/nitro_kit/components/toast.css +11 -47
  131. data/src/stylesheets/nitro_kit/components/toolbar.css +16 -41
  132. data/src/stylesheets/nitro_kit/components/tooltip.css +38 -29
  133. data/src/stylesheets/nitro_kit/components/typeset.css +150 -4
  134. data/src/stylesheets/nitro_kit/reset.css +15 -15
  135. data/src/stylesheets/nitro_kit/tokens.css +501 -79
  136. metadata +4 -4
  137. data/docs/new_app_strategy.md +0 -22
  138. data/src/stylesheets/nitro_kit/components/form_section.css +0 -71
@@ -13,7 +13,7 @@ module NitroKit
13
13
  data: {},
14
14
  desperately_need_a_class: nil
15
15
  )
16
- @identifier = component_id(id)
16
+ @identifier = validate_id!("Accordion id", id)
17
17
  @mode = validate_choice!(:mode, mode, MODES)
18
18
  @items = []
19
19
 
@@ -106,25 +106,7 @@ module NitroKit
106
106
  end
107
107
 
108
108
  def chevron
109
- svg(
110
- **slot_attributes(
111
- :icon,
112
- attributes: {
113
- viewbox: "0 0 16 16",
114
- width: 16,
115
- height: 16,
116
- fill: "none",
117
- stroke: "currentColor",
118
- stroke_width: 1.5,
119
- stroke_linecap: "round",
120
- stroke_linejoin: "round",
121
- focusable: "false",
122
- aria: { hidden: true }
123
- }
124
- )
125
- ) do |svg|
126
- svg.path(d: "m4 6 4 4 4-4")
127
- end
109
+ render_in_slot(Icon.new(:chevron_down, size: :sm), :icon)
128
110
  end
129
111
 
130
112
  def trigger_id(item)
@@ -152,11 +134,5 @@ module NitroKit
152
134
 
153
135
  raise ArgumentError, "Accordion #{name} must be true or false"
154
136
  end
155
-
156
- def component_id(value)
157
- return value if value.is_a?(String) && value.present? && !value.match?(/\s/)
158
-
159
- raise ArgumentError, "Accordion id must be a non-blank String without whitespace"
160
- end
161
137
  end
162
138
  end
@@ -2,17 +2,7 @@
2
2
 
3
3
  module NitroKit
4
4
  class Alert < Component
5
- VARIANTS = %i[default info success warning error].freeze
6
-
7
- # The palette family each semantic variant borrows in `palette.css`.
8
- # Alert owns this mapping; it is not the Badge color axis.
9
- VARIANT_PALETTE = {
10
- default: :zinc,
11
- info: :blue,
12
- success: :green,
13
- warning: :amber,
14
- error: :red
15
- }.freeze
5
+ VARIANTS = %i[default info success warning destructive].freeze
16
6
 
17
7
  LIVE_MODES = %i[off polite assertive].freeze
18
8
 
@@ -5,8 +5,8 @@ module NitroKit
5
5
  alias_method :html_header, :header
6
6
  alias_method :html_footer, :footer
7
7
 
8
- Item = ::Data.define(:text, :href, :icon, :badge, :current, :html, :aria, :data, :css_class)
9
- Section = ::Data.define(:label, :entries)
8
+ Item = ::Data.define(:text, :href, :icon, :icon_end, :badge, :current, :html, :aria, :data, :css_class)
9
+ Section = ::Data.define(:label, :entries, :collapsible, :expanded)
10
10
  Entry = ::Data.define(:kind)
11
11
 
12
12
  def initialize(
@@ -78,10 +78,16 @@ module NitroKit
78
78
  nil
79
79
  end
80
80
 
81
- def section(label: nil, &content)
81
+ def section(label: nil, collapsible: false, expanded: true, &content)
82
82
  ensure_phase!(:body, :section)
83
83
  raise ArgumentError, "AppNavigation section requires a block" unless content
84
84
 
85
+ collapsible = validate_boolean!(:collapsible, collapsible)
86
+ expanded = validate_boolean!(:expanded, expanded)
87
+ if collapsible && label.nil?
88
+ raise ArgumentError, "AppNavigation collapsible section requires a label"
89
+ end
90
+
85
91
  entries = []
86
92
  previous_entries = @entry_target
87
93
  previous_phase = @phase
@@ -94,7 +100,12 @@ module NitroKit
94
100
  unless entries.any?(Item)
95
101
  raise ArgumentError, "AppNavigation section requires at least one item"
96
102
  end
97
- previous_entries << Section.new(label: validate_optional_text!(:label, label), entries:)
103
+ previous_entries << Section.new(
104
+ label: validate_optional_text!(:label, label),
105
+ entries:,
106
+ collapsible:,
107
+ expanded:
108
+ )
98
109
  nil
99
110
  ensure
100
111
  @entry_target = previous_entries
@@ -106,6 +117,7 @@ module NitroKit
106
117
  text,
107
118
  href:,
108
119
  icon: nil,
120
+ icon_end: nil,
109
121
  badge: nil,
110
122
  badge_color: :neutral,
111
123
  current: false,
@@ -118,6 +130,7 @@ module NitroKit
118
130
  text = validate_text!(:text, text)
119
131
  href = validate_text!(:href, href)
120
132
  icon = item_icon(icon)
133
+ icon_end = item_icon(icon_end, name: :icon_end)
121
134
  badge = item_badge(badge, badge_color)
122
135
  current = validate_boolean!(:current, current)
123
136
 
@@ -130,6 +143,7 @@ module NitroKit
130
143
  text:,
131
144
  href:,
132
145
  icon:,
146
+ icon_end:,
133
147
  badge:,
134
148
  current:,
135
149
  html:,
@@ -205,13 +219,33 @@ module NitroKit
205
219
 
206
220
  def render_section(entry)
207
221
  li(**slot_attributes(:section)) do
208
- span(**slot_attributes(:section_label)) { plain(entry.label) } if entry.label
209
- ul(**slot_attributes(:section_list, attributes: section_list_attributes(entry))) do
210
- entry.entries.each { |child| render_entry(child) }
222
+ if entry.collapsible
223
+ details(
224
+ **slot_attributes(:section_disclosure, attributes: { open: entry.expanded })
225
+ ) do
226
+ summary(**slot_attributes(:section_label)) do
227
+ plain(entry.label)
228
+ section_chevron
229
+ end
230
+ render_section_list(entry)
231
+ end
232
+ else
233
+ span(**slot_attributes(:section_label)) { plain(entry.label) } if entry.label
234
+ render_section_list(entry)
211
235
  end
212
236
  end
213
237
  end
214
238
 
239
+ def render_section_list(entry)
240
+ ul(**slot_attributes(:section_list, attributes: section_list_attributes(entry))) do
241
+ entry.entries.each { |child| render_entry(child) }
242
+ end
243
+ end
244
+
245
+ def section_chevron
246
+ render_in_slot(Icon.new(:chevron_down, size: :xs), :section_icon)
247
+ end
248
+
215
249
  def section_list_attributes(entry)
216
250
  entry.label ? { aria: { label: entry.label } } : {}
217
251
  end
@@ -223,8 +257,7 @@ module NitroKit
223
257
  :item_link,
224
258
  attributes: {
225
259
  href: entry.href,
226
- aria: { current: entry.current ? "page" : nil },
227
- data: { state: entry.current ? "current" : "default" }
260
+ aria: { current: entry.current ? "page" : nil }
228
261
  },
229
262
  html: entry.html,
230
263
  aria: entry.aria,
@@ -235,6 +268,7 @@ module NitroKit
235
268
  render_in_slot(entry.icon, :item_icon) if entry.icon
236
269
  span(**slot_attributes(:item_label)) { plain(entry.text) }
237
270
  render_in_slot(entry.badge, :item_badge) if entry.badge
271
+ render_in_slot(entry.icon_end, :item_icon_end) if entry.icon_end
238
272
  end
239
273
  end
240
274
  end
@@ -278,10 +312,10 @@ module NitroKit
278
312
  validate_text!(name, value)
279
313
  end
280
314
 
281
- def item_icon(value)
315
+ def item_icon(value, name: :icon)
282
316
  return if value.nil?
283
317
  unless (value.is_a?(String) || value.is_a?(Symbol)) && !value.to_s.strip.empty?
284
- raise ArgumentError, "icon must be a non-blank String or Symbol"
318
+ raise ArgumentError, "#{name} must be a non-blank String or Symbol"
285
319
  end
286
320
 
287
321
  Icon.new(value, size: :sm)
@@ -21,7 +21,7 @@ module NitroKit
21
21
  data: {},
22
22
  desperately_need_a_class: nil
23
23
  )
24
- @identifier = component_id(id)
24
+ @identifier = validate_id!("AppShell id", id)
25
25
  @layout = validate_choice!(:layout, layout, LAYOUTS)
26
26
  @skip_link_label = validate_label!(:skip_link_label, skip_link_label)
27
27
  @open_navigation_label = validate_label!(:open_navigation_label, open_navigation_label)
@@ -212,12 +212,6 @@ module NitroKit
212
212
  "#{identifier}-main"
213
213
  end
214
214
 
215
- def component_id(value)
216
- return value if value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
217
-
218
- raise ArgumentError, "AppShell id must be a fragment-safe String using letters, numbers, underscores, or hyphens"
219
- end
220
-
221
215
  def validate_label!(name, value)
222
216
  return value if value.is_a?(String) && !value.strip.empty?
223
217
 
@@ -13,6 +13,7 @@ module NitroKit
13
13
  def initialize(
14
14
  id:,
15
15
  label: I18n.t("nitro_kit.appearance_picker.label"),
16
+ label_visible: true,
16
17
  presentation: :segmented,
17
18
  preference: :system,
18
19
  html: {},
@@ -20,15 +21,23 @@ module NitroKit
20
21
  data: {},
21
22
  desperately_need_a_class: nil
22
23
  )
23
- @identifier = component_id(id)
24
+ @identifier = validate_id!("AppearancePicker id", id)
24
25
  @label = validate_label!(label)
26
+ @label_visible = validate_boolean!(:label_visible, label_visible)
25
27
  @presentation = validate_choice!(:presentation, presentation, PRESENTATIONS)
26
28
  @preference = validate_choice!(:preference, preference, PREFERENCES)
29
+ if !@label_visible && @presentation != :segmented
30
+ raise ArgumentError,
31
+ "AppearancePicker label_visible: false requires the segmented presentation"
32
+ end
27
33
 
28
34
  super(
29
35
  component: :appearance_picker,
30
36
  attributes: {
31
37
  id: @identifier,
38
+ # Browsers give legends special layout, so a hidden label omits the
39
+ # element entirely and the fieldset keeps its name through ARIA.
40
+ aria: @label_visible ? {} : { label: @label },
32
41
  data: {
33
42
  controller: "nk--appearance",
34
43
  presentation: @presentation,
@@ -50,7 +59,7 @@ module NitroKit
50
59
  return render_dropdown if @presentation == :dropdown
51
60
 
52
61
  fieldset(**root_attributes) do
53
- legend(**slot_attributes(:legend)) { plain(@label) }
62
+ legend(**slot_attributes(:legend)) { plain(@label) } if @label_visible
54
63
  div(**slot_attributes(:options)) do
55
64
  PREFERENCES.each { |preference| render_option(preference) }
56
65
  end
@@ -137,16 +146,6 @@ module NitroKit
137
146
  "#{identifier}-#{preference}"
138
147
  end
139
148
 
140
- # Private copy of the AppShell identifier contract. Extract a shared
141
- # validator into Component once its owner lands.
142
- def component_id(value)
143
- return value if value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
144
-
145
- raise ArgumentError,
146
- "AppearancePicker id must be a String starting with a letter or digit and containing " \
147
- "only letters, digits, hyphens, and underscores"
148
- end
149
-
150
149
  def validate_label!(value)
151
150
  return value if value.is_a?(String) && value.present?
152
151
 
@@ -9,6 +9,7 @@ module NitroKit
9
9
  alt: "",
10
10
  fallback: nil,
11
11
  size: :md,
12
+ decorative: false,
12
13
  loading: "lazy",
13
14
  decoding: "async",
14
15
  id: nil,
@@ -18,12 +19,16 @@ module NitroKit
18
19
  desperately_need_a_class: nil
19
20
  )
20
21
  raise ArgumentError, "alt must be a String" unless alt.is_a?(String)
22
+ @decorative = validate_boolean!(:decorative, decorative)
21
23
  unless fallback.nil? || fallback.is_a?(String)
22
24
  raise ArgumentError, "fallback must be a String or nil"
23
25
  end
24
26
 
25
27
  @src = src
26
28
  @alt = alt
29
+ if src? && alt.empty? && !@decorative
30
+ raise ArgumentError, "Avatar images require alt: text unless decorative: true"
31
+ end
27
32
  @fallback = fallback || initials_for(alt)
28
33
  @size = validate_choice!(:size, size, SIZES)
29
34
  root_aria = fallback_aria(aria)
@@ -49,7 +54,7 @@ module NitroKit
49
54
  }
50
55
  end
51
56
 
52
- attr_reader :src, :alt, :fallback, :size
57
+ attr_reader :src, :alt, :fallback, :size, :decorative
53
58
 
54
59
  def view_template
55
60
  span(**root_attributes) do
@@ -61,6 +61,7 @@ module NitroKit
61
61
  src: nil,
62
62
  alt: "",
63
63
  fallback: nil,
64
+ decorative: false,
64
65
  loading: "lazy",
65
66
  decoding: "async",
66
67
  id: nil,
@@ -75,6 +76,7 @@ module NitroKit
75
76
  src:,
76
77
  alt:,
77
78
  fallback:,
79
+ decorative:,
78
80
  size:,
79
81
  loading:,
80
82
  decoding:,
@@ -156,6 +158,7 @@ module NitroKit
156
158
  span(
157
159
  **slot_attributes(
158
160
  :overflow,
161
+ attributes: { role: "img" },
159
162
  html: declaration.html,
160
163
  aria: declaration.aria.merge(
161
164
  label: declaration.label || I18n.t("nitro_kit.avatar_stack.overflow", count: declaration.count)
@@ -4,16 +4,25 @@ module NitroKit
4
4
  class Badge < Component
5
5
  VARIANTS = %i[default outline].freeze
6
6
  SIZES = %i[xs sm md].freeze
7
- COLORS = %i[
8
- zinc red orange amber yellow lime green emerald teal cyan sky blue indigo
9
- violet purple fuchsia pink rose neutral info success warning danger
7
+ # Two axes on one option, both public and both themeable.
8
+ #
9
+ # The semantic families follow the `--nk-palette-{family}` tint roles, so
10
+ # they move with an application's brand: retheme `--nk-palette-destructive` and
11
+ # every `destructive` badge follows. The decorative hues follow the
12
+ # `--nk-palette-{hue}` roles and stay the color they name, for categorical
13
+ # labelling where meaning is not the point.
14
+ SEMANTIC_COLORS = %i[neutral info success warning destructive].freeze
15
+ PALETTE_COLORS = %i[
16
+ red orange amber yellow lime green emerald teal cyan sky blue indigo
17
+ violet purple fuchsia pink rose
10
18
  ].freeze
19
+ COLORS = (PALETTE_COLORS + SEMANTIC_COLORS).freeze
11
20
 
12
21
  def initialize(
13
22
  text = nil,
14
23
  variant: :default,
15
24
  size: :md,
16
- color: :zinc,
25
+ color: :neutral,
17
26
  id: nil,
18
27
  html: {},
19
28
  aria: {},
@@ -28,9 +28,9 @@ module NitroKit
28
28
 
29
29
  super(
30
30
  component: :button_group,
31
- attributes: { id:, role: "group" },
31
+ attributes: { id:, role: "group", aria: { label: }.compact },
32
32
  html:,
33
- aria: label ? aria.merge(label:) : aria,
33
+ aria:,
34
34
  data:,
35
35
  desperately_need_a_class:
36
36
  )
@@ -78,12 +78,15 @@ module NitroKit
78
78
  render_in_slot(Label.new(for: id), :label) do
79
79
  render_control
80
80
  span(**slot_attributes(:indicator), aria: { hidden: "true" })
81
- span(**slot_attributes(:content)) do
82
- span(**slot_attributes(:label_text)) { text_or_block(label, &block) }
83
- span(**slot_attributes(:description, attributes: { id: description_id })) { plain(description) } if description
84
- end
81
+ span(**slot_attributes(:label_text)) { text_or_block(label, &block) }
85
82
  end
86
83
  end
84
+
85
+ # The description stays outside the label so aria-describedby carries it
86
+ # instead of it joining the control's accessible name.
87
+ if description
88
+ span(**slot_attributes(:description, attributes: { id: description_id })) { plain(description) }
89
+ end
87
90
  end
88
91
  end
89
92
 
@@ -122,30 +122,5 @@ module NitroKit
122
122
  def multiple_name(name)
123
123
  name.end_with?("[]") ? name : "#{name}[]"
124
124
  end
125
-
126
- def deterministic_id(name)
127
- derived = name.to_s.gsub(/[^a-zA-Z0-9_-]+/, "_").gsub(/\A_+|_+\z/, "")
128
- return derived unless derived.empty?
129
-
130
- raise ArgumentError, "name #{name.inspect} cannot derive an id; pass id:"
131
- end
132
-
133
- def choice_id(index)
134
- "#{id}-#{index}" if id
135
- end
136
-
137
- def validate_text!(name, text)
138
- return text if text.is_a?(String) && !text.strip.empty?
139
-
140
- raise ArgumentError, "#{name} must be a non-blank String"
141
- end
142
-
143
- def validate_unique_choices!
144
- duplicate_value = options.group_by { |choice| choice.value.to_s }.find { |_value, matches| matches.many? }
145
- raise ArgumentError, "choice values must be unique" if duplicate_value
146
-
147
- ids = options.each_with_index.filter_map { |choice, index| choice.id || choice_id(index) }
148
- raise ArgumentError, "choice ids must be unique" unless ids.uniq.length == ids.length
149
- end
150
125
  end
151
126
  end
@@ -22,8 +22,8 @@ module NitroKit
22
22
  control_aria: {},
23
23
  desperately_need_a_class: nil
24
24
  )
25
- @identifier = component_id(id)
26
- @name = form_name(name)
25
+ @identifier = validate_id!("Combobox id", id)
26
+ @name = validate_text!("Combobox name", name)
27
27
  @label = visible_label(label)
28
28
  @options = typed_options(options)
29
29
  @value = value
@@ -52,7 +52,6 @@ module NitroKit
52
52
  controller: "nk--combobox",
53
53
  placement: placement_value,
54
54
  state: "closed",
55
- action: "click@window->nk--combobox#closeFromOutside",
56
55
  nk__combobox_open_value: "false",
57
56
  nk__combobox_required_value: @required,
58
57
  nk__combobox_invalid_selection_value: I18n.t("nitro_kit.combobox.invalid_selection"),
@@ -103,7 +102,7 @@ module NitroKit
103
102
  autocomplete: "list",
104
103
  controls: listbox_id,
105
104
  expanded: false,
106
- required: @required
105
+ required: @required || nil
107
106
  ),
108
107
  data: {
109
108
  nk__combobox_target: "input",
@@ -118,23 +117,7 @@ module NitroKit
118
117
  :input
119
118
  )
120
119
 
121
- svg(
122
- **slot_attributes(
123
- :icon,
124
- attributes: {
125
- viewbox: "0 0 16 16",
126
- width: 16,
127
- height: 16,
128
- fill: "none",
129
- stroke: "currentColor",
130
- stroke_width: 1.5,
131
- stroke_linecap: "round",
132
- stroke_linejoin: "round",
133
- focusable: "false",
134
- aria: { hidden: true }
135
- }
136
- )
137
- ) { |svg| svg.path(d: "m4 6 4 4 4-4") }
120
+ render_in_slot(Icon.new(:chevron_down, size: :sm), :icon)
138
121
  end
139
122
  end
140
123
 
@@ -299,18 +282,6 @@ module NitroKit
299
282
  end
300
283
  end
301
284
 
302
- def component_id(value)
303
- return value if value.is_a?(String) && value.present? && !value.match?(/\s/)
304
-
305
- raise ArgumentError, "Combobox id must be a non-blank String without whitespace"
306
- end
307
-
308
- def form_name(value)
309
- return value if value.is_a?(String) && value.present?
310
-
311
- raise ArgumentError, "Combobox name must be a non-blank String"
312
- end
313
-
314
285
  def visible_label(value)
315
286
  return value if value == false
316
287
  return value if value.is_a?(String) && value.present?
@@ -16,12 +16,6 @@ module NitroKit
16
16
  module Validation
17
17
  private
18
18
 
19
- def component_id(value)
20
- return value if value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
21
-
22
- raise ArgumentError, "CommandPalette id: must contain only letters, numbers, underscores, and hyphens"
23
- end
24
-
25
19
  def required_text(name, value)
26
20
  return value if value.is_a?(String) && !value.strip.empty?
27
21
 
@@ -30,10 +24,38 @@ module NitroKit
30
24
  end
31
25
  private_constant :Validation
32
26
 
27
+ module Destinations
28
+ def destination(
29
+ label,
30
+ href:,
31
+ description: nil,
32
+ html: {},
33
+ aria: {},
34
+ data: {},
35
+ desperately_need_a_class: nil
36
+ )
37
+ ensure_collecting!
38
+
39
+ @destinations << Destination.new(
40
+ label: required_text(:label, label),
41
+ href: required_text(:href, href),
42
+ description: validate_optional_text!(:description, description),
43
+ html:,
44
+ aria:,
45
+ data:,
46
+ css_class: desperately_need_a_class
47
+ )
48
+ nil
49
+ end
50
+ end
51
+ private_constant :Destinations
52
+
33
53
  include Validation
54
+ include Destinations
34
55
 
35
56
  class Results < Component
36
57
  include Validation
58
+ include Destinations
37
59
 
38
60
  def initialize(
39
61
  id:,
@@ -42,7 +64,7 @@ module NitroKit
42
64
  data: {},
43
65
  desperately_need_a_class: nil
44
66
  )
45
- @identifier = component_id(id)
67
+ @identifier = validate_id!("CommandPalette id:", id)
46
68
  @destinations = []
47
69
 
48
70
  super(
@@ -65,38 +87,16 @@ module NitroKit
65
87
  )
66
88
  end
67
89
 
68
- def destination(
69
- label,
70
- href:,
71
- description: nil,
72
- html: {},
73
- aria: {},
74
- data: {},
75
- desperately_need_a_class: nil
76
- )
77
- ensure_collecting!
78
-
79
- @destinations << Destination.new(
80
- label: required_text(:label, label),
81
- href: required_text(:href, href),
82
- description: validate_optional_text!(:description, description),
83
- html:,
84
- aria:,
85
- data:,
86
- css_class: desperately_need_a_class
87
- )
88
- nil
89
- end
90
-
91
90
  def view_template(&block)
92
91
  @collecting = true
93
92
  yield(self) if block
94
93
 
95
94
  tag(:"turbo-frame", **root_attributes) do
96
95
  nav(
97
- id: results_id,
98
- aria: { labelledby: title_id },
99
- data: { slot: "command-palette-results" }
96
+ **slot_attributes(
97
+ :results,
98
+ attributes: { id: results_id, aria: { labelledby: title_id } }
99
+ )
100
100
  ) do
101
101
  @destinations.each_with_index { |destination, index| render_destination(destination, index) }
102
102
  end
@@ -163,7 +163,7 @@ module NitroKit
163
163
  data: {},
164
164
  desperately_need_a_class: nil
165
165
  )
166
- @identifier = component_id(id)
166
+ @identifier = validate_id!("CommandPalette id:", id)
167
167
  @label = required_text(:label, label)
168
168
  @placeholder = required_text(:placeholder, placeholder)
169
169
  @empty_text = required_text(:empty_text, empty_text)
@@ -204,29 +204,6 @@ module NitroKit
204
204
  end
205
205
  end
206
206
 
207
- def destination(
208
- label,
209
- href:,
210
- description: nil,
211
- html: {},
212
- aria: {},
213
- data: {},
214
- desperately_need_a_class: nil
215
- )
216
- ensure_collecting!
217
-
218
- @destinations << Destination.new(
219
- label: required_text(:label, label),
220
- href: required_text(:href, href),
221
- description: validate_optional_text!(:description, description),
222
- html:,
223
- aria:,
224
- data:,
225
- css_class: desperately_need_a_class
226
- )
227
- nil
228
- end
229
-
230
207
  private
231
208
 
232
209
  def collect_destinations
@@ -250,7 +227,8 @@ module NitroKit
250
227
  }.compact,
251
228
  data: {
252
229
  nk__command_palette_target: "trigger",
253
- action: "click->nk--command-palette#open"
230
+ nk__dialog_command: "show-modal",
231
+ action: "click->nk--command-palette#openedByTrigger"
254
232
  }
255
233
  ),
256
234
  :trigger
@@ -276,8 +254,10 @@ module NitroKit
276
254
  nk__dialog_target: "panel",
277
255
  nk__command_palette_target: "panel",
278
256
  action: [
257
+ "command->nk--command-palette#guardOpen",
279
258
  "click->nk--dialog#dismiss",
280
259
  "cancel->nk--dialog#cancel",
260
+ "close->nk--dialog#restoreFocus",
281
261
  "close->nk--command-palette#closed"
282
262
  ].join(" ")
283
263
  }
@@ -342,6 +322,7 @@ module NitroKit
342
322
  variant: :ghost,
343
323
  size: :md,
344
324
  html: { command: "close", commandfor: panel_id },
325
+ data: { nk__dialog_command: "close" },
345
326
  aria: { label: @close_label }
346
327
  ),
347
328
  :close
@@ -392,7 +373,9 @@ module NitroKit
392
373
  def root_actions
393
374
  actions = [
394
375
  "turbo:before-visit@document->nk--command-palette#closeForVisit",
395
- "turbo:before-cache@document->nk--command-palette#closeForVisit"
376
+ "turbo:before-cache@document->nk--command-palette#closeForVisit",
377
+ "click->nk--dialog#invoke",
378
+ "turbo:before-cache@document->nk--dialog#closeForCache"
396
379
  ]
397
380
  actions.prepend("keydown@document->nk--command-palette#shortcut") if @shortcut
398
381
  actions.join(" ")