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
@@ -43,8 +43,8 @@ module NitroKit
43
43
 
44
44
  attr_reader :record, :route_base
45
45
 
46
- def view_template
47
- yield self if block_given?
46
+ def view_template(&block)
47
+ collect_declarations(&block)
48
48
  raise ArgumentError, "DetailsTable requires at least one field" if @fields.empty?
49
49
 
50
50
  div(**root_attributes) do
@@ -65,6 +65,7 @@ module NitroKit
65
65
  end
66
66
 
67
67
  def field(attribute, label: nil, value: UNSET, &content)
68
+ ensure_collecting!
68
69
  attribute = validate_attribute!(attribute)
69
70
  if @fields.any? { |field| field.attribute.to_s == attribute.to_s }
70
71
  raise ArgumentError, "DetailsTable field keys must be unique: #{attribute.inspect}"
@@ -78,6 +79,21 @@ module NitroKit
78
79
 
79
80
  private
80
81
 
82
+ def collect_declarations
83
+ return unless block_given?
84
+
85
+ @collecting = true
86
+ yield(self)
87
+ ensure
88
+ @collecting = false
89
+ end
90
+
91
+ def ensure_collecting!
92
+ return if @collecting
93
+
94
+ raise ArgumentError, "DetailsTable fields must be declared inside the render block"
95
+ end
96
+
81
97
  def render_field(field)
82
98
  @table.tr do
83
99
  @table.th(field.label, scope: :row)
@@ -114,12 +130,7 @@ module NitroKit
114
130
 
115
131
  def render_string(value)
116
132
  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) }
133
+ a(**slot_attributes(:link, attributes: { href: value })) { plain(value) }
123
134
  else
124
135
  plain(value)
125
136
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module NitroKit
4
4
  class Dialog < Component
5
- Trigger = ::Data.define(:text, :variant, :size, :disabled, :html, :aria, :data, :css_class, :content)
5
+ Trigger = ::Data.define(:text, :variant, :size, :icon, :icon_end, :label, :disabled, :html, :aria, :data, :css_class, :content)
6
6
  private_constant :Trigger
7
7
 
8
8
  Panel = ::Data.define(:title, :description, :nonmodal, :html, :aria, :data, :css_class, :content)
@@ -32,6 +32,10 @@ module NitroKit
32
32
  id:,
33
33
  data: {
34
34
  controller: "nk--dialog",
35
+ action: [
36
+ "click->nk--dialog#invoke",
37
+ "turbo:before-cache@document->nk--dialog#closeForCache"
38
+ ].join(" "),
35
39
  nk__dialog_dismissible_value: @dismissible
36
40
  }
37
41
  },
@@ -57,6 +61,9 @@ module NitroKit
57
61
  text = nil,
58
62
  variant: :default,
59
63
  size: :md,
64
+ icon: nil,
65
+ icon_end: nil,
66
+ label: nil,
60
67
  disabled: false,
61
68
  html: {},
62
69
  aria: {},
@@ -71,6 +78,9 @@ module NitroKit
71
78
  text:,
72
79
  variant:,
73
80
  size:,
81
+ icon:,
82
+ icon_end:,
83
+ label:,
74
84
  disabled: validate_boolean!(:disabled, disabled),
75
85
  html:,
76
86
  aria:,
@@ -115,7 +125,7 @@ module NitroKit
115
125
  end
116
126
 
117
127
  def close_button(
118
- label: I18n.t("nitro_kit.dialog.close"),
128
+ label: default_close_label,
119
129
  html: {},
120
130
  aria: {},
121
131
  data: {},
@@ -159,10 +169,13 @@ module NitroKit
159
169
  @trigger.text,
160
170
  variant: @trigger.variant,
161
171
  size: @trigger.size,
172
+ icon: @trigger.icon,
173
+ icon_end: @trigger.icon_end,
174
+ label: @trigger.label,
162
175
  disabled: @trigger.disabled,
163
- html: with_command(@trigger.html, "show-modal", disabled: @trigger.disabled),
176
+ html: command_attributes(@trigger.html, "show-modal", commandfor: element_id(:panel), disabled: @trigger.disabled),
164
177
  aria: @trigger.aria.merge(haspopup: "dialog"),
165
- data: @trigger.data,
178
+ data: command_data(@trigger.data, "show-modal", disabled: @trigger.disabled),
166
179
  desperately_need_a_class: @trigger.css_class
167
180
  )
168
181
 
@@ -191,7 +204,7 @@ module NitroKit
191
204
  aria: owned_aria,
192
205
  data: {
193
206
  nk__dialog_target: "panel",
194
- action: "click->nk--dialog#dismiss cancel->nk--dialog#cancel"
207
+ action: "click->nk--dialog#dismiss cancel->nk--dialog#cancel close->nk--dialog#restoreFocus"
195
208
  }
196
209
  },
197
210
  html: @panel.html,
@@ -221,7 +234,7 @@ module NitroKit
221
234
 
222
235
  def render_close_button
223
236
  declaration = @close_button || CloseButton.new(
224
- label: I18n.t("nitro_kit.dialog.close"),
237
+ label: default_close_label,
225
238
  html: {},
226
239
  aria: {},
227
240
  data: {},
@@ -233,9 +246,9 @@ module NitroKit
233
246
  icon: :x,
234
247
  variant: :ghost,
235
248
  size: :sm,
236
- html: with_command(declaration.html, "close"),
249
+ html: command_attributes(declaration.html, "close", commandfor: element_id(:panel)),
237
250
  aria: declaration.aria.merge(label: declaration.label),
238
- data: declaration.data,
251
+ data: command_data(declaration.data, "close"),
239
252
  desperately_need_a_class: declaration.css_class
240
253
  ),
241
254
  :close
@@ -276,17 +289,8 @@ module NitroKit
276
289
  raise ArgumentError, "Dialog close button must be declared inside the panel block"
277
290
  end
278
291
 
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))
292
+ def default_close_label
293
+ I18n.t("nitro_kit.dialog.close")
290
294
  end
291
295
  end
292
296
  end
@@ -373,15 +373,10 @@ module NitroKit
373
373
  "Unknown Dropdown trigger #{name} #{value.inspect}; expected one of: #{choices.map(&:inspect).join(", ")}"
374
374
  end
375
375
 
376
- # Private copy of the AppShell identifier contract. Extract a shared
377
- # validator into Component once its owner lands.
378
376
  def component_id(value)
379
377
  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
378
 
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"
379
+ validate_id!("Dropdown id", value)
385
380
  end
386
381
 
387
382
  def generated_id
@@ -57,8 +57,8 @@ module NitroKit
57
57
  data: {},
58
58
  desperately_need_a_class: nil
59
59
  )
60
- @identifier = component_id(id)
61
- @name = form_name(name)
60
+ @identifier = validate_id!("Dropzone id", id)
61
+ @name = validate_text!("Dropzone name", name)
62
62
  @label = required_text(:label, label)
63
63
  @description = optional_text(:description, description)
64
64
  @presentation = validate_choice!(:presentation, presentation, PRESENTATIONS)
@@ -127,6 +127,7 @@ module NitroKit
127
127
  ) do
128
128
  strong(**slot_attributes(:title, attributes: { id: title_id })) { plain(@label) }
129
129
  span(**slot_attributes(:instruction)) { plain(I18n.t("nitro_kit.dropzone.prompt")) }
130
+ span(**slot_attributes(:compact_instruction)) { plain(I18n.t("nitro_kit.dropzone.compact_prompt")) }
130
131
 
131
132
  if description
132
133
  span(**slot_attributes(:description, attributes: { id: description_id })) { plain(description) }
@@ -291,18 +292,6 @@ module NitroKit
291
292
  Rails.application.routes.url_helpers
292
293
  end
293
294
 
294
- def component_id(value)
295
- return value if value.is_a?(String) && value.present? && !value.match?(/\s/)
296
-
297
- raise ArgumentError, "Dropzone id must be a non-blank String without whitespace"
298
- end
299
-
300
- def form_name(value)
301
- return value if value.is_a?(String) && value.present?
302
-
303
- raise ArgumentError, "Dropzone name must be a non-blank String"
304
- end
305
-
306
295
  def required_text(name, value)
307
296
  return value if value.is_a?(String) && value.present?
308
297
 
@@ -39,8 +39,8 @@ module NitroKit
39
39
 
40
40
  attr_reader :level, :variant
41
41
 
42
- def view_template
43
- yield self if block_given?
42
+ def view_template(&block)
43
+ collect_declarations(&block)
44
44
  require_content!("EmptyState", :title, @title_content)
45
45
 
46
46
  section(**root_attributes) do
@@ -54,16 +54,19 @@ module NitroKit
54
54
  end
55
55
 
56
56
  def title(text = nil, &block)
57
+ ensure_collecting!
57
58
  @title_content = declare_content(:title, @title_content, text, &block)
58
59
  nil
59
60
  end
60
61
 
61
62
  def description(text = nil, &block)
63
+ ensure_collecting!
62
64
  @description_content = declare_content(:description, @description_content, text, &block)
63
65
  nil
64
66
  end
65
67
 
66
68
  def icon(component, &content)
69
+ ensure_collecting!
67
70
  unless component.is_a?(NitroKit::Icon)
68
71
  raise ArgumentError, "EmptyState icon must be a NitroKit::Icon"
69
72
  end
@@ -74,6 +77,7 @@ module NitroKit
74
77
  end
75
78
 
76
79
  def action(component, &content)
80
+ ensure_collecting!
77
81
  unless component.is_a?(NitroKit::Button)
78
82
  raise ArgumentError, "EmptyState actions must be NitroKit::Button instances"
79
83
  end
@@ -88,6 +92,21 @@ module NitroKit
88
92
 
89
93
  private
90
94
 
95
+ def collect_declarations
96
+ return unless block_given?
97
+
98
+ @collecting = true
99
+ yield(self)
100
+ ensure
101
+ @collecting = false
102
+ end
103
+
104
+ def ensure_collecting!
105
+ return if @collecting
106
+
107
+ raise ArgumentError, "EmptyState declarations must be inside the render block"
108
+ end
109
+
91
110
  def render_actions
92
111
  div(**slot_attributes(:actions)) do
93
112
  @actions.each do |action|
@@ -64,7 +64,7 @@ module NitroKit
64
64
  @id = id || form&.field_id(field_name) || default_field_id
65
65
  @name = name || form&.field_name(field_name, multiple: @multiple) || default_field_name
66
66
  @explicit_value = value
67
- @field_label = label.nil? ? derived_label : label
67
+ @field_label = label.nil? ? UNSET : label
68
68
  @field_description = description
69
69
  @field_error_messages = Array(errors).compact
70
70
  @options = options
@@ -124,7 +124,11 @@ module NitroKit
124
124
  )
125
125
  end
126
126
 
127
- attr_reader :as, :form, :name, :id, :field_label, :field_description, :field_error_messages
127
+ attr_reader :as, :form, :name, :id, :field_description, :field_error_messages
128
+
129
+ def field_label
130
+ @field_label.equal?(UNSET) ? derived_label : @field_label
131
+ end
128
132
 
129
133
  def view_template(&block)
130
134
  div(**root_attributes) do
@@ -335,17 +339,32 @@ module NitroKit
335
339
  end
336
340
 
337
341
  def rich_text_control(html:, aria:, data:)
342
+ editor_aria = control_aria(aria)
343
+ content = annotated_rich_text_content(editor_aria)
344
+
338
345
  render_in_slot(
339
346
  RichTextArea.new(
340
- @rich_text_content,
347
+ content,
341
348
  html: @control_html.merge(html),
342
- aria: control_aria(aria),
349
+ aria: content.equal?(@rich_text_content) ? editor_aria : {},
343
350
  data: @control_data.merge(data)
344
351
  ),
345
352
  :control
346
353
  )
347
354
  end
348
355
 
356
+ # The application owns the editor markup, so the control ARIA is injected
357
+ # into the lexxy-editor tag where assistive technology reads it instead of
358
+ # landing on the role-less wrapper.
359
+ def annotated_rich_text_content(aria)
360
+ return @rich_text_content unless @rich_text_content&.include?("<lexxy-editor")
361
+
362
+ attributes = aria.map { |key, value| %(aria-#{key}="#{ERB::Util.html_escape(value)}") }.join(" ")
363
+ return @rich_text_content if attributes.empty?
364
+
365
+ @rich_text_content.sub("<lexxy-editor", "<lexxy-editor #{attributes}").html_safe
366
+ end
367
+
349
368
  def checkbox_control(html:, aria:, data:)
350
369
  render_in_slot(
351
370
  Checkbox.new(
@@ -360,7 +379,7 @@ module NitroKit
360
379
  disabled: @disabled,
361
380
  required: @required,
362
381
  control_html: @control_html.merge(html),
363
- control_aria: control_aria(aria),
382
+ control_aria: labeled_control_aria(aria),
364
383
  control_data: @control_data.merge(data)
365
384
  ),
366
385
  :control
@@ -381,7 +400,7 @@ module NitroKit
381
400
  disabled: @disabled,
382
401
  required: @required,
383
402
  control_html: @control_html.merge(html),
384
- control_aria: switch_aria(aria),
403
+ control_aria: labeled_control_aria(aria),
385
404
  control_data: @control_data.merge(data)
386
405
  ),
387
406
  :control
@@ -401,7 +420,7 @@ module NitroKit
401
420
  disabled: @disabled || choice.disabled,
402
421
  required: @required,
403
422
  control_html: @control_html.merge(html),
404
- control_aria: control_aria(aria),
423
+ control_aria: labeled_control_aria(aria),
405
424
  control_data: @control_data.merge(data)
406
425
  ),
407
426
  :control
@@ -420,7 +439,9 @@ module NitroKit
420
439
  disabled: @disabled,
421
440
  required: @required,
422
441
  html: @control_html.merge(html),
423
- aria: control_aria(aria),
442
+ # The fieldset's implicit group role does not support aria-invalid,
443
+ # so the error state stays on the error list it references.
444
+ aria: control_aria(aria).except(:invalid),
424
445
  data: @control_data.merge(data)
425
446
  ),
426
447
  :control
@@ -443,13 +464,15 @@ module NitroKit
443
464
  ).compact
444
465
  end
445
466
 
446
- def switch_aria(extra)
467
+ def labeled_control_aria(extra)
447
468
  attributes = control_aria(extra)
448
469
  return attributes unless field_label == false
449
470
  return attributes if attributes.keys.any? { |key| %w[label labelledby].include?(key.to_s.delete("_-")) }
450
471
 
451
472
  accessible_name = derived_label || name.to_s.humanize.presence
452
- raise ArgumentError, "an unlabeled switch requires a field name or control ARIA label" unless accessible_name
473
+ unless accessible_name
474
+ raise ArgumentError, "an unlabeled #{as.to_s.tr("_", " ")} requires a field name or control ARIA label"
475
+ end
453
476
 
454
477
  attributes.merge(label: accessible_name)
455
478
  end
@@ -460,10 +483,11 @@ module NitroKit
460
483
  end
461
484
 
462
485
  def derived_label
486
+ return @derived_label if defined?(@derived_label)
463
487
  return unless @field_name
464
488
 
465
489
  object = form&.object
466
- if object && object.class.respond_to?(:human_attribute_name)
490
+ @derived_label = if object && object.class.respond_to?(:human_attribute_name)
467
491
  object.class.human_attribute_name(@field_name)
468
492
  else
469
493
  @field_name.humanize
@@ -66,7 +66,6 @@ module NitroKit
66
66
  &block
67
67
  )
68
68
  as = validate_as!(as)
69
- label = default_label_for(field_name) if label.nil?
70
69
  errors ||= errors_for(field_name)
71
70
  control_html = merge_control_boundary(:html, control_html, html)
72
71
  control_aria = merge_control_boundary(:aria, control_aria, aria)
@@ -31,10 +31,10 @@ module NitroKit
31
31
  component: :progressive_image,
32
32
  attributes: {
33
33
  id:,
34
- aria: { busy: @attached ? true : nil },
35
34
  data: {
36
35
  state: @attached ? "loading" : "empty",
37
- controller: @attached ? "nk--progressive-image" : nil
36
+ controller: @attached ? "nk--progressive-image" : nil,
37
+ action: @attached ? "turbo:before-cache@document->nk--progressive-image#prepareForCache" : nil
38
38
  }.compact
39
39
  },
40
40
  html:,
@@ -67,12 +67,15 @@ module NitroKit
67
67
  render_in_slot(Label.new(for: id), :label) do
68
68
  render_control
69
69
  span(**slot_attributes(:indicator), aria: { hidden: "true" })
70
- span(**slot_attributes(:content)) do
71
- span(**slot_attributes(:label_text)) { text_or_block(label, &block) }
72
- span(**slot_attributes(:description, attributes: { id: description_id })) { plain(description) } if description
73
- end
70
+ span(**slot_attributes(:label_text)) { text_or_block(label, &block) }
74
71
  end
75
72
  end
73
+
74
+ # The description stays outside the label so aria-describedby carries it
75
+ # instead of it joining the control's accessible name.
76
+ if description
77
+ span(**slot_attributes(:description, attributes: { id: description_id })) { plain(description) }
78
+ end
76
79
  end
77
80
  end
78
81
 
@@ -99,30 +99,5 @@ module NitroKit
99
99
  def selected?(choice)
100
100
  !value.nil? && value.to_s == choice.value.to_s
101
101
  end
102
-
103
- def deterministic_id(name)
104
- derived = name.to_s.gsub(/[^a-zA-Z0-9_-]+/, "_").gsub(/\A_+|_+\z/, "")
105
- return derived unless derived.empty?
106
-
107
- raise ArgumentError, "name #{name.inspect} cannot derive an id; pass id:"
108
- end
109
-
110
- def choice_id(index)
111
- "#{id}-#{index}" if id
112
- end
113
-
114
- def validate_text!(name, text)
115
- return text if text.is_a?(String) && !text.strip.empty?
116
-
117
- raise ArgumentError, "#{name} must be a non-blank String"
118
- end
119
-
120
- def validate_unique_choices!
121
- duplicate_value = options.group_by { |choice| choice.value.to_s }.find { |_value, matches| matches.many? }
122
- raise ArgumentError, "choice values must be unique" if duplicate_value
123
-
124
- ids = options.each_with_index.filter_map { |choice, index| choice.id || choice_id(index) }
125
- raise ArgumentError, "choice ids must be unique" unless ids.uniq.length == ids.length
126
- end
127
102
  end
128
103
  end
@@ -26,7 +26,7 @@ module NitroKit
26
26
  @option_tags = validate_option_tags!(option_tags)
27
27
  @value = value
28
28
  @include_blank = validate_blank_option!(include_blank)
29
- @prompt = validate_optional_text!(:prompt, prompt)
29
+ @prompt = validate_prompt!(prompt)
30
30
  if @include_blank && @prompt
31
31
  raise ArgumentError, "include_blank: and prompt: are mutually exclusive"
32
32
  end
@@ -151,11 +151,11 @@ module NitroKit
151
151
  raise ArgumentError, "include_blank must be true, false, nil, or a String"
152
152
  end
153
153
 
154
- def validate_optional_text!(name, value)
154
+ def validate_prompt!(value)
155
155
  return if value.nil? || value == false
156
156
  return value if value.is_a?(String) && !value.strip.empty?
157
157
 
158
- raise ArgumentError, "#{name} must be a non-blank String, false, or nil"
158
+ raise ArgumentError, "prompt must be a non-blank String, false, or nil"
159
159
  end
160
160
  end
161
161
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module NitroKit
4
4
  class SettingsLayout < Component
5
- Item = ::Data.define(:text, :href, :icon, :current)
5
+ Item = ::Data.define(:text, :href, :icon, :current, :html, :aria, :data, :css_class)
6
6
 
7
7
  def initialize(id: nil, html: {}, aria: {}, data: {}, desperately_need_a_class: nil)
8
8
  @label = nil
@@ -42,7 +42,16 @@ module NitroKit
42
42
  nil
43
43
  end
44
44
 
45
- def item(text, href:, icon: nil, current: false)
45
+ def item(
46
+ text,
47
+ href:,
48
+ icon: nil,
49
+ current: false,
50
+ html: {},
51
+ aria: {},
52
+ data: {},
53
+ desperately_need_a_class: nil
54
+ )
46
55
  ensure_phase!(:navigation, :item)
47
56
  text = validate_text!(:text, text)
48
57
  href = validate_text!(:href, href)
@@ -54,7 +63,7 @@ module NitroKit
54
63
  end
55
64
 
56
65
  @current_item = true if current
57
- @items << Item.new(text:, href:, icon:, current:)
66
+ @items << Item.new(text:, href:, icon:, current:, html:, aria:, data:, css_class: desperately_need_a_class)
58
67
  nil
59
68
  end
60
69
 
@@ -107,7 +116,11 @@ module NitroKit
107
116
  href: item.href,
108
117
  aria: { current: item.current ? "page" : nil },
109
118
  data: { state: item.current ? "current" : "default" }
110
- }
119
+ },
120
+ html: item.html,
121
+ aria: item.aria,
122
+ data: item.data,
123
+ desperately_need_a_class: item.css_class
111
124
  )
112
125
  ) do
113
126
  render_in_slot(item.icon, :item_icon) if item.icon
@@ -1,26 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NitroKit
4
- class FormSection < Component
4
+ class SettingsSection < Component
5
5
  Child = Data.define(:component, :content)
6
+ TITLE_LEVELS = (1..6).freeze
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: {},
13
15
  data: {},
14
16
  desperately_need_a_class: nil
15
17
  )
18
+ @title_level = validate_choice!(:level, level, TITLE_LEVELS)
16
19
  @title_content = content_from_keyword(:title, title)
17
20
  @description_content = content_from_keyword(:description, description)
18
21
  @status = nil
19
22
  @form = nil
20
- @title_id = "#{id || "nk-form-section-#{SecureRandom.hex(4)}"}-title"
23
+ @title_id = "#{id || "nk-settings-section-#{SecureRandom.hex(4)}"}-title"
21
24
 
22
25
  super(
23
- component: :form_section,
26
+ component: :settings_section,
24
27
  attributes: { id:, aria: { labelledby: @title_id } }.compact,
25
28
  html:,
26
29
  aria:,
@@ -31,12 +34,15 @@ module NitroKit
31
34
 
32
35
  def view_template
33
36
  yield self if block_given?
34
- require_content!("FormSection", :title, @title_content)
35
- raise ArgumentError, "FormSection requires exactly one form" unless @form
37
+ require_content!("SettingsSection", :title, @title_content)
38
+ raise ArgumentError, "SettingsSection requires exactly one form" unless @form
36
39
 
37
40
  section(**root_attributes) do
38
41
  header(**slot_attributes(:header)) do
39
- h2(**slot_attributes(:title, attributes: { id: @title_id })) do
42
+ public_send(
43
+ :"h#{@title_level}",
44
+ **slot_attributes(:title, attributes: { id: @title_id })
45
+ ) do
40
46
  render_deferred_content(@title_content)
41
47
  end
42
48
  if @description_content
@@ -48,7 +54,8 @@ module NitroKit
48
54
  end
49
55
  end
50
56
 
51
- def title(text = nil, &block)
57
+ def title(text = nil, level: nil, &block)
58
+ @title_level = validate_choice!(:level, level, TITLE_LEVELS) if level
52
59
  @title_content = declare_content(:title, @title_content, text, &block)
53
60
  nil
54
61
  end
@@ -60,17 +67,17 @@ module NitroKit
60
67
 
61
68
  def status(component, &content)
62
69
  unless component.is_a?(NitroKit::Alert)
63
- raise ArgumentError, "FormSection status must be a NitroKit::Alert"
70
+ raise ArgumentError, "SettingsSection status must be a NitroKit::Alert"
64
71
  end
65
- raise ArgumentError, "FormSection accepts at most one status" if @status
72
+ raise ArgumentError, "SettingsSection accepts at most one status" if @status
66
73
 
67
74
  @status = Child.new(component:, content:)
68
75
  nil
69
76
  end
70
77
 
71
78
  def form(&content)
72
- raise ArgumentError, "FormSection form requires a block" unless content
73
- raise ArgumentError, "FormSection accepts exactly one form" if @form
79
+ raise ArgumentError, "SettingsSection form requires a block" unless content
80
+ raise ArgumentError, "SettingsSection accepts exactly one form" if @form
74
81
 
75
82
  @form = content
76
83
  nil