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,5 +1,150 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/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/menu_controller.ts
|
|
4
149
|
var MenuController = class extends Controller {
|
|
5
150
|
static targets = ["trigger", "menu", "item"];
|
|
@@ -11,13 +156,20 @@ var MenuController = class extends Controller {
|
|
|
11
156
|
"open",
|
|
12
157
|
"toggle"
|
|
13
158
|
];
|
|
14
|
-
|
|
159
|
+
#timers = new SafeTimeout();
|
|
160
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
161
|
+
#escapeLayer = new EscapeLayer();
|
|
162
|
+
/** Starts closed and registers delegated listeners. */
|
|
15
163
|
connect() {
|
|
16
164
|
this.close();
|
|
165
|
+
this.element.addEventListener("click", this.#onItemClickCapture, true);
|
|
17
166
|
document.addEventListener("click", this.#onOutsideClick);
|
|
18
167
|
}
|
|
19
|
-
/**
|
|
168
|
+
/** Releases the listeners, stack membership, and any pending Tab-close task. */
|
|
20
169
|
disconnect() {
|
|
170
|
+
this.#timers.clearAll();
|
|
171
|
+
this.#escapeLayer.deactivate();
|
|
172
|
+
this.element.removeEventListener("click", this.#onItemClickCapture, true);
|
|
21
173
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
22
174
|
}
|
|
23
175
|
/** Toggles the menu open/closed. Bound via `data-action` (click). */
|
|
@@ -31,12 +183,19 @@ var MenuController = class extends Controller {
|
|
|
31
183
|
}
|
|
32
184
|
/** Opens the menu and reflects the expanded state on the trigger. */
|
|
33
185
|
open() {
|
|
186
|
+
this.#timers.clearAll();
|
|
34
187
|
if (!this.hasMenuTarget) return;
|
|
188
|
+
this.#escapeLayer.activate(document, {
|
|
189
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
190
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
191
|
+
});
|
|
35
192
|
this.menuTarget.hidden = false;
|
|
36
193
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
37
194
|
}
|
|
38
195
|
/** Closes the menu and reflects the collapsed state on the trigger. */
|
|
39
196
|
close() {
|
|
197
|
+
this.#timers.clearAll();
|
|
198
|
+
this.#escapeLayer.deactivate();
|
|
40
199
|
if (!this.hasMenuTarget) return;
|
|
41
200
|
this.menuTarget.hidden = true;
|
|
42
201
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
@@ -81,18 +240,18 @@ var MenuController = class extends Controller {
|
|
|
81
240
|
event.preventDefault();
|
|
82
241
|
items[items.length - 1]?.focus();
|
|
83
242
|
break;
|
|
84
|
-
case "Escape":
|
|
85
|
-
event.preventDefault();
|
|
86
|
-
this.close();
|
|
87
|
-
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
88
|
-
break;
|
|
89
243
|
case "Tab":
|
|
90
|
-
this.
|
|
244
|
+
this.#timers.clearAll();
|
|
245
|
+
this.#timers.set(() => this.close(), 0);
|
|
91
246
|
break;
|
|
92
247
|
}
|
|
93
248
|
}
|
|
94
249
|
/** Closes the menu after an item is activated. Bound via `data-action`. */
|
|
95
250
|
activate() {
|
|
251
|
+
this.#closeAndRestore();
|
|
252
|
+
}
|
|
253
|
+
/** Closes and returns focus to the trigger (Escape / item-activation path). */
|
|
254
|
+
#closeAndRestore() {
|
|
96
255
|
this.close();
|
|
97
256
|
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
98
257
|
}
|
|
@@ -100,6 +259,20 @@ var MenuController = class extends Controller {
|
|
|
100
259
|
#onOutsideClick = (event) => {
|
|
101
260
|
if (this.#isOpen && !this.element.contains(event.target)) this.close();
|
|
102
261
|
};
|
|
262
|
+
/**
|
|
263
|
+
* Captures clicks so `aria-disabled` commands cannot reach consumer handlers.
|
|
264
|
+
* Native Enter/Space activation also synthesizes a click and is blocked here.
|
|
265
|
+
*/
|
|
266
|
+
#onItemClickCapture = (event) => {
|
|
267
|
+
const target = event.target;
|
|
268
|
+
if (!(target instanceof Node)) return;
|
|
269
|
+
const disabled = this.itemTargets.some(
|
|
270
|
+
(item) => item.getAttribute("aria-disabled") === "true" && item.contains(target)
|
|
271
|
+
);
|
|
272
|
+
if (!disabled) return;
|
|
273
|
+
event.preventDefault();
|
|
274
|
+
event.stopImmediatePropagation();
|
|
275
|
+
};
|
|
103
276
|
/** Moves focus to the first navigable item (no-op if none). */
|
|
104
277
|
#focusFirst() {
|
|
105
278
|
this.#navigableItems[0]?.focus();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/menu_controller.ts"],"names":[],"mappings":"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","file":"menu_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible menu button behavior.\n *\n * Markup contract (identifier: `stimeo--menu`):\n * <div data-controller=\"stimeo--menu\">\n * <button data-stimeo--menu-target=\"trigger\"\n * data-action=\"click->stimeo--menu#toggle\n * keydown->stimeo--menu#onTriggerKeydown\"\n * aria-haspopup=\"menu\" aria-expanded=\"false\" aria-controls=\"menu\">\n * Actions\n * </button>\n * <ul id=\"menu\" role=\"menu\" data-stimeo--menu-target=\"menu\" hidden>\n * <li role=\"none\">\n * <button role=\"menuitem\" tabindex=\"-1\"\n * data-stimeo--menu-target=\"item\"\n * data-action=\"click->stimeo--menu#activate\n * keydown->stimeo--menu#onItemKeydown\">…</button>\n * </li>\n * </ul>\n * </div>\n *\n * Implements the WAI-ARIA APG **Menu Button** pattern (a button that opens a menu\n * of commands). Unlike `stimeo--dropdown` (a disclosure for arbitrary content),\n * this is a true `role=\"menu\"` widget with roving focus across `role=\"menuitem\"`\n * children.\n *\n * @remarks\n * Behavior only — static placement is the consumer's responsibility (CSS), and\n * viewport-edge collision handling is intentionally out of scope (a future shared\n * positioning module will own it). State is exposed via `aria-expanded` and the\n * menu's `hidden` attribute.\n *\n * Behavior provided:\n * - Click the trigger to toggle; `ArrowDown`/`ArrowUp` open and focus the\n * first/last item.\n * - Within the menu, `ArrowDown`/`ArrowUp` move focus (wrapping), `Home`/`End`\n * jump to the first/last item, `Escape`/`Tab` close and return focus to the\n * trigger, and activating an item closes the menu.\n * - A click outside the controller closes the menu.\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--command-palette` / `stimeo--toolbar`).\n */\nexport class MenuController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"menu\", \"item\"];\n static actions = [\n \"activate\",\n \"close\",\n \"onItemKeydown\",\n \"onTriggerKeydown\",\n \"open\",\n \"toggle\",\n ] as const;\n\n declare readonly triggerTarget: HTMLButtonElement;\n declare readonly menuTarget: HTMLElement;\n declare readonly itemTargets: HTMLButtonElement[];\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasMenuTarget: boolean;\n\n /** Starts closed and registers the outside-click listener. */\n override connect(): void {\n this.close();\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 /** Toggles the menu open/closed. Bound via `data-action` (click). */\n toggle(): void {\n if (this.#isOpen) {\n this.close();\n } else {\n this.open();\n this.#focusFirst();\n }\n }\n\n /** Opens the menu and reflects the expanded state on the trigger. */\n open(): void {\n if (!this.hasMenuTarget) return;\n this.menuTarget.hidden = false;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n }\n\n /** Closes the menu and reflects the collapsed state on the trigger. */\n close(): void {\n if (!this.hasMenuTarget) return;\n this.menuTarget.hidden = true;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n }\n\n /**\n * Opens the menu with the keyboard per the APG (Down → first, Up → last).\n *\n * Enter/Space are intentionally not handled here: on a native `<button>`\n * trigger the browser turns them into a click, which already runs\n * {@link toggle} (open + focus first item). Handling them again here would\n * open and then immediately re-toggle the menu.\n */\n onTriggerKeydown(event: KeyboardEvent): void {\n if (event.key === \"ArrowDown\") {\n event.preventDefault();\n this.open();\n this.#focusFirst();\n } else if (event.key === \"ArrowUp\") {\n event.preventDefault();\n this.open();\n this.#focusLast();\n }\n }\n\n /** Implements 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.close();\n if (this.hasTriggerTarget) this.triggerTarget.focus();\n break;\n case \"Tab\":\n // Per APG, Tab closes the menu but focus moves on naturally (it is not\n // returned to the trigger — that is Escape's job).\n this.close();\n break;\n default:\n break;\n }\n }\n\n /** Closes the menu after an item is activated. Bound via `data-action`. */\n activate(): void {\n this.close();\n if (this.hasTriggerTarget) this.triggerTarget.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.close();\n };\n\n /** Moves focus to the first navigable item (no-op if none). */\n #focusFirst(): void {\n this.#navigableItems[0]?.focus();\n }\n\n /** Moves focus to the last navigable item (no-op if none). */\n #focusLast(): void {\n const items = this.#navigableItems;\n items[items.length - 1]?.focus();\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/menu_controller.ts"],"names":[],"mappings":"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","file":"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 menu button behavior.\n *\n * Markup contract (identifier: `stimeo--menu`):\n * <div data-controller=\"stimeo--menu\">\n * <button id=\"menu-trigger\" data-stimeo--menu-target=\"trigger\"\n * data-action=\"click->stimeo--menu#toggle\n * keydown->stimeo--menu#onTriggerKeydown\"\n * aria-haspopup=\"menu\" aria-expanded=\"false\" aria-controls=\"menu\">\n * Actions\n * </button>\n * <ul id=\"menu\" role=\"menu\" aria-labelledby=\"menu-trigger\"\n * data-stimeo--menu-target=\"menu\" hidden>\n * <li role=\"none\">\n * <button role=\"menuitem\" tabindex=\"-1\"\n * data-stimeo--menu-target=\"item\"\n * data-action=\"click->stimeo--menu#activate\n * keydown->stimeo--menu#onItemKeydown\">…</button>\n * </li>\n * </ul>\n * </div>\n *\n * Implements the WAI-ARIA APG **Menu Button** pattern (a button that opens a menu\n * of commands). Unlike `stimeo--dropdown` (a disclosure for arbitrary content),\n * this is a true `role=\"menu\"` widget with roving focus across `role=\"menuitem\"`\n * children.\n *\n * @remarks\n * Behavior only — the core controller owns no placement. Consumers can use static\n * CSS or compose the menu with the opt-in `stimeo-ui/positioning` entrypoint for\n * viewport-aware placement. State is exposed via `aria-expanded` and the menu's\n * `hidden` attribute.\n *\n * Behavior provided:\n * - Click the trigger to toggle; `ArrowDown`/`ArrowUp` open and focus the\n * first/last item.\n * - Within the menu, `ArrowDown`/`ArrowUp` move focus (wrapping), `Home`/`End`\n * jump to the first/last item, `Tab` lets the browser move focus first and\n * then closes on the next task, and activating an enabled item closes the menu.\n * - `Escape` closes and returns focus to the trigger. 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) and a newer layer\n * (e.g. a tooltip shown over an item) is dismissed first.\n * - A click outside the controller closes the menu without moving focus away\n * from the clicked element.\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--command-palette` / `stimeo--toolbar`).\n */\nexport class MenuController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"menu\", \"item\"];\n static actions = [\n \"activate\",\n \"close\",\n \"onItemKeydown\",\n \"onTriggerKeydown\",\n \"open\",\n \"toggle\",\n ] as const;\n\n declare readonly triggerTarget: HTMLButtonElement;\n declare readonly menuTarget: HTMLElement;\n declare readonly itemTargets: HTMLButtonElement[];\n declare readonly hasTriggerTarget: 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 listeners. */\n override connect(): void {\n this.close();\n this.element.addEventListener(\"click\", this.#onItemClickCapture, true);\n document.addEventListener(\"click\", this.#onOutsideClick);\n }\n\n /** Releases the listeners, stack membership, and any 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.#onOutsideClick);\n }\n\n /** Toggles the menu open/closed. Bound via `data-action` (click). */\n toggle(): void {\n if (this.#isOpen) {\n this.close();\n } else {\n this.open();\n this.#focusFirst();\n }\n }\n\n /** Opens the menu and reflects the expanded state on the trigger. */\n open(): void {\n // A reopen must discard a pending Tab close, or the stale task would slam\n // the freshly opened menu shut on the next tick.\n this.#timers.clearAll();\n if (!this.hasMenuTarget) return;\n this.#escapeLayer.activate(document, {\n onDismiss: () => this.#closeAndRestore(),\n claims: claimsWhileFocusWithin(this.element),\n });\n this.menuTarget.hidden = false;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n }\n\n /** Closes the menu and reflects the collapsed state on the trigger. */\n close(): void {\n this.#timers.clearAll();\n this.#escapeLayer.deactivate();\n if (!this.hasMenuTarget) return;\n this.menuTarget.hidden = true;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n }\n\n /**\n * Opens the menu with the keyboard per the APG (Down → first, Up → last).\n *\n * Enter/Space are intentionally not handled here: on a native `<button>`\n * trigger the browser turns them into a click, which already runs\n * {@link toggle} (open + focus first item). Handling them again here would\n * open and then immediately re-toggle the menu.\n */\n onTriggerKeydown(event: KeyboardEvent): void {\n if (event.key === \"ArrowDown\") {\n event.preventDefault();\n this.open();\n this.#focusFirst();\n } else if (event.key === \"ArrowUp\") {\n event.preventDefault();\n this.open();\n this.#focusLast();\n }\n }\n\n /** Implements 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 \"Tab\":\n // Per APG, Tab closes the menu but focus moves on naturally (it is not\n // returned to the trigger — that is Escape's job). Closing synchronously\n // would remove the focused item before the browser's default Tab action,\n // which can restart traversal at the document head, so the close is\n // deferred to the next task.\n this.#timers.clearAll();\n this.#timers.set(() => this.close(), 0);\n break;\n default:\n break;\n }\n }\n\n /** Closes the menu after an item is activated. Bound via `data-action`. */\n activate(): void {\n this.#closeAndRestore();\n }\n\n /** Closes and returns focus to the trigger (Escape / item-activation path). */\n #closeAndRestore(): void {\n this.close();\n if (this.hasTriggerTarget) this.triggerTarget.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.close();\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 /** Moves focus to the first navigable item (no-op if none). */\n #focusFirst(): void {\n this.#navigableItems[0]?.focus();\n }\n\n /** Moves focus to the last navigable item (no-op if none). */\n #focusLast(): void {\n const items = this.#navigableItems;\n items[items.length - 1]?.focus();\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,5 +1,54 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/otp_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/composition_tracker.ts
|
|
6
|
+
var CompositionTracker = class {
|
|
7
|
+
#observedTargets = /* @__PURE__ */ new Set();
|
|
8
|
+
#activeTargets = /* @__PURE__ */ new Set();
|
|
9
|
+
#onStart;
|
|
10
|
+
#onEnd;
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.#onStart = options.onStart;
|
|
13
|
+
this.#onEnd = options.onEnd;
|
|
14
|
+
}
|
|
15
|
+
/** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
|
|
16
|
+
observe(target) {
|
|
17
|
+
if (this.#observedTargets.has(target)) return;
|
|
18
|
+
target.addEventListener("compositionstart", this.#handleStart);
|
|
19
|
+
target.addEventListener("compositionend", this.#handleEnd);
|
|
20
|
+
this.#observedTargets.add(target);
|
|
21
|
+
}
|
|
22
|
+
/** Stops tracking one target and clears any active composition it owned. */
|
|
23
|
+
unobserve(target) {
|
|
24
|
+
if (!this.#observedTargets.delete(target)) return;
|
|
25
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
26
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
27
|
+
this.#activeTargets.delete(target);
|
|
28
|
+
}
|
|
29
|
+
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
30
|
+
disconnect() {
|
|
31
|
+
for (const target of this.#observedTargets) {
|
|
32
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
33
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
34
|
+
}
|
|
35
|
+
this.#observedTargets.clear();
|
|
36
|
+
this.#activeTargets.clear();
|
|
37
|
+
}
|
|
38
|
+
/** True when lifecycle tracking or the current event reports composition. */
|
|
39
|
+
isComposing(event) {
|
|
40
|
+
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
41
|
+
}
|
|
42
|
+
#handleStart = (event) => {
|
|
43
|
+
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
44
|
+
this.#onStart?.(event);
|
|
45
|
+
};
|
|
46
|
+
#handleEnd = (event) => {
|
|
47
|
+
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
48
|
+
this.#onEnd?.(event);
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
3
52
|
// src/controllers/otp_controller.ts
|
|
4
53
|
var OtpController = class extends Controller {
|
|
5
54
|
static targets = ["field", "value", "error"];
|
|
@@ -9,21 +58,24 @@ var OtpController = class extends Controller {
|
|
|
9
58
|
};
|
|
10
59
|
static actions = ["onInput", "onKeydown", "onPaste"];
|
|
11
60
|
static events = ["change", "complete", "invalid"];
|
|
12
|
-
|
|
61
|
+
/** Owns IME lifecycle state across every digit field. */
|
|
62
|
+
#composition = new CompositionTracker({
|
|
63
|
+
onEnd: (event) => {
|
|
64
|
+
const input = event.currentTarget;
|
|
65
|
+
if (input) this.#handleInputValidation(input);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
13
68
|
connect() {
|
|
14
69
|
for (const field of this.fieldTargets) {
|
|
15
70
|
field.addEventListener("focus", this.#onFieldFocus);
|
|
16
|
-
|
|
17
|
-
field.addEventListener("compositionend", this.#onCompositionEnd);
|
|
71
|
+
this.#composition.observe(field);
|
|
18
72
|
}
|
|
19
73
|
}
|
|
20
74
|
disconnect() {
|
|
21
75
|
for (const field of this.fieldTargets) {
|
|
22
76
|
field.removeEventListener("focus", this.#onFieldFocus);
|
|
23
|
-
field.removeEventListener("compositionstart", this.#onCompositionStart);
|
|
24
|
-
field.removeEventListener("compositionend", this.#onCompositionEnd);
|
|
25
77
|
}
|
|
26
|
-
this.#
|
|
78
|
+
this.#composition.disconnect();
|
|
27
79
|
}
|
|
28
80
|
/**
|
|
29
81
|
* Stimulus lifecycle callback when a new field target enters the DOM.
|
|
@@ -31,21 +83,18 @@ var OtpController = class extends Controller {
|
|
|
31
83
|
*/
|
|
32
84
|
fieldTargetConnected(element) {
|
|
33
85
|
element.addEventListener("focus", this.#onFieldFocus);
|
|
34
|
-
|
|
35
|
-
element.addEventListener("compositionend", this.#onCompositionEnd);
|
|
86
|
+
this.#composition.observe(element);
|
|
36
87
|
}
|
|
37
88
|
/** Removes focus listeners when fields are dropped. */
|
|
38
89
|
fieldTargetDisconnected(element) {
|
|
39
90
|
element.removeEventListener("focus", this.#onFieldFocus);
|
|
40
|
-
|
|
41
|
-
element.removeEventListener("compositionend", this.#onCompositionEnd);
|
|
42
|
-
this.#isComposing.delete(element);
|
|
91
|
+
this.#composition.unobserve(element);
|
|
43
92
|
}
|
|
44
93
|
/** Handles keystroke inputs and advances focus to the next field. */
|
|
45
94
|
onInput(event) {
|
|
46
95
|
const input = event.currentTarget;
|
|
47
96
|
if (!input) return;
|
|
48
|
-
if (this.#isComposing
|
|
97
|
+
if (this.#composition.isComposing(event)) return;
|
|
49
98
|
this.#handleInputValidation(input);
|
|
50
99
|
}
|
|
51
100
|
/** Handles Backspace retreating, arrows, and home/end navigation. */
|
|
@@ -54,7 +103,7 @@ var OtpController = class extends Controller {
|
|
|
54
103
|
if (!input) return;
|
|
55
104
|
const index = this.fieldTargets.indexOf(input);
|
|
56
105
|
if (index === -1) return;
|
|
57
|
-
if (this.#isComposing
|
|
106
|
+
if (this.#composition.isComposing(event)) return;
|
|
58
107
|
switch (event.key) {
|
|
59
108
|
case "Backspace":
|
|
60
109
|
if (!input.value) {
|
|
@@ -136,15 +185,6 @@ var OtpController = class extends Controller {
|
|
|
136
185
|
input.select();
|
|
137
186
|
}
|
|
138
187
|
};
|
|
139
|
-
#onCompositionStart = (event) => {
|
|
140
|
-
const input = event.currentTarget;
|
|
141
|
-
this.#isComposing.set(input, true);
|
|
142
|
-
};
|
|
143
|
-
#onCompositionEnd = (event) => {
|
|
144
|
-
const input = event.currentTarget;
|
|
145
|
-
this.#isComposing.set(input, false);
|
|
146
|
-
this.#handleInputValidation(input);
|
|
147
|
-
};
|
|
148
188
|
#handleInputValidation(input) {
|
|
149
189
|
const index = this.fieldTargets.indexOf(input);
|
|
150
190
|
if (index === -1) return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/otp_controller.ts"],"names":[],"mappings":"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","file":"otp_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless, accessible One-Time Password / PIN input logic.\n *\n * Markup contract (identifier: `stimeo--otp`):\n * <div data-controller=\"stimeo--otp\"\n * data-stimeo--otp-length-value=\"6\"\n * data-stimeo--otp-pattern-value=\"[0-9]\"\n * role=\"group\" aria-label=\"One-time passcode\">\n * <input data-stimeo--otp-target=\"field\" inputmode=\"numeric\" maxlength=\"1\"\n * data-action=\"input->stimeo--otp#onInput\n * keydown->stimeo--otp#onKeydown\n * paste->stimeo--otp#onPaste\" />\n * <input type=\"hidden\" data-stimeo--otp-target=\"value\" />\n * </div>\n *\n * Implements highly polished UX conventions for single-digit field groups:\n * - Dynamic autofocus advancement upon valid inputs.\n * - Auto-selection `select()` upon gaining focus to enable frictionless digit overwrites.\n * - Retroactive Backspace key focus stepping to clear preceding values safely.\n * - Clipboard intercepting paste division across fields with trailing focus positioning.\n * - Hidden form sync and complete/change event dispatching.\n *\n * @remarks\n * Behavior only. The controller binds events dynamically to pre-rendered inputs,\n * updates `data-filled` and hidden value bindings, and emits Stimulus custom events.\n */\nexport class OtpController extends Controller<HTMLElement> {\n static override targets = [\"field\", \"value\", \"error\"];\n static override values = {\n length: { type: Number, default: 6 },\n pattern: { type: String, default: \"[0-9]\" },\n };\n static actions = [\"onInput\", \"onKeydown\", \"onPaste\"] as const;\n static events = [\"change\", \"complete\", \"invalid\"] as const;\n\n declare readonly fieldTargets: HTMLInputElement[];\n declare readonly valueTarget: HTMLInputElement;\n declare readonly errorTarget: HTMLElement;\n declare readonly hasValueTarget: boolean;\n declare readonly hasErrorTarget: boolean;\n\n declare lengthValue: number;\n declare patternValue: string;\n\n #isComposing = new Map<HTMLInputElement, boolean>();\n\n override connect(): void {\n // Dynamically bind auto-selection and IME tracking logic to all connected inputs\n for (const field of this.fieldTargets) {\n field.addEventListener(\"focus\", this.#onFieldFocus);\n field.addEventListener(\"compositionstart\", this.#onCompositionStart);\n field.addEventListener(\"compositionend\", this.#onCompositionEnd);\n }\n }\n\n override disconnect(): void {\n for (const field of this.fieldTargets) {\n field.removeEventListener(\"focus\", this.#onFieldFocus);\n field.removeEventListener(\"compositionstart\", this.#onCompositionStart);\n field.removeEventListener(\"compositionend\", this.#onCompositionEnd);\n }\n this.#isComposing.clear();\n }\n\n /**\n * Stimulus lifecycle callback when a new field target enters the DOM.\n * Ensures new additions are also wired with overwriting support.\n */\n fieldTargetConnected(element: HTMLInputElement): void {\n element.addEventListener(\"focus\", this.#onFieldFocus);\n element.addEventListener(\"compositionstart\", this.#onCompositionStart);\n element.addEventListener(\"compositionend\", this.#onCompositionEnd);\n }\n\n /** Removes focus listeners when fields are dropped. */\n fieldTargetDisconnected(element: HTMLInputElement): void {\n element.removeEventListener(\"focus\", this.#onFieldFocus);\n element.removeEventListener(\"compositionstart\", this.#onCompositionStart);\n element.removeEventListener(\"compositionend\", this.#onCompositionEnd);\n this.#isComposing.delete(element);\n }\n\n /** Handles keystroke inputs and advances focus to the next field. */\n onInput(event: Event): void {\n const input = event.currentTarget as HTMLInputElement | null;\n if (!input) return;\n\n // Guard during active composition to prevent premature focus switching\n if (this.#isComposing.get(input)) return;\n\n this.#handleInputValidation(input);\n }\n\n /** Handles Backspace retreating, arrows, and home/end navigation. */\n onKeydown(event: KeyboardEvent): void {\n const input = event.currentTarget as HTMLInputElement | null;\n if (!input) return;\n\n const index = this.fieldTargets.indexOf(input);\n if (index === -1) return;\n\n // Do not trigger keydown actions during composition\n if (this.#isComposing.get(input)) return;\n\n switch (event.key) {\n case \"Backspace\":\n if (!input.value) {\n // Empty field: step backward, wipe previous digit, and focus it\n if (index > 0) {\n event.preventDefault();\n const prevField = this.fieldTargets[index - 1];\n if (prevField) {\n prevField.value = \"\";\n prevField.removeAttribute(\"data-filled\");\n prevField.focus();\n this.#clearError();\n this.#syncAndDispatch();\n }\n }\n } else {\n // Filled field: clear current value\n input.value = \"\";\n input.removeAttribute(\"data-filled\");\n this.#clearError();\n this.#syncAndDispatch();\n }\n break;\n\n case \"ArrowLeft\":\n if (index > 0) {\n event.preventDefault();\n this.fieldTargets[index - 1]?.focus();\n }\n break;\n\n case \"ArrowRight\":\n if (index < this.lengthValue - 1) {\n event.preventDefault();\n this.fieldTargets[index + 1]?.focus();\n }\n break;\n\n case \"Home\":\n event.preventDefault();\n this.fieldTargets[0]?.focus();\n break;\n\n case \"End\":\n event.preventDefault();\n this.fieldTargets[this.lengthValue - 1]?.focus();\n break;\n\n default:\n break;\n }\n }\n\n /** Divides pasted string characters across available input fields. */\n onPaste(event: ClipboardEvent): void {\n const input = event.currentTarget as HTMLInputElement | null;\n if (!input) return;\n\n const startIndex = this.fieldTargets.indexOf(input);\n if (startIndex === -1) return;\n\n event.preventDefault();\n\n const rawText = event.clipboardData?.getData(\"text\") || \"\";\n const text = this.#normalizeValue(rawText);\n const regex = new RegExp(`^${this.patternValue}$`);\n const validChars = Array.from(text).filter((char) => regex.test(char));\n\n if (validChars.length === 0) {\n this.#showError();\n return;\n }\n\n const limit = Math.min(validChars.length, this.lengthValue - startIndex);\n let lastFocusedIndex = startIndex;\n\n for (let i = 0; i < limit; i++) {\n const fieldIndex = startIndex + i;\n const field = this.fieldTargets[fieldIndex];\n const char = validChars[i];\n\n if (field && char) {\n field.value = char;\n field.setAttribute(\"data-filled\", \"true\");\n lastFocusedIndex = fieldIndex;\n }\n }\n\n this.#clearError();\n\n // Set trailing focus position to the last filled box (or next empty if one exists)\n const focusTargetIndex =\n lastFocusedIndex < this.lengthValue - 1 ? lastFocusedIndex + 1 : lastFocusedIndex;\n this.fieldTargets[focusTargetIndex]?.focus();\n\n this.#syncAndDispatch();\n }\n\n readonly #onFieldFocus = (event: FocusEvent): void => {\n const input = event.currentTarget as HTMLInputElement | null;\n if (input) {\n // Auto-selection enables effortless character overwrites\n input.select();\n }\n };\n\n readonly #onCompositionStart = (event: CompositionEvent): void => {\n const input = event.currentTarget as HTMLInputElement;\n this.#isComposing.set(input, true);\n };\n\n readonly #onCompositionEnd = (event: CompositionEvent): void => {\n const input = event.currentTarget as HTMLInputElement;\n this.#isComposing.set(input, false);\n\n // Force validation after IME conversion finishes\n this.#handleInputValidation(input);\n };\n\n #handleInputValidation(input: HTMLInputElement): void {\n const index = this.fieldTargets.indexOf(input);\n if (index === -1) return;\n\n const rawValue = input.value;\n const normalized = this.#normalizeValue(rawValue);\n const regex = new RegExp(`^${this.patternValue}$`);\n\n if (normalized && regex.test(normalized)) {\n input.value = normalized;\n input.setAttribute(\"data-filled\", \"true\");\n this.#clearError();\n\n // Step focus forward\n if (index < this.lengthValue - 1) {\n const nextField = this.fieldTargets[index + 1];\n nextField?.focus();\n }\n } else if (normalized) {\n input.value = \"\";\n input.removeAttribute(\"data-filled\");\n this.#showError();\n } else {\n input.removeAttribute(\"data-filled\");\n }\n\n this.#syncAndDispatch();\n }\n\n #normalizeValue(val: string): string {\n // Replaces full-width numbers (0-9) with standard half-width ones (0-9)\n return val.replace(/[0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 0xfee0));\n }\n\n #showError(): void {\n if (this.hasErrorTarget) {\n this.errorTarget.removeAttribute(\"hidden\");\n }\n // Behavior only: emit a neutral payload and let the consumer compose/localize\n // the user-facing message. The pattern is reported so consumers can word it.\n this.dispatch(\"invalid\", { detail: { pattern: this.patternValue } });\n }\n\n #clearError(): void {\n if (this.hasErrorTarget) {\n this.errorTarget.setAttribute(\"hidden\", \"true\");\n }\n }\n\n #syncAndDispatch(): void {\n const fields = this.fieldTargets;\n const combinedValue = fields.map((f) => f.value).join(\"\");\n\n if (this.hasValueTarget) {\n this.valueTarget.value = combinedValue;\n }\n\n this.dispatch(\"change\", { detail: { value: combinedValue } });\n\n // Completed state when all fields contain a digit\n const isCompleted =\n fields.every((f) => f.value.length > 0) && combinedValue.length === this.lengthValue;\n if (isCompleted) {\n this.dispatch(\"complete\", { detail: { value: combinedValue } });\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/composition_tracker.ts","../../src/controllers/otp_controller.ts"],"names":[],"mappings":"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","file":"otp_controller.js","sourcesContent":["/** Minimal event shape carrying the platform's per-event composition signal. */\nexport interface CompositionSignal {\n readonly isComposing?: boolean;\n}\n\n/** Hooks that keep component-specific work outside {@link CompositionTracker}. */\nexport interface CompositionTrackerOptions {\n /** Runs after lifecycle state is set for a `compositionstart` event. */\n readonly onStart?: (event: Event) => void;\n /** Runs after lifecycle state is cleared for a `compositionend` event. */\n readonly onEnd?: (event: Event) => void;\n}\n\n/**\n * Owns IME composition listeners and transient state for one or more event targets.\n *\n * Some browsers omit `KeyboardEvent.isComposing` on the keydown that confirms a\n * conversion. Tracking `compositionstart` through `compositionend` supplies the\n * missing lifecycle signal without relying on deprecated numeric key codes.\n * Component policy stays with the consumer: filtering, validation, and submission\n * belong in the optional {@link CompositionTrackerOptions.onEnd} callback.\n *\n * @example\n * ```ts\n * #composition = new CompositionTracker({ onEnd: () => this.filter() });\n *\n * connect(): void {\n * this.#composition.observe(this.inputTarget);\n * }\n *\n * disconnect(): void {\n * this.#composition.disconnect();\n * }\n *\n * onKeydown(event: KeyboardEvent): void {\n * if (this.#composition.isComposing(event)) return;\n * }\n * ```\n */\nexport class CompositionTracker {\n readonly #observedTargets = new Set<EventTarget>();\n readonly #activeTargets = new Set<EventTarget>();\n readonly #onStart: ((event: Event) => void) | undefined;\n readonly #onEnd: ((event: Event) => void) | undefined;\n\n constructor(options: CompositionTrackerOptions = {}) {\n this.#onStart = options.onStart;\n this.#onEnd = options.onEnd;\n }\n\n /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */\n observe(target: EventTarget): void {\n if (this.#observedTargets.has(target)) return;\n target.addEventListener(\"compositionstart\", this.#handleStart);\n target.addEventListener(\"compositionend\", this.#handleEnd);\n this.#observedTargets.add(target);\n }\n\n /** Stops tracking one target and clears any active composition it owned. */\n unobserve(target: EventTarget): void {\n if (!this.#observedTargets.delete(target)) return;\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n this.#activeTargets.delete(target);\n }\n\n /** Releases every listener and clears state so reconnect starts cleanly. */\n disconnect(): void {\n for (const target of this.#observedTargets) {\n target.removeEventListener(\"compositionstart\", this.#handleStart);\n target.removeEventListener(\"compositionend\", this.#handleEnd);\n }\n this.#observedTargets.clear();\n this.#activeTargets.clear();\n }\n\n /** True when lifecycle tracking or the current event reports composition. */\n isComposing(event?: CompositionSignal): boolean {\n return this.#activeTargets.size > 0 || event?.isComposing === true;\n }\n\n readonly #handleStart = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.add(event.currentTarget);\n this.#onStart?.(event);\n };\n\n readonly #handleEnd = (event: Event): void => {\n if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);\n this.#onEnd?.(event);\n };\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { CompositionTracker } from \"../utils/composition_tracker\";\n\n/**\n * Headless, accessible One-Time Password / PIN input logic.\n *\n * Markup contract (identifier: `stimeo--otp`):\n * <div data-controller=\"stimeo--otp\"\n * data-stimeo--otp-length-value=\"6\"\n * data-stimeo--otp-pattern-value=\"[0-9]\"\n * role=\"group\" aria-label=\"One-time passcode\">\n * <input data-stimeo--otp-target=\"field\" inputmode=\"numeric\" maxlength=\"1\"\n * data-action=\"input->stimeo--otp#onInput\n * keydown->stimeo--otp#onKeydown\n * paste->stimeo--otp#onPaste\" />\n * <input type=\"hidden\" data-stimeo--otp-target=\"value\" />\n * </div>\n *\n * Implements highly polished UX conventions for single-digit field groups:\n * - Dynamic autofocus advancement upon valid inputs.\n * - Auto-selection `select()` upon gaining focus to enable frictionless digit overwrites.\n * - Retroactive Backspace key focus stepping to clear preceding values safely.\n * - Clipboard intercepting paste division across fields with trailing focus positioning.\n * - Hidden form sync and complete/change event dispatching.\n *\n * @remarks\n * Behavior only. The controller binds events dynamically to pre-rendered inputs,\n * updates `data-filled` and hidden value bindings, and emits Stimulus custom events.\n */\nexport class OtpController extends Controller<HTMLElement> {\n static override targets = [\"field\", \"value\", \"error\"];\n static override values = {\n length: { type: Number, default: 6 },\n pattern: { type: String, default: \"[0-9]\" },\n };\n static actions = [\"onInput\", \"onKeydown\", \"onPaste\"] as const;\n static events = [\"change\", \"complete\", \"invalid\"] as const;\n\n declare readonly fieldTargets: HTMLInputElement[];\n declare readonly valueTarget: HTMLInputElement;\n declare readonly errorTarget: HTMLElement;\n declare readonly hasValueTarget: boolean;\n declare readonly hasErrorTarget: boolean;\n\n declare lengthValue: number;\n declare patternValue: string;\n\n /** Owns IME lifecycle state across every digit field. */\n readonly #composition = new CompositionTracker({\n onEnd: (event) => {\n const input = event.currentTarget as HTMLInputElement | null;\n if (input) this.#handleInputValidation(input);\n },\n });\n\n override connect(): void {\n // Dynamically bind auto-selection and IME tracking logic to all connected inputs\n for (const field of this.fieldTargets) {\n field.addEventListener(\"focus\", this.#onFieldFocus);\n this.#composition.observe(field);\n }\n }\n\n override disconnect(): void {\n for (const field of this.fieldTargets) {\n field.removeEventListener(\"focus\", this.#onFieldFocus);\n }\n this.#composition.disconnect();\n }\n\n /**\n * Stimulus lifecycle callback when a new field target enters the DOM.\n * Ensures new additions are also wired with overwriting support.\n */\n fieldTargetConnected(element: HTMLInputElement): void {\n element.addEventListener(\"focus\", this.#onFieldFocus);\n this.#composition.observe(element);\n }\n\n /** Removes focus listeners when fields are dropped. */\n fieldTargetDisconnected(element: HTMLInputElement): void {\n element.removeEventListener(\"focus\", this.#onFieldFocus);\n this.#composition.unobserve(element);\n }\n\n /** Handles keystroke inputs and advances focus to the next field. */\n onInput(event: Event): void {\n const input = event.currentTarget as HTMLInputElement | null;\n if (!input) return;\n\n // Guard during active composition to prevent premature focus switching\n if (this.#composition.isComposing(event as InputEvent)) return;\n\n this.#handleInputValidation(input);\n }\n\n /** Handles Backspace retreating, arrows, and home/end navigation. */\n onKeydown(event: KeyboardEvent): void {\n const input = event.currentTarget as HTMLInputElement | null;\n if (!input) return;\n\n const index = this.fieldTargets.indexOf(input);\n if (index === -1) return;\n\n // Do not trigger keydown actions during composition\n if (this.#composition.isComposing(event)) return;\n\n switch (event.key) {\n case \"Backspace\":\n if (!input.value) {\n // Empty field: step backward, wipe previous digit, and focus it\n if (index > 0) {\n event.preventDefault();\n const prevField = this.fieldTargets[index - 1];\n if (prevField) {\n prevField.value = \"\";\n prevField.removeAttribute(\"data-filled\");\n prevField.focus();\n this.#clearError();\n this.#syncAndDispatch();\n }\n }\n } else {\n // Filled field: clear current value\n input.value = \"\";\n input.removeAttribute(\"data-filled\");\n this.#clearError();\n this.#syncAndDispatch();\n }\n break;\n\n case \"ArrowLeft\":\n if (index > 0) {\n event.preventDefault();\n this.fieldTargets[index - 1]?.focus();\n }\n break;\n\n case \"ArrowRight\":\n if (index < this.lengthValue - 1) {\n event.preventDefault();\n this.fieldTargets[index + 1]?.focus();\n }\n break;\n\n case \"Home\":\n event.preventDefault();\n this.fieldTargets[0]?.focus();\n break;\n\n case \"End\":\n event.preventDefault();\n this.fieldTargets[this.lengthValue - 1]?.focus();\n break;\n\n default:\n break;\n }\n }\n\n /** Divides pasted string characters across available input fields. */\n onPaste(event: ClipboardEvent): void {\n const input = event.currentTarget as HTMLInputElement | null;\n if (!input) return;\n\n const startIndex = this.fieldTargets.indexOf(input);\n if (startIndex === -1) return;\n\n event.preventDefault();\n\n const rawText = event.clipboardData?.getData(\"text\") || \"\";\n const text = this.#normalizeValue(rawText);\n const regex = new RegExp(`^${this.patternValue}$`);\n const validChars = Array.from(text).filter((char) => regex.test(char));\n\n if (validChars.length === 0) {\n this.#showError();\n return;\n }\n\n const limit = Math.min(validChars.length, this.lengthValue - startIndex);\n let lastFocusedIndex = startIndex;\n\n for (let i = 0; i < limit; i++) {\n const fieldIndex = startIndex + i;\n const field = this.fieldTargets[fieldIndex];\n const char = validChars[i];\n\n if (field && char) {\n field.value = char;\n field.setAttribute(\"data-filled\", \"true\");\n lastFocusedIndex = fieldIndex;\n }\n }\n\n this.#clearError();\n\n // Set trailing focus position to the last filled box (or next empty if one exists)\n const focusTargetIndex =\n lastFocusedIndex < this.lengthValue - 1 ? lastFocusedIndex + 1 : lastFocusedIndex;\n this.fieldTargets[focusTargetIndex]?.focus();\n\n this.#syncAndDispatch();\n }\n\n readonly #onFieldFocus = (event: FocusEvent): void => {\n const input = event.currentTarget as HTMLInputElement | null;\n if (input) {\n // Auto-selection enables effortless character overwrites\n input.select();\n }\n };\n\n #handleInputValidation(input: HTMLInputElement): void {\n const index = this.fieldTargets.indexOf(input);\n if (index === -1) return;\n\n const rawValue = input.value;\n const normalized = this.#normalizeValue(rawValue);\n const regex = new RegExp(`^${this.patternValue}$`);\n\n if (normalized && regex.test(normalized)) {\n input.value = normalized;\n input.setAttribute(\"data-filled\", \"true\");\n this.#clearError();\n\n // Step focus forward\n if (index < this.lengthValue - 1) {\n const nextField = this.fieldTargets[index + 1];\n nextField?.focus();\n }\n } else if (normalized) {\n input.value = \"\";\n input.removeAttribute(\"data-filled\");\n this.#showError();\n } else {\n input.removeAttribute(\"data-filled\");\n }\n\n this.#syncAndDispatch();\n }\n\n #normalizeValue(val: string): string {\n // Replaces full-width numbers (0-9) with standard half-width ones (0-9)\n return val.replace(/[0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 0xfee0));\n }\n\n #showError(): void {\n if (this.hasErrorTarget) {\n this.errorTarget.removeAttribute(\"hidden\");\n }\n // Behavior only: emit a neutral payload and let the consumer compose/localize\n // the user-facing message. The pattern is reported so consumers can word it.\n this.dispatch(\"invalid\", { detail: { pattern: this.patternValue } });\n }\n\n #clearError(): void {\n if (this.hasErrorTarget) {\n this.errorTarget.setAttribute(\"hidden\", \"true\");\n }\n }\n\n #syncAndDispatch(): void {\n const fields = this.fieldTargets;\n const combinedValue = fields.map((f) => f.value).join(\"\");\n\n if (this.hasValueTarget) {\n this.valueTarget.value = combinedValue;\n }\n\n this.dispatch(\"change\", { detail: { value: combinedValue } });\n\n // Completed state when all fields contain a digit\n const isCompleted =\n fields.every((f) => f.value.length > 0) && combinedValue.length === this.lengthValue;\n if (isCompleted) {\n this.dispatch(\"complete\", { detail: { value: combinedValue } });\n }\n }\n}\n"]}
|
|
@@ -12,8 +12,9 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
12
12
|
* <a data-priority="1">Save</a><a data-priority="2">Duplicate</a><a>Delete</a>
|
|
13
13
|
* </div>
|
|
14
14
|
* <div data-controller="stimeo--menu" data-stimeo--overflow-menu-target="more" hidden>
|
|
15
|
-
* <button data-stimeo--menu-target="trigger">More</button>
|
|
16
|
-
* <ul role="menu"
|
|
15
|
+
* <button id="more-trigger" data-stimeo--menu-target="trigger">More</button>
|
|
16
|
+
* <ul role="menu" aria-labelledby="more-trigger"
|
|
17
|
+
* data-stimeo--menu-target="menu" hidden></ul>
|
|
17
18
|
* </div>
|
|
18
19
|
* </div>
|
|
19
20
|
*
|