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
@@ -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
@@ -77,20 +77,7 @@ module NitroKit
77
77
  def render_toggle_icon
78
78
  return if @multiple
79
79
 
80
- svg(
81
- **slot_attributes(
82
- :icon,
83
- attributes: {
84
- viewbox: "0 0 20 20",
85
- fill: "none",
86
- stroke: "currentColor",
87
- stroke_width: 1.5,
88
- aria: { hidden: true }
89
- }
90
- )
91
- ) do |icon|
92
- icon.path(d: "m6.5 8 3.5 3.5L13.5 8", stroke_linecap: "round", stroke_linejoin: "round")
93
- end
80
+ render_in_slot(Icon.new(:chevron_down, size: :sm), :icon)
94
81
  end
95
82
 
96
83
  def control_attributes
@@ -151,11 +138,11 @@ module NitroKit
151
138
  raise ArgumentError, "include_blank must be true, false, nil, or a String"
152
139
  end
153
140
 
154
- def validate_optional_text!(name, value)
141
+ def validate_prompt!(value)
155
142
  return if value.nil? || value == false
156
143
  return value if value.is_a?(String) && !value.strip.empty?
157
144
 
158
- raise ArgumentError, "#{name} must be a non-blank String, false, or nil"
145
+ raise ArgumentError, "prompt must be a non-blank String, false, or nil"
159
146
  end
160
147
  end
161
148
  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
@@ -5,10 +5,10 @@ module NitroKit
5
5
  SIDES = %i[left right].freeze
6
6
  SIZES = %i[sm md lg].freeze
7
7
 
8
- Trigger = Data.define(:text, :variant, :size, :icon, :icon_end, :label, :disabled, :html, :aria, :data, :content)
8
+ Trigger = Data.define(:text, :variant, :size, :icon, :icon_end, :label, :disabled, :html, :aria, :data, :css_class, :content)
9
9
  private_constant :Trigger
10
10
 
11
- Panel = Data.define(:title, :description, :content)
11
+ Panel = Data.define(:title, :description, :html, :aria, :data, :css_class, :content)
12
12
  private_constant :Panel
13
13
 
14
14
  def initialize(
@@ -39,6 +39,10 @@ module NitroKit
39
39
  id:,
40
40
  data: {
41
41
  controller: "nk--dialog",
42
+ action: [
43
+ "click->nk--dialog#invoke",
44
+ "turbo:before-cache@document->nk--dialog#closeForCache"
45
+ ].join(" "),
42
46
  side: @side,
43
47
  nk__dialog_dismissible_value: true
44
48
  }
@@ -73,6 +77,7 @@ module NitroKit
73
77
  html: {},
74
78
  aria: {},
75
79
  data: {},
80
+ desperately_need_a_class: nil,
76
81
  &content
77
82
  )
78
83
  ensure_collecting!
@@ -89,12 +94,21 @@ module NitroKit
89
94
  html:,
90
95
  aria:,
91
96
  data:,
97
+ css_class: desperately_need_a_class,
92
98
  content:
93
99
  )
94
100
  nil
95
101
  end
96
102
 
97
- def panel(title:, description: nil, &content)
103
+ def panel(
104
+ title:,
105
+ description: nil,
106
+ html: {},
107
+ aria: {},
108
+ data: {},
109
+ desperately_need_a_class: nil,
110
+ &content
111
+ )
98
112
  ensure_collecting!
99
113
  raise ArgumentError, "Sheet accepts exactly one panel" if @panel
100
114
  unless title.is_a?(String) && title.present?
@@ -104,7 +118,7 @@ module NitroKit
104
118
  raise ArgumentError, "Sheet description: must be nil or a non-blank String"
105
119
  end
106
120
 
107
- @panel = Panel.new(title:, description:, content:)
121
+ @panel = Panel.new(title:, description:, html:, aria:, data:, css_class: desperately_need_a_class, content:)
108
122
  nil
109
123
  end
110
124
 
@@ -130,9 +144,10 @@ module NitroKit
130
144
  icon_end: @trigger.icon_end,
131
145
  label: @trigger.label,
132
146
  disabled: @trigger.disabled,
133
- html: command_attributes(@trigger.html, "show-modal", disabled: @trigger.disabled),
147
+ html: command_attributes(@trigger.html, "show-modal", commandfor: panel_id, disabled: @trigger.disabled),
134
148
  aria: @trigger.aria.merge(haspopup: "dialog"),
135
- data: @trigger.data
149
+ data: command_data(@trigger.data, "show-modal", disabled: @trigger.disabled),
150
+ desperately_need_a_class: @trigger.css_class
136
151
  )
137
152
 
138
153
  render_in_slot(button, :trigger, &@trigger.content)
@@ -151,9 +166,13 @@ module NitroKit
151
166
  }.compact,
152
167
  data: {
153
168
  nk__dialog_target: "panel",
154
- action: "click->nk--dialog#dismiss cancel->nk--dialog#cancel"
169
+ action: "click->nk--dialog#dismiss cancel->nk--dialog#cancel close->nk--dialog#restoreFocus"
155
170
  }
156
- }
171
+ },
172
+ html: @panel.html,
173
+ aria: @panel.aria,
174
+ data: @panel.data,
175
+ desperately_need_a_class: @panel.css_class
157
176
  )
158
177
  ) do
159
178
  render_close
@@ -172,23 +191,13 @@ module NitroKit
172
191
  label: @close_label,
173
192
  variant: :ghost,
174
193
  size: :sm,
175
- html: command_attributes({}, "close")
194
+ html: command_attributes({}, "close", commandfor: panel_id),
195
+ data: command_data({}, "close")
176
196
  ),
177
197
  :close
178
198
  )
179
199
  end
180
200
 
181
- def command_attributes(html, command, disabled: false)
182
- unless html.is_a?(Hash)
183
- raise ArgumentError, "html must be a Hash"
184
- end
185
- if html.keys.any? { |key| %w[command commandfor].include?(key.to_s.downcase) }
186
- raise ArgumentError, "command and commandfor are owned by Sheet"
187
- end
188
-
189
- disabled ? html : html.merge(command:, commandfor: panel_id)
190
- end
191
-
192
201
  def panel_id = "#{id}-panel"
193
202
  def title_id = "#{id}-title"
194
203
  def description_id = "#{id}-description"
@@ -27,8 +27,8 @@ module NitroKit
27
27
  )
28
28
  end
29
29
 
30
- def view_template
31
- yield self if block_given?
30
+ def view_template(&block)
31
+ collect_declarations(&block)
32
32
  raise ArgumentError, "StatGrid requires at least one stat" if @stats.empty?
33
33
 
34
34
  div(**root_attributes) do
@@ -41,6 +41,7 @@ module NitroKit
41
41
  end
42
42
 
43
43
  def stat(key:, label:, value:, detail: nil)
44
+ ensure_collecting!
44
45
  key = normalize_identity(key, name: "stat key")
45
46
  raise ArgumentError, "StatGrid stat keys must be unique: #{key.inspect}" if @stats.any? { |stat| stat.key == key }
46
47
 
@@ -57,6 +58,21 @@ module NitroKit
57
58
 
58
59
  private
59
60
 
61
+ def collect_declarations
62
+ return unless block_given?
63
+
64
+ @collecting = true
65
+ yield(self)
66
+ ensure
67
+ @collecting = false
68
+ end
69
+
70
+ def ensure_collecting!
71
+ return if @collecting
72
+
73
+ raise ArgumentError, "StatGrid stats must be declared inside the render block"
74
+ end
75
+
60
76
  def render_stat(stat)
61
77
  div(**slot_attributes(:stat, attributes: { data: { key: stat.key } })) do
62
78
  dt(**slot_attributes(:label)) { plain(stat.label) }
@@ -28,6 +28,9 @@ module NitroKit
28
28
  desperately_need_a_class: nil
29
29
  )
30
30
  @sort = sort.nil? ? nil : normalize_sort_key(sort, name: "sort")
31
+ unless direction.nil? || direction.is_a?(Symbol) || direction.is_a?(String)
32
+ raise ArgumentError, "Table direction must be a Symbol or String"
33
+ end
31
34
  @direction = direction.nil? ? nil : validate_choice!(:direction, direction.to_sym, DIRECTIONS)
32
35
  unless @sort.nil? == @direction.nil?
33
36
  raise ArgumentError, "Table sort: and direction: must both be set or both be nil"
@@ -49,6 +52,7 @@ module NitroKit
49
52
  data: table_data
50
53
  )
51
54
  @caption = nil
55
+ @caption_id = "#{id || "nk-table-#{SecureRandom.hex(4)}"}-caption"
52
56
  @head = nil
53
57
  @bodies = []
54
58
  @sort_keys = []
@@ -61,7 +65,7 @@ module NitroKit
61
65
  yield self if block_given?
62
66
  @collecting_sections = false
63
67
 
64
- div(**root_attributes) do
68
+ div(**scroll_region_attributes) do
65
69
  table(**@table_attributes) do
66
70
  render_caption if @caption
67
71
  render_section(:head, @head, :html_thead) if @head
@@ -141,6 +145,7 @@ module NitroKit
141
145
  ensure_rendering_row!(:th)
142
146
  raise ArgumentError, "Table th accepts text or a block, not both" if !text.nil? && block
143
147
  raise ArgumentError, "Table th href: requires sort:" if sort.nil? && !href.nil?
148
+ raise ArgumentError, "Table th sort: must be declared inside thead" if !sort.nil? && @rendering_section != :head
144
149
  alignment = validate_choice!(:align, align, ALIGNMENTS)
145
150
  scope = validate_choice!(:scope, scope, SCOPES)
146
151
  key = sort.nil? ? nil : declare_sort_header!(sort, href:)
@@ -235,10 +240,24 @@ module NitroKit
235
240
  raise ArgumentError, "Table #{name} must be a Symbol or non-blank String"
236
241
  end
237
242
 
243
+ # The wrapper scrolls horizontally, so keyboard users need it focusable and
244
+ # named. The caption is the only Nitro-owned name source, so the region is
245
+ # only exposed when one exists.
246
+ def scroll_region_attributes
247
+ return root_attributes unless @caption
248
+
249
+ root_attributes.merge(
250
+ tabindex: "0",
251
+ role: "region",
252
+ aria: root_attributes.fetch(:aria, {}).merge(labelledby: @caption_id)
253
+ )
254
+ end
255
+
238
256
  def render_caption
239
257
  html_caption(
240
258
  **slot_attributes(
241
259
  :caption,
260
+ attributes: { id: @caption_id },
242
261
  html: @caption.html,
243
262
  aria: @caption.aria,
244
263
  data: @caption.data,
@@ -258,10 +277,10 @@ module NitroKit
258
277
  desperately_need_a_class: section.css_class
259
278
  )
260
279
  ) do
261
- @rendering_section = true
280
+ @rendering_section = slot
262
281
  render(section.content)
263
282
  ensure
264
- @rendering_section = false
283
+ @rendering_section = nil
265
284
  end
266
285
  end
267
286
 
@@ -17,7 +17,7 @@ module NitroKit
17
17
  data: {},
18
18
  desperately_need_a_class: nil
19
19
  )
20
- @identifier = component_id(id)
20
+ @identifier = validate_id!("Tabs id", id)
21
21
  @default = default.nil? ? nil : normalize_identity(default, name: "default tab key")
22
22
  @label = validate_label!(label)
23
23
  @orientation = validate_choice!(:orientation, orientation, ORIENTATIONS)
@@ -140,6 +140,7 @@ module NitroKit
140
140
  data: {
141
141
  key: tab.key,
142
142
  state: active ? "active" : "inactive",
143
+ action: "beforematch->nk--tabs#reveal",
143
144
  nk__tabs_target: "panel"
144
145
  }
145
146
  }
@@ -193,17 +194,5 @@ module NitroKit
193
194
 
194
195
  raise ArgumentError, "Tabs labels must be non-blank Strings"
195
196
  end
196
-
197
- def validate_boolean!(name, value)
198
- return value if value == true || value == false
199
-
200
- raise ArgumentError, "Tabs #{name} must be true or false"
201
- end
202
-
203
- def component_id(value)
204
- return value if value.is_a?(String) && value.present? && !value.match?(/\s/)
205
-
206
- raise ArgumentError, "Tabs id must be a non-blank String without whitespace"
207
- end
208
197
  end
209
198
  end
@@ -32,6 +32,7 @@ module NitroKit
32
32
  attributes: {
33
33
  id:,
34
34
  role: ASSERTIVE_VARIANTS.include?(@variant) ? "alert" : "status",
35
+ aria: { atomic: true },
35
36
  data: {
36
37
  state: "open",
37
38
  turbo_temporary: true,
@@ -47,7 +48,7 @@ module NitroKit
47
48
  }
48
49
  },
49
50
  html:,
50
- aria: aria.merge(atomic: true),
51
+ aria:,
51
52
  data:,
52
53
  variant:,
53
54
  desperately_need_a_class:
@@ -175,6 +176,7 @@ module NitroKit
175
176
  attributes: {
176
177
  id:,
177
178
  role: "region",
179
+ aria: { label: },
178
180
  data: {
179
181
  controller: "nk--toast",
180
182
  action: "turbo:before-cache@document->nk--toast#teardown",
@@ -182,7 +184,7 @@ module NitroKit
182
184
  }
183
185
  },
184
186
  html:,
185
- aria: aria.merge(label:, live: "polite"),
187
+ aria:,
186
188
  data:,
187
189
  desperately_need_a_class:
188
190
  )
@@ -30,6 +30,7 @@ module NitroKit
30
30
 
31
31
  def leading(html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
32
32
  raise ArgumentError, "Toolbar accepts at most one leading region" if @leading_region
33
+ raise ArgumentError, "Toolbar leading region requires a block" unless block
33
34
  @leading_region = Region.new(
34
35
  content: block,
35
36
  html:,
@@ -42,6 +43,7 @@ module NitroKit
42
43
 
43
44
  def trailing(html: {}, aria: {}, data: {}, desperately_need_a_class: nil, &block)
44
45
  raise ArgumentError, "Toolbar accepts at most one trailing region" if @trailing_region
46
+ raise ArgumentError, "Toolbar trailing region requires a block" unless block
45
47
  @trailing_region = Region.new(
46
48
  content: block,
47
49
  html:,
@@ -16,7 +16,6 @@ module NitroKit
16
16
  :icon,
17
17
  :icon_end,
18
18
  :label,
19
- :disabled,
20
19
  :target,
21
20
  :rel,
22
21
  :download,
@@ -48,7 +47,7 @@ module NitroKit
48
47
  controller: "nk--tooltip",
49
48
  placement: @placement,
50
49
  dismissed: nil,
51
- action: "pointerleave->nk--tooltip#resetIfUninterested focusout->nk--tooltip#resetIfUninterested"
50
+ action: "keydown.esc@document->nk--tooltip#dismiss pointerleave->nk--tooltip#resetIfUninterested focusout->nk--tooltip#resetIfUninterested"
52
51
  }
53
52
  },
54
53
  html:,
@@ -81,8 +80,8 @@ module NitroKit
81
80
  text = nil,
82
81
  as: Button,
83
82
  href: nil,
84
- variant: :default,
85
- size: :md,
83
+ variant: nil,
84
+ size: nil,
86
85
  icon: nil,
87
86
  icon_end: nil,
88
87
  label: nil,
@@ -106,7 +105,7 @@ module NitroKit
106
105
  if as == :custom && content&.arity != 1
107
106
  raise ArgumentError, "Tooltip custom trigger block must accept trigger attributes"
108
107
  end
109
- if as != Button && [ href, icon, icon_end, label, target, rel, download ].any?
108
+ if as != Button && [ href, variant, size, icon, icon_end, label, target, rel, download ].any?
110
109
  raise ArgumentError, "Tooltip #{as.inspect} trigger does not accept Button options"
111
110
  end
112
111
 
@@ -114,12 +113,11 @@ module NitroKit
114
113
  as:,
115
114
  text:,
116
115
  href:,
117
- variant:,
118
- size:,
116
+ variant: variant || :default,
117
+ size: size || :md,
119
118
  icon:,
120
119
  icon_end:,
121
120
  label:,
122
- disabled: false,
123
121
  target:,
124
122
  rel:,
125
123
  download:,
@@ -156,7 +154,6 @@ module NitroKit
156
154
  icon: @trigger.icon,
157
155
  icon_end: @trigger.icon_end,
158
156
  label: @trigger.label,
159
- disabled: @trigger.disabled,
160
157
  target: @trigger.target,
161
158
  rel: @trigger.rel,
162
159
  download: @trigger.download,
@@ -192,7 +189,7 @@ module NitroKit
192
189
  end
193
190
 
194
191
  def render_custom_trigger
195
- span(**slot_attributes(:trigger)) do
192
+ div(**slot_attributes(:trigger)) do
196
193
  raw(safe(capture(trigger_attributes, &@trigger.content)))
197
194
  end
198
195
  end
@@ -1,6 +1,6 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
- const narrowViewport = "(max-width: 48rem)";
3
+ const narrowViewport = "(width < 48rem)";
4
4
 
5
5
  export default class extends Controller {
6
6
  static targets = ["dialog", "navigation", "sidebar", "trigger"];
@@ -168,8 +168,8 @@ export default class extends Controller {
168
168
  const focusTarget = this.previouslyFocused?.isConnected
169
169
  ? this.previouslyFocused
170
170
  : this.hasTriggerTarget
171
- ? this.triggerTarget
172
- : null;
171
+ ? this.triggerTarget
172
+ : null;
173
173
 
174
174
  this.restoreNavigation();
175
175
  this.setState(false);
@@ -48,6 +48,12 @@ export default class extends Controller {
48
48
  input.checked = input.value === preference;
49
49
  } else if (input.localName === "select") {
50
50
  input.value = preference;
51
+ } else if (input.dataset.appearancePreference) {
52
+ input.setAttribute("role", "menuitemradio");
53
+ input.setAttribute(
54
+ "aria-checked",
55
+ input.dataset.appearancePreference === preference,
56
+ );
51
57
  }
52
58
  });
53
59
  this.updateTriggerGlyph(preference);