stimeo-ui 0.1.0-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.
- package/CHANGELOG.md +90 -0
- package/README.md +6 -6
- package/dist/controllers/auto_submit_controller.js +56 -21
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/character_counter_controller.js +52 -15
- package/dist/controllers/character_counter_controller.js.map +1 -1
- package/dist/controllers/combobox_controller.d.ts +6 -2
- package/dist/controllers/combobox_controller.js +64 -3
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +13 -6
- package/dist/controllers/command_palette_controller.js +243 -39
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js +94 -8
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +11 -6
- package/dist/controllers/context_menu_controller.js +191 -13
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +2 -2
- package/dist/controllers/countdown_controller.js +2 -2
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.js +94 -8
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +9 -6
- package/dist/controllers/dropdown_controller.js +139 -16
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/focus_controller.js +94 -8
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +2 -2
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +15 -12
- package/dist/controllers/hover_card_controller.js +103 -27
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.js +1 -0
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/menu_controller.d.ts +18 -11
- package/dist/controllers/menu_controller.js +181 -8
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/otp_controller.js +62 -22
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +3 -2
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.js +3 -3
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +18 -10
- package/dist/controllers/popover_controller.js +118 -27
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +1 -2
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +7 -9
- package/dist/controllers/toast_controller.js +108 -29
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +15 -18
- package/dist/controllers/tooltip_controller.js +148 -42
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/index.d.ts +40 -10
- package/dist/index.js +833 -348
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +6 -6
- package/dist/inspector/manifest.json +16 -3
- package/package.json +7 -7
|
@@ -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));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/context_menu_controller.ts"],"names":[],"mappings":"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","file":"context_menu_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible **context menu** behavior.\n *\n * Markup contract (identifier: `stimeo--context-menu`):\n * <div data-controller=\"stimeo--context-menu\">\n * <div data-stimeo--context-menu-target=\"region\" tabindex=\"0\"\n * aria-haspopup=\"menu\" aria-controls=\"ctx\"\n * data-action=\"contextmenu->stimeo--context-menu#open\n * keydown->stimeo--context-menu#onRegionKeydown\">…</div>\n * <ul id=\"ctx\" role=\"menu\" data-stimeo--context-menu-target=\"menu\" hidden>\n * <li role=\"none\">\n * <button role=\"menuitem\" tabindex=\"-1\"\n * data-stimeo--context-menu-target=\"item\"\n * data-action=\"click->stimeo--context-menu#activate\n * keydown->stimeo--context-menu#onItemKeydown\">…</button>\n * </li>\n * </ul>\n * </div>\n *\n * Implements the WAI-ARIA APG **Menu** pattern; the only differences from\n * `stimeo--menu` are the *trigger* (a `contextmenu` event or `Shift+F10` /\n * `ContextMenu` key, not a button click) and that the menu is shown at the\n * pointer coordinate.\n *\n * @remarks\n * Behavior only — the controller reflects the click coordinate as the CSS custom\n * properties `--stimeo-context-menu-x` / `--stimeo-context-menu-y` on the menu\n * so the consumer's CSS can place it (works standalone, no positioning module\n * required). Viewport-edge flip/shift is delegated to the opt-in\n * `stimeo-ui/positioning` module, which this controller never imports.\n *\n * Open state is exposed on the region as `data-state` (`open`/`closed`) — a CSS\n * hook, not an ARIA one. `aria-expanded` is deliberately *not* set on the region\n * because it is a generic container, not a role that supports that state (doing so\n * is an ARIA violation); the region's static `aria-haspopup=\"menu\"` advertises the\n * popup, and assistive tech perceives the open state when focus moves into the\n * `role=\"menu\"`.\n *\n * Behavior provided:\n * - `contextmenu` on the region suppresses the browser menu and opens this one at\n * the pointer; `Shift+F10` / `ContextMenu` opens it at the region's center.\n * - On open, focus moves to the first item; the region's `data-state` syncs.\n * - Roving focus inside the menu: `ArrowUp`/`ArrowDown` (wrapping), `Home`/`End`.\n * - Activating an item (click / native `Enter`/`Space` on the button) closes the\n * menu and restores focus to the region.\n * - `Escape` closes and restores focus to the region; `Tab` closes without\n * restoring (focus moves on naturally); an outside click closes.\n *\n * Roving focus skips items that are not navigable — `hidden`, natively\n * `disabled`, or `aria-disabled=\"true\"` — so the keyboard never lands focus on an\n * inert command (matching `stimeo--menu` / `stimeo--command-palette`).\n */\nexport class ContextMenuController extends Controller<HTMLElement> {\n static override targets = [\"region\", \"menu\", \"item\"];\n static actions = [\"activate\", \"onItemKeydown\", \"onRegionKeydown\", \"open\"] as const;\n\n declare readonly regionTarget: HTMLElement;\n declare readonly menuTarget: HTMLElement;\n declare readonly itemTargets: HTMLButtonElement[];\n declare readonly hasRegionTarget: boolean;\n declare readonly hasMenuTarget: boolean;\n\n /** Starts closed and registers the outside-click listener. */\n override connect(): void {\n this.#closeMenu();\n document.addEventListener(\"click\", this.#onOutsideClick);\n }\n\n /** Removes the document-level listener registered in {@link connect}. */\n override disconnect(): void {\n document.removeEventListener(\"click\", this.#onOutsideClick);\n }\n\n /**\n * Opens the menu from a `contextmenu` event: suppresses the native menu and\n * places this one at the pointer coordinate.\n */\n open(event: MouseEvent): void {\n event.preventDefault();\n this.#openAt(event.clientX, event.clientY);\n }\n\n /** Keyboard entry on the region: `Shift+F10` / `ContextMenu` open at center. */\n onRegionKeydown(event: KeyboardEvent): void {\n const isContextKey = event.key === \"ContextMenu\" || (event.key === \"F10\" && event.shiftKey);\n if (!isContextKey) return;\n event.preventDefault();\n const rect = this.hasRegionTarget\n ? this.regionTarget.getBoundingClientRect()\n : { left: 0, top: 0, width: 0, height: 0 };\n this.#openAt(rect.left + rect.width / 2, rect.top + rect.height / 2);\n }\n\n /** Roving focus and closing keys inside the menu. */\n onItemKeydown(event: KeyboardEvent): void {\n const items = this.#navigableItems;\n const currentIndex = items.indexOf(event.currentTarget as HTMLButtonElement);\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n if (items.length > 0) items[(currentIndex + 1) % items.length]?.focus();\n break;\n case \"ArrowUp\":\n event.preventDefault();\n if (items.length > 0) items[(currentIndex - 1 + items.length) % items.length]?.focus();\n break;\n case \"Home\":\n event.preventDefault();\n items[0]?.focus();\n break;\n case \"End\":\n event.preventDefault();\n items[items.length - 1]?.focus();\n break;\n case \"Escape\":\n event.preventDefault();\n this.#closeAndRestore();\n break;\n case \"Tab\":\n // Per APG, Tab closes the menu but lets focus move on naturally.\n this.#closeMenu();\n break;\n default:\n break;\n }\n }\n\n /** Closes after an item is activated and restores focus to the region. */\n activate(): void {\n this.#closeAndRestore();\n }\n\n /** Opens the menu at viewport coordinates `(x, y)` and focuses the first item. */\n #openAt(x: number, y: number): void {\n if (!this.hasMenuTarget) return;\n this.menuTarget.style.setProperty(\"--stimeo-context-menu-x\", `${x}px`);\n this.menuTarget.style.setProperty(\"--stimeo-context-menu-y\", `${y}px`);\n this.menuTarget.hidden = false;\n if (this.hasRegionTarget) this.regionTarget.setAttribute(\"data-state\", \"open\");\n this.#navigableItems[0]?.focus();\n }\n\n /** Hides the menu and reflects the collapsed state on the region. */\n #closeMenu(): void {\n if (!this.hasMenuTarget) return;\n this.menuTarget.hidden = true;\n if (this.hasRegionTarget) this.regionTarget.setAttribute(\"data-state\", \"closed\");\n }\n\n /** Closes the menu and returns focus to the region (Escape / activation). */\n #closeAndRestore(): void {\n this.#closeMenu();\n if (this.hasRegionTarget) this.regionTarget.focus();\n }\n\n /** Closes the menu when a click lands outside the controller's element. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (this.#isOpen && !this.element.contains(event.target as Node)) this.#closeMenu();\n };\n\n /** Menu items eligible for roving focus (excludes disabled / hidden). */\n get #navigableItems(): HTMLButtonElement[] {\n return this.itemTargets.filter((item) => this.#isNavigable(item));\n }\n\n /**\n * An item can take roving focus unless it is `hidden`, `aria-disabled=\"true\"`,\n * or a natively `disabled` form control. CSS-only visibility is not detectable\n * here and is the consumer's responsibility.\n */\n #isNavigable(item: HTMLButtonElement): boolean {\n if (item.hasAttribute(\"hidden\")) return false;\n if (item.getAttribute(\"aria-disabled\") === \"true\") return false;\n return !item.disabled;\n }\n\n /** Whether the menu is currently visible. */\n get #isOpen(): boolean {\n return this.hasMenuTarget && !this.menuTarget.hidden;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/escape_layer.ts","../../src/utils/safe_timeout.ts","../../src/controllers/context_menu_controller.ts"],"names":[],"mappings":"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","file":"context_menu_controller.js","sourcesContent":["/**\n * Single resolver for layered Escape dismissal.\n *\n * Every Escape-dismissable overlay layer (modal focus traps, disclosure\n * overlays like dropdown/popover/menu, and hover-triggered transient layers)\n * registers here while it is open. One document-level listener per document\n * resolves each press to exactly one owner and invokes that layer's\n * {@link EscapeLayerOptions.onDismiss} — controllers never listen for a\n * dismissing Escape themselves.\n *\n * @remarks\n * Each document owns an activation-ordered stack. The owner of a press is the\n * **topmost layer whose {@link EscapeLayerOptions.claims} passes**; a layer\n * that declines is transparent, so a background overlay opened behind a modal\n * never blocks it. Because a layer opened from within another is necessarily\n * activated later, LIFO order is also inner-first for nested layers — no DOM\n * inspection is needed.\n *\n * The shared listener runs in the document bubble phase and honors\n * `event.defaultPrevented`, so an element-level widget handler that consumes\n * Escape first (an editor cancelling its edit, a combobox closing its list)\n * always wins over every registered layer — the deepest-first half of the\n * shared layered-Escape contract. A keydown that is part of an IME composition\n * (`event.isComposing`) cancels the composition, never a layer, and is ignored\n * here for every layer at once.\n *\n * A WeakMap keeps documents collectible; the listener is installed only while\n * a document's stack is non-empty, and controller lifecycle hooks guarantee\n * that disconnected layers never remain registered.\n */\n\n/** Behavior a layer registers when it activates. */\nexport interface EscapeLayerOptions {\n /**\n * Dismisses the layer. Called by the shared resolver when this layer owns a\n * press; the resolver has already consumed the event (`preventDefault()`),\n * so the callback only needs to close and place focus per the widget's\n * contract.\n */\n onDismiss: () => void;\n /**\n * Whether the layer claims the current press. Evaluated per press, so it can\n * depend on live state (e.g. \"focus is inside me or fell to the body\"). A\n * declining layer is skipped and the next layer down is consulted; omitting\n * it means the layer always claims while active.\n */\n claims?: () => boolean;\n}\n\n/** A document's stack plus the one shared listener bound to it. */\ninterface EscapeLayerRegistry {\n stack: EscapeLayer[];\n onKeydown: (event: KeyboardEvent) => void;\n}\n\n/**\n * Claims predicate shared by the click-opened disclosure overlays (dropdown /\n * popover / navigation-menu / menu / context-menu / menubar): the layer claims\n * a press while focus is inside `element`, or after focus fell to the body —\n * a click on non-focusable overlay content blurs to `<body>`, and Escape must\n * still close the overlay (the \"body-focus rescue\"). A press made after focus\n * moved to another interactive element is declined, so closing never yanks\n * focus away from where the user deliberately went.\n */\nexport function claimsWhileFocusWithin(element: Element): () => boolean {\n return () => {\n const active = element.ownerDocument.activeElement;\n return active === null || active === element.ownerDocument.body || element.contains(active);\n };\n}\n\nexport class EscapeLayer {\n static readonly #registries = new WeakMap<Document, EscapeLayerRegistry>();\n\n #ownerDocument: Document | null = null;\n /** Dismissal callback while active; `null` when inactive. */\n #onDismiss: (() => void) | null = null;\n /** Live predicate deciding whether the layer claims a press; `null` = always. */\n #claims: (() => boolean) | null = null;\n\n /**\n * Activates this layer at the top of its document's Escape stack, installing\n * the document's shared resolver listener if this is its first layer.\n * Re-activating an already-active layer moves it to the top.\n */\n activate(ownerDocument: Document = document, options: EscapeLayerOptions): void {\n this.deactivate();\n let registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) {\n registry = EscapeLayer.#createRegistry();\n EscapeLayer.#registries.set(ownerDocument, registry);\n ownerDocument.addEventListener(\"keydown\", registry.onKeydown);\n }\n registry.stack.push(this);\n this.#ownerDocument = ownerDocument;\n this.#onDismiss = options.onDismiss;\n this.#claims = options.claims ?? null;\n }\n\n /**\n * Removes this layer from its document's Escape stack, uninstalling the\n * shared listener when the stack empties. Safe to call when inactive.\n */\n deactivate(): void {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return;\n\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (registry) {\n const index = registry.stack.lastIndexOf(this);\n if (index >= 0) registry.stack.splice(index, 1);\n if (registry.stack.length === 0) {\n ownerDocument.removeEventListener(\"keydown\", registry.onKeydown);\n EscapeLayer.#registries.delete(ownerDocument);\n }\n }\n this.#ownerDocument = null;\n this.#onDismiss = null;\n this.#claims = null;\n }\n\n /**\n * Whether this active layer would own a press right now: it is the topmost\n * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests\n * and diagnostics — production dismissal goes through the shared listener.\n */\n get ownsEscape(): boolean {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return false;\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) return false;\n return EscapeLayer.#resolveOwner(registry.stack) === this;\n }\n\n /** Builds a document's registry with its shared resolver listener. */\n static #createRegistry(): EscapeLayerRegistry {\n const registry: EscapeLayerRegistry = {\n stack: [],\n onKeydown: (event: KeyboardEvent): void => {\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n const owner = EscapeLayer.#resolveOwner(registry.stack);\n if (!owner) return;\n event.preventDefault();\n owner.#onDismiss?.();\n },\n };\n return registry;\n }\n\n /** The topmost stack layer whose claims predicate passes, or `null`. */\n static #resolveOwner(stack: EscapeLayer[]): EscapeLayer | null {\n for (let index = stack.length - 1; index >= 0; index--) {\n const layer = stack[index];\n if (!layer) continue;\n if (layer.#claims && !layer.#claims()) continue;\n return layer;\n }\n return null;\n }\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { claimsWhileFocusWithin, EscapeLayer } from \"../utils/escape_layer\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/**\n * Headless, accessible **context menu** behavior.\n *\n * Markup contract (identifier: `stimeo--context-menu`):\n * <div data-controller=\"stimeo--context-menu\">\n * <div data-stimeo--context-menu-target=\"region\" tabindex=\"0\"\n * aria-haspopup=\"menu\" aria-controls=\"ctx\"\n * data-action=\"contextmenu->stimeo--context-menu#open\n * keydown->stimeo--context-menu#onRegionKeydown\">…</div>\n * <ul id=\"ctx\" role=\"menu\" data-stimeo--context-menu-target=\"menu\" hidden>\n * <li role=\"none\">\n * <button role=\"menuitem\" tabindex=\"-1\"\n * data-stimeo--context-menu-target=\"item\"\n * data-action=\"click->stimeo--context-menu#activate\n * keydown->stimeo--context-menu#onItemKeydown\">…</button>\n * </li>\n * </ul>\n * </div>\n *\n * Implements the WAI-ARIA APG **Menu** pattern; the only differences from\n * `stimeo--menu` are the *trigger* (a `contextmenu` event or `Shift+F10` /\n * `ContextMenu` key, not a button click) and that the menu is shown at the\n * pointer coordinate.\n *\n * @remarks\n * Behavior only — the controller reflects the click coordinate as the CSS custom\n * properties `--stimeo-context-menu-x` / `--stimeo-context-menu-y` on the menu\n * so the consumer's CSS can place it (works standalone, no positioning module\n * required). Viewport-edge flip/shift is delegated to the opt-in\n * `stimeo-ui/positioning` module, which this controller never imports.\n *\n * Open state is exposed on the region as `data-state` (`open`/`closed`) — a CSS\n * hook, not an ARIA one. `aria-expanded` is deliberately *not* set on the region\n * because it is a generic container, not a role that supports that state (doing so\n * is an ARIA violation); the region's static `aria-haspopup=\"menu\"` advertises the\n * popup, and assistive tech perceives the open state when focus moves into the\n * `role=\"menu\"`.\n *\n * Behavior provided:\n * - `contextmenu` on the region suppresses the browser menu and opens this one at\n * the pointer; `Shift+F10` / `ContextMenu` opens it at the region's center.\n * - On open, focus moves to the first item; the region's `data-state` syncs.\n * - Roving focus inside the menu: `ArrowUp`/`ArrowDown` (wrapping), `Home`/`End`.\n * - Activating an enabled item (click / native `Enter`/`Space` on the button)\n * closes the menu and restores focus to the region. `aria-disabled` activation\n * is blocked before consumer click handlers run.\n * - `Escape` closes and restores focus to the region. While open the menu is a\n * layer on the shared {@link EscapeLayer} stack; it claims a press only while\n * focus is inside the controller or fell to the body, so one keypress closes\n * exactly one layer (the shared layered-Escape contract). `Tab` lets the\n * browser move focus first, then closes on the next task. An outside click or\n * context-menu invocation closes without stealing focus from its destination.\n *\n * Roving focus skips items that are not navigable — `hidden`, natively\n * `disabled`, or `aria-disabled=\"true\"` — so the keyboard never lands focus on an\n * inert command (matching `stimeo--menu` / `stimeo--command-palette`).\n */\nexport class ContextMenuController extends Controller<HTMLElement> {\n static override targets = [\"region\", \"menu\", \"item\"];\n static actions = [\"activate\", \"onItemKeydown\", \"onRegionKeydown\", \"open\"] as const;\n\n declare readonly regionTarget: HTMLElement;\n declare readonly menuTarget: HTMLElement;\n declare readonly itemTargets: HTMLButtonElement[];\n declare readonly hasRegionTarget: boolean;\n declare readonly hasMenuTarget: boolean;\n\n readonly #timers = new SafeTimeout();\n\n /** Escape-stack membership while open; the shared resolver dismisses via it. */\n readonly #escapeLayer = new EscapeLayer();\n\n /** Starts closed and registers delegated activation and outside-pointer listeners. */\n override connect(): void {\n this.#closeMenu();\n this.element.addEventListener(\"click\", this.#onItemClickCapture, true);\n document.addEventListener(\"click\", this.#onOutsidePointer);\n document.addEventListener(\"contextmenu\", this.#onOutsidePointer);\n }\n\n /** Releases the listeners, stack membership, and pending Tab-close task. */\n override disconnect(): void {\n this.#timers.clearAll();\n this.#escapeLayer.deactivate();\n this.element.removeEventListener(\"click\", this.#onItemClickCapture, true);\n document.removeEventListener(\"click\", this.#onOutsidePointer);\n document.removeEventListener(\"contextmenu\", this.#onOutsidePointer);\n }\n\n /**\n * Opens the menu from a `contextmenu` event: suppresses the native menu and\n * places this one at the pointer coordinate.\n */\n open(event: MouseEvent): void {\n event.preventDefault();\n this.#openAt(event.clientX, event.clientY);\n }\n\n /** Keyboard entry on the region: `Shift+F10` / `ContextMenu` open at center. */\n onRegionKeydown(event: KeyboardEvent): void {\n const isContextKey = event.key === \"ContextMenu\" || (event.key === \"F10\" && event.shiftKey);\n if (!isContextKey) return;\n event.preventDefault();\n const rect = this.hasRegionTarget\n ? this.regionTarget.getBoundingClientRect()\n : { left: 0, top: 0, width: 0, height: 0 };\n this.#openAt(rect.left + rect.width / 2, rect.top + rect.height / 2);\n }\n\n /** Roving focus and closing keys inside the menu. */\n onItemKeydown(event: KeyboardEvent): void {\n const items = this.#navigableItems;\n const currentIndex = items.indexOf(event.currentTarget as HTMLButtonElement);\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n if (items.length > 0) {\n const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;\n items[nextIndex]?.focus();\n }\n break;\n case \"ArrowUp\":\n event.preventDefault();\n if (items.length > 0) {\n const previousIndex = currentIndex < 0 ? items.length - 1 : currentIndex - 1;\n items[(previousIndex + items.length) % items.length]?.focus();\n }\n break;\n case \"Home\":\n event.preventDefault();\n items[0]?.focus();\n break;\n case \"End\":\n event.preventDefault();\n items[items.length - 1]?.focus();\n break;\n case \"Tab\":\n // Closing synchronously removes the focused item before the browser's\n // default Tab action, which can restart traversal at the document head.\n this.#timers.clearAll();\n this.#timers.set(() => this.#closeMenu(), 0);\n break;\n default:\n break;\n }\n }\n\n /** Closes after an item is activated and restores focus to the region. */\n activate(): void {\n this.#closeAndRestore();\n }\n\n /** Opens the menu at viewport coordinates `(x, y)` and focuses the first item. */\n #openAt(x: number, y: number): void {\n if (!this.hasMenuTarget) return;\n this.#timers.clearAll();\n this.#escapeLayer.activate(document, {\n onDismiss: () => this.#closeAndRestore(),\n claims: claimsWhileFocusWithin(this.element),\n });\n this.menuTarget.style.setProperty(\"--stimeo-context-menu-x\", `${x}px`);\n this.menuTarget.style.setProperty(\"--stimeo-context-menu-y\", `${y}px`);\n this.menuTarget.hidden = false;\n if (this.hasRegionTarget) this.regionTarget.setAttribute(\"data-state\", \"open\");\n this.#navigableItems[0]?.focus();\n }\n\n /** Hides the menu and reflects the collapsed state on the region. */\n #closeMenu(): void {\n this.#timers.clearAll();\n this.#escapeLayer.deactivate();\n if (!this.hasMenuTarget) return;\n this.menuTarget.hidden = true;\n if (this.hasRegionTarget) this.regionTarget.setAttribute(\"data-state\", \"closed\");\n }\n\n /** Closes the menu and returns focus to the region (Escape / activation). */\n #closeAndRestore(): void {\n this.#closeMenu();\n if (this.hasRegionTarget) this.regionTarget.focus();\n }\n\n /** Closes when a click or context-menu invocation lands outside this instance. */\n readonly #onOutsidePointer = (event: MouseEvent): void => {\n if (this.#isOpen && !this.element.contains(event.target as Node)) this.#closeMenu();\n };\n\n /**\n * Captures clicks so `aria-disabled` commands cannot reach consumer handlers.\n * Native Enter/Space activation also synthesizes a click and is blocked here.\n */\n readonly #onItemClickCapture = (event: MouseEvent): void => {\n const target = event.target;\n if (!(target instanceof Node)) return;\n const disabled = this.itemTargets.some(\n (item) => item.getAttribute(\"aria-disabled\") === \"true\" && item.contains(target),\n );\n if (!disabled) return;\n event.preventDefault();\n event.stopImmediatePropagation();\n };\n\n /** Menu items eligible for roving focus (excludes disabled / hidden). */\n get #navigableItems(): HTMLButtonElement[] {\n return this.itemTargets.filter((item) => this.#isNavigable(item));\n }\n\n /**\n * An item can take roving focus unless it is `hidden`, `aria-disabled=\"true\"`,\n * or a natively `disabled` form control. CSS-only visibility is not detectable\n * here and is the consumer's responsibility.\n */\n #isNavigable(item: HTMLButtonElement): boolean {\n if (item.hasAttribute(\"hidden\")) return false;\n if (item.getAttribute(\"aria-disabled\") === \"true\") return false;\n return !item.disabled;\n }\n\n /** Whether the menu is currently visible. */\n get #isOpen(): boolean {\n return this.hasMenuTarget && !this.menuTarget.hidden;\n }\n}\n"]}
|
|
@@ -84,8 +84,8 @@ declare class CountdownController extends Controller<HTMLElement> {
|
|
|
84
84
|
* paused (or completed) one resets the displayed amount but stays paused until the
|
|
85
85
|
* user resumes — it never silently restarts. The run state is read from the DOM,
|
|
86
86
|
* not re-derived from the declarative `autostart` Value (which only governs the
|
|
87
|
-
* initial state on connect); re-deriving it would override a user's pause
|
|
88
|
-
*
|
|
87
|
+
* initial state on connect); re-deriving it would override a user's pause —
|
|
88
|
+
* the DOM, not a re-run of declarative config, is the source of truth.
|
|
89
89
|
*/
|
|
90
90
|
reset(): void;
|
|
91
91
|
}
|
|
@@ -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";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/countdown_controller.ts"],"names":[],"mappings":"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","file":"countdown_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeInterval } from \"../utils/safe_timeout\";\n\n/**\n * Headless countdown/timer behavior built on the live-region `role=\"timer\"`\n * practice (no dedicated APG pattern).\n *\n * Markup contract (identifier: `stimeo--countdown`):\n * <div data-controller=\"stimeo--countdown\" role=\"timer\" aria-live=\"off\"\n * data-stimeo--countdown-deadline-value=\"2026-12-31T23:59:59+09:00\"\n * data-action=\"countdown:pause->stimeo--countdown#pause\n * countdown:resume->stimeo--countdown#resume\">\n * <span data-stimeo--countdown-target=\"days\">0</span>\n * <span data-stimeo--countdown-target=\"hours\">00</span>\n * <span data-stimeo--countdown-target=\"minutes\">00</span>\n * <span data-stimeo--countdown-target=\"seconds\">00</span>\n * </div>\n *\n * Computes the time remaining to `deadline` (or elapsed since it, in\n * `direction=\"up\"`), formats it into the day/hour/minute/second slots, and ticks\n * on `interval`. `aria-live=\"off\"` is recommended so the timer is not announced\n * every second; only the milestone (completion) is surfaced — via the optional\n * `status` live region when a `completeLabel` is provided, or the `complete`\n * event for the consumer to handle.\n *\n * @remarks\n * Behavior only — slot text is updated, not styled. Pause/resume shifts an\n * internal time anchor so the displayed amount is preserved across a pause. The\n * interval is owned by {@link SafeInterval} and torn down on `disconnect()`\n * (Turbo navigation included).\n */\nexport class CountdownController extends Controller<HTMLElement> {\n static override targets = [\"days\", \"hours\", \"minutes\", \"seconds\", \"status\"];\n static override values = {\n deadline: { type: String, default: \"\" },\n interval: { type: Number, default: 1000 },\n direction: { type: String, default: \"down\" },\n autostart: { type: Boolean, default: true },\n completeLabel: { type: String, default: \"\" },\n };\n static actions = [\"pause\", \"reset\", \"resume\", \"start\"] as const;\n static events = [\"complete\", \"tick\"] as const;\n\n declare readonly daysTarget: HTMLElement;\n declare readonly hoursTarget: HTMLElement;\n declare readonly minutesTarget: HTMLElement;\n declare readonly secondsTarget: HTMLElement;\n declare readonly statusTarget: HTMLElement;\n declare readonly hasDaysTarget: boolean;\n declare readonly hasHoursTarget: boolean;\n declare readonly hasMinutesTarget: boolean;\n declare readonly hasSecondsTarget: boolean;\n declare readonly hasStatusTarget: boolean;\n\n declare deadlineValue: string;\n declare intervalValue: number;\n declare directionValue: string;\n declare autostartValue: boolean;\n declare completeLabelValue: string;\n\n readonly #intervals = new SafeInterval();\n #intervalId: number | null = null;\n\n /** Epoch-ms anchor: the deadline (down) or the count-up origin (up). */\n #reference = 0;\n /** Amount (ms) captured at pause, so resume can restore the same display. */\n #pausedAmount = 0;\n\n override connect(): void {\n this.#initReference();\n this.#render(this.#currentAmount());\n if (this.autostartValue && this.#isValidDeadline) {\n this.start();\n } else {\n this.element.setAttribute(\"data-state\", \"paused\");\n }\n }\n\n override disconnect(): void {\n this.#intervals.clearAll();\n this.#intervalId = null;\n }\n\n /** Starts (or restarts after pause) ticking toward the deadline. */\n start(): void {\n if (this.#state === \"running\" || !this.#isValidDeadline) return;\n if (this.#isDown && this.#currentAmount() <= 0) {\n this.#complete();\n return;\n }\n this.#runInterval();\n }\n\n /** Pauses ticking, preserving the currently displayed amount. */\n pause(): void {\n if (this.#state !== \"running\") return;\n this.#pausedAmount = this.#currentAmount();\n this.#teardownInterval();\n this.element.setAttribute(\"data-state\", \"paused\");\n }\n\n /** Resumes from a pause, continuing from the preserved amount. */\n resume(): void {\n if (this.#state !== \"paused\" || !this.#isValidDeadline) return;\n const now = Date.now();\n this.#reference = this.#isDown ? now + this.#pausedAmount : now - this.#pausedAmount;\n this.start();\n }\n\n /**\n * Re-syncs to the deadline and clears any pause offset, **preserving the current\n * run state**: a running timer keeps counting down from the reset amount, while a\n * paused (or completed) one resets the displayed amount but stays paused until the\n * user resumes — it never silently restarts. The run state is read from the DOM,\n * not re-derived from the declarative `autostart` Value (which only governs the\n * initial state on connect); re-deriving it would override a user's pause, the same\n * anti-pattern the Turbo-lifecycle guide warns about.\n */\n reset(): void {\n const wasRunning = this.#state === \"running\";\n this.#teardownInterval();\n this.#initReference();\n const amount = this.#currentAmount();\n this.#render(amount);\n // A prior complete() may have written a completion message into the status live\n // region; clear it so a reset timer does not keep showing (and announcing to a\n // screen reader) the stale \"finished\" text.\n if (this.hasStatusTarget) this.statusTarget.textContent = \"\";\n // teardownInterval() leaves data-state untouched; drop any lingering \"running\" to a\n // resting \"paused\" so start() (a no-op while \"running\") can re-arm when we resume.\n this.element.setAttribute(\"data-state\", \"paused\");\n if (wasRunning && this.#isValidDeadline) {\n // Was counting down: re-arm and keep running from the reset amount.\n this.#pausedAmount = 0;\n this.start();\n } else {\n // Was paused/complete: hold at the reset amount and wait for the user to resume.\n // Remember the amount so resume() restores it instead of snapping the anchor to 0.\n this.#pausedAmount = amount;\n }\n }\n\n /** Schedules the repeating tick and marks the timer running. */\n #runInterval(): void {\n this.element.setAttribute(\"data-state\", \"running\");\n this.#intervalId = this.#intervals.set(() => this.#tick(), this.intervalValue);\n }\n\n /** Cancels the repeating tick, if any. */\n #teardownInterval(): void {\n if (this.#intervalId !== null) {\n this.#intervals.clear(this.#intervalId);\n this.#intervalId = null;\n }\n }\n\n /** Recomputes, renders, emits `tick`, and completes when a countdown hits 0. */\n #tick(): void {\n const amount = this.#currentAmount();\n this.#render(amount);\n // `remaining` is the displayed amount (time left when counting down, elapsed\n // when counting up); `direction` lets consumers disambiguate the two.\n this.dispatch(\"tick\", {\n detail: { remaining: amount, direction: this.#isDown ? \"down\" : \"up\" },\n });\n if (this.#isDown && this.#reference - Date.now() <= 0) {\n this.#complete();\n }\n }\n\n /** Stops at zero, marks completion, announces it, and emits `complete`. */\n #complete(): void {\n this.#teardownInterval();\n this.#render(0);\n this.element.setAttribute(\"data-state\", \"complete\");\n if (this.hasStatusTarget && this.completeLabelValue.length > 0) {\n this.statusTarget.textContent = this.completeLabelValue;\n }\n this.dispatch(\"complete\", { detail: {} });\n }\n\n /** Sets the time anchor from the `deadline` value. */\n #initReference(): void {\n this.#reference = Date.parse(this.deadlineValue);\n }\n\n /** Remaining (down) or elapsed (up) ms, never negative. */\n #currentAmount(): number {\n if (!this.#isValidDeadline) return 0;\n const now = Date.now();\n const raw = this.#isDown ? this.#reference - now : now - this.#reference;\n return Math.max(0, raw);\n }\n\n /** Writes the amount into the day/hour/minute/second slots. */\n #render(amount: number): void {\n const totalSeconds = Math.floor(amount / 1000);\n const days = Math.floor(totalSeconds / 86400);\n const hours = Math.floor((totalSeconds % 86400) / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const seconds = totalSeconds % 60;\n\n if (this.hasDaysTarget) this.daysTarget.textContent = String(days);\n if (this.hasHoursTarget) this.hoursTarget.textContent = this.#pad(hours);\n if (this.hasMinutesTarget) this.minutesTarget.textContent = this.#pad(minutes);\n if (this.hasSecondsTarget) this.secondsTarget.textContent = this.#pad(seconds);\n }\n\n /** Zero-pads a unit to two digits. */\n #pad(unit: number): string {\n return String(unit).padStart(2, \"0\");\n }\n\n get #isDown(): boolean {\n return this.directionValue !== \"up\";\n }\n\n get #isValidDeadline(): boolean {\n return !Number.isNaN(this.#reference);\n }\n\n /** Current lifecycle phase as reflected on `data-state`. */\n get #state(): string {\n return this.element.getAttribute(\"data-state\") ?? \"paused\";\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/countdown_controller.ts"],"names":[],"mappings":"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","file":"countdown_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeInterval } from \"../utils/safe_timeout\";\n\n/**\n * Headless countdown/timer behavior built on the live-region `role=\"timer\"`\n * practice (no dedicated APG pattern).\n *\n * Markup contract (identifier: `stimeo--countdown`):\n * <div data-controller=\"stimeo--countdown\" role=\"timer\" aria-live=\"off\"\n * data-stimeo--countdown-deadline-value=\"2026-12-31T23:59:59+09:00\"\n * data-action=\"countdown:pause->stimeo--countdown#pause\n * countdown:resume->stimeo--countdown#resume\">\n * <span data-stimeo--countdown-target=\"days\">0</span>\n * <span data-stimeo--countdown-target=\"hours\">00</span>\n * <span data-stimeo--countdown-target=\"minutes\">00</span>\n * <span data-stimeo--countdown-target=\"seconds\">00</span>\n * </div>\n *\n * Computes the time remaining to `deadline` (or elapsed since it, in\n * `direction=\"up\"`), formats it into the day/hour/minute/second slots, and ticks\n * on `interval`. `aria-live=\"off\"` is recommended so the timer is not announced\n * every second; only the milestone (completion) is surfaced — via the optional\n * `status` live region when a `completeLabel` is provided, or the `complete`\n * event for the consumer to handle.\n *\n * @remarks\n * Behavior only — slot text is updated, not styled. Pause/resume shifts an\n * internal time anchor so the displayed amount is preserved across a pause. The\n * interval is owned by {@link SafeInterval} and torn down on `disconnect()`\n * (Turbo navigation included).\n */\nexport class CountdownController extends Controller<HTMLElement> {\n static override targets = [\"days\", \"hours\", \"minutes\", \"seconds\", \"status\"];\n static override values = {\n deadline: { type: String, default: \"\" },\n interval: { type: Number, default: 1000 },\n direction: { type: String, default: \"down\" },\n autostart: { type: Boolean, default: true },\n completeLabel: { type: String, default: \"\" },\n };\n static actions = [\"pause\", \"reset\", \"resume\", \"start\"] as const;\n static events = [\"complete\", \"tick\"] as const;\n\n declare readonly daysTarget: HTMLElement;\n declare readonly hoursTarget: HTMLElement;\n declare readonly minutesTarget: HTMLElement;\n declare readonly secondsTarget: HTMLElement;\n declare readonly statusTarget: HTMLElement;\n declare readonly hasDaysTarget: boolean;\n declare readonly hasHoursTarget: boolean;\n declare readonly hasMinutesTarget: boolean;\n declare readonly hasSecondsTarget: boolean;\n declare readonly hasStatusTarget: boolean;\n\n declare deadlineValue: string;\n declare intervalValue: number;\n declare directionValue: string;\n declare autostartValue: boolean;\n declare completeLabelValue: string;\n\n readonly #intervals = new SafeInterval();\n #intervalId: number | null = null;\n\n /** Epoch-ms anchor: the deadline (down) or the count-up origin (up). */\n #reference = 0;\n /** Amount (ms) captured at pause, so resume can restore the same display. */\n #pausedAmount = 0;\n\n override connect(): void {\n this.#initReference();\n this.#render(this.#currentAmount());\n if (this.autostartValue && this.#isValidDeadline) {\n this.start();\n } else {\n this.element.setAttribute(\"data-state\", \"paused\");\n }\n }\n\n override disconnect(): void {\n this.#intervals.clearAll();\n this.#intervalId = null;\n }\n\n /** Starts (or restarts after pause) ticking toward the deadline. */\n start(): void {\n if (this.#state === \"running\" || !this.#isValidDeadline) return;\n if (this.#isDown && this.#currentAmount() <= 0) {\n this.#complete();\n return;\n }\n this.#runInterval();\n }\n\n /** Pauses ticking, preserving the currently displayed amount. */\n pause(): void {\n if (this.#state !== \"running\") return;\n this.#pausedAmount = this.#currentAmount();\n this.#teardownInterval();\n this.element.setAttribute(\"data-state\", \"paused\");\n }\n\n /** Resumes from a pause, continuing from the preserved amount. */\n resume(): void {\n if (this.#state !== \"paused\" || !this.#isValidDeadline) return;\n const now = Date.now();\n this.#reference = this.#isDown ? now + this.#pausedAmount : now - this.#pausedAmount;\n this.start();\n }\n\n /**\n * Re-syncs to the deadline and clears any pause offset, **preserving the current\n * run state**: a running timer keeps counting down from the reset amount, while a\n * paused (or completed) one resets the displayed amount but stays paused until the\n * user resumes — it never silently restarts. The run state is read from the DOM,\n * not re-derived from the declarative `autostart` Value (which only governs the\n * initial state on connect); re-deriving it would override a user's pause —\n * the DOM, not a re-run of declarative config, is the source of truth.\n */\n reset(): void {\n const wasRunning = this.#state === \"running\";\n this.#teardownInterval();\n this.#initReference();\n const amount = this.#currentAmount();\n this.#render(amount);\n // A prior complete() may have written a completion message into the status live\n // region; clear it so a reset timer does not keep showing (and announcing to a\n // screen reader) the stale \"finished\" text.\n if (this.hasStatusTarget) this.statusTarget.textContent = \"\";\n // teardownInterval() leaves data-state untouched; drop any lingering \"running\" to a\n // resting \"paused\" so start() (a no-op while \"running\") can re-arm when we resume.\n this.element.setAttribute(\"data-state\", \"paused\");\n if (wasRunning && this.#isValidDeadline) {\n // Was counting down: re-arm and keep running from the reset amount.\n this.#pausedAmount = 0;\n this.start();\n } else {\n // Was paused/complete: hold at the reset amount and wait for the user to resume.\n // Remember the amount so resume() restores it instead of snapping the anchor to 0.\n this.#pausedAmount = amount;\n }\n }\n\n /** Schedules the repeating tick and marks the timer running. */\n #runInterval(): void {\n this.element.setAttribute(\"data-state\", \"running\");\n this.#intervalId = this.#intervals.set(() => this.#tick(), this.intervalValue);\n }\n\n /** Cancels the repeating tick, if any. */\n #teardownInterval(): void {\n if (this.#intervalId !== null) {\n this.#intervals.clear(this.#intervalId);\n this.#intervalId = null;\n }\n }\n\n /** Recomputes, renders, emits `tick`, and completes when a countdown hits 0. */\n #tick(): void {\n const amount = this.#currentAmount();\n this.#render(amount);\n // `remaining` is the displayed amount (time left when counting down, elapsed\n // when counting up); `direction` lets consumers disambiguate the two.\n this.dispatch(\"tick\", {\n detail: { remaining: amount, direction: this.#isDown ? \"down\" : \"up\" },\n });\n if (this.#isDown && this.#reference - Date.now() <= 0) {\n this.#complete();\n }\n }\n\n /** Stops at zero, marks completion, announces it, and emits `complete`. */\n #complete(): void {\n this.#teardownInterval();\n this.#render(0);\n this.element.setAttribute(\"data-state\", \"complete\");\n if (this.hasStatusTarget && this.completeLabelValue.length > 0) {\n this.statusTarget.textContent = this.completeLabelValue;\n }\n this.dispatch(\"complete\", { detail: {} });\n }\n\n /** Sets the time anchor from the `deadline` value. */\n #initReference(): void {\n this.#reference = Date.parse(this.deadlineValue);\n }\n\n /** Remaining (down) or elapsed (up) ms, never negative. */\n #currentAmount(): number {\n if (!this.#isValidDeadline) return 0;\n const now = Date.now();\n const raw = this.#isDown ? this.#reference - now : now - this.#reference;\n return Math.max(0, raw);\n }\n\n /** Writes the amount into the day/hour/minute/second slots. */\n #render(amount: number): void {\n const totalSeconds = Math.floor(amount / 1000);\n const days = Math.floor(totalSeconds / 86400);\n const hours = Math.floor((totalSeconds % 86400) / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const seconds = totalSeconds % 60;\n\n if (this.hasDaysTarget) this.daysTarget.textContent = String(days);\n if (this.hasHoursTarget) this.hoursTarget.textContent = this.#pad(hours);\n if (this.hasMinutesTarget) this.minutesTarget.textContent = this.#pad(minutes);\n if (this.hasSecondsTarget) this.secondsTarget.textContent = this.#pad(seconds);\n }\n\n /** Zero-pads a unit to two digits. */\n #pad(unit: number): string {\n return String(unit).padStart(2, \"0\");\n }\n\n get #isDown(): boolean {\n return this.directionValue !== \"up\";\n }\n\n get #isValidDeadline(): boolean {\n return !Number.isNaN(this.#reference);\n }\n\n /** Current lifecycle phase as reflected on `data-state`. */\n get #state(): string {\n return this.element.getAttribute(\"data-state\") ?? \"paused\";\n }\n}\n"]}
|
|
@@ -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}. */
|
|
@@ -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. */
|