stimeo-ui 0.2.0 → 0.3.0

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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +163 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/alert_dialog_controller.js +318 -0
  5. data/dist/controllers/breadcrumb_controller.js +225 -13
  6. data/dist/controllers/calendar_controller.js +89 -22
  7. data/dist/controllers/carousel_controller.js +313 -0
  8. data/dist/controllers/clipboard_controller.js +144 -0
  9. data/dist/controllers/collapsible_controller.js +327 -0
  10. data/dist/controllers/color_picker_controller.js +252 -0
  11. data/dist/controllers/combobox_controller.js +162 -23
  12. data/dist/controllers/command_palette_controller.js +194 -17
  13. data/dist/controllers/context_menu_controller.js +32 -10
  14. data/dist/controllers/count_up_controller.js +8 -1
  15. data/dist/controllers/currency_input_controller.js +147 -0
  16. data/dist/controllers/data_grid_controller.js +246 -0
  17. data/dist/controllers/date_range_picker_controller.js +441 -0
  18. data/dist/controllers/dismissible_controller.js +117 -0
  19. data/dist/controllers/drawer_controller.js +630 -0
  20. data/dist/controllers/editable_controller.js +169 -0
  21. data/dist/controllers/file_dropzone_controller.js +165 -0
  22. data/dist/controllers/filter_controller.js +86 -0
  23. data/dist/controllers/flash_controller.js +36 -5
  24. data/dist/controllers/form_validation_controller.js +1 -1
  25. data/dist/controllers/highlight_controller.js +6 -4
  26. data/dist/controllers/intersection_controller.js +67 -19
  27. data/dist/controllers/lazy_frame_controller.js +54 -11
  28. data/dist/controllers/listbox_controller.js +257 -53
  29. data/dist/controllers/local_time_controller.js +2 -2
  30. data/dist/controllers/masonry_controller.js +142 -0
  31. data/dist/controllers/menu_controller.js +104 -17
  32. data/dist/controllers/menubar_controller.js +785 -0
  33. data/dist/controllers/multi_select_controller.js +755 -0
  34. data/dist/controllers/navigation_menu_controller.js +511 -0
  35. data/dist/controllers/number_input_controller.js +7 -0
  36. data/dist/controllers/otp_controller.js +18 -1
  37. data/dist/controllers/overflow_indicator_controller.js +246 -27
  38. data/dist/controllers/overflow_menu_controller.js +381 -57
  39. data/dist/controllers/pagination_controller.js +163 -32
  40. data/dist/controllers/password_reveal_controller.js +117 -0
  41. data/dist/controllers/persist_controller.js +6 -6
  42. data/dist/controllers/pointer_drag_controller.js +9 -1
  43. data/dist/controllers/popover_controller.js +2 -2
  44. data/dist/controllers/radio_group_controller.js +22 -3
  45. data/dist/controllers/range_slider_controller.js +192 -0
  46. data/dist/controllers/rating_controller.js +16 -2
  47. data/dist/controllers/read_more_controller.js +238 -0
  48. data/dist/controllers/resizable_controller.js +65 -1
  49. data/dist/controllers/roving_controller.js +17 -2
  50. data/dist/controllers/scroll_area_controller.js +101 -14
  51. data/dist/controllers/scroll_restore_controller.js +93 -0
  52. data/dist/controllers/scroll_visibility_controller.js +40 -6
  53. data/dist/controllers/scrollspy_controller.js +369 -74
  54. data/dist/controllers/separator_controller.js +96 -0
  55. data/dist/controllers/sidebar_controller.js +761 -0
  56. data/dist/controllers/skeleton_controller.js +1 -1
  57. data/dist/controllers/slider_controller.js +32 -6
  58. data/dist/controllers/sortable_controller.js +34 -3
  59. data/dist/controllers/spinner_controller.js +1 -1
  60. data/dist/controllers/stepper_controller.js +28 -12
  61. data/dist/controllers/stick_to_bottom_controller.js +9 -4
  62. data/dist/controllers/sticky_observer_controller.js +109 -20
  63. data/dist/controllers/switch_controller.js +1 -0
  64. data/dist/controllers/tabs_controller.js +26 -3
  65. data/dist/controllers/tags_input_controller.js +295 -0
  66. data/dist/controllers/theme_controller.js +42 -13
  67. data/dist/controllers/time_picker_controller.js +231 -0
  68. data/dist/controllers/toast_controller.js +40 -14
  69. data/dist/controllers/toggle_group_controller.js +23 -2
  70. data/dist/controllers/toolbar_controller.js +230 -31
  71. data/dist/controllers/transition_controller.js +153 -38
  72. data/dist/controllers/tree_view_controller.js +691 -0
  73. data/dist/index.js +4256 -915
  74. data/lib/stimeo/ui/version.rb +2 -3
  75. metadata +28 -2
@@ -2,6 +2,24 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/combobox_controller.ts
4
4
 
5
+ // src/utils/active_option.ts
6
+ function syncActiveOption(options, active) {
7
+ let written = 0;
8
+ for (const option of options) {
9
+ const next = option === active ? "true" : "false";
10
+ if (option.getAttribute("aria-selected") === next) continue;
11
+ option.setAttribute("aria-selected", next);
12
+ written += 1;
13
+ }
14
+ return written;
15
+ }
16
+
17
+ // src/utils/arrow_step.ts
18
+ function isReservedArrowChord(event, allow = []) {
19
+ if (!event.key.startsWith("Arrow")) return false;
20
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
21
+ }
22
+
5
23
  // src/utils/composition_tracker.ts
6
24
  var CompositionTracker = class {
7
25
  #observedTargets = /* @__PURE__ */ new Set();
@@ -49,6 +67,39 @@ var CompositionTracker = class {
49
67
  };
50
68
  };
51
69
 
70
+ // src/utils/microtask_coalescer.ts
71
+ var MicrotaskCoalescer = class {
72
+ #run;
73
+ #queued = false;
74
+ #active = false;
75
+ #generation = 0;
76
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
77
+ constructor(run) {
78
+ this.#run = run;
79
+ }
80
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
81
+ activate() {
82
+ this.#active = true;
83
+ }
84
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
85
+ cancel() {
86
+ this.#active = false;
87
+ this.#queued = false;
88
+ this.#generation += 1;
89
+ }
90
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
91
+ schedule() {
92
+ if (!this.#active || this.#queued) return;
93
+ this.#queued = true;
94
+ const generation = this.#generation;
95
+ queueMicrotask(() => {
96
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
97
+ this.#queued = false;
98
+ this.#run();
99
+ });
100
+ }
101
+ };
102
+
52
103
  // src/utils/option_scroll.ts
53
104
  function scrollOptionIntoView(list, option) {
54
105
  if (list.scrollHeight <= list.clientHeight) return;
@@ -66,8 +117,11 @@ var ComboboxController = class extends Controller {
66
117
  static targets = ["input", "list", "option"];
67
118
  static actions = ["close", "filter", "onKeydown", "open", "selectByClick"];
68
119
  static events = ["selected"];
69
- /** Index into the *visible* options of the active option, or -1 if none. */
70
- #activeIndex = -1;
120
+ /** Stable ID of the active option; the live element is resolved before every use. */
121
+ #activeId = null;
122
+ #connected = false;
123
+ /** Collapses one mutation batch of target callbacks into a single pass. */
124
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());
71
125
  /**
72
126
  * Suppresses {@link open} for the duration of the programmatic re-focus in
73
127
  * `#select`, so committing a value (which returns focus to the input)
@@ -80,21 +134,36 @@ var ComboboxController = class extends Controller {
80
134
  connect() {
81
135
  if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
82
136
  this.close();
83
- document.addEventListener("click", this.#onOutsideClick);
137
+ document.addEventListener("click", this.#onOutsideClick, true);
138
+ this.#connected = true;
139
+ this.#reconcile.activate();
84
140
  }
85
141
  /** Removes the document-level listener registered in {@link connect}. */
86
142
  disconnect() {
143
+ this.#connected = false;
144
+ this.#reconcile.cancel();
87
145
  this.#composition.disconnect();
88
- document.removeEventListener("click", this.#onOutsideClick);
146
+ document.removeEventListener("click", this.#onOutsideClick, true);
89
147
  }
90
148
  /** Tracks an input added initially or after connect. */
91
149
  inputTargetConnected(input) {
92
150
  this.#composition.observe(input);
151
+ if (this.#connected) this.#queueOptionReconciliation();
93
152
  }
94
153
  /** Removes composition listeners when the active input is replaced or removed. */
95
154
  inputTargetDisconnected(input) {
96
155
  this.#composition.unobserve(input);
97
156
  }
157
+ /** Establishes the active-state baseline, then reconciles a runtime addition. */
158
+ optionTargetConnected(option) {
159
+ option.setAttribute("aria-selected", "false");
160
+ if (this.#connected) this.#queueOptionReconciliation();
161
+ }
162
+ /** Clears the disconnected node's active state, then reconciles the survivors. */
163
+ optionTargetDisconnected(option) {
164
+ option.setAttribute("aria-selected", "false");
165
+ if (this.#connected) this.#queueOptionReconciliation();
166
+ }
98
167
  /** Filters confirmed input text and opens the listbox. */
99
168
  filter(event) {
100
169
  if (this.#composition.isComposing(event)) return;
@@ -106,7 +175,7 @@ var ComboboxController = class extends Controller {
106
175
  * re-opening with a stale non-matching value still surfaces the empty state).
107
176
  */
108
177
  open() {
109
- if (!this.hasListTarget || this.#suppressOpen) return;
178
+ if (!this.hasListTarget || !this.hasInputTarget || this.#suppressOpen) return;
110
179
  this.#applyFilter();
111
180
  this.listTarget.hidden = false;
112
181
  this.inputTarget.setAttribute("aria-expanded", "true");
@@ -124,24 +193,47 @@ var ComboboxController = class extends Controller {
124
193
  option.hidden = query.length > 0 && !text.includes(query);
125
194
  }
126
195
  }
127
- /** Closes the listbox, clears the active option, and updates ARIA state. */
196
+ /**
197
+ * Closes the listbox, clears the active option, and updates ARIA state.
198
+ *
199
+ * Survives a missing input in both directions. {@link inputTargetConnected}
200
+ * accepts an input added after connect, so dereferencing it here would abort
201
+ * `connect()` before it registers the outside-click listener, and nothing
202
+ * re-registers it later. Symmetrically, an input removed while the popup is
203
+ * open must still let the popup come down — so hiding the list is
204
+ * unconditional and only the ARIA half is guarded.
205
+ */
128
206
  close() {
129
207
  if (!this.hasListTarget) return;
130
208
  this.listTarget.hidden = true;
131
- this.inputTarget.setAttribute("aria-expanded", "false");
132
- this.#setActive(-1);
133
209
  this.element.removeAttribute("data-stimeo--combobox-empty");
210
+ if (this.hasInputTarget) this.inputTarget.setAttribute("aria-expanded", "false");
211
+ this.#setActive(-1);
134
212
  }
135
213
  /** Routes keyboard interaction per the APG combobox model. */
136
214
  onKeydown(event) {
137
215
  if (this.#composition.isComposing(event)) return;
216
+ if (event.defaultPrevented) return;
217
+ if (isReservedArrowChord(event, ["alt"])) return;
218
+ if (event.altKey && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
219
+ if (event.key === "ArrowDown" ? !this.#isClosed : this.#isClosed) return;
220
+ event.preventDefault();
221
+ if (event.key === "ArrowDown") {
222
+ this.open();
223
+ } else {
224
+ this.close();
225
+ }
226
+ return;
227
+ }
228
+ if (!this.#isClosed) this.#reconcileActive();
138
229
  switch (event.key) {
139
230
  case "ArrowDown": {
140
231
  event.preventDefault();
141
232
  if (this.#isClosed) this.open();
142
233
  const visible = this.#visibleOptions();
143
234
  if (visible.length > 0) {
144
- const next = this.#activeIndex === -1 ? 0 : (this.#activeIndex + 1) % visible.length;
235
+ const activeIndex = this.#findActiveIndex(visible);
236
+ const next = activeIndex === -1 ? 0 : (activeIndex + 1) % visible.length;
145
237
  this.#setActive(next);
146
238
  }
147
239
  break;
@@ -151,7 +243,8 @@ var ComboboxController = class extends Controller {
151
243
  if (this.#isClosed) this.open();
152
244
  const visible = this.#visibleOptions();
153
245
  if (visible.length > 0) {
154
- const next = this.#activeIndex === -1 ? visible.length - 1 : (this.#activeIndex - 1 + visible.length) % visible.length;
246
+ const activeIndex = this.#findActiveIndex(visible);
247
+ const next = activeIndex === -1 ? visible.length - 1 : (activeIndex - 1 + visible.length) % visible.length;
155
248
  this.#setActive(next);
156
249
  }
157
250
  break;
@@ -172,7 +265,8 @@ var ComboboxController = class extends Controller {
172
265
  }
173
266
  case "Enter": {
174
267
  const visible = this.#visibleOptions();
175
- const active = this.#activeIndex === -1 ? void 0 : visible[this.#activeIndex];
268
+ const activeIndex = this.#findActiveIndex(visible);
269
+ const active = activeIndex === -1 ? void 0 : visible[activeIndex];
176
270
  if (active) {
177
271
  event.preventDefault();
178
272
  this.#select(active);
@@ -180,7 +274,7 @@ var ComboboxController = class extends Controller {
180
274
  break;
181
275
  }
182
276
  case "Escape":
183
- if (event.defaultPrevented || this.#isClosed) break;
277
+ if (this.#isClosed) break;
184
278
  event.preventDefault();
185
279
  this.close();
186
280
  break;
@@ -200,11 +294,24 @@ var ComboboxController = class extends Controller {
200
294
  /** Selects the clicked option. Bound via `data-action` (click). */
201
295
  selectByClick(event) {
202
296
  const option = event.currentTarget.closest('[role="option"]');
203
- if (option) this.#select(option);
297
+ if (option && this.optionTargets.includes(option)) this.#select(option);
204
298
  }
205
- /** Commits an option: fills the input, closes the listbox, notifies listeners. */
299
+ /**
300
+ * Commits an option: fills the input, closes the listbox, notifies listeners.
301
+ *
302
+ * An input removed while the popup is open leaves the options clickable, and
303
+ * {@link close} already survives that state. Selection does too: the popup
304
+ * comes down and listeners still hear the choice, with only the field-bound
305
+ * half — the value write, the focus return, and the native `change` — skipped,
306
+ * because there is no field to carry them.
307
+ */
206
308
  #select(option) {
207
309
  const value = option.dataset.value ?? (option.textContent ?? "").trim();
310
+ if (!this.hasInputTarget) {
311
+ this.close();
312
+ this.dispatch("selected", { detail: { value } });
313
+ return;
314
+ }
208
315
  const changed = this.inputTarget.value !== value;
209
316
  this.inputTarget.value = value;
210
317
  this.close();
@@ -235,19 +342,51 @@ var ComboboxController = class extends Controller {
235
342
  * input's `aria-activedescendant`. Pass `-1` to clear the active option.
236
343
  */
237
344
  #setActive(index) {
238
- this.#activeIndex = index;
239
345
  const visible = this.#visibleOptions();
240
- const active = index === -1 ? null : visible[index];
241
- for (const option of this.optionTargets) {
242
- option.setAttribute("aria-selected", option === active ? "true" : "false");
243
- }
244
- if (active?.id) {
245
- this.inputTarget.setAttribute("aria-activedescendant", active.id);
246
- } else {
247
- this.inputTarget.removeAttribute("aria-activedescendant");
346
+ const active = index === -1 ? null : visible[index] ?? null;
347
+ syncActiveOption(this.optionTargets, active);
348
+ this.#activeId = active?.id || null;
349
+ if (this.hasInputTarget) {
350
+ if (active?.id) {
351
+ this.inputTarget.setAttribute("aria-activedescendant", active.id);
352
+ } else {
353
+ this.inputTarget.removeAttribute("aria-activedescendant");
354
+ }
248
355
  }
249
356
  if (active && this.hasListTarget) scrollOptionIntoView(this.listTarget, active);
250
357
  }
358
+ /** Re-applies filtering and active state after a target collection change. */
359
+ #reconcileOptions() {
360
+ if (this.hasInputTarget && !this.#isClosed) this.#applyFilter();
361
+ this.#reconcileActive();
362
+ this.#reflectEmptyState();
363
+ }
364
+ /** Preserves a surviving active ID; disappearance deliberately clears active state. */
365
+ #reconcileActive() {
366
+ if (!this.hasInputTarget || this.#isClosed) {
367
+ this.#setActive(-1);
368
+ return;
369
+ }
370
+ const visible = this.#visibleOptions();
371
+ const index = this.#findActiveIndex(visible);
372
+ const active = index === -1 ? null : visible[index] ?? null;
373
+ const selected = this.optionTargets.filter(
374
+ (option) => option.getAttribute("aria-selected") === "true"
375
+ );
376
+ const idref = this.inputTarget.getAttribute("aria-activedescendant");
377
+ const stateMatches = active ? this.#activeId === (active.id || null) && selected.length === 1 && selected[0] === active && idref === (active.id || null) : this.#activeId === null && selected.length === 0 && idref === null;
378
+ if (!stateMatches) this.#setActive(index);
379
+ }
380
+ /** Resolves the stable ID, with `aria-selected` as the ID-less compatibility marker. */
381
+ #findActiveIndex(options) {
382
+ const activeId = (this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
383
+ if (activeId) return options.findIndex((option) => option.id === activeId);
384
+ return options.findIndex((option) => option.getAttribute("aria-selected") === "true");
385
+ }
386
+ /** Coalesces all target callbacks from one MutationObserver batch. */
387
+ #queueOptionReconciliation() {
388
+ this.#reconcile.schedule();
389
+ }
251
390
  /** The options currently shown (not filtered out). */
252
391
  #visibleOptions() {
253
392
  return this.optionTargets.filter((option) => !option.hidden);
@@ -2,6 +2,24 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/command_palette_controller.ts
4
4
 
5
+ // src/utils/active_option.ts
6
+ function syncActiveOption(options, active) {
7
+ let written = 0;
8
+ for (const option of options) {
9
+ const next = option === active ? "true" : "false";
10
+ if (option.getAttribute("aria-selected") === next) continue;
11
+ option.setAttribute("aria-selected", next);
12
+ written += 1;
13
+ }
14
+ return written;
15
+ }
16
+
17
+ // src/utils/arrow_step.ts
18
+ function isReservedArrowChord(event, allow = []) {
19
+ if (!event.key.startsWith("Arrow")) return false;
20
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
21
+ }
22
+
5
23
  // src/utils/composition_tracker.ts
6
24
  var CompositionTracker = class {
7
25
  #observedTargets = /* @__PURE__ */ new Set();
@@ -304,6 +322,39 @@ var FocusTrap = class {
304
322
  }
305
323
  };
306
324
 
325
+ // src/utils/microtask_coalescer.ts
326
+ var MicrotaskCoalescer = class {
327
+ #run;
328
+ #queued = false;
329
+ #active = false;
330
+ #generation = 0;
331
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
332
+ constructor(run) {
333
+ this.#run = run;
334
+ }
335
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
336
+ activate() {
337
+ this.#active = true;
338
+ }
339
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
340
+ cancel() {
341
+ this.#active = false;
342
+ this.#queued = false;
343
+ this.#generation += 1;
344
+ }
345
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
346
+ schedule() {
347
+ if (!this.#active || this.#queued) return;
348
+ this.#queued = true;
349
+ const generation = this.#generation;
350
+ queueMicrotask(() => {
351
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
352
+ this.#queued = false;
353
+ this.#run();
354
+ });
355
+ }
356
+ };
357
+
307
358
  // src/controllers/command_palette_controller.ts
308
359
  var CommandPaletteController = class _CommandPaletteController extends Controller {
309
360
  static #ORIGINAL_ARIA_DISABLED = "data-command-palette-original-aria-disabled";
@@ -323,8 +374,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
323
374
  "toggle"
324
375
  ];
325
376
  static events = ["select"];
326
- /** The index of the currently active option within the visible subset. */
327
- #activeIndex = -1;
377
+ /** Stable ID of the active option; the live target is resolved before every use. */
378
+ #activeId = null;
379
+ /** Visible/selectable target ID order captured for removal fallback. */
380
+ #activeOrder = [];
381
+ #connected = false;
382
+ /** Collapses one mutation batch of target callbacks into a single pass. */
383
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileActive());
328
384
  /** Owns IME lifecycle state; confirmed text re-filters an open palette once. */
329
385
  #composition = new CompositionTracker({
330
386
  onEnd: () => {
@@ -358,9 +414,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
358
414
  const shouldOpen = this.#isOpen || this.openValue;
359
415
  this.#resetToClosedState();
360
416
  if (shouldOpen) this.open();
417
+ this.#connected = true;
418
+ this.#reconcile.activate();
361
419
  }
362
420
  /** Tears down the global hotkey listener and reverts the modal side effects. */
363
421
  disconnect() {
422
+ this.#connected = false;
423
+ this.#reconcile.cancel();
364
424
  document.removeEventListener("keydown", this.#onGlobalKeydown);
365
425
  this.#composition.disconnect();
366
426
  this.#trap.deactivate({ restoreFocus: false });
@@ -369,10 +429,20 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
369
429
  /** Initializes semantics for an option added before or after the controller connects. */
370
430
  optionTargetConnected(option) {
371
431
  this.#syncOptionSemanticsFor(option);
432
+ option.setAttribute("aria-selected", "false");
433
+ option.removeAttribute("data-active");
434
+ if (this.#connected) this.#queueOptionReconciliation();
435
+ }
436
+ /** Clears active residue on the old node and reconciles the surviving targets. */
437
+ optionTargetDisconnected(option) {
438
+ option.setAttribute("aria-selected", "false");
439
+ option.removeAttribute("data-active");
440
+ if (this.#connected) this.#queueOptionReconciliation();
372
441
  }
373
442
  /** Tracks an input added initially or after connect without extra consumer actions. */
374
443
  inputTargetConnected(input) {
375
444
  this.#composition.observe(input);
445
+ if (this.#connected) this.#queueOptionReconciliation();
376
446
  }
377
447
  /** Removes controller-owned listeners when the input target is replaced or removed. */
378
448
  inputTargetDisconnected(input) {
@@ -412,7 +482,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
412
482
  const matches = searchText.includes(query);
413
483
  if (matches) {
414
484
  option.removeAttribute("hidden");
415
- if (!this.#isDisabled(option)) hasSelectableMatch = true;
485
+ if (this.#isNavigable(option)) hasSelectableMatch = true;
416
486
  } else {
417
487
  option.setAttribute("hidden", "true");
418
488
  }
@@ -427,8 +497,9 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
427
497
  const target = event.currentTarget;
428
498
  if (!target) return;
429
499
  const option = target.closest("[role='option']");
430
- if (option) this.#syncOptionSemanticsFor(option);
431
- if (option && !this.#isDisabled(option)) {
500
+ if (!option || !this.optionTargets.includes(option)) return;
501
+ this.#syncOptionSemanticsFor(option);
502
+ if (!option.hasAttribute("hidden") && !this.#isDisabled(option)) {
432
503
  this.#confirmSelection(option);
433
504
  }
434
505
  }
@@ -443,7 +514,10 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
443
514
  * focus — not only the input.
444
515
  */
445
516
  onKeydown(event) {
517
+ if (event.defaultPrevented) return;
518
+ if (isReservedArrowChord(event)) return;
446
519
  if (this.#composition.isComposing(event) || !this.#isOpen) return;
520
+ this.#reconcileActive();
447
521
  switch (event.key) {
448
522
  case "ArrowDown":
449
523
  event.preventDefault();
@@ -463,7 +537,9 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
463
537
  break;
464
538
  case "Enter": {
465
539
  event.preventDefault();
466
- const activeOption = this.#visibleOptions[this.#activeIndex];
540
+ const visible = this.#visibleOptions;
541
+ const activeIndex = this.#findActiveIndex(visible);
542
+ const activeOption = activeIndex < 0 ? void 0 : visible[activeIndex];
467
543
  if (activeOption) this.#confirmSelection(activeOption);
468
544
  break;
469
545
  }
@@ -472,7 +548,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
472
548
  #navigate(direction) {
473
549
  const visible = this.#visibleOptions;
474
550
  if (visible.length === 0) return;
475
- let newIndex = this.#activeIndex + direction;
551
+ let newIndex = this.#findActiveIndex(visible) + direction;
476
552
  if (newIndex >= visible.length) newIndex = 0;
477
553
  if (newIndex < 0) newIndex = visible.length - 1;
478
554
  this.#setActiveIndex(newIndex);
@@ -481,16 +557,16 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
481
557
  this.#syncOptionSemantics();
482
558
  const visible = this.#visibleOptions;
483
559
  const activeOption = visible[index] ?? null;
484
- this.#activeIndex = activeOption ? index : -1;
560
+ syncActiveOption(this.optionTargets, activeOption);
485
561
  for (const option of this.optionTargets) {
486
- option.setAttribute("aria-selected", "false");
487
- option.removeAttribute("data-active");
562
+ if (option === activeOption) option.setAttribute("data-active", "true");
563
+ else option.removeAttribute("data-active");
488
564
  }
489
565
  if (activeOption) {
490
- activeOption.setAttribute("aria-selected", "true");
491
- activeOption.setAttribute("data-active", "true");
492
566
  activeOption.scrollIntoView({ block: "nearest" });
493
567
  }
568
+ this.#activeId = activeOption?.id || null;
569
+ this.#activeOrder = activeOption ? visible.map((option) => option.id).filter(Boolean) : [];
494
570
  if (this.hasInputTarget) {
495
571
  if (activeOption?.id) {
496
572
  this.inputTarget.setAttribute("aria-activedescendant", activeOption.id);
@@ -499,7 +575,75 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
499
575
  }
500
576
  }
501
577
  }
578
+ /** Re-resolves active identity and component-specific fallback against live targets. */
579
+ #reconcileActive() {
580
+ if (!this.#isOpen) {
581
+ this.#setActiveIndex(-1);
582
+ this.#reflectEmptyState();
583
+ return;
584
+ }
585
+ const visible = this.#visibleOptions;
586
+ const currentIndex = this.#findActiveIndex(visible);
587
+ if (currentIndex >= 0) {
588
+ const active = visible[currentIndex] ?? null;
589
+ const options = this.optionTargets;
590
+ const marked = options.filter((option) => option.hasAttribute("data-active"));
591
+ const selected = options.filter((option) => option.getAttribute("aria-selected") === "true");
592
+ const stateMatches = this.#activeId === (active?.id || null) && marked.length === 1 && marked[0] === active && selected.length === 1 && selected[0] === active && (!this.hasInputTarget || this.inputTarget.getAttribute("aria-activedescendant") === (active?.id || null));
593
+ if (stateMatches) {
594
+ this.#activeOrder = visible.map((option) => option.id).filter(Boolean);
595
+ } else {
596
+ this.#setActiveIndex(currentIndex);
597
+ }
598
+ this.#reflectEmptyState();
599
+ return;
600
+ }
601
+ const activeId = (this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
602
+ this.#setActiveIndex(activeId ? this.#findFallbackIndex(visible, activeId) : -1);
603
+ this.#reflectEmptyState();
604
+ }
605
+ /** Resolves stable ID first, then active markers for the existing ID-less case. */
606
+ #findActiveIndex(options) {
607
+ const activeId = (this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
608
+ if (activeId) return options.findIndex((option) => option.id === activeId);
609
+ return options.findIndex(
610
+ (option) => option.hasAttribute("data-active") || option.getAttribute("aria-selected") === "true"
611
+ );
612
+ }
613
+ /** Chooses a surviving former successor, then predecessor, from selectable targets only. */
614
+ #findFallbackIndex(options, activeId) {
615
+ const oldIndex = this.#activeOrder.indexOf(activeId);
616
+ if (oldIndex < 0) return -1;
617
+ const indexesById = new Map(options.map((option, index) => [option.id, index]));
618
+ for (let index = oldIndex + 1; index < this.#activeOrder.length; index += 1) {
619
+ const fallback = indexesById.get(this.#activeOrder[index] ?? "");
620
+ if (fallback !== void 0) return fallback;
621
+ }
622
+ for (let index = oldIndex - 1; index >= 0; index -= 1) {
623
+ const fallback = indexesById.get(this.#activeOrder[index] ?? "");
624
+ if (fallback !== void 0) return fallback;
625
+ }
626
+ return -1;
627
+ }
628
+ /** Reflects whether any visible, navigable command remains. */
629
+ #reflectEmptyState() {
630
+ if (this.hasEmptyTarget) this.emptyTarget.hidden = this.#visibleOptions.length > 0;
631
+ }
632
+ /** Coalesces all target callbacks from one MutationObserver batch. */
633
+ #queueOptionReconciliation() {
634
+ this.#reconcile.schedule();
635
+ }
636
+ /**
637
+ * Runs `option`: dispatches `select` and closes.
638
+ *
639
+ * **Activation is refused here, not at the call sites.** Every path that runs a
640
+ * command funnels through this method, so one check covers keyboard, pointer,
641
+ * and anything added later. Per-call-site guards would leave `aria-disabled`
642
+ * options — which stay within the keyboard's reach — runnable from whichever
643
+ * path forgets to consult the predicate.
644
+ */
502
645
  #confirmSelection(option) {
646
+ if (this.#isDisabled(option)) return;
503
647
  const value = option.dataset.value || option.textContent || "";
504
648
  this.dispatch("select", { detail: { value, option } });
505
649
  this.close();
@@ -510,14 +654,33 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
510
654
  option.removeAttribute("hidden");
511
655
  }
512
656
  const hasSelectableOption = this.#visibleOptions.length > 0;
513
- if (this.hasEmptyTarget) this.emptyTarget.hidden = hasSelectableOption;
657
+ this.#reflectEmptyState();
514
658
  this.#setActiveIndex(hasSelectableOption ? 0 : -1);
515
659
  }
516
660
  get #visibleOptions() {
517
661
  return this.optionTargets.filter(
518
- (option) => !option.hasAttribute("hidden") && !this.#isDisabled(option)
662
+ (option) => !option.hasAttribute("hidden") && this.#isNavigable(option)
519
663
  );
520
664
  }
665
+ /**
666
+ * Whether virtual focus may land on `option`.
667
+ *
668
+ * Only `data-disabled` disqualifies. That attribute is this controller's own —
669
+ * authors reach for it to mark a row that is not a destination at all, such as
670
+ * a group heading — so it can mean "skip entirely" without contradicting ARIA.
671
+ *
672
+ * **`aria-disabled` does not disqualify.** It marks a command that must stay
673
+ * *discoverable*: `aria-activedescendant` names it, so a reader announces it as
674
+ * unavailable rather than hiding its existence. Virtual focus does
675
+ * not change that — pointing `aria-activedescendant` at a disabled option is
676
+ * ordinary ARIA. Keeping the two attributes distinct is what leaves both
677
+ * meanings expressible; collapsing them would take "show it, say it is
678
+ * unavailable, do not run it" away from consumers.
679
+ */
680
+ #isNavigable(option) {
681
+ return option.dataset.disabled !== "true";
682
+ }
683
+ /** Whether activating `option` may run. Suppressed for either disabled marker. */
521
684
  #isDisabled(option) {
522
685
  return option.dataset.disabled === "true" || option.getAttribute("aria-disabled") === "true";
523
686
  }
@@ -525,9 +688,18 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
525
688
  #syncOptionSemantics() {
526
689
  for (const option of this.optionTargets) this.#syncOptionSemanticsFor(option);
527
690
  }
528
- /** Reflects `data-disabled` to ARIA without losing a pre-existing authored value. */
691
+ /**
692
+ * Reflects `data-disabled` to ARIA without losing a pre-existing authored value,
693
+ * and puts `aria-selected` back to its baseline.
694
+ *
695
+ * Here `aria-selected` marks the *active* option, not a committed choice — the
696
+ * palette runs a command and keeps nothing selected — so an authored value is
697
+ * meaningless and is overwritten rather than preserved. Every caller either
698
+ * establishes the active option straight after (`filter`, `#setActiveIndex`) or
699
+ * is handling an option that cannot be the active one yet
700
+ * (`optionTargetConnected`).
701
+ */
529
702
  #syncOptionSemanticsFor(option) {
530
- if (!option.hasAttribute("aria-selected")) option.setAttribute("aria-selected", "false");
531
703
  const originalAttribute = _CommandPaletteController.#ORIGINAL_ARIA_DISABLED;
532
704
  const originalValue = option.getAttribute(originalAttribute);
533
705
  if (option.dataset.disabled === "true") {
@@ -581,8 +753,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
581
753
  }
582
754
  /** Resets transient open state so reconnect starts from a predictable closed snapshot. */
583
755
  #resetToClosedState() {
584
- this.#activeIndex = -1;
756
+ this.#activeId = null;
757
+ this.#activeOrder = [];
585
758
  this.openValue = false;
759
+ for (const option of this.optionTargets) {
760
+ option.setAttribute("aria-selected", "false");
761
+ option.removeAttribute("data-active");
762
+ }
586
763
  if (this.hasDialogTarget) {
587
764
  this.dialogTarget.hidden = true;
588
765
  }