nitro_kit 2.0.0.alpha.2 → 2.0.0.alpha.3

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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/README.md +23 -4
  4. data/STYLE_GUIDE.md +37 -5
  5. data/app/assets/stylesheets/nitro_kit.css +1714 -1358
  6. data/app/components/nitro_kit/accordion.rb +1 -7
  7. data/app/components/nitro_kit/app_navigation.rb +1 -2
  8. data/app/components/nitro_kit/app_shell.rb +1 -7
  9. data/app/components/nitro_kit/appearance_picker.rb +1 -11
  10. data/app/components/nitro_kit/avatar.rb +6 -1
  11. data/app/components/nitro_kit/avatar_stack.rb +3 -0
  12. data/app/components/nitro_kit/button_group.rb +2 -2
  13. data/app/components/nitro_kit/checkbox.rb +7 -4
  14. data/app/components/nitro_kit/checkbox_group.rb +0 -25
  15. data/app/components/nitro_kit/combobox.rb +3 -16
  16. data/app/components/nitro_kit/command_palette.rb +41 -59
  17. data/app/components/nitro_kit/component.rb +57 -5
  18. data/app/components/nitro_kit/danger_zone.rb +5 -1
  19. data/app/components/nitro_kit/data_section.rb +28 -4
  20. data/app/components/nitro_kit/details_table.rb +19 -8
  21. data/app/components/nitro_kit/dialog.rb +23 -19
  22. data/app/components/nitro_kit/dropdown.rb +1 -6
  23. data/app/components/nitro_kit/dropzone.rb +3 -14
  24. data/app/components/nitro_kit/empty_state.rb +21 -2
  25. data/app/components/nitro_kit/field.rb +35 -11
  26. data/app/components/nitro_kit/form_builder.rb +0 -1
  27. data/app/components/nitro_kit/progressive_image.rb +2 -2
  28. data/app/components/nitro_kit/radio_button.rb +7 -4
  29. data/app/components/nitro_kit/radio_button_group.rb +0 -25
  30. data/app/components/nitro_kit/select.rb +3 -3
  31. data/app/components/nitro_kit/settings_layout.rb +17 -4
  32. data/app/components/nitro_kit/{form_section.rb → settings_section.rb} +18 -11
  33. data/app/components/nitro_kit/sheet.rb +29 -20
  34. data/app/components/nitro_kit/stat_grid.rb +18 -2
  35. data/app/components/nitro_kit/table.rb +22 -3
  36. data/app/components/nitro_kit/tabs.rb +1 -13
  37. data/app/components/nitro_kit/toast.rb +4 -2
  38. data/app/components/nitro_kit/toolbar.rb +2 -0
  39. data/app/components/nitro_kit/tooltip.rb +7 -10
  40. data/app/javascript/controllers/nk/app_shell_controller.js +3 -3
  41. data/app/javascript/controllers/nk/appearance_controller.js +6 -0
  42. data/app/javascript/controllers/nk/combobox_controller.js +51 -1
  43. data/app/javascript/controllers/nk/command_palette_controller.js +21 -11
  44. data/app/javascript/controllers/nk/dialog_controller.js +54 -0
  45. data/app/javascript/controllers/nk/dropdown_controller.js +45 -0
  46. data/app/javascript/controllers/nk/dropzone_controller.js +2 -2
  47. data/app/javascript/controllers/nk/progressive_image_controller.js +11 -7
  48. data/app/javascript/controllers/nk/tabs_controller.js +8 -1
  49. data/app/javascript/controllers/nk/tooltip_controller.js +9 -0
  50. data/config/locales/en.yml +4 -1
  51. data/docs/agent_guide.md +31 -16
  52. data/docs/agent_native_spec.md +27 -10
  53. data/docs/browser_support.md +193 -0
  54. data/docs/component_contracts.md +88 -82
  55. data/docs/customization.md +27 -15
  56. data/docs/hotwire.md +15 -3
  57. data/docs/initialization_prompt.md +31 -6
  58. data/docs/migration_1_to_2.md +61 -19
  59. data/docs/patterns/application_foundation.md +6 -4
  60. data/docs/patterns/crud_resource.md +6 -3
  61. data/docs/patterns/destructive_action.md +25 -0
  62. data/docs/patterns/resource_form.md +1 -1
  63. data/docs/rails_conventions.md +4 -1
  64. data/docs/rails_integration.md +39 -11
  65. data/lib/generators/nitro_kit/upgrade_smoke_tests_generator.rb +12 -0
  66. data/lib/nitro_kit/installation.rb +9 -4
  67. data/lib/nitro_kit/migration_inventory.rb +268 -8
  68. data/lib/nitro_kit/upgrade_smoke_test.rb +10 -12
  69. data/lib/nitro_kit/version.rb +1 -1
  70. data/lib/rails/commands/nitro_kit/nitro_kit_command.rb +10 -3
  71. data/plugins/nitro-kit/skills/nitro-kit-hotwire/SKILL.md +4 -0
  72. data/plugins/nitro-kit/skills/nitro-kit-rails/SKILL.md +6 -1
  73. data/plugins/nitro-kit/skills/nitro-kit-ui/SKILL.md +18 -8
  74. data/src/stylesheets/nitro_kit/components/accordion.css +40 -40
  75. data/src/stylesheets/nitro_kit/components/alert.css +9 -9
  76. data/src/stylesheets/nitro_kit/components/app_navigation.css +19 -19
  77. data/src/stylesheets/nitro_kit/components/app_shell.css +196 -193
  78. data/src/stylesheets/nitro_kit/components/appearance_picker.css +41 -31
  79. data/src/stylesheets/nitro_kit/components/avatar.css +3 -3
  80. data/src/stylesheets/nitro_kit/components/avatar_stack.css +36 -19
  81. data/src/stylesheets/nitro_kit/components/button.css +83 -58
  82. data/src/stylesheets/nitro_kit/components/button_group.css +19 -14
  83. data/src/stylesheets/nitro_kit/components/card.css +11 -5
  84. data/src/stylesheets/nitro_kit/components/checkbox.css +40 -36
  85. data/src/stylesheets/nitro_kit/components/checkbox_group.css +13 -13
  86. data/src/stylesheets/nitro_kit/components/combobox.css +33 -23
  87. data/src/stylesheets/nitro_kit/components/command_palette.css +48 -47
  88. data/src/stylesheets/nitro_kit/components/control_group.css +52 -58
  89. data/src/stylesheets/nitro_kit/components/danger_zone.css +3 -3
  90. data/src/stylesheets/nitro_kit/components/data_section.css +1 -1
  91. data/src/stylesheets/nitro_kit/components/details_table.css +13 -13
  92. data/src/stylesheets/nitro_kit/components/dialog.css +6 -3
  93. data/src/stylesheets/nitro_kit/components/dropdown.css +29 -23
  94. data/src/stylesheets/nitro_kit/components/dropzone.css +71 -28
  95. data/src/stylesheets/nitro_kit/components/empty_state.css +6 -6
  96. data/src/stylesheets/nitro_kit/components/field.css +30 -26
  97. data/src/stylesheets/nitro_kit/components/field_group.css +13 -0
  98. data/src/stylesheets/nitro_kit/components/fieldset.css +6 -0
  99. data/src/stylesheets/nitro_kit/components/input.css +9 -2
  100. data/src/stylesheets/nitro_kit/components/layout.css +165 -165
  101. data/src/stylesheets/nitro_kit/components/page_header.css +3 -3
  102. data/src/stylesheets/nitro_kit/components/pagination.css +55 -46
  103. data/src/stylesheets/nitro_kit/components/pagination_bar.css +7 -7
  104. data/src/stylesheets/nitro_kit/components/palette.css +20 -20
  105. data/src/stylesheets/nitro_kit/components/progressive_image.css +44 -54
  106. data/src/stylesheets/nitro_kit/components/radio_button.css +36 -29
  107. data/src/stylesheets/nitro_kit/components/radio_button_group.css +43 -43
  108. data/src/stylesheets/nitro_kit/components/rich_text_area.css +15 -0
  109. data/src/stylesheets/nitro_kit/components/select.css +2 -9
  110. data/src/stylesheets/nitro_kit/components/settings_layout.css +21 -18
  111. data/src/stylesheets/nitro_kit/components/settings_section.css +84 -0
  112. data/src/stylesheets/nitro_kit/components/sheet.css +23 -3
  113. data/src/stylesheets/nitro_kit/components/stat_grid.css +10 -9
  114. data/src/stylesheets/nitro_kit/components/switch.css +37 -33
  115. data/src/stylesheets/nitro_kit/components/table.css +35 -31
  116. data/src/stylesheets/nitro_kit/components/tabs.css +43 -39
  117. data/src/stylesheets/nitro_kit/components/toast.css +20 -9
  118. data/src/stylesheets/nitro_kit/components/toolbar.css +14 -39
  119. data/src/stylesheets/nitro_kit/components/tooltip.css +34 -29
  120. data/src/stylesheets/nitro_kit/components/typeset.css +147 -1
  121. data/src/stylesheets/nitro_kit/reset.css +9 -9
  122. data/src/stylesheets/nitro_kit/tokens.css +8 -9
  123. metadata +4 -3
  124. 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
 
@@ -152,11 +152,5 @@ module NitroKit
152
152
 
153
153
  raise ArgumentError, "Accordion #{name} must be true or false"
154
154
  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
155
  end
162
156
  end
@@ -223,8 +223,7 @@ module NitroKit
223
223
  :item_link,
224
224
  attributes: {
225
225
  href: entry.href,
226
- aria: { current: entry.current ? "page" : nil },
227
- data: { state: entry.current ? "current" : "default" }
226
+ aria: { current: entry.current ? "page" : nil }
228
227
  },
229
228
  html: entry.html,
230
229
  aria: entry.aria,
@@ -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
 
@@ -20,7 +20,7 @@ module NitroKit
20
20
  data: {},
21
21
  desperately_need_a_class: nil
22
22
  )
23
- @identifier = component_id(id)
23
+ @identifier = validate_id!("AppearancePicker id", id)
24
24
  @label = validate_label!(label)
25
25
  @presentation = validate_choice!(:presentation, presentation, PRESENTATIONS)
26
26
  @preference = validate_choice!(:preference, preference, PREFERENCES)
@@ -137,16 +137,6 @@ module NitroKit
137
137
  "#{identifier}-#{preference}"
138
138
  end
139
139
 
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
140
  def validate_label!(value)
151
141
  return value if value.is_a?(String) && value.present?
152
142
 
@@ -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)
@@ -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",
@@ -299,18 +298,6 @@ module NitroKit
299
298
  end
300
299
  end
301
300
 
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
301
  def visible_label(value)
315
302
  return value if value == false
316
303
  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
@@ -278,6 +256,7 @@ module NitroKit
278
256
  action: [
279
257
  "click->nk--dialog#dismiss",
280
258
  "cancel->nk--dialog#cancel",
259
+ "close->nk--dialog#restoreFocus",
281
260
  "close->nk--command-palette#closed"
282
261
  ].join(" ")
283
262
  }
@@ -342,6 +321,7 @@ module NitroKit
342
321
  variant: :ghost,
343
322
  size: :md,
344
323
  html: { command: "close", commandfor: panel_id },
324
+ data: { nk__dialog_command: "close" },
345
325
  aria: { label: @close_label }
346
326
  ),
347
327
  :close
@@ -392,7 +372,9 @@ module NitroKit
392
372
  def root_actions
393
373
  actions = [
394
374
  "turbo:before-visit@document->nk--command-palette#closeForVisit",
395
- "turbo:before-cache@document->nk--command-palette#closeForVisit"
375
+ "turbo:before-cache@document->nk--command-palette#closeForVisit",
376
+ "click->nk--dialog#invoke",
377
+ "turbo:before-cache@document->nk--dialog#closeForCache"
396
378
  ]
397
379
  actions.prepend("keydown@document->nk--command-palette#shortcut") if @shortcut
398
380
  actions.join(" ")
@@ -7,6 +7,7 @@ module NitroKit
7
7
  # Data keys a component sets for itself through `attributes:`.
8
8
  COMPONENT_OWNED_DATA_ATTRIBUTES = %w[
9
9
  state disabled required orientation presentation placement layout side field-type
10
+ dir gap align justify wrap cols mode key
10
11
  ].freeze
11
12
  # Every data key Nitro owns. Applications may not pass any of them through `data:`.
12
13
  RESERVED_DATA_ATTRIBUTES = (
@@ -93,6 +94,18 @@ module NitroKit
93
94
  raise ArgumentError, "#{name} must be #{expected}; received #{value.inspect}"
94
95
  end
95
96
 
97
+ def validate_id!(name, value)
98
+ return value if value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
99
+
100
+ raise ArgumentError, "#{name} must be a fragment-safe String using letters, numbers, underscores, or hyphens"
101
+ end
102
+
103
+ def validate_text!(name, text)
104
+ return text if text.is_a?(String) && !text.strip.empty?
105
+
106
+ raise ArgumentError, "#{name} must be a non-blank String"
107
+ end
108
+
96
109
  def owned_attributes(attributes:, html:, aria:, data:, owned_data:, desperately_need_a_class:)
97
110
  attributes = attribute_hash(attributes, name: "attributes")
98
111
  html = attribute_hash(html, name: "html")
@@ -117,8 +130,7 @@ module NitroKit
117
130
  merged[:data] = merged_data if merged_data.any?
118
131
 
119
132
  unless desperately_need_a_class.nil?
120
- validate_class_escape!(desperately_need_a_class)
121
- merged[:class] = desperately_need_a_class
133
+ merged[:class] = normalize_class_escape(desperately_need_a_class)
122
134
  merged[:data] = merge_owned_data(merged.fetch(:data, {}), { nk_escape: "class" })
123
135
  end
124
136
 
@@ -293,6 +305,43 @@ module NitroKit
293
305
  @component_name.tr("-", " ")
294
306
  end
295
307
 
308
+ # Conventions shared by the choice group components. They read the `id`
309
+ # and `options` readers those components already keep.
310
+
311
+ def deterministic_id(name)
312
+ derived = name.to_s.gsub(/[^a-zA-Z0-9_-]+/, "_").gsub(/\A_+|_+\z/, "")
313
+ return derived unless derived.empty?
314
+
315
+ raise ArgumentError, "name #{name.inspect} cannot derive an id; pass id:"
316
+ end
317
+
318
+ def choice_id(index)
319
+ "#{id}-#{index}" if id
320
+ end
321
+
322
+ def validate_unique_choices!
323
+ duplicate_value = options.group_by { |choice| choice.value.to_s }.find { |_value, matches| matches.many? }
324
+ raise ArgumentError, "choice values must be unique" if duplicate_value
325
+
326
+ ids = options.each_with_index.filter_map { |choice, index| choice.id || choice_id(index) }
327
+ raise ArgumentError, "choice ids must be unique" unless ids.uniq.length == ids.length
328
+ end
329
+
330
+ # Command invoker plumbing shared by the nk--dialog overlay components.
331
+
332
+ def command_attributes(html, command, commandfor:, disabled: false)
333
+ raise ArgumentError, "html must be a Hash" unless html.is_a?(Hash)
334
+
335
+ collision = html.keys.find { |key| %w[command commandfor].include?(key.to_s.downcase) }
336
+ raise ArgumentError, "#{collision}: is owned by #{component_words.capitalize}" if collision
337
+
338
+ disabled ? html : html.merge(command:, commandfor:)
339
+ end
340
+
341
+ def command_data(data, command, disabled: false)
342
+ disabled ? data : data.merge(nk__dialog_command: command)
343
+ end
344
+
296
345
  def validate_optional_text!(name, text)
297
346
  return if text.nil?
298
347
  return text if text.is_a?(String) && !text.strip.empty?
@@ -306,10 +355,13 @@ module NitroKit
306
355
  end
307
356
  end
308
357
 
309
- def validate_class_escape!(value)
310
- return if value.is_a?(String) && !value.strip.empty?
358
+ def normalize_class_escape(value)
359
+ tokens = ActionView::Helpers::TagHelper.build_tag_values(value)
360
+ .flat_map { CGI.unescape_html(_1.to_s).split }
361
+ .uniq
362
+ return tokens.join(" ") if tokens.any?
311
363
 
312
- raise ArgumentError, "desperately_need_a_class: must be a non-blank String"
364
+ raise ArgumentError, "desperately_need_a_class: must contain at least one class name"
313
365
  end
314
366
 
315
367
  def text_or_block(text = nil, &block)
@@ -2,11 +2,14 @@
2
2
 
3
3
  module NitroKit
4
4
  class DangerZone < Component
5
+ TITLE_LEVELS = (1..6).freeze
6
+
5
7
  Child = Data.define(:component, :content)
6
8
 
7
9
  def initialize(
8
10
  title: nil,
9
11
  description: nil,
12
+ level: 2,
10
13
  id: nil,
11
14
  html: {},
12
15
  aria: {},
@@ -15,6 +18,7 @@ module NitroKit
15
18
  )
16
19
  @title_content = content_from_keyword(:title, title)
17
20
  @description_content = content_from_keyword(:description, description)
21
+ @level = validate_choice!(:level, level, TITLE_LEVELS)
18
22
  @confirmation = nil
19
23
  @escape = nil
20
24
 
@@ -36,7 +40,7 @@ module NitroKit
36
40
 
37
41
  section(**root_attributes) do
38
42
  header(**slot_attributes(:header)) do
39
- h2(**slot_attributes(:title)) { render_deferred_content(@title_content) }
43
+ public_send(:"h#{@level}", **slot_attributes(:title)) { render_deferred_content(@title_content) }
40
44
  p(**slot_attributes(:description)) { render_deferred_content(@description_content) }
41
45
  end
42
46
  div(**slot_attributes(:confirmation), &@confirmation)
@@ -2,11 +2,13 @@
2
2
 
3
3
  module NitroKit
4
4
  class DataSection < Component
5
+ TITLE_LEVELS = (1..6).freeze
5
6
  Child = Data.define(:component, :content)
6
7
 
7
8
  def initialize(
8
9
  title: nil,
9
10
  description: nil,
11
+ level: 2,
10
12
  id: nil,
11
13
  html: {},
12
14
  aria: {},
@@ -15,12 +17,14 @@ module NitroKit
15
17
  )
16
18
  @title_content = content_from_keyword(:title, title)
17
19
  @description_content = content_from_keyword(:description, description)
20
+ @level = validate_choice!(:level, level, TITLE_LEVELS)
18
21
  @actions = nil
19
22
  @content = nil
23
+ @title_id = "#{id || "nk-data-section-#{SecureRandom.hex(4)}"}-title"
20
24
 
21
25
  super(
22
26
  component: :data_section,
23
- attributes: { id: }.compact,
27
+ attributes: { id:, aria: { labelledby: @title_id } }.compact,
24
28
  html:,
25
29
  aria:,
26
30
  data:,
@@ -28,15 +32,17 @@ module NitroKit
28
32
  )
29
33
  end
30
34
 
31
- def view_template
32
- yield self if block_given?
35
+ def view_template(&block)
36
+ collect_declarations(&block)
33
37
  require_content!("DataSection", :title, @title_content)
34
38
  raise ArgumentError, "DataSection requires exactly one table or EmptyState" unless @content
35
39
 
36
40
  section(**root_attributes) do
37
41
  header(**slot_attributes(:header)) do
38
42
  div(**slot_attributes(:heading)) do
39
- h2(**slot_attributes(:title)) { render_deferred_content(@title_content) }
43
+ public_send(:"h#{@level}", **slot_attributes(:title, attributes: { id: @title_id })) do
44
+ render_deferred_content(@title_content)
45
+ end
40
46
  if @description_content
41
47
  p(**slot_attributes(:description)) { render_deferred_content(@description_content) }
42
48
  end
@@ -48,6 +54,7 @@ module NitroKit
48
54
  end
49
55
 
50
56
  def title(text = nil, &block)
57
+ ensure_collecting!
51
58
  @title_content = declare_content(:title, @title_content, text, &block)
52
59
  nil
53
60
  end
@@ -60,6 +67,7 @@ module NitroKit
60
67
  alias :html_table :table
61
68
 
62
69
  def actions(component, &content)
70
+ ensure_collecting!
63
71
  unless component.is_a?(NitroKit::ButtonGroup) || component.is_a?(NitroKit::Button)
64
72
  raise ArgumentError, "DataSection actions must be a NitroKit::ButtonGroup or NitroKit::Button"
65
73
  end
@@ -70,6 +78,7 @@ module NitroKit
70
78
  end
71
79
 
72
80
  def table(component, &content)
81
+ ensure_collecting!
73
82
  unless table_component?(component)
74
83
  raise ArgumentError, "DataSection table must be a NitroKit::Table or NitroKit::DetailsTable"
75
84
  end
@@ -88,6 +97,21 @@ module NitroKit
88
97
 
89
98
  private
90
99
 
100
+ def collect_declarations
101
+ return unless block_given?
102
+
103
+ @collecting = true
104
+ yield(self)
105
+ ensure
106
+ @collecting = false
107
+ end
108
+
109
+ def ensure_collecting!
110
+ return if @collecting
111
+
112
+ raise ArgumentError, "DataSection content must be declared inside the render block"
113
+ end
114
+
91
115
  def table_component?(component)
92
116
  component.is_a?(NitroKit::Table) || component.is_a?(NitroKit::DetailsTable)
93
117
  end