stimeo-ui 0.1.0.pre.beta.2 → 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 +107 -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 +256 -39
- data/dist/controllers/confirm_controller.js +107 -8
- data/dist/controllers/context_menu_controller.js +191 -13
- data/dist/controllers/countdown_controller.js +2 -2
- data/dist/controllers/dialog_controller.js +107 -8
- data/dist/controllers/dropdown_controller.js +139 -16
- data/dist/controllers/focus_controller.js +107 -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/sortable_controller.js +1 -1
- data/dist/controllers/toast_controller.js +108 -29
- data/dist/controllers/tooltip_controller.js +148 -42
- data/dist/index.js +847 -349
- 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}. */
|
|
@@ -49,6 +182,9 @@ var FocusTrap = class {
|
|
|
49
182
|
}
|
|
50
183
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
51
184
|
document.addEventListener("keydown", this.#onKeydown);
|
|
185
|
+
document.addEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
186
|
+
const onEscape = this.#options.onEscape;
|
|
187
|
+
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
52
188
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
53
189
|
}
|
|
54
190
|
/**
|
|
@@ -61,7 +197,9 @@ var FocusTrap = class {
|
|
|
61
197
|
deactivate({ restoreFocus = true } = {}) {
|
|
62
198
|
if (!this.#activeState) return;
|
|
63
199
|
this.#activeState = false;
|
|
200
|
+
this.#escapeLayer.deactivate();
|
|
64
201
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
202
|
+
document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
65
203
|
if (this.#scrollLocked) {
|
|
66
204
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
67
205
|
this.#scrollLocked = false;
|
|
@@ -77,15 +215,23 @@ var FocusTrap = class {
|
|
|
77
215
|
if (option === void 0) return fallback;
|
|
78
216
|
return typeof option === "function" ? option() : option;
|
|
79
217
|
}
|
|
80
|
-
/**
|
|
218
|
+
/**
|
|
219
|
+
* Reverts the side effects just before Turbo caches the page snapshot, so an
|
|
220
|
+
* overlay left open does not bake the scroll lock into `body[style]` — a
|
|
221
|
+
* restored page would feed that locked value back into {@link activate} as the
|
|
222
|
+
* baseline, and closing would then never unlock the page. Markup state stays
|
|
223
|
+
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
224
|
+
* is left alone mid-navigation. The listener lives only while active.
|
|
225
|
+
*/
|
|
226
|
+
#onBeforeCache = () => {
|
|
227
|
+
this.deactivate({ restoreFocus: false });
|
|
228
|
+
};
|
|
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
|
+
*/
|
|
81
234
|
#onKeydown = (event) => {
|
|
82
|
-
if (event.key === "Escape") {
|
|
83
|
-
if (this.#options.onEscape) {
|
|
84
|
-
event.preventDefault();
|
|
85
|
-
this.#options.onEscape();
|
|
86
|
-
}
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
235
|
if (event.key === "Tab") this.#trapTab(event);
|
|
90
236
|
};
|
|
91
237
|
/** Keeps `Tab` focus cycling within the container's focusable elements. */
|
|
@@ -159,7 +305,9 @@ var FocusTrap = class {
|
|
|
159
305
|
};
|
|
160
306
|
|
|
161
307
|
// src/controllers/command_palette_controller.ts
|
|
162
|
-
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";
|
|
163
311
|
static targets = ["dialog", "input", "list", "option", "empty"];
|
|
164
312
|
static values = {
|
|
165
313
|
hotkey: { type: String, default: "mod+k" },
|
|
@@ -177,6 +325,12 @@ var CommandPaletteController = class extends Controller {
|
|
|
177
325
|
static events = ["select"];
|
|
178
326
|
/** The index of the currently active option within the visible subset. */
|
|
179
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
|
+
});
|
|
180
334
|
/**
|
|
181
335
|
* Owns the modal side effects (focus trap, scroll lock, background `inert`, focus
|
|
182
336
|
* restore). Escape closes; focus on open goes to the input, and is restored to
|
|
@@ -199,6 +353,8 @@ var CommandPaletteController = class extends Controller {
|
|
|
199
353
|
*/
|
|
200
354
|
connect() {
|
|
201
355
|
document.addEventListener("keydown", this.#onGlobalKeydown);
|
|
356
|
+
if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
|
|
357
|
+
this.#syncOptionSemantics();
|
|
202
358
|
const shouldOpen = this.#isOpen || this.openValue;
|
|
203
359
|
this.#resetToClosedState();
|
|
204
360
|
if (shouldOpen) this.open();
|
|
@@ -206,9 +362,22 @@ var CommandPaletteController = class extends Controller {
|
|
|
206
362
|
/** Tears down the global hotkey listener and reverts the modal side effects. */
|
|
207
363
|
disconnect() {
|
|
208
364
|
document.removeEventListener("keydown", this.#onGlobalKeydown);
|
|
365
|
+
this.#composition.disconnect();
|
|
209
366
|
this.#trap.deactivate({ restoreFocus: false });
|
|
210
367
|
this.#resetToClosedState();
|
|
211
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
|
+
}
|
|
212
381
|
/** Toggles the open state of the command palette. */
|
|
213
382
|
toggle() {
|
|
214
383
|
if (this.#isOpen) {
|
|
@@ -233,8 +402,9 @@ var CommandPaletteController = class extends Controller {
|
|
|
233
402
|
this.#trap.deactivate();
|
|
234
403
|
}
|
|
235
404
|
/** Filters option elements in-memory matching the input value. Bound to input target. */
|
|
236
|
-
filter() {
|
|
237
|
-
if (!this.hasInputTarget) return;
|
|
405
|
+
filter(event) {
|
|
406
|
+
if (!this.hasInputTarget || this.#composition.isComposing(event)) return;
|
|
407
|
+
this.#syncOptionSemantics();
|
|
238
408
|
const query = this.inputTarget.value.trim().toLowerCase();
|
|
239
409
|
let hasSelectableMatch = false;
|
|
240
410
|
for (const option of this.optionTargets) {
|
|
@@ -257,6 +427,7 @@ var CommandPaletteController = class extends Controller {
|
|
|
257
427
|
const target = event.currentTarget;
|
|
258
428
|
if (!target) return;
|
|
259
429
|
const option = target.closest("[role='option']");
|
|
430
|
+
if (option) this.#syncOptionSemanticsFor(option);
|
|
260
431
|
if (option && !this.#isDisabled(option)) {
|
|
261
432
|
this.#confirmSelection(option);
|
|
262
433
|
}
|
|
@@ -272,7 +443,7 @@ var CommandPaletteController = class extends Controller {
|
|
|
272
443
|
* focus — not only the input.
|
|
273
444
|
*/
|
|
274
445
|
onKeydown(event) {
|
|
275
|
-
if (!this.#isOpen) return;
|
|
446
|
+
if (this.#composition.isComposing(event) || !this.#isOpen) return;
|
|
276
447
|
switch (event.key) {
|
|
277
448
|
case "ArrowDown":
|
|
278
449
|
event.preventDefault();
|
|
@@ -307,23 +478,25 @@ var CommandPaletteController = class extends Controller {
|
|
|
307
478
|
this.#setActiveIndex(newIndex);
|
|
308
479
|
}
|
|
309
480
|
#setActiveIndex(index) {
|
|
481
|
+
this.#syncOptionSemantics();
|
|
310
482
|
const visible = this.#visibleOptions;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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);
|
|
320
497
|
} else {
|
|
321
|
-
|
|
322
|
-
option.removeAttribute("data-active");
|
|
498
|
+
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
323
499
|
}
|
|
324
|
-
});
|
|
325
|
-
if (index === -1 && this.hasInputTarget) {
|
|
326
|
-
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
327
500
|
}
|
|
328
501
|
}
|
|
329
502
|
#confirmSelection(option) {
|
|
@@ -336,8 +509,9 @@ var CommandPaletteController = class extends Controller {
|
|
|
336
509
|
for (const option of this.optionTargets) {
|
|
337
510
|
option.removeAttribute("hidden");
|
|
338
511
|
}
|
|
339
|
-
|
|
340
|
-
this
|
|
512
|
+
const hasSelectableOption = this.#visibleOptions.length > 0;
|
|
513
|
+
if (this.hasEmptyTarget) this.emptyTarget.hidden = hasSelectableOption;
|
|
514
|
+
this.#setActiveIndex(hasSelectableOption ? 0 : -1);
|
|
341
515
|
}
|
|
342
516
|
get #visibleOptions() {
|
|
343
517
|
return this.optionTargets.filter(
|
|
@@ -345,23 +519,66 @@ var CommandPaletteController = class extends Controller {
|
|
|
345
519
|
);
|
|
346
520
|
}
|
|
347
521
|
#isDisabled(option) {
|
|
348
|
-
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);
|
|
349
550
|
}
|
|
350
551
|
get #isOpen() {
|
|
351
552
|
return this.hasDialogTarget && !this.dialogTarget.hidden;
|
|
352
553
|
}
|
|
353
554
|
#onGlobalKeydown = (event) => {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (!key) return;
|
|
358
|
-
const modPressed = isMod ? event.metaKey || event.ctrlKey : !event.metaKey && !event.ctrlKey;
|
|
359
|
-
const keyMatch = event.key.toLowerCase() === key;
|
|
360
|
-
if (modPressed && keyMatch) {
|
|
361
|
-
event.preventDefault();
|
|
362
|
-
this.toggle();
|
|
363
|
-
}
|
|
555
|
+
if (this.#composition.isComposing(event) || !this.#matchesHotkey(event)) return;
|
|
556
|
+
event.preventDefault();
|
|
557
|
+
this.toggle();
|
|
364
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
|
+
}
|
|
365
582
|
/** Resets transient open state so reconnect starts from a predictable closed snapshot. */
|
|
366
583
|
#resetToClosedState() {
|
|
367
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}. */
|
|
@@ -49,6 +135,9 @@ var FocusTrap = class {
|
|
|
49
135
|
}
|
|
50
136
|
if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
|
|
51
137
|
document.addEventListener("keydown", this.#onKeydown);
|
|
138
|
+
document.addEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
139
|
+
const onEscape = this.#options.onEscape;
|
|
140
|
+
if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
|
|
52
141
|
if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
|
|
53
142
|
}
|
|
54
143
|
/**
|
|
@@ -61,7 +150,9 @@ var FocusTrap = class {
|
|
|
61
150
|
deactivate({ restoreFocus = true } = {}) {
|
|
62
151
|
if (!this.#activeState) return;
|
|
63
152
|
this.#activeState = false;
|
|
153
|
+
this.#escapeLayer.deactivate();
|
|
64
154
|
document.removeEventListener("keydown", this.#onKeydown);
|
|
155
|
+
document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
|
|
65
156
|
if (this.#scrollLocked) {
|
|
66
157
|
document.body.style.overflow = this.#previousBodyOverflow;
|
|
67
158
|
this.#scrollLocked = false;
|
|
@@ -77,15 +168,23 @@ var FocusTrap = class {
|
|
|
77
168
|
if (option === void 0) return fallback;
|
|
78
169
|
return typeof option === "function" ? option() : option;
|
|
79
170
|
}
|
|
80
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* Reverts the side effects just before Turbo caches the page snapshot, so an
|
|
173
|
+
* overlay left open does not bake the scroll lock into `body[style]` — a
|
|
174
|
+
* restored page would feed that locked value back into {@link activate} as the
|
|
175
|
+
* baseline, and closing would then never unlock the page. Markup state stays
|
|
176
|
+
* untouched (restore-open designs reopen against a clean baseline), and focus
|
|
177
|
+
* is left alone mid-navigation. The listener lives only while active.
|
|
178
|
+
*/
|
|
179
|
+
#onBeforeCache = () => {
|
|
180
|
+
this.deactivate({ restoreFocus: false });
|
|
181
|
+
};
|
|
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
|
+
*/
|
|
81
187
|
#onKeydown = (event) => {
|
|
82
|
-
if (event.key === "Escape") {
|
|
83
|
-
if (this.#options.onEscape) {
|
|
84
|
-
event.preventDefault();
|
|
85
|
-
this.#options.onEscape();
|
|
86
|
-
}
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
188
|
if (event.key === "Tab") this.#trapTab(event);
|
|
90
189
|
};
|
|
91
190
|
/** Keeps `Tab` focus cycling within the container's focusable elements. */
|