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
|
@@ -1,17 +1,171 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/context_menu_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/escape_layer.ts
|
|
6
|
+
function claimsWhileFocusWithin(element) {
|
|
7
|
+
return () => {
|
|
8
|
+
const active = element.ownerDocument.activeElement;
|
|
9
|
+
return active === null || active === element.ownerDocument.body || element.contains(active);
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
var EscapeLayer = class _EscapeLayer {
|
|
13
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
14
|
+
#ownerDocument = null;
|
|
15
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
16
|
+
#onDismiss = null;
|
|
17
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
18
|
+
#claims = null;
|
|
19
|
+
/**
|
|
20
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
21
|
+
* the document's shared resolver listener if this is its first layer.
|
|
22
|
+
* Re-activating an already-active layer moves it to the top.
|
|
23
|
+
*/
|
|
24
|
+
activate(ownerDocument = document, options) {
|
|
25
|
+
this.deactivate();
|
|
26
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
27
|
+
if (!registry) {
|
|
28
|
+
registry = _EscapeLayer.#createRegistry();
|
|
29
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
30
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
31
|
+
}
|
|
32
|
+
registry.stack.push(this);
|
|
33
|
+
this.#ownerDocument = ownerDocument;
|
|
34
|
+
this.#onDismiss = options.onDismiss;
|
|
35
|
+
this.#claims = options.claims ?? null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
39
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
40
|
+
*/
|
|
41
|
+
deactivate() {
|
|
42
|
+
const ownerDocument = this.#ownerDocument;
|
|
43
|
+
if (!ownerDocument) return;
|
|
44
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
45
|
+
if (registry) {
|
|
46
|
+
const index = registry.stack.lastIndexOf(this);
|
|
47
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
48
|
+
if (registry.stack.length === 0) {
|
|
49
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
50
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
this.#ownerDocument = null;
|
|
54
|
+
this.#onDismiss = null;
|
|
55
|
+
this.#claims = null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
59
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
60
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
61
|
+
*/
|
|
62
|
+
get ownsEscape() {
|
|
63
|
+
const ownerDocument = this.#ownerDocument;
|
|
64
|
+
if (!ownerDocument) return false;
|
|
65
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
66
|
+
if (!registry) return false;
|
|
67
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
68
|
+
}
|
|
69
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
70
|
+
static #createRegistry() {
|
|
71
|
+
const registry = {
|
|
72
|
+
stack: [],
|
|
73
|
+
onKeydown: (event) => {
|
|
74
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
75
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
76
|
+
if (!owner) return;
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
owner.#onDismiss?.();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
return registry;
|
|
82
|
+
}
|
|
83
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
84
|
+
static #resolveOwner(stack) {
|
|
85
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
86
|
+
const layer = stack[index];
|
|
87
|
+
if (!layer) continue;
|
|
88
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
89
|
+
return layer;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// src/utils/safe_timeout.ts
|
|
96
|
+
var TimerRegistry = class {
|
|
97
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
98
|
+
ids = /* @__PURE__ */ new Set();
|
|
99
|
+
/**
|
|
100
|
+
* Cancels a single tracked timer.
|
|
101
|
+
*
|
|
102
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
103
|
+
* registry), so callers can clear defensively without guarding.
|
|
104
|
+
*/
|
|
105
|
+
clear(id) {
|
|
106
|
+
if (this.ids.delete(id)) {
|
|
107
|
+
this.cancel(id);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
112
|
+
* to guarantee no timer outlives the element.
|
|
113
|
+
*/
|
|
114
|
+
clearAll() {
|
|
115
|
+
for (const id of this.ids) {
|
|
116
|
+
this.cancel(id);
|
|
117
|
+
}
|
|
118
|
+
this.ids.clear();
|
|
119
|
+
}
|
|
120
|
+
/** Number of timers currently tracked (pending). */
|
|
121
|
+
get size() {
|
|
122
|
+
return this.ids.size;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
var SafeTimeout = class extends TimerRegistry {
|
|
126
|
+
/**
|
|
127
|
+
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
128
|
+
*
|
|
129
|
+
* The id is removed from the registry automatically when the timeout fires,
|
|
130
|
+
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
131
|
+
*/
|
|
132
|
+
set(callback, delay) {
|
|
133
|
+
const id = this.schedule(() => {
|
|
134
|
+
this.ids.delete(id);
|
|
135
|
+
callback();
|
|
136
|
+
}, delay);
|
|
137
|
+
this.ids.add(id);
|
|
138
|
+
return id;
|
|
139
|
+
}
|
|
140
|
+
schedule(callback, delay) {
|
|
141
|
+
return window.setTimeout(callback, delay);
|
|
142
|
+
}
|
|
143
|
+
cancel(id) {
|
|
144
|
+
window.clearTimeout(id);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
3
148
|
// src/controllers/context_menu_controller.ts
|
|
4
149
|
var ContextMenuController = class extends Controller {
|
|
5
150
|
static targets = ["region", "menu", "item"];
|
|
6
151
|
static actions = ["activate", "onItemKeydown", "onRegionKeydown", "open"];
|
|
7
|
-
|
|
152
|
+
#timers = new SafeTimeout();
|
|
153
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
154
|
+
#escapeLayer = new EscapeLayer();
|
|
155
|
+
/** Starts closed and registers delegated activation and outside-pointer listeners. */
|
|
8
156
|
connect() {
|
|
9
157
|
this.#closeMenu();
|
|
10
|
-
|
|
158
|
+
this.element.addEventListener("click", this.#onItemClickCapture, true);
|
|
159
|
+
document.addEventListener("click", this.#onOutsidePointer);
|
|
160
|
+
document.addEventListener("contextmenu", this.#onOutsidePointer);
|
|
11
161
|
}
|
|
12
|
-
/**
|
|
162
|
+
/** Releases the listeners, stack membership, and pending Tab-close task. */
|
|
13
163
|
disconnect() {
|
|
14
|
-
|
|
164
|
+
this.#timers.clearAll();
|
|
165
|
+
this.#escapeLayer.deactivate();
|
|
166
|
+
this.element.removeEventListener("click", this.#onItemClickCapture, true);
|
|
167
|
+
document.removeEventListener("click", this.#onOutsidePointer);
|
|
168
|
+
document.removeEventListener("contextmenu", this.#onOutsidePointer);
|
|
15
169
|
}
|
|
16
170
|
/**
|
|
17
171
|
* Opens the menu from a `contextmenu` event: suppresses the native menu and
|
|
@@ -36,11 +190,17 @@ var ContextMenuController = class extends Controller {
|
|
|
36
190
|
switch (event.key) {
|
|
37
191
|
case "ArrowDown":
|
|
38
192
|
event.preventDefault();
|
|
39
|
-
if (items.length > 0)
|
|
193
|
+
if (items.length > 0) {
|
|
194
|
+
const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
|
|
195
|
+
items[nextIndex]?.focus();
|
|
196
|
+
}
|
|
40
197
|
break;
|
|
41
198
|
case "ArrowUp":
|
|
42
199
|
event.preventDefault();
|
|
43
|
-
if (items.length > 0)
|
|
200
|
+
if (items.length > 0) {
|
|
201
|
+
const previousIndex = currentIndex < 0 ? items.length - 1 : currentIndex - 1;
|
|
202
|
+
items[(previousIndex + items.length) % items.length]?.focus();
|
|
203
|
+
}
|
|
44
204
|
break;
|
|
45
205
|
case "Home":
|
|
46
206
|
event.preventDefault();
|
|
@@ -50,12 +210,9 @@ var ContextMenuController = class extends Controller {
|
|
|
50
210
|
event.preventDefault();
|
|
51
211
|
items[items.length - 1]?.focus();
|
|
52
212
|
break;
|
|
53
|
-
case "Escape":
|
|
54
|
-
event.preventDefault();
|
|
55
|
-
this.#closeAndRestore();
|
|
56
|
-
break;
|
|
57
213
|
case "Tab":
|
|
58
|
-
this.#
|
|
214
|
+
this.#timers.clearAll();
|
|
215
|
+
this.#timers.set(() => this.#closeMenu(), 0);
|
|
59
216
|
break;
|
|
60
217
|
}
|
|
61
218
|
}
|
|
@@ -66,6 +223,11 @@ var ContextMenuController = class extends Controller {
|
|
|
66
223
|
/** Opens the menu at viewport coordinates `(x, y)` and focuses the first item. */
|
|
67
224
|
#openAt(x, y) {
|
|
68
225
|
if (!this.hasMenuTarget) return;
|
|
226
|
+
this.#timers.clearAll();
|
|
227
|
+
this.#escapeLayer.activate(document, {
|
|
228
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
229
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
230
|
+
});
|
|
69
231
|
this.menuTarget.style.setProperty("--stimeo-context-menu-x", `${x}px`);
|
|
70
232
|
this.menuTarget.style.setProperty("--stimeo-context-menu-y", `${y}px`);
|
|
71
233
|
this.menuTarget.hidden = false;
|
|
@@ -74,6 +236,8 @@ var ContextMenuController = class extends Controller {
|
|
|
74
236
|
}
|
|
75
237
|
/** Hides the menu and reflects the collapsed state on the region. */
|
|
76
238
|
#closeMenu() {
|
|
239
|
+
this.#timers.clearAll();
|
|
240
|
+
this.#escapeLayer.deactivate();
|
|
77
241
|
if (!this.hasMenuTarget) return;
|
|
78
242
|
this.menuTarget.hidden = true;
|
|
79
243
|
if (this.hasRegionTarget) this.regionTarget.setAttribute("data-state", "closed");
|
|
@@ -83,10 +247,24 @@ var ContextMenuController = class extends Controller {
|
|
|
83
247
|
this.#closeMenu();
|
|
84
248
|
if (this.hasRegionTarget) this.regionTarget.focus();
|
|
85
249
|
}
|
|
86
|
-
/** Closes
|
|
87
|
-
#
|
|
250
|
+
/** Closes when a click or context-menu invocation lands outside this instance. */
|
|
251
|
+
#onOutsidePointer = (event) => {
|
|
88
252
|
if (this.#isOpen && !this.element.contains(event.target)) this.#closeMenu();
|
|
89
253
|
};
|
|
254
|
+
/**
|
|
255
|
+
* Captures clicks so `aria-disabled` commands cannot reach consumer handlers.
|
|
256
|
+
* Native Enter/Space activation also synthesizes a click and is blocked here.
|
|
257
|
+
*/
|
|
258
|
+
#onItemClickCapture = (event) => {
|
|
259
|
+
const target = event.target;
|
|
260
|
+
if (!(target instanceof Node)) return;
|
|
261
|
+
const disabled = this.itemTargets.some(
|
|
262
|
+
(item) => item.getAttribute("aria-disabled") === "true" && item.contains(target)
|
|
263
|
+
);
|
|
264
|
+
if (!disabled) return;
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
event.stopImmediatePropagation();
|
|
267
|
+
};
|
|
90
268
|
/** Menu items eligible for roving focus (excludes disabled / hidden). */
|
|
91
269
|
get #navigableItems() {
|
|
92
270
|
return this.itemTargets.filter((item) => this.#isNavigable(item));
|
|
@@ -107,8 +107,8 @@ var CountdownController = class extends Controller {
|
|
|
107
107
|
* paused (or completed) one resets the displayed amount but stays paused until the
|
|
108
108
|
* user resumes — it never silently restarts. The run state is read from the DOM,
|
|
109
109
|
* not re-derived from the declarative `autostart` Value (which only governs the
|
|
110
|
-
* initial state on connect); re-deriving it would override a user's pause
|
|
111
|
-
*
|
|
110
|
+
* initial state on connect); re-deriving it would override a user's pause —
|
|
111
|
+
* the DOM, not a re-run of declarative config, is the source of truth.
|
|
112
112
|
*/
|
|
113
113
|
reset() {
|
|
114
114
|
const wasRunning = this.#state === "running";
|
|
@@ -2,6 +2,90 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/dialog_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. */
|
|
@@ -1,35 +1,136 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/dropdown_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/aria_ids.ts
|
|
6
|
+
var counter = 0;
|
|
7
|
+
function uniqueId(prefix = "stimeo") {
|
|
8
|
+
let candidate;
|
|
9
|
+
do {
|
|
10
|
+
counter += 1;
|
|
11
|
+
candidate = `${prefix}-${counter}`;
|
|
12
|
+
} while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
|
|
13
|
+
return candidate;
|
|
14
|
+
}
|
|
15
|
+
function ensureId(element, prefix = "stimeo") {
|
|
16
|
+
if (element.id) return element.id;
|
|
17
|
+
const id = uniqueId(prefix);
|
|
18
|
+
element.id = id;
|
|
19
|
+
return id;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/utils/escape_layer.ts
|
|
23
|
+
function claimsWhileFocusWithin(element) {
|
|
24
|
+
return () => {
|
|
25
|
+
const active = element.ownerDocument.activeElement;
|
|
26
|
+
return active === null || active === element.ownerDocument.body || element.contains(active);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
var EscapeLayer = class _EscapeLayer {
|
|
30
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
31
|
+
#ownerDocument = null;
|
|
32
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
33
|
+
#onDismiss = null;
|
|
34
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
35
|
+
#claims = null;
|
|
36
|
+
/**
|
|
37
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
38
|
+
* the document's shared resolver listener if this is its first layer.
|
|
39
|
+
* Re-activating an already-active layer moves it to the top.
|
|
40
|
+
*/
|
|
41
|
+
activate(ownerDocument = document, options) {
|
|
42
|
+
this.deactivate();
|
|
43
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
44
|
+
if (!registry) {
|
|
45
|
+
registry = _EscapeLayer.#createRegistry();
|
|
46
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
47
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
48
|
+
}
|
|
49
|
+
registry.stack.push(this);
|
|
50
|
+
this.#ownerDocument = ownerDocument;
|
|
51
|
+
this.#onDismiss = options.onDismiss;
|
|
52
|
+
this.#claims = options.claims ?? null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
56
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
57
|
+
*/
|
|
58
|
+
deactivate() {
|
|
59
|
+
const ownerDocument = this.#ownerDocument;
|
|
60
|
+
if (!ownerDocument) return;
|
|
61
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
62
|
+
if (registry) {
|
|
63
|
+
const index = registry.stack.lastIndexOf(this);
|
|
64
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
65
|
+
if (registry.stack.length === 0) {
|
|
66
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
67
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
this.#ownerDocument = null;
|
|
71
|
+
this.#onDismiss = null;
|
|
72
|
+
this.#claims = null;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
76
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
77
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
78
|
+
*/
|
|
79
|
+
get ownsEscape() {
|
|
80
|
+
const ownerDocument = this.#ownerDocument;
|
|
81
|
+
if (!ownerDocument) return false;
|
|
82
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
83
|
+
if (!registry) return false;
|
|
84
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
85
|
+
}
|
|
86
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
87
|
+
static #createRegistry() {
|
|
88
|
+
const registry = {
|
|
89
|
+
stack: [],
|
|
90
|
+
onKeydown: (event) => {
|
|
91
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
92
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
93
|
+
if (!owner) return;
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
owner.#onDismiss?.();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
return registry;
|
|
99
|
+
}
|
|
100
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
101
|
+
static #resolveOwner(stack) {
|
|
102
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
103
|
+
const layer = stack[index];
|
|
104
|
+
if (!layer) continue;
|
|
105
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
106
|
+
return layer;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
3
112
|
// src/controllers/dropdown_controller.ts
|
|
4
113
|
var DropdownController = class extends Controller {
|
|
5
114
|
static targets = ["trigger", "menu"];
|
|
6
115
|
static actions = ["close", "open", "toggle"];
|
|
116
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
117
|
+
#escapeLayer = new EscapeLayer();
|
|
7
118
|
/** Closes the menu when a click lands outside the controller's element. */
|
|
8
119
|
#onOutsideClick = (event) => {
|
|
9
120
|
if (!this.element.contains(event.target)) {
|
|
10
121
|
this.close();
|
|
11
122
|
}
|
|
12
123
|
};
|
|
13
|
-
/**
|
|
14
|
-
#onKeydown = (event) => {
|
|
15
|
-
if (event.key === "Escape" && this.#isOpen) {
|
|
16
|
-
this.close();
|
|
17
|
-
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
18
|
-
}
|
|
19
|
-
};
|
|
20
|
-
/**
|
|
21
|
-
* Starts in the closed state and registers the document-level listeners that
|
|
22
|
-
* power outside-click and `Escape` handling.
|
|
23
|
-
*/
|
|
124
|
+
/** Starts in the closed state and registers outside-click handling. */
|
|
24
125
|
connect() {
|
|
126
|
+
this.#associateTriggerWithMenu();
|
|
25
127
|
this.close();
|
|
26
|
-
document.addEventListener("click", this.#onOutsideClick);
|
|
27
|
-
document.addEventListener("keydown", this.#onKeydown);
|
|
128
|
+
document.addEventListener("click", this.#onOutsideClick, true);
|
|
28
129
|
}
|
|
29
|
-
/** Removes the
|
|
130
|
+
/** Removes the listeners registered in {@link connect}. */
|
|
30
131
|
disconnect() {
|
|
31
|
-
|
|
32
|
-
document.removeEventListener("
|
|
132
|
+
this.#escapeLayer.deactivate();
|
|
133
|
+
document.removeEventListener("click", this.#onOutsideClick, true);
|
|
33
134
|
}
|
|
34
135
|
/** Toggles the menu between open and closed. Bound via `data-action`. */
|
|
35
136
|
toggle() {
|
|
@@ -42,6 +143,10 @@ var DropdownController = class extends Controller {
|
|
|
42
143
|
/** Reveals the menu and reflects the open state on the trigger. */
|
|
43
144
|
open() {
|
|
44
145
|
if (!this.hasMenuTarget) return;
|
|
146
|
+
this.#escapeLayer.activate(document, {
|
|
147
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
148
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
149
|
+
});
|
|
45
150
|
this.menuTarget.hidden = false;
|
|
46
151
|
if (this.hasTriggerTarget) {
|
|
47
152
|
this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
@@ -49,16 +154,34 @@ var DropdownController = class extends Controller {
|
|
|
49
154
|
}
|
|
50
155
|
/** Hides the menu and reflects the closed state on the trigger. */
|
|
51
156
|
close() {
|
|
157
|
+
this.#escapeLayer.deactivate();
|
|
52
158
|
if (!this.hasMenuTarget) return;
|
|
53
159
|
this.menuTarget.hidden = true;
|
|
54
160
|
if (this.hasTriggerTarget) {
|
|
55
161
|
this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
56
162
|
}
|
|
57
163
|
}
|
|
164
|
+
/** Closes and restores focus to the trigger (the keyboard-dismissal path). */
|
|
165
|
+
#closeAndRestore() {
|
|
166
|
+
this.close();
|
|
167
|
+
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
168
|
+
}
|
|
58
169
|
/** Whether the menu is currently visible. */
|
|
59
170
|
get #isOpen() {
|
|
60
171
|
return this.hasMenuTarget && !this.menuTarget.hidden;
|
|
61
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Associates the disclosure trigger and controlled region without clobbering
|
|
175
|
+
* authored markup.
|
|
176
|
+
*/
|
|
177
|
+
#associateTriggerWithMenu() {
|
|
178
|
+
if (!this.hasTriggerTarget || !this.hasMenuTarget) return;
|
|
179
|
+
if (this.triggerTarget.hasAttribute("aria-controls")) return;
|
|
180
|
+
this.triggerTarget.setAttribute(
|
|
181
|
+
"aria-controls",
|
|
182
|
+
ensureId(this.menuTarget, "stimeo--dropdown-menu")
|
|
183
|
+
);
|
|
184
|
+
}
|
|
62
185
|
};
|
|
63
186
|
|
|
64
187
|
export { DropdownController };
|