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
@@ -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)
@@ -193,17 +193,5 @@ module NitroKit
193
193
 
194
194
  raise ArgumentError, "Tabs labels must be non-blank Strings"
195
195
  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
196
  end
209
197
  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);
@@ -50,6 +50,7 @@ export default class extends Controller {
50
50
  }
51
51
 
52
52
  connect() {
53
+ this.outsidePointerDown = this.outsidePointerDown.bind(this);
53
54
  this.activeOption = null;
54
55
  this.enhanced = true;
55
56
  this.nativeRequired = this.requiredValue;
@@ -64,6 +65,7 @@ export default class extends Controller {
64
65
 
65
66
  disconnect() {
66
67
  this.stopPositioning?.();
68
+ this.stopOutsideDismissal();
67
69
  this.enhanced = false;
68
70
  const control = this.element.querySelector(
69
71
  ':scope > [data-slot="combobox-control"]',
@@ -143,6 +145,17 @@ export default class extends Controller {
143
145
  open() {
144
146
  if (this.inputTarget.disabled) return;
145
147
 
148
+ // A committed selection is replaced, not appended to: opening selects the
149
+ // visible text so the first keystroke starts a fresh filter.
150
+ if (!this.openValue && this.inputTarget.value !== "") {
151
+ this.inputTarget.select();
152
+ this.inputTarget.addEventListener(
153
+ "mouseup",
154
+ (event) => event.preventDefault(),
155
+ { once: true },
156
+ );
157
+ }
158
+
146
159
  this.openValue = true;
147
160
  }
148
161
 
@@ -151,10 +164,30 @@ export default class extends Controller {
151
164
  this.setActive(null);
152
165
  }
153
166
 
154
- closeFromOutside(event) {
167
+ // Capture-phase document pointerdown mirrors the Dropdown controller: iOS
168
+ // Safari does not reliably deliver window click events for outside taps.
169
+ outsidePointerDown(event) {
155
170
  if (!this.element.contains(event.target)) this.close();
156
171
  }
157
172
 
173
+ startOutsideDismissal() {
174
+ if (this.stopOutsideDismissalListener) return;
175
+
176
+ document.addEventListener("pointerdown", this.outsidePointerDown, true);
177
+ this.stopOutsideDismissalListener = () => {
178
+ document.removeEventListener(
179
+ "pointerdown",
180
+ this.outsidePointerDown,
181
+ true,
182
+ );
183
+ this.stopOutsideDismissalListener = null;
184
+ };
185
+ }
186
+
187
+ stopOutsideDismissal() {
188
+ this.stopOutsideDismissalListener?.();
189
+ }
190
+
158
191
  filter() {
159
192
  const query = this.inputTarget.value.trim().toLocaleLowerCase();
160
193
  let exactMatch = null;
@@ -268,9 +301,11 @@ export default class extends Controller {
268
301
 
269
302
  if (open) {
270
303
  this.startPositioning();
304
+ this.startOutsideDismissal();
271
305
  } else {
272
306
  this.stopPositioning?.();
273
307
  this.stopPositioning = null;
308
+ this.stopOutsideDismissal();
274
309
  }
275
310
  }
276
311
 
@@ -351,6 +386,21 @@ export default class extends Controller {
351
386
 
352
387
  this.statusTarget.textContent =
353
388
  count === 0 ? this.noResultsMessage : this.resultsMessage(count);
389
+ this.toggleEmptyState(count === 0);
390
+ }
391
+
392
+ toggleEmptyState(empty) {
393
+ if (empty && !this.emptyElement) {
394
+ const item = document.createElement("li");
395
+ item.setAttribute("role", "presentation");
396
+ item.setAttribute("data-slot", "combobox-empty");
397
+ item.textContent = this.noResultsMessage;
398
+ this.listboxTarget.append(item);
399
+ this.emptyElement = item;
400
+ } else if (!empty && this.emptyElement) {
401
+ this.emptyElement.remove();
402
+ this.emptyElement = null;
403
+ }
354
404
  }
355
405
 
356
406
  get visibleOptions() {
@@ -22,13 +22,13 @@ export default class extends Controller {
22
22
  this.connected = true;
23
23
  this.restoreFocus = true;
24
24
  this.loadedQuery = this.inputTarget.value;
25
- this.triggerTarget.removeAttribute("command");
26
25
  this.searchTarget.hidden = false;
27
26
  this.element.dataset.enhanced = "true";
28
27
  }
29
28
 
30
29
  disconnect() {
31
30
  this.connected = false;
31
+ clearTimeout(this.openTimer);
32
32
  clearTimeout(this.searchTimer);
33
33
  const trigger = this.element.querySelector(
34
34
  ':scope > [data-slot="command-palette-trigger"]',
@@ -51,21 +51,21 @@ export default class extends Controller {
51
51
  });
52
52
  if (empty) empty.hidden = true;
53
53
  if (status) status.textContent = "";
54
- trigger?.setAttribute("command", "show-modal");
55
54
  if (search) search.hidden = true;
56
55
  delete this.element.dataset.enhanced;
57
56
  }
58
57
 
59
- open(event) {
60
- event?.preventDefault();
61
- if (!this.canOpen) return;
58
+ openedByTrigger(event) {
59
+ if (!this.canOpen) {
60
+ event.preventDefault();
61
+ event.stopPropagation();
62
+ return;
63
+ }
62
64
 
63
- this.returnFocus = document.activeElement;
65
+ this.returnFocus = event.currentTarget;
64
66
  this.restoreFocus = true;
65
- this.reset();
66
- this.panelTarget.showModal();
67
- this.inputTarget.focus();
68
- if (this.hasFormTarget) this.submitSearch();
67
+ clearTimeout(this.openTimer);
68
+ this.openTimer = setTimeout(() => this.#prepareOpenPanel(), 0);
69
69
  }
70
70
 
71
71
  shortcut(event) {
@@ -87,7 +87,9 @@ export default class extends Controller {
87
87
  if (!this.canOpen) return;
88
88
 
89
89
  event.preventDefault();
90
- this.open();
90
+ this.returnFocus = document.activeElement;
91
+ this.panelTarget.showModal();
92
+ this.#prepareOpenPanel();
91
93
  }
92
94
  }
93
95
 
@@ -246,4 +248,12 @@ export default class extends Controller {
246
248
 
247
249
  return !activeModal || activeModal.contains(this.element);
248
250
  }
251
+
252
+ #prepareOpenPanel() {
253
+ if (!this.panelTarget.open) return;
254
+
255
+ this.reset();
256
+ this.inputTarget.focus();
257
+ if (this.hasFormTarget) this.submitSearch();
258
+ }
249
259
  }
@@ -4,6 +4,43 @@ export default class extends Controller {
4
4
  static targets = ["panel"];
5
5
  static values = { dismissible: Boolean };
6
6
 
7
+ disconnect() {
8
+ this.returnFocus = null;
9
+ }
10
+
11
+ invoke(event) {
12
+ const invoker = event.target.closest(
13
+ "[command], [data-nk--dialog-command]",
14
+ );
15
+ if (!invoker || !this.element.contains(invoker) || invoker.disabled) return;
16
+
17
+ const command =
18
+ invoker.getAttribute("command") ||
19
+ invoker.getAttribute("data-nk--dialog-command");
20
+ if (!["show-modal", "close"].includes(command)) return;
21
+
22
+ const panel = this.#commandPanel(invoker);
23
+ if (!panel) return;
24
+ if (command === "show-modal") this.returnFocus = invoker;
25
+ if (this.#nativeRelationshipRuns(invoker, panel, command)) return;
26
+
27
+ event.preventDefault();
28
+ if (command === "show-modal" && !panel.open) panel.showModal();
29
+ if (command === "close" && panel.open) panel.close();
30
+ }
31
+
32
+ closeForCache() {
33
+ this.returnFocus = null;
34
+ if (this.panelTarget.open) this.panelTarget.close();
35
+ }
36
+
37
+ restoreFocus() {
38
+ const returnFocus = this.returnFocus;
39
+
40
+ this.returnFocus = null;
41
+ if (returnFocus?.isConnected) returnFocus.focus();
42
+ }
43
+
7
44
  dismiss(event) {
8
45
  if (!this.dismissibleValue || event.target !== this.panelTarget) return;
9
46
 
@@ -20,4 +57,21 @@ export default class extends Controller {
20
57
  cancel(event) {
21
58
  if (!this.dismissibleValue) event.preventDefault();
22
59
  }
60
+
61
+ #commandPanel(invoker) {
62
+ const targetId = invoker.getAttribute("commandfor");
63
+ if (targetId) {
64
+ const target = document.getElementById(targetId);
65
+ if (target instanceof HTMLDialogElement && this.element.contains(target))
66
+ return target;
67
+ }
68
+
69
+ return invoker.hasAttribute("data-nk--dialog-command")
70
+ ? this.panelTarget
71
+ : null;
72
+ }
73
+
74
+ #nativeRelationshipRuns(invoker, panel, command) {
75
+ return invoker.commandForElement === panel && invoker.command === command;
76
+ }
23
77
  }