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
@@ -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
@@ -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
@@ -44,7 +44,7 @@ module NitroKit
44
44
  name:,
45
45
  label: I18n.t("nitro_kit.dropzone.label"),
46
46
  description: nil,
47
- presentation: :input,
47
+ presentation: :minimal,
48
48
  direct_upload: true,
49
49
  multiple: false,
50
50
  accept: nil,
@@ -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)
@@ -116,7 +115,7 @@ module NitroKit
116
115
  id: nil,
117
116
  label: I18n.t("nitro_kit.dropzone.label"),
118
117
  description: nil,
119
- presentation: :input,
118
+ presentation: :minimal,
120
119
  direct_upload: true,
121
120
  multiple: false,
122
121
  accept: nil,
@@ -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:,