stimeo-ui 0.2.1 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +160 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/alert_dialog_controller.js +32 -5
- data/dist/controllers/announcer_controller.js +255 -20
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +47 -6
- data/dist/controllers/collapsible_controller.js +2 -2
- data/dist/controllers/color_picker_controller.js +92 -7
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +226 -22
- data/dist/controllers/confirm_controller.js +32 -5
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/countdown_controller.js +112 -12
- data/dist/controllers/data_grid_controller.js +82 -4
- data/dist/controllers/date_range_picker_controller.js +77 -3
- data/dist/controllers/dialog_controller.js +32 -5
- data/dist/controllers/drawer_controller.js +32 -5
- data/dist/controllers/editable_controller.js +1 -0
- data/dist/controllers/empty_state_controller.js +24 -10
- data/dist/controllers/focus_controller.js +32 -5
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/frame_loading_controller.js +177 -15
- data/dist/controllers/intersection_controller.js +36 -11
- data/dist/controllers/lazy_frame_controller.js +31 -10
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +102 -8
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +415 -63
- data/dist/controllers/meter_controller.js +145 -26
- data/dist/controllers/multi_select_controller.js +312 -29
- data/dist/controllers/navigation_menu_controller.js +154 -27
- data/dist/controllers/network_status_controller.js +28 -8
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +81 -13
- data/dist/controllers/overflow_menu_controller.js +408 -57
- data/dist/controllers/pagination_controller.js +163 -32
- data/dist/controllers/persist_controller.js +6 -6
- data/dist/controllers/pointer_drag_controller.js +9 -1
- data/dist/controllers/popover_controller.js +2 -2
- data/dist/controllers/progress_controller.js +116 -9
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +100 -9
- data/dist/controllers/rating_controller.js +69 -2
- data/dist/controllers/read_more_controller.js +63 -19
- data/dist/controllers/relative_time_controller.js +133 -12
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +86 -12
- data/dist/controllers/scroll_restore_controller.js +1 -1
- data/dist/controllers/scroll_visibility_controller.js +33 -3
- data/dist/controllers/scrollspy_controller.js +346 -73
- data/dist/controllers/separator_controller.js +9 -0
- data/dist/controllers/sidebar_controller.js +37 -8
- data/dist/controllers/skeleton_controller.js +73 -20
- data/dist/controllers/slider_controller.js +49 -8
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +228 -27
- data/dist/controllers/step_indicator_controller.js +82 -5
- data/dist/controllers/stick_to_bottom_controller.js +61 -10
- data/dist/controllers/sticky_observer_controller.js +32 -11
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +22 -2
- data/dist/controllers/theme_controller.js +22 -3
- data/dist/controllers/time_picker_controller.js +20 -1
- data/dist/controllers/toast_controller.js +4 -5
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/tree_view_controller.js +467 -51
- data/dist/index.js +4507 -907
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +2 -2
|
@@ -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();
|
|
@@ -49,6 +67,35 @@ var CompositionTracker = class {
|
|
|
49
67
|
};
|
|
50
68
|
};
|
|
51
69
|
|
|
70
|
+
// src/utils/before_cache_reset.ts
|
|
71
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
72
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
73
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
74
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
75
|
+
static #onBeforeCache = () => {
|
|
76
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
77
|
+
};
|
|
78
|
+
#rewind;
|
|
79
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
80
|
+
constructor(rewind) {
|
|
81
|
+
this.#rewind = rewind;
|
|
82
|
+
}
|
|
83
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
84
|
+
activate() {
|
|
85
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
86
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
87
|
+
if (first) {
|
|
88
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
92
|
+
deactivate() {
|
|
93
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
94
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
95
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
52
99
|
// src/utils/escape_layer.ts
|
|
53
100
|
var EscapeLayer = class _EscapeLayer {
|
|
54
101
|
static #registries = /* @__PURE__ */ new WeakMap();
|
|
@@ -182,7 +229,7 @@ var FocusTrap = class {
|
|
|
182
229
|
}
|
|
183
230
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
184
231
|
document.addEventListener("keydown", this.#onKeydown);
|
|
185
|
-
|
|
232
|
+
this.#beforeCache.activate();
|
|
186
233
|
const onEscape = this.#options.onEscape;
|
|
187
234
|
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
188
235
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
@@ -199,7 +246,7 @@ var FocusTrap = class {
|
|
|
199
246
|
this.#activeState = false;
|
|
200
247
|
this.#escapeLayer.deactivate();
|
|
201
248
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
202
|
-
|
|
249
|
+
this.#beforeCache.deactivate();
|
|
203
250
|
if (this.#scrollLocked) {
|
|
204
251
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
205
252
|
this.#scrollLocked = false;
|
|
@@ -223,9 +270,7 @@ var FocusTrap = class {
|
|
|
223
270
|
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
224
271
|
* is left alone mid-navigation. The listener lives only while active.
|
|
225
272
|
*/
|
|
226
|
-
#
|
|
227
|
-
this.deactivate({ restoreFocus: false });
|
|
228
|
-
};
|
|
273
|
+
#beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
|
|
229
274
|
/**
|
|
230
275
|
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
231
276
|
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
@@ -304,6 +349,39 @@ var FocusTrap = class {
|
|
|
304
349
|
}
|
|
305
350
|
};
|
|
306
351
|
|
|
352
|
+
// src/utils/microtask_coalescer.ts
|
|
353
|
+
var MicrotaskCoalescer = class {
|
|
354
|
+
#run;
|
|
355
|
+
#queued = false;
|
|
356
|
+
#active = false;
|
|
357
|
+
#generation = 0;
|
|
358
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
359
|
+
constructor(run) {
|
|
360
|
+
this.#run = run;
|
|
361
|
+
}
|
|
362
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
363
|
+
activate() {
|
|
364
|
+
this.#active = true;
|
|
365
|
+
}
|
|
366
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
367
|
+
cancel() {
|
|
368
|
+
this.#active = false;
|
|
369
|
+
this.#queued = false;
|
|
370
|
+
this.#generation += 1;
|
|
371
|
+
}
|
|
372
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
373
|
+
schedule() {
|
|
374
|
+
if (!this.#active || this.#queued) return;
|
|
375
|
+
this.#queued = true;
|
|
376
|
+
const generation = this.#generation;
|
|
377
|
+
queueMicrotask(() => {
|
|
378
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
379
|
+
this.#queued = false;
|
|
380
|
+
this.#run();
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
307
385
|
// src/controllers/command_palette_controller.ts
|
|
308
386
|
var CommandPaletteController = class _CommandPaletteController extends Controller {
|
|
309
387
|
static #ORIGINAL_ARIA_DISABLED = "data-command-palette-original-aria-disabled";
|
|
@@ -323,8 +401,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
323
401
|
"toggle"
|
|
324
402
|
];
|
|
325
403
|
static events = ["select"];
|
|
326
|
-
/**
|
|
327
|
-
#
|
|
404
|
+
/** Stable ID of the active option; the live target is resolved before every use. */
|
|
405
|
+
#activeId = null;
|
|
406
|
+
/** Visible/selectable target ID order captured for removal fallback. */
|
|
407
|
+
#activeOrder = [];
|
|
408
|
+
#connected = false;
|
|
409
|
+
/** Collapses one mutation batch of target callbacks into a single pass. */
|
|
410
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileActive());
|
|
328
411
|
/** Owns IME lifecycle state; confirmed text re-filters an open palette once. */
|
|
329
412
|
#composition = new CompositionTracker({
|
|
330
413
|
onEnd: () => {
|
|
@@ -358,9 +441,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
358
441
|
const shouldOpen = this.#isOpen || this.openValue;
|
|
359
442
|
this.#resetToClosedState();
|
|
360
443
|
if (shouldOpen) this.open();
|
|
444
|
+
this.#connected = true;
|
|
445
|
+
this.#reconcile.activate();
|
|
361
446
|
}
|
|
362
447
|
/** Tears down the global hotkey listener and reverts the modal side effects. */
|
|
363
448
|
disconnect() {
|
|
449
|
+
this.#connected = false;
|
|
450
|
+
this.#reconcile.cancel();
|
|
364
451
|
document.removeEventListener("keydown", this.#onGlobalKeydown);
|
|
365
452
|
this.#composition.disconnect();
|
|
366
453
|
this.#trap.deactivate({ restoreFocus: false });
|
|
@@ -369,10 +456,20 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
369
456
|
/** Initializes semantics for an option added before or after the controller connects. */
|
|
370
457
|
optionTargetConnected(option) {
|
|
371
458
|
this.#syncOptionSemanticsFor(option);
|
|
459
|
+
option.setAttribute("aria-selected", "false");
|
|
460
|
+
option.removeAttribute("data-active");
|
|
461
|
+
if (this.#connected) this.#queueOptionReconciliation();
|
|
462
|
+
}
|
|
463
|
+
/** Clears active residue on the old node and reconciles the surviving targets. */
|
|
464
|
+
optionTargetDisconnected(option) {
|
|
465
|
+
option.setAttribute("aria-selected", "false");
|
|
466
|
+
option.removeAttribute("data-active");
|
|
467
|
+
if (this.#connected) this.#queueOptionReconciliation();
|
|
372
468
|
}
|
|
373
469
|
/** Tracks an input added initially or after connect without extra consumer actions. */
|
|
374
470
|
inputTargetConnected(input) {
|
|
375
471
|
this.#composition.observe(input);
|
|
472
|
+
if (this.#connected) this.#queueOptionReconciliation();
|
|
376
473
|
}
|
|
377
474
|
/** Removes controller-owned listeners when the input target is replaced or removed. */
|
|
378
475
|
inputTargetDisconnected(input) {
|
|
@@ -412,7 +509,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
412
509
|
const matches = searchText.includes(query);
|
|
413
510
|
if (matches) {
|
|
414
511
|
option.removeAttribute("hidden");
|
|
415
|
-
if (
|
|
512
|
+
if (this.#isNavigable(option)) hasSelectableMatch = true;
|
|
416
513
|
} else {
|
|
417
514
|
option.setAttribute("hidden", "true");
|
|
418
515
|
}
|
|
@@ -427,8 +524,9 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
427
524
|
const target = event.currentTarget;
|
|
428
525
|
if (!target) return;
|
|
429
526
|
const option = target.closest("[role='option']");
|
|
430
|
-
if (option
|
|
431
|
-
|
|
527
|
+
if (!option || !this.optionTargets.includes(option)) return;
|
|
528
|
+
this.#syncOptionSemanticsFor(option);
|
|
529
|
+
if (!option.hasAttribute("hidden") && !this.#isDisabled(option)) {
|
|
432
530
|
this.#confirmSelection(option);
|
|
433
531
|
}
|
|
434
532
|
}
|
|
@@ -443,7 +541,10 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
443
541
|
* focus — not only the input.
|
|
444
542
|
*/
|
|
445
543
|
onKeydown(event) {
|
|
544
|
+
if (event.defaultPrevented) return;
|
|
545
|
+
if (isReservedArrowChord(event)) return;
|
|
446
546
|
if (this.#composition.isComposing(event) || !this.#isOpen) return;
|
|
547
|
+
this.#reconcileActive();
|
|
447
548
|
switch (event.key) {
|
|
448
549
|
case "ArrowDown":
|
|
449
550
|
event.preventDefault();
|
|
@@ -463,7 +564,9 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
463
564
|
break;
|
|
464
565
|
case "Enter": {
|
|
465
566
|
event.preventDefault();
|
|
466
|
-
const
|
|
567
|
+
const visible = this.#visibleOptions;
|
|
568
|
+
const activeIndex = this.#findActiveIndex(visible);
|
|
569
|
+
const activeOption = activeIndex < 0 ? void 0 : visible[activeIndex];
|
|
467
570
|
if (activeOption) this.#confirmSelection(activeOption);
|
|
468
571
|
break;
|
|
469
572
|
}
|
|
@@ -472,7 +575,7 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
472
575
|
#navigate(direction) {
|
|
473
576
|
const visible = this.#visibleOptions;
|
|
474
577
|
if (visible.length === 0) return;
|
|
475
|
-
let newIndex = this.#
|
|
578
|
+
let newIndex = this.#findActiveIndex(visible) + direction;
|
|
476
579
|
if (newIndex >= visible.length) newIndex = 0;
|
|
477
580
|
if (newIndex < 0) newIndex = visible.length - 1;
|
|
478
581
|
this.#setActiveIndex(newIndex);
|
|
@@ -481,16 +584,16 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
481
584
|
this.#syncOptionSemantics();
|
|
482
585
|
const visible = this.#visibleOptions;
|
|
483
586
|
const activeOption = visible[index] ?? null;
|
|
484
|
-
this
|
|
587
|
+
syncActiveOption(this.optionTargets, activeOption);
|
|
485
588
|
for (const option of this.optionTargets) {
|
|
486
|
-
option.setAttribute("
|
|
487
|
-
option.removeAttribute("data-active");
|
|
589
|
+
if (option === activeOption) option.setAttribute("data-active", "true");
|
|
590
|
+
else option.removeAttribute("data-active");
|
|
488
591
|
}
|
|
489
592
|
if (activeOption) {
|
|
490
|
-
activeOption.setAttribute("aria-selected", "true");
|
|
491
|
-
activeOption.setAttribute("data-active", "true");
|
|
492
593
|
activeOption.scrollIntoView({ block: "nearest" });
|
|
493
594
|
}
|
|
595
|
+
this.#activeId = activeOption?.id || null;
|
|
596
|
+
this.#activeOrder = activeOption ? visible.map((option) => option.id).filter(Boolean) : [];
|
|
494
597
|
if (this.hasInputTarget) {
|
|
495
598
|
if (activeOption?.id) {
|
|
496
599
|
this.inputTarget.setAttribute("aria-activedescendant", activeOption.id);
|
|
@@ -499,7 +602,75 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
499
602
|
}
|
|
500
603
|
}
|
|
501
604
|
}
|
|
605
|
+
/** Re-resolves active identity and component-specific fallback against live targets. */
|
|
606
|
+
#reconcileActive() {
|
|
607
|
+
if (!this.#isOpen) {
|
|
608
|
+
this.#setActiveIndex(-1);
|
|
609
|
+
this.#reflectEmptyState();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const visible = this.#visibleOptions;
|
|
613
|
+
const currentIndex = this.#findActiveIndex(visible);
|
|
614
|
+
if (currentIndex >= 0) {
|
|
615
|
+
const active = visible[currentIndex] ?? null;
|
|
616
|
+
const options = this.optionTargets;
|
|
617
|
+
const marked = options.filter((option) => option.hasAttribute("data-active"));
|
|
618
|
+
const selected = options.filter((option) => option.getAttribute("aria-selected") === "true");
|
|
619
|
+
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));
|
|
620
|
+
if (stateMatches) {
|
|
621
|
+
this.#activeOrder = visible.map((option) => option.id).filter(Boolean);
|
|
622
|
+
} else {
|
|
623
|
+
this.#setActiveIndex(currentIndex);
|
|
624
|
+
}
|
|
625
|
+
this.#reflectEmptyState();
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const activeId = (this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
|
|
629
|
+
this.#setActiveIndex(activeId ? this.#findFallbackIndex(visible, activeId) : -1);
|
|
630
|
+
this.#reflectEmptyState();
|
|
631
|
+
}
|
|
632
|
+
/** Resolves stable ID first, then active markers for the existing ID-less case. */
|
|
633
|
+
#findActiveIndex(options) {
|
|
634
|
+
const activeId = (this.hasInputTarget ? this.inputTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
|
|
635
|
+
if (activeId) return options.findIndex((option) => option.id === activeId);
|
|
636
|
+
return options.findIndex(
|
|
637
|
+
(option) => option.hasAttribute("data-active") || option.getAttribute("aria-selected") === "true"
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
/** Chooses a surviving former successor, then predecessor, from selectable targets only. */
|
|
641
|
+
#findFallbackIndex(options, activeId) {
|
|
642
|
+
const oldIndex = this.#activeOrder.indexOf(activeId);
|
|
643
|
+
if (oldIndex < 0) return -1;
|
|
644
|
+
const indexesById = new Map(options.map((option, index) => [option.id, index]));
|
|
645
|
+
for (let index = oldIndex + 1; index < this.#activeOrder.length; index += 1) {
|
|
646
|
+
const fallback = indexesById.get(this.#activeOrder[index] ?? "");
|
|
647
|
+
if (fallback !== void 0) return fallback;
|
|
648
|
+
}
|
|
649
|
+
for (let index = oldIndex - 1; index >= 0; index -= 1) {
|
|
650
|
+
const fallback = indexesById.get(this.#activeOrder[index] ?? "");
|
|
651
|
+
if (fallback !== void 0) return fallback;
|
|
652
|
+
}
|
|
653
|
+
return -1;
|
|
654
|
+
}
|
|
655
|
+
/** Reflects whether any visible, navigable command remains. */
|
|
656
|
+
#reflectEmptyState() {
|
|
657
|
+
if (this.hasEmptyTarget) this.emptyTarget.hidden = this.#visibleOptions.length > 0;
|
|
658
|
+
}
|
|
659
|
+
/** Coalesces all target callbacks from one MutationObserver batch. */
|
|
660
|
+
#queueOptionReconciliation() {
|
|
661
|
+
this.#reconcile.schedule();
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Runs `option`: dispatches `select` and closes.
|
|
665
|
+
*
|
|
666
|
+
* **Activation is refused here, not at the call sites.** Every path that runs a
|
|
667
|
+
* command funnels through this method, so one check covers keyboard, pointer,
|
|
668
|
+
* and anything added later. Per-call-site guards would leave `aria-disabled`
|
|
669
|
+
* options — which stay within the keyboard's reach — runnable from whichever
|
|
670
|
+
* path forgets to consult the predicate.
|
|
671
|
+
*/
|
|
502
672
|
#confirmSelection(option) {
|
|
673
|
+
if (this.#isDisabled(option)) return;
|
|
503
674
|
const value = option.dataset.value || option.textContent || "";
|
|
504
675
|
this.dispatch("select", { detail: { value, option } });
|
|
505
676
|
this.close();
|
|
@@ -510,14 +681,33 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
510
681
|
option.removeAttribute("hidden");
|
|
511
682
|
}
|
|
512
683
|
const hasSelectableOption = this.#visibleOptions.length > 0;
|
|
513
|
-
|
|
684
|
+
this.#reflectEmptyState();
|
|
514
685
|
this.#setActiveIndex(hasSelectableOption ? 0 : -1);
|
|
515
686
|
}
|
|
516
687
|
get #visibleOptions() {
|
|
517
688
|
return this.optionTargets.filter(
|
|
518
|
-
(option) => !option.hasAttribute("hidden") &&
|
|
689
|
+
(option) => !option.hasAttribute("hidden") && this.#isNavigable(option)
|
|
519
690
|
);
|
|
520
691
|
}
|
|
692
|
+
/**
|
|
693
|
+
* Whether virtual focus may land on `option`.
|
|
694
|
+
*
|
|
695
|
+
* Only `data-disabled` disqualifies. That attribute is this controller's own —
|
|
696
|
+
* authors reach for it to mark a row that is not a destination at all, such as
|
|
697
|
+
* a group heading — so it can mean "skip entirely" without contradicting ARIA.
|
|
698
|
+
*
|
|
699
|
+
* **`aria-disabled` does not disqualify.** It marks a command that must stay
|
|
700
|
+
* *discoverable*: `aria-activedescendant` names it, so a reader announces it as
|
|
701
|
+
* unavailable rather than hiding its existence. Virtual focus does
|
|
702
|
+
* not change that — pointing `aria-activedescendant` at a disabled option is
|
|
703
|
+
* ordinary ARIA. Keeping the two attributes distinct is what leaves both
|
|
704
|
+
* meanings expressible; collapsing them would take "show it, say it is
|
|
705
|
+
* unavailable, do not run it" away from consumers.
|
|
706
|
+
*/
|
|
707
|
+
#isNavigable(option) {
|
|
708
|
+
return option.dataset.disabled !== "true";
|
|
709
|
+
}
|
|
710
|
+
/** Whether activating `option` may run. Suppressed for either disabled marker. */
|
|
521
711
|
#isDisabled(option) {
|
|
522
712
|
return option.dataset.disabled === "true" || option.getAttribute("aria-disabled") === "true";
|
|
523
713
|
}
|
|
@@ -525,9 +715,18 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
525
715
|
#syncOptionSemantics() {
|
|
526
716
|
for (const option of this.optionTargets) this.#syncOptionSemanticsFor(option);
|
|
527
717
|
}
|
|
528
|
-
/**
|
|
718
|
+
/**
|
|
719
|
+
* Reflects `data-disabled` to ARIA without losing a pre-existing authored value,
|
|
720
|
+
* and puts `aria-selected` back to its baseline.
|
|
721
|
+
*
|
|
722
|
+
* Here `aria-selected` marks the *active* option, not a committed choice — the
|
|
723
|
+
* palette runs a command and keeps nothing selected — so an authored value is
|
|
724
|
+
* meaningless and is overwritten rather than preserved. Every caller either
|
|
725
|
+
* establishes the active option straight after (`filter`, `#setActiveIndex`) or
|
|
726
|
+
* is handling an option that cannot be the active one yet
|
|
727
|
+
* (`optionTargetConnected`).
|
|
728
|
+
*/
|
|
529
729
|
#syncOptionSemanticsFor(option) {
|
|
530
|
-
if (!option.hasAttribute("aria-selected")) option.setAttribute("aria-selected", "false");
|
|
531
730
|
const originalAttribute = _CommandPaletteController.#ORIGINAL_ARIA_DISABLED;
|
|
532
731
|
const originalValue = option.getAttribute(originalAttribute);
|
|
533
732
|
if (option.dataset.disabled === "true") {
|
|
@@ -581,8 +780,13 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
581
780
|
}
|
|
582
781
|
/** Resets transient open state so reconnect starts from a predictable closed snapshot. */
|
|
583
782
|
#resetToClosedState() {
|
|
584
|
-
this.#
|
|
783
|
+
this.#activeId = null;
|
|
784
|
+
this.#activeOrder = [];
|
|
585
785
|
this.openValue = false;
|
|
786
|
+
for (const option of this.optionTargets) {
|
|
787
|
+
option.setAttribute("aria-selected", "false");
|
|
788
|
+
option.removeAttribute("data-active");
|
|
789
|
+
}
|
|
586
790
|
if (this.hasDialogTarget) {
|
|
587
791
|
this.dialogTarget.hidden = true;
|
|
588
792
|
}
|
|
@@ -2,6 +2,35 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/confirm_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/before_cache_reset.ts
|
|
6
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
8
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
9
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
10
|
+
static #onBeforeCache = () => {
|
|
11
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
12
|
+
};
|
|
13
|
+
#rewind;
|
|
14
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
15
|
+
constructor(rewind) {
|
|
16
|
+
this.#rewind = rewind;
|
|
17
|
+
}
|
|
18
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
19
|
+
activate() {
|
|
20
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
21
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
22
|
+
if (first) {
|
|
23
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
27
|
+
deactivate() {
|
|
28
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
29
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
30
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
5
34
|
// src/utils/escape_layer.ts
|
|
6
35
|
var EscapeLayer = class _EscapeLayer {
|
|
7
36
|
static #registries = /* @__PURE__ */ new WeakMap();
|
|
@@ -135,7 +164,7 @@ var FocusTrap = class {
|
|
|
135
164
|
}
|
|
136
165
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
137
166
|
document.addEventListener("keydown", this.#onKeydown);
|
|
138
|
-
|
|
167
|
+
this.#beforeCache.activate();
|
|
139
168
|
const onEscape = this.#options.onEscape;
|
|
140
169
|
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
141
170
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
@@ -152,7 +181,7 @@ var FocusTrap = class {
|
|
|
152
181
|
this.#activeState = false;
|
|
153
182
|
this.#escapeLayer.deactivate();
|
|
154
183
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
155
|
-
|
|
184
|
+
this.#beforeCache.deactivate();
|
|
156
185
|
if (this.#scrollLocked) {
|
|
157
186
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
158
187
|
this.#scrollLocked = false;
|
|
@@ -176,9 +205,7 @@ var FocusTrap = class {
|
|
|
176
205
|
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
177
206
|
* is left alone mid-navigation. The listener lives only while active.
|
|
178
207
|
*/
|
|
179
|
-
#
|
|
180
|
-
this.deactivate({ restoreFocus: false });
|
|
181
|
-
};
|
|
208
|
+
#beforeCache = new BeforeCacheReset(() => this.deactivate({ restoreFocus: false }));
|
|
182
209
|
/**
|
|
183
210
|
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
211
|
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
@@ -2,6 +2,12 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/context_menu_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/arrow_step.ts
|
|
6
|
+
function isReservedArrowChord(event, allow = []) {
|
|
7
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
8
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
// src/utils/escape_layer.ts
|
|
6
12
|
function claimsWhileFocusWithin(element) {
|
|
7
13
|
return () => {
|
|
@@ -156,16 +162,16 @@ var ContextMenuController = class extends Controller {
|
|
|
156
162
|
connect() {
|
|
157
163
|
this.#closeMenu();
|
|
158
164
|
this.element.addEventListener("click", this.#onItemClickCapture, true);
|
|
159
|
-
document.addEventListener("click", this.#onOutsidePointer);
|
|
160
|
-
document.addEventListener("contextmenu", this.#onOutsidePointer);
|
|
165
|
+
document.addEventListener("click", this.#onOutsidePointer, true);
|
|
166
|
+
document.addEventListener("contextmenu", this.#onOutsidePointer, true);
|
|
161
167
|
}
|
|
162
168
|
/** Releases the listeners, stack membership, and pending Tab-close task. */
|
|
163
169
|
disconnect() {
|
|
164
170
|
this.#timers.clearAll();
|
|
165
171
|
this.#escapeLayer.deactivate();
|
|
166
172
|
this.element.removeEventListener("click", this.#onItemClickCapture, true);
|
|
167
|
-
document.removeEventListener("click", this.#onOutsidePointer);
|
|
168
|
-
document.removeEventListener("contextmenu", this.#onOutsidePointer);
|
|
173
|
+
document.removeEventListener("click", this.#onOutsidePointer, true);
|
|
174
|
+
document.removeEventListener("contextmenu", this.#onOutsidePointer, true);
|
|
169
175
|
}
|
|
170
176
|
/**
|
|
171
177
|
* Opens the menu from a `contextmenu` event: suppresses the native menu and
|
|
@@ -177,6 +183,7 @@ var ContextMenuController = class extends Controller {
|
|
|
177
183
|
}
|
|
178
184
|
/** Keyboard entry on the region: `Shift+F10` / `ContextMenu` open at center. */
|
|
179
185
|
onRegionKeydown(event) {
|
|
186
|
+
if (event.defaultPrevented) return;
|
|
180
187
|
const isContextKey = event.key === "ContextMenu" || event.key === "F10" && event.shiftKey;
|
|
181
188
|
if (!isContextKey) return;
|
|
182
189
|
event.preventDefault();
|
|
@@ -185,6 +192,8 @@ var ContextMenuController = class extends Controller {
|
|
|
185
192
|
}
|
|
186
193
|
/** Roving focus and closing keys inside the menu. */
|
|
187
194
|
onItemKeydown(event) {
|
|
195
|
+
if (event.defaultPrevented) return;
|
|
196
|
+
if (isReservedArrowChord(event)) return;
|
|
188
197
|
const items = this.#navigableItems;
|
|
189
198
|
const currentIndex = items.indexOf(event.currentTarget);
|
|
190
199
|
switch (event.key) {
|
|
@@ -247,7 +256,13 @@ var ContextMenuController = class extends Controller {
|
|
|
247
256
|
this.#closeMenu();
|
|
248
257
|
if (this.hasRegionTarget) this.regionTarget.focus();
|
|
249
258
|
}
|
|
250
|
-
/**
|
|
259
|
+
/**
|
|
260
|
+
* Closes when a click or context-menu invocation lands outside this instance.
|
|
261
|
+
* Both subscriptions observe in the capture phase, so the target is judged
|
|
262
|
+
* against the tree the user actually pressed even when a consumer handler
|
|
263
|
+
* detaches it, and application code that stops bubbling cannot leave the menu
|
|
264
|
+
* open.
|
|
265
|
+
*/
|
|
251
266
|
#onOutsidePointer = (event) => {
|
|
252
267
|
if (this.#isOpen && !this.element.contains(event.target)) this.#closeMenu();
|
|
253
268
|
};
|
|
@@ -265,18 +280,25 @@ var ContextMenuController = class extends Controller {
|
|
|
265
280
|
event.preventDefault();
|
|
266
281
|
event.stopImmediatePropagation();
|
|
267
282
|
};
|
|
268
|
-
/** Menu items eligible for roving focus (excludes
|
|
283
|
+
/** Menu items eligible for roving focus (excludes hidden / natively disabled). */
|
|
269
284
|
get #navigableItems() {
|
|
270
285
|
return this.itemTargets.filter((item) => this.#isNavigable(item));
|
|
271
286
|
}
|
|
272
287
|
/**
|
|
273
|
-
* An item can take roving focus unless it is `hidden
|
|
274
|
-
*
|
|
275
|
-
*
|
|
288
|
+
* An item can take roving focus unless it is `hidden` or a natively `disabled`
|
|
289
|
+
* form control. CSS-only visibility is not detectable here and is the
|
|
290
|
+
* consumer's responsibility.
|
|
291
|
+
*
|
|
292
|
+
* **`aria-disabled="true"` stays reachable.** APG separates the two attributes
|
|
293
|
+
* by intent: `disabled` is for controls whose existence can be inferred from a
|
|
294
|
+
* neighbour (a greyed Next next to a Prev), while `aria-disabled` marks a
|
|
295
|
+
* control that must stay *discoverable* — and it names menu items as the
|
|
296
|
+
* example. Skipping it would hide the command's existence from a keyboard user
|
|
297
|
+
* entirely. Activation is suppressed separately, so the item announces itself
|
|
298
|
+
* and does nothing.
|
|
276
299
|
*/
|
|
277
300
|
#isNavigable(item) {
|
|
278
301
|
if (item.hasAttribute("hidden")) return false;
|
|
279
|
-
if (item.getAttribute("aria-disabled") === "true") return false;
|
|
280
302
|
return !item.disabled;
|
|
281
303
|
}
|
|
282
304
|
/** Whether the menu is currently visible. */
|