stimeo-ui 0.1.0.pre.beta.3 → 0.2.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 +90 -0
- data/README.md +6 -6
- data/dist/controllers/auto_submit_controller.js +56 -21
- data/dist/controllers/character_counter_controller.js +52 -15
- data/dist/controllers/combobox_controller.js +64 -3
- data/dist/controllers/command_palette_controller.js +243 -39
- data/dist/controllers/confirm_controller.js +94 -8
- data/dist/controllers/context_menu_controller.js +191 -13
- data/dist/controllers/countdown_controller.js +2 -2
- data/dist/controllers/dialog_controller.js +94 -8
- data/dist/controllers/dropdown_controller.js +139 -16
- data/dist/controllers/focus_controller.js +94 -8
- data/dist/controllers/form_validation_controller.js +2 -2
- data/dist/controllers/hover_card_controller.js +103 -27
- data/dist/controllers/listbox_controller.js +1 -0
- data/dist/controllers/menu_controller.js +181 -8
- data/dist/controllers/otp_controller.js +62 -22
- data/dist/controllers/pointer_drag_controller.js +3 -3
- data/dist/controllers/popover_controller.js +118 -27
- data/dist/controllers/toast_controller.js +108 -29
- data/dist/controllers/tooltip_controller.js +148 -42
- data/dist/index.js +833 -348
- data/lib/stimeo/ui/version.rb +5 -4
- metadata +2 -2
|
@@ -2,6 +2,137 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/command_palette_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/composition_tracker.ts
|
|
6
|
+
var CompositionTracker = class {
|
|
7
|
+
#observedTargets = /* @__PURE__ */ new Set();
|
|
8
|
+
#activeTargets = /* @__PURE__ */ new Set();
|
|
9
|
+
#onStart;
|
|
10
|
+
#onEnd;
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.#onStart = options.onStart;
|
|
13
|
+
this.#onEnd = options.onEnd;
|
|
14
|
+
}
|
|
15
|
+
/** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
|
|
16
|
+
observe(target) {
|
|
17
|
+
if (this.#observedTargets.has(target)) return;
|
|
18
|
+
target.addEventListener("compositionstart", this.#handleStart);
|
|
19
|
+
target.addEventListener("compositionend", this.#handleEnd);
|
|
20
|
+
this.#observedTargets.add(target);
|
|
21
|
+
}
|
|
22
|
+
/** Stops tracking one target and clears any active composition it owned. */
|
|
23
|
+
unobserve(target) {
|
|
24
|
+
if (!this.#observedTargets.delete(target)) return;
|
|
25
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
26
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
27
|
+
this.#activeTargets.delete(target);
|
|
28
|
+
}
|
|
29
|
+
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
30
|
+
disconnect() {
|
|
31
|
+
for (const target of this.#observedTargets) {
|
|
32
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
33
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
34
|
+
}
|
|
35
|
+
this.#observedTargets.clear();
|
|
36
|
+
this.#activeTargets.clear();
|
|
37
|
+
}
|
|
38
|
+
/** True when lifecycle tracking or the current event reports composition. */
|
|
39
|
+
isComposing(event) {
|
|
40
|
+
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
41
|
+
}
|
|
42
|
+
#handleStart = (event) => {
|
|
43
|
+
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
44
|
+
this.#onStart?.(event);
|
|
45
|
+
};
|
|
46
|
+
#handleEnd = (event) => {
|
|
47
|
+
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
48
|
+
this.#onEnd?.(event);
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// src/utils/escape_layer.ts
|
|
53
|
+
var EscapeLayer = class _EscapeLayer {
|
|
54
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
55
|
+
#ownerDocument = null;
|
|
56
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
57
|
+
#onDismiss = null;
|
|
58
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
59
|
+
#claims = null;
|
|
60
|
+
/**
|
|
61
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
62
|
+
* the document's shared resolver listener if this is its first layer.
|
|
63
|
+
* Re-activating an already-active layer moves it to the top.
|
|
64
|
+
*/
|
|
65
|
+
activate(ownerDocument = document, options) {
|
|
66
|
+
this.deactivate();
|
|
67
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
68
|
+
if (!registry) {
|
|
69
|
+
registry = _EscapeLayer.#createRegistry();
|
|
70
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
71
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
72
|
+
}
|
|
73
|
+
registry.stack.push(this);
|
|
74
|
+
this.#ownerDocument = ownerDocument;
|
|
75
|
+
this.#onDismiss = options.onDismiss;
|
|
76
|
+
this.#claims = options.claims ?? null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
80
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
81
|
+
*/
|
|
82
|
+
deactivate() {
|
|
83
|
+
const ownerDocument = this.#ownerDocument;
|
|
84
|
+
if (!ownerDocument) return;
|
|
85
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
86
|
+
if (registry) {
|
|
87
|
+
const index = registry.stack.lastIndexOf(this);
|
|
88
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
89
|
+
if (registry.stack.length === 0) {
|
|
90
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
91
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
this.#ownerDocument = null;
|
|
95
|
+
this.#onDismiss = null;
|
|
96
|
+
this.#claims = null;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
100
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
101
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
102
|
+
*/
|
|
103
|
+
get ownsEscape() {
|
|
104
|
+
const ownerDocument = this.#ownerDocument;
|
|
105
|
+
if (!ownerDocument) return false;
|
|
106
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
107
|
+
if (!registry) return false;
|
|
108
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
109
|
+
}
|
|
110
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
111
|
+
static #createRegistry() {
|
|
112
|
+
const registry = {
|
|
113
|
+
stack: [],
|
|
114
|
+
onKeydown: (event) => {
|
|
115
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
116
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
117
|
+
if (!owner) return;
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
owner.#onDismiss?.();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
return registry;
|
|
123
|
+
}
|
|
124
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
125
|
+
static #resolveOwner(stack) {
|
|
126
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
127
|
+
const layer = stack[index];
|
|
128
|
+
if (!layer) continue;
|
|
129
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
130
|
+
return layer;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
5
136
|
// src/utils/focus_trap.ts
|
|
6
137
|
var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
7
138
|
var FocusTrap = class {
|
|
@@ -15,6 +146,8 @@ var FocusTrap = class {
|
|
|
15
146
|
#inertedSiblings = [];
|
|
16
147
|
/** Whether the modal side effects are currently applied. */
|
|
17
148
|
#activeState = false;
|
|
149
|
+
/** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */
|
|
150
|
+
#escapeLayer = new EscapeLayer();
|
|
18
151
|
/** Returns the trapped element; called on every operation for the live target. */
|
|
19
152
|
#getContainer;
|
|
20
153
|
/** Closing/focus hooks; see {@link FocusTrapOptions}. */
|
|
@@ -50,6 +183,8 @@ var FocusTrap = class {
|
|
|
50
183
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
51
184
|
document.addEventListener("keydown", this.#onKeydown);
|
|
52
185
|
document.addEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
186
|
+
const onEscape = this.#options.onEscape;
|
|
187
|
+
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
53
188
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
54
189
|
}
|
|
55
190
|
/**
|
|
@@ -62,6 +197,7 @@ var FocusTrap = class {
|
|
|
62
197
|
deactivate({ restoreFocus = true } = {}) {
|
|
63
198
|
if (!this.#activeState) return;
|
|
64
199
|
this.#activeState = false;
|
|
200
|
+
this.#escapeLayer.deactivate();
|
|
65
201
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
66
202
|
document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
67
203
|
if (this.#scrollLocked) {
|
|
@@ -90,15 +226,12 @@ var FocusTrap = class {
|
|
|
90
226
|
#onBeforeCache = () => {
|
|
91
227
|
this.deactivate({ restoreFocus: false });
|
|
92
228
|
};
|
|
93
|
-
/**
|
|
229
|
+
/**
|
|
230
|
+
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
231
|
+
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
232
|
+
* which layer currently owns Escape.
|
|
233
|
+
*/
|
|
94
234
|
#onKeydown = (event) => {
|
|
95
|
-
if (event.key === "Escape") {
|
|
96
|
-
if (this.#options.onEscape) {
|
|
97
|
-
event.preventDefault();
|
|
98
|
-
this.#options.onEscape();
|
|
99
|
-
}
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
235
|
if (event.key === "Tab") this.#trapTab(event);
|
|
103
236
|
};
|
|
104
237
|
/** Keeps `Tab` focus cycling within the container's focusable elements. */
|
|
@@ -172,7 +305,9 @@ var FocusTrap = class {
|
|
|
172
305
|
};
|
|
173
306
|
|
|
174
307
|
// src/controllers/command_palette_controller.ts
|
|
175
|
-
var CommandPaletteController = class extends Controller {
|
|
308
|
+
var CommandPaletteController = class _CommandPaletteController extends Controller {
|
|
309
|
+
static #ORIGINAL_ARIA_DISABLED = "data-command-palette-original-aria-disabled";
|
|
310
|
+
static #ABSENT_ARIA_DISABLED = "absent";
|
|
176
311
|
static targets = ["dialog", "input", "list", "option", "empty"];
|
|
177
312
|
static values = {
|
|
178
313
|
hotkey: { type: String, default: "mod+k" },
|
|
@@ -190,6 +325,12 @@ var CommandPaletteController = class extends Controller {
|
|
|
190
325
|
static events = ["select"];
|
|
191
326
|
/** The index of the currently active option within the visible subset. */
|
|
192
327
|
#activeIndex = -1;
|
|
328
|
+
/** Owns IME lifecycle state; confirmed text re-filters an open palette once. */
|
|
329
|
+
#composition = new CompositionTracker({
|
|
330
|
+
onEnd: () => {
|
|
331
|
+
if (this.#isOpen) this.filter();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
193
334
|
/**
|
|
194
335
|
* Owns the modal side effects (focus trap, scroll lock, background `inert`, focus
|
|
195
336
|
* restore). Escape closes; focus on open goes to the input, and is restored to
|
|
@@ -212,6 +353,8 @@ var CommandPaletteController = class extends Controller {
|
|
|
212
353
|
*/
|
|
213
354
|
connect() {
|
|
214
355
|
document.addEventListener("keydown", this.#onGlobalKeydown);
|
|
356
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
357
|
+
this.#syncOptionSemantics();
|
|
215
358
|
const shouldOpen = this.#isOpen || this.openValue;
|
|
216
359
|
this.#resetToClosedState();
|
|
217
360
|
if (shouldOpen) this.open();
|
|
@@ -219,9 +362,22 @@ var CommandPaletteController = class extends Controller {
|
|
|
219
362
|
/** Tears down the global hotkey listener and reverts the modal side effects. */
|
|
220
363
|
disconnect() {
|
|
221
364
|
document.removeEventListener("keydown", this.#onGlobalKeydown);
|
|
365
|
+
this.#composition.disconnect();
|
|
222
366
|
this.#trap.deactivate({ restoreFocus: false });
|
|
223
367
|
this.#resetToClosedState();
|
|
224
368
|
}
|
|
369
|
+
/** Initializes semantics for an option added before or after the controller connects. */
|
|
370
|
+
optionTargetConnected(option) {
|
|
371
|
+
this.#syncOptionSemanticsFor(option);
|
|
372
|
+
}
|
|
373
|
+
/** Tracks an input added initially or after connect without extra consumer actions. */
|
|
374
|
+
inputTargetConnected(input) {
|
|
375
|
+
this.#composition.observe(input);
|
|
376
|
+
}
|
|
377
|
+
/** Removes controller-owned listeners when the input target is replaced or removed. */
|
|
378
|
+
inputTargetDisconnected(input) {
|
|
379
|
+
this.#composition.unobserve(input);
|
|
380
|
+
}
|
|
225
381
|
/** Toggles the open state of the command palette. */
|
|
226
382
|
toggle() {
|
|
227
383
|
if (this.#isOpen) {
|
|
@@ -246,8 +402,9 @@ var CommandPaletteController = class extends Controller {
|
|
|
246
402
|
this.#trap.deactivate();
|
|
247
403
|
}
|
|
248
404
|
/** Filters option elements in-memory matching the input value. Bound to input target. */
|
|
249
|
-
filter() {
|
|
250
|
-
if (!this.hasInputTarget) return;
|
|
405
|
+
filter(event) {
|
|
406
|
+
if (!this.hasInputTarget || this.#composition.isComposing(event)) return;
|
|
407
|
+
this.#syncOptionSemantics();
|
|
251
408
|
const query = this.inputTarget.value.trim().toLowerCase();
|
|
252
409
|
let hasSelectableMatch = false;
|
|
253
410
|
for (const option of this.optionTargets) {
|
|
@@ -270,6 +427,7 @@ var CommandPaletteController = class extends Controller {
|
|
|
270
427
|
const target = event.currentTarget;
|
|
271
428
|
if (!target) return;
|
|
272
429
|
const option = target.closest("[role='option']");
|
|
430
|
+
if (option) this.#syncOptionSemanticsFor(option);
|
|
273
431
|
if (option && !this.#isDisabled(option)) {
|
|
274
432
|
this.#confirmSelection(option);
|
|
275
433
|
}
|
|
@@ -285,7 +443,7 @@ var CommandPaletteController = class extends Controller {
|
|
|
285
443
|
* focus — not only the input.
|
|
286
444
|
*/
|
|
287
445
|
onKeydown(event) {
|
|
288
|
-
if (!this.#isOpen) return;
|
|
446
|
+
if (this.#composition.isComposing(event) || !this.#isOpen) return;
|
|
289
447
|
switch (event.key) {
|
|
290
448
|
case "ArrowDown":
|
|
291
449
|
event.preventDefault();
|
|
@@ -320,23 +478,25 @@ var CommandPaletteController = class extends Controller {
|
|
|
320
478
|
this.#setActiveIndex(newIndex);
|
|
321
479
|
}
|
|
322
480
|
#setActiveIndex(index) {
|
|
481
|
+
this.#syncOptionSemantics();
|
|
323
482
|
const visible = this.#visibleOptions;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
483
|
+
const activeOption = visible[index] ?? null;
|
|
484
|
+
this.#activeIndex = activeOption ? index : -1;
|
|
485
|
+
for (const option of this.optionTargets) {
|
|
486
|
+
option.setAttribute("aria-selected", "false");
|
|
487
|
+
option.removeAttribute("data-active");
|
|
488
|
+
}
|
|
489
|
+
if (activeOption) {
|
|
490
|
+
activeOption.setAttribute("aria-selected", "true");
|
|
491
|
+
activeOption.setAttribute("data-active", "true");
|
|
492
|
+
activeOption.scrollIntoView({ block: "nearest" });
|
|
493
|
+
}
|
|
494
|
+
if (this.hasInputTarget) {
|
|
495
|
+
if (activeOption?.id) {
|
|
496
|
+
this.inputTarget.setAttribute("aria-activedescendant", activeOption.id);
|
|
333
497
|
} else {
|
|
334
|
-
|
|
335
|
-
option.removeAttribute("data-active");
|
|
498
|
+
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
336
499
|
}
|
|
337
|
-
});
|
|
338
|
-
if (index === -1 && this.hasInputTarget) {
|
|
339
|
-
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
340
500
|
}
|
|
341
501
|
}
|
|
342
502
|
#confirmSelection(option) {
|
|
@@ -349,8 +509,9 @@ var CommandPaletteController = class extends Controller {
|
|
|
349
509
|
for (const option of this.optionTargets) {
|
|
350
510
|
option.removeAttribute("hidden");
|
|
351
511
|
}
|
|
352
|
-
|
|
353
|
-
this
|
|
512
|
+
const hasSelectableOption = this.#visibleOptions.length > 0;
|
|
513
|
+
if (this.hasEmptyTarget) this.emptyTarget.hidden = hasSelectableOption;
|
|
514
|
+
this.#setActiveIndex(hasSelectableOption ? 0 : -1);
|
|
354
515
|
}
|
|
355
516
|
get #visibleOptions() {
|
|
356
517
|
return this.optionTargets.filter(
|
|
@@ -358,23 +519,66 @@ var CommandPaletteController = class extends Controller {
|
|
|
358
519
|
);
|
|
359
520
|
}
|
|
360
521
|
#isDisabled(option) {
|
|
361
|
-
return option.dataset.disabled === "true";
|
|
522
|
+
return option.dataset.disabled === "true" || option.getAttribute("aria-disabled") === "true";
|
|
523
|
+
}
|
|
524
|
+
/** Synchronizes every option's controller-owned selection and disabled semantics. */
|
|
525
|
+
#syncOptionSemantics() {
|
|
526
|
+
for (const option of this.optionTargets) this.#syncOptionSemanticsFor(option);
|
|
527
|
+
}
|
|
528
|
+
/** Reflects `data-disabled` to ARIA without losing a pre-existing authored value. */
|
|
529
|
+
#syncOptionSemanticsFor(option) {
|
|
530
|
+
if (!option.hasAttribute("aria-selected")) option.setAttribute("aria-selected", "false");
|
|
531
|
+
const originalAttribute = _CommandPaletteController.#ORIGINAL_ARIA_DISABLED;
|
|
532
|
+
const originalValue = option.getAttribute(originalAttribute);
|
|
533
|
+
if (option.dataset.disabled === "true") {
|
|
534
|
+
if (originalValue === null) {
|
|
535
|
+
option.setAttribute(
|
|
536
|
+
originalAttribute,
|
|
537
|
+
option.getAttribute("aria-disabled") ?? _CommandPaletteController.#ABSENT_ARIA_DISABLED
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
option.setAttribute("aria-disabled", "true");
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (originalValue === null) return;
|
|
544
|
+
if (originalValue === _CommandPaletteController.#ABSENT_ARIA_DISABLED) {
|
|
545
|
+
option.removeAttribute("aria-disabled");
|
|
546
|
+
} else {
|
|
547
|
+
option.setAttribute("aria-disabled", originalValue);
|
|
548
|
+
}
|
|
549
|
+
option.removeAttribute(originalAttribute);
|
|
362
550
|
}
|
|
363
551
|
get #isOpen() {
|
|
364
552
|
return this.hasDialogTarget && !this.dialogTarget.hidden;
|
|
365
553
|
}
|
|
366
554
|
#onGlobalKeydown = (event) => {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
if (!key) return;
|
|
371
|
-
const modPressed = isMod ? event.metaKey || event.ctrlKey : !event.metaKey && !event.ctrlKey;
|
|
372
|
-
const keyMatch = event.key.toLowerCase() === key;
|
|
373
|
-
if (modPressed && keyMatch) {
|
|
374
|
-
event.preventDefault();
|
|
375
|
-
this.toggle();
|
|
376
|
-
}
|
|
555
|
+
if (this.#composition.isComposing(event) || !this.#matchesHotkey(event)) return;
|
|
556
|
+
event.preventDefault();
|
|
557
|
+
this.toggle();
|
|
377
558
|
};
|
|
559
|
+
/** Matches the configured `mod+key` or bare-key hotkey without extra modifiers. */
|
|
560
|
+
#matchesHotkey(event) {
|
|
561
|
+
const hotkey = this.#parseHotkey();
|
|
562
|
+
if (!hotkey || event.altKey || event.shiftKey) return false;
|
|
563
|
+
if (hotkey.requiresMod) {
|
|
564
|
+
const hasExactlyOneModKey = event.metaKey !== event.ctrlKey;
|
|
565
|
+
if (!hasExactlyOneModKey) return false;
|
|
566
|
+
} else if (event.metaKey || event.ctrlKey) {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
return event.key.toLowerCase() === hotkey.key;
|
|
570
|
+
}
|
|
571
|
+
/** Parses the intentionally small public hotkey grammar. */
|
|
572
|
+
#parseHotkey() {
|
|
573
|
+
const parts = this.hotkeyValue.toLowerCase().split("+");
|
|
574
|
+
if (parts.length === 1 && parts[0] && parts[0] !== "mod") {
|
|
575
|
+
return { key: parts[0], requiresMod: false };
|
|
576
|
+
}
|
|
577
|
+
if (parts.length === 2 && parts[0] === "mod" && parts[1]) {
|
|
578
|
+
return { key: parts[1], requiresMod: true };
|
|
579
|
+
}
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
378
582
|
/** Resets transient open state so reconnect starts from a predictable closed snapshot. */
|
|
379
583
|
#resetToClosedState() {
|
|
380
584
|
this.#activeIndex = -1;
|
|
@@ -2,6 +2,90 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/confirm_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/escape_layer.ts
|
|
6
|
+
var EscapeLayer = class _EscapeLayer {
|
|
7
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
8
|
+
#ownerDocument = null;
|
|
9
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
10
|
+
#onDismiss = null;
|
|
11
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
12
|
+
#claims = null;
|
|
13
|
+
/**
|
|
14
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
15
|
+
* the document's shared resolver listener if this is its first layer.
|
|
16
|
+
* Re-activating an already-active layer moves it to the top.
|
|
17
|
+
*/
|
|
18
|
+
activate(ownerDocument = document, options) {
|
|
19
|
+
this.deactivate();
|
|
20
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
21
|
+
if (!registry) {
|
|
22
|
+
registry = _EscapeLayer.#createRegistry();
|
|
23
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
24
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
25
|
+
}
|
|
26
|
+
registry.stack.push(this);
|
|
27
|
+
this.#ownerDocument = ownerDocument;
|
|
28
|
+
this.#onDismiss = options.onDismiss;
|
|
29
|
+
this.#claims = options.claims ?? null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
33
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
34
|
+
*/
|
|
35
|
+
deactivate() {
|
|
36
|
+
const ownerDocument = this.#ownerDocument;
|
|
37
|
+
if (!ownerDocument) return;
|
|
38
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
39
|
+
if (registry) {
|
|
40
|
+
const index = registry.stack.lastIndexOf(this);
|
|
41
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
42
|
+
if (registry.stack.length === 0) {
|
|
43
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
44
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this.#ownerDocument = null;
|
|
48
|
+
this.#onDismiss = null;
|
|
49
|
+
this.#claims = null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
53
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
54
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
55
|
+
*/
|
|
56
|
+
get ownsEscape() {
|
|
57
|
+
const ownerDocument = this.#ownerDocument;
|
|
58
|
+
if (!ownerDocument) return false;
|
|
59
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
60
|
+
if (!registry) return false;
|
|
61
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
62
|
+
}
|
|
63
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
64
|
+
static #createRegistry() {
|
|
65
|
+
const registry = {
|
|
66
|
+
stack: [],
|
|
67
|
+
onKeydown: (event) => {
|
|
68
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
69
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
70
|
+
if (!owner) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
owner.#onDismiss?.();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
return registry;
|
|
76
|
+
}
|
|
77
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
78
|
+
static #resolveOwner(stack) {
|
|
79
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
80
|
+
const layer = stack[index];
|
|
81
|
+
if (!layer) continue;
|
|
82
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
83
|
+
return layer;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
5
89
|
// src/utils/focus_trap.ts
|
|
6
90
|
var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
7
91
|
var FocusTrap = class {
|
|
@@ -15,6 +99,8 @@ var FocusTrap = class {
|
|
|
15
99
|
#inertedSiblings = [];
|
|
16
100
|
/** Whether the modal side effects are currently applied. */
|
|
17
101
|
#activeState = false;
|
|
102
|
+
/** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */
|
|
103
|
+
#escapeLayer = new EscapeLayer();
|
|
18
104
|
/** Returns the trapped element; called on every operation for the live target. */
|
|
19
105
|
#getContainer;
|
|
20
106
|
/** Closing/focus hooks; see {@link FocusTrapOptions}. */
|
|
@@ -50,6 +136,8 @@ var FocusTrap = class {
|
|
|
50
136
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
51
137
|
document.addEventListener("keydown", this.#onKeydown);
|
|
52
138
|
document.addEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
139
|
+
const onEscape = this.#options.onEscape;
|
|
140
|
+
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
53
141
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
54
142
|
}
|
|
55
143
|
/**
|
|
@@ -62,6 +150,7 @@ var FocusTrap = class {
|
|
|
62
150
|
deactivate({ restoreFocus = true } = {}) {
|
|
63
151
|
if (!this.#activeState) return;
|
|
64
152
|
this.#activeState = false;
|
|
153
|
+
this.#escapeLayer.deactivate();
|
|
65
154
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
66
155
|
document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
67
156
|
if (this.#scrollLocked) {
|
|
@@ -90,15 +179,12 @@ var FocusTrap = class {
|
|
|
90
179
|
#onBeforeCache = () => {
|
|
91
180
|
this.deactivate({ restoreFocus: false });
|
|
92
181
|
};
|
|
93
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
|
|
184
|
+
* shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
|
|
185
|
+
* which layer currently owns Escape.
|
|
186
|
+
*/
|
|
94
187
|
#onKeydown = (event) => {
|
|
95
|
-
if (event.key === "Escape") {
|
|
96
|
-
if (this.#options.onEscape) {
|
|
97
|
-
event.preventDefault();
|
|
98
|
-
this.#options.onEscape();
|
|
99
|
-
}
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
188
|
if (event.key === "Tab") this.#trapTab(event);
|
|
103
189
|
};
|
|
104
190
|
/** Keeps `Tab` focus cycling within the container's focusable elements. */
|