stimeo-ui 0.1.0.pre.beta.3 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +149 -0
- data/README.md +6 -6
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/auto_submit_controller.js +56 -21
- data/dist/controllers/carousel_controller.js +272 -0
- data/dist/controllers/character_counter_controller.js +52 -15
- data/dist/controllers/clipboard_controller.js +144 -0
- data/dist/controllers/collapsible_controller.js +327 -0
- data/dist/controllers/color_picker_controller.js +213 -0
- data/dist/controllers/combobox_controller.js +64 -3
- data/dist/controllers/command_palette_controller.js +243 -39
- data/dist/controllers/confirm_controller.js +94 -8
- data/dist/controllers/context_menu_controller.js +191 -13
- data/dist/controllers/count_up_controller.js +8 -1
- data/dist/controllers/countdown_controller.js +2 -2
- data/dist/controllers/currency_input_controller.js +147 -0
- data/dist/controllers/data_grid_controller.js +168 -0
- data/dist/controllers/date_range_picker_controller.js +417 -0
- data/dist/controllers/dialog_controller.js +94 -8
- data/dist/controllers/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- data/dist/controllers/dropdown_controller.js +139 -16
- data/dist/controllers/editable_controller.js +168 -0
- data/dist/controllers/file_dropzone_controller.js +165 -0
- data/dist/controllers/filter_controller.js +86 -0
- data/dist/controllers/flash_controller.js +36 -5
- data/dist/controllers/focus_controller.js +94 -8
- data/dist/controllers/form_validation_controller.js +2 -2
- data/dist/controllers/highlight_controller.js +6 -4
- data/dist/controllers/hover_card_controller.js +103 -27
- data/dist/controllers/intersection_controller.js +41 -18
- data/dist/controllers/lazy_frame_controller.js +33 -11
- data/dist/controllers/listbox_controller.js +1 -0
- data/dist/controllers/masonry_controller.js +142 -0
- data/dist/controllers/menu_controller.js +181 -8
- data/dist/controllers/menubar_controller.js +433 -0
- data/dist/controllers/multi_select_controller.js +472 -0
- data/dist/controllers/navigation_menu_controller.js +384 -0
- data/dist/controllers/otp_controller.js +62 -22
- data/dist/controllers/overflow_indicator_controller.js +178 -27
- data/dist/controllers/password_reveal_controller.js +117 -0
- data/dist/controllers/pointer_drag_controller.js +3 -3
- data/dist/controllers/popover_controller.js +118 -27
- data/dist/controllers/range_slider_controller.js +166 -0
- data/dist/controllers/read_more_controller.js +194 -0
- data/dist/controllers/scroll_area_controller.js +15 -2
- data/dist/controllers/scroll_restore_controller.js +93 -0
- data/dist/controllers/scroll_visibility_controller.js +8 -4
- data/dist/controllers/scrollspy_controller.js +33 -11
- data/dist/controllers/separator_controller.js +87 -0
- data/dist/controllers/sidebar_controller.js +761 -0
- data/dist/controllers/stepper_controller.js +28 -12
- data/dist/controllers/stick_to_bottom_controller.js +8 -4
- data/dist/controllers/sticky_observer_controller.js +88 -20
- data/dist/controllers/tags_input_controller.js +275 -0
- data/dist/controllers/theme_controller.js +20 -10
- data/dist/controllers/time_picker_controller.js +212 -0
- data/dist/controllers/toast_controller.js +144 -38
- data/dist/controllers/tooltip_controller.js +148 -42
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +275 -0
- data/dist/index.js +1644 -643
- data/lib/stimeo/ui/version.rb +5 -4
- metadata +28 -2
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/navigation_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
|
+
|
|
148
|
+
// src/controllers/navigation_menu_controller.ts
|
|
149
|
+
var NavigationMenuController = class extends Controller {
|
|
150
|
+
static targets = ["trigger", "panel", "hoverArea"];
|
|
151
|
+
static values = {
|
|
152
|
+
openOnHover: { type: Boolean, default: false },
|
|
153
|
+
hoverDelay: { type: Number, default: 150 }
|
|
154
|
+
};
|
|
155
|
+
static actions = ["onTriggerKeydown", "toggle"];
|
|
156
|
+
/** Open/close delay timers for hover mode; cleared together on disconnect. */
|
|
157
|
+
#hoverTimers = new SafeTimeout();
|
|
158
|
+
/**
|
|
159
|
+
* Elements currently carrying hover listeners. Removal always mirrors this
|
|
160
|
+
* set (not a recomputed target snapshot), so add/remove stays symmetric even
|
|
161
|
+
* when targets churn between connect and disconnect.
|
|
162
|
+
*/
|
|
163
|
+
#hoverWired = /* @__PURE__ */ new Set();
|
|
164
|
+
/** Escape-stack membership while any panel is open; the shared resolver dismisses via it. */
|
|
165
|
+
#escapeLayer = new EscapeLayer();
|
|
166
|
+
/** Establishes the closed baseline and the dismissal listeners. */
|
|
167
|
+
connect() {
|
|
168
|
+
this.#closeAll();
|
|
169
|
+
document.addEventListener("click", this.#onOutsideClick);
|
|
170
|
+
this.element.addEventListener("focusout", this.#onFocusOut);
|
|
171
|
+
if (this.openOnHoverValue) this.#addHoverListeners();
|
|
172
|
+
}
|
|
173
|
+
/** Removes every listener, pending hover timer, and stack membership registered while connected. */
|
|
174
|
+
disconnect() {
|
|
175
|
+
this.#escapeLayer.deactivate();
|
|
176
|
+
document.removeEventListener("click", this.#onOutsideClick);
|
|
177
|
+
this.element.removeEventListener("focusout", this.#onFocusOut);
|
|
178
|
+
this.#removeHoverListeners();
|
|
179
|
+
this.#hoverTimers.clearAll();
|
|
180
|
+
}
|
|
181
|
+
/** Re-wires hover listeners when a target is added after connect (Turbo Streams etc.). */
|
|
182
|
+
triggerTargetConnected() {
|
|
183
|
+
this.#rewireHoverListeners();
|
|
184
|
+
}
|
|
185
|
+
/** Re-wires hover listeners when a target is removed while connected. */
|
|
186
|
+
triggerTargetDisconnected() {
|
|
187
|
+
this.#rewireHoverListeners();
|
|
188
|
+
}
|
|
189
|
+
/** See {@link NavigationMenuController.triggerTargetConnected}. */
|
|
190
|
+
panelTargetConnected() {
|
|
191
|
+
this.#rewireHoverListeners();
|
|
192
|
+
}
|
|
193
|
+
/** See {@link NavigationMenuController.triggerTargetDisconnected}. */
|
|
194
|
+
panelTargetDisconnected() {
|
|
195
|
+
this.#rewireHoverListeners();
|
|
196
|
+
}
|
|
197
|
+
/** See {@link NavigationMenuController.triggerTargetConnected}. */
|
|
198
|
+
hoverAreaTargetConnected() {
|
|
199
|
+
this.#rewireHoverListeners();
|
|
200
|
+
}
|
|
201
|
+
/** See {@link NavigationMenuController.triggerTargetDisconnected}. */
|
|
202
|
+
hoverAreaTargetDisconnected() {
|
|
203
|
+
this.#rewireHoverListeners();
|
|
204
|
+
}
|
|
205
|
+
/** Toggles a trigger's panel (single-open). Bound via `data-action` (click). */
|
|
206
|
+
toggle(event) {
|
|
207
|
+
const trigger = event.currentTarget;
|
|
208
|
+
if (this.#isExpanded(trigger)) {
|
|
209
|
+
this.#closePanel(trigger);
|
|
210
|
+
} else {
|
|
211
|
+
this.#openPanel(trigger);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** `ArrowLeft`/`ArrowRight` move focus between triggers (keeping Tab order). */
|
|
215
|
+
onTriggerKeydown(event) {
|
|
216
|
+
const triggers = this.triggerTargets;
|
|
217
|
+
const index = triggers.indexOf(event.currentTarget);
|
|
218
|
+
if (index === -1) return;
|
|
219
|
+
const length = triggers.length;
|
|
220
|
+
if (event.key === "ArrowRight") {
|
|
221
|
+
event.preventDefault();
|
|
222
|
+
triggers[(index + 1) % length]?.focus();
|
|
223
|
+
} else if (event.key === "ArrowLeft") {
|
|
224
|
+
event.preventDefault();
|
|
225
|
+
triggers[(index - 1 + length) % length]?.focus();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** Opens `trigger`'s panel, closing any other open panel first. */
|
|
229
|
+
#openPanel(trigger) {
|
|
230
|
+
this.#closeAll();
|
|
231
|
+
const panel = this.#panelFor(trigger);
|
|
232
|
+
if (!panel) return;
|
|
233
|
+
panel.hidden = false;
|
|
234
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
235
|
+
this.#syncEscapeLayer();
|
|
236
|
+
}
|
|
237
|
+
/** Closes `trigger`'s panel and reflects the collapsed state. */
|
|
238
|
+
#closePanel(trigger) {
|
|
239
|
+
const panel = this.#panelFor(trigger);
|
|
240
|
+
if (panel) panel.hidden = true;
|
|
241
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
242
|
+
this.#syncEscapeLayer();
|
|
243
|
+
}
|
|
244
|
+
/** Closes every open panel. */
|
|
245
|
+
#closeAll() {
|
|
246
|
+
for (const trigger of this.triggerTargets) this.#closePanel(trigger);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Aligns Escape-stack membership with the open state: joins (or re-asserts to
|
|
250
|
+
* the top) while a panel is open, leaves once none is. Re-asserting when the
|
|
251
|
+
* user switches panels is deliberate — the nav is again the newest layer.
|
|
252
|
+
*/
|
|
253
|
+
#syncEscapeLayer() {
|
|
254
|
+
if (this.#isAnyOpen) {
|
|
255
|
+
this.#escapeLayer.activate(document, {
|
|
256
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
257
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
258
|
+
});
|
|
259
|
+
} else {
|
|
260
|
+
this.#escapeLayer.deactivate();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** Closes any open panel and returns focus to its trigger (Escape path). */
|
|
264
|
+
#closeAndRestore() {
|
|
265
|
+
const open = this.#openTrigger;
|
|
266
|
+
if (!open) return;
|
|
267
|
+
this.#closePanel(open);
|
|
268
|
+
open.focus();
|
|
269
|
+
}
|
|
270
|
+
/** Closes panels when a click lands outside the nav element. */
|
|
271
|
+
#onOutsideClick = (event) => {
|
|
272
|
+
if (this.#isAnyOpen && !this.element.contains(event.target)) this.#closeAll();
|
|
273
|
+
};
|
|
274
|
+
/**
|
|
275
|
+
* Closes (without restoring focus) when focus leaves the nav for a known
|
|
276
|
+
* external destination. A null/non-Node destination is indeterminate:
|
|
277
|
+
* browsers use it for clicks on non-focusable content and for window
|
|
278
|
+
* deactivation, so those never close the nav here (matching the popover
|
|
279
|
+
* convention) — the outside-click handler decides pointer dismissal, and the
|
|
280
|
+
* Escape stack's body-focus claim keeps keyboard dismissal working.
|
|
281
|
+
*/
|
|
282
|
+
#onFocusOut = (event) => {
|
|
283
|
+
const next = event.relatedTarget;
|
|
284
|
+
if (!(next instanceof Node) || this.element.contains(next)) return;
|
|
285
|
+
this.#closeAll();
|
|
286
|
+
};
|
|
287
|
+
/** Opens a trigger's panel after the hover delay (hover mode). */
|
|
288
|
+
#onPointerEnter = (event) => {
|
|
289
|
+
const trigger = this.#triggerForHover(event.currentTarget);
|
|
290
|
+
if (!trigger) return;
|
|
291
|
+
this.#hoverTimers.clearAll();
|
|
292
|
+
this.#hoverTimers.set(() => this.#openPanel(trigger), this.hoverDelayValue);
|
|
293
|
+
};
|
|
294
|
+
/**
|
|
295
|
+
* Closes the open panel after the hover delay (hover mode) — unless the
|
|
296
|
+
* pointer moved directly into another part of the hover region (an adjacent
|
|
297
|
+
* trigger, panel, or hoverArea): crossing a shared edge must not schedule a
|
|
298
|
+
* spurious close, e.g. a hoverArea whose panel sits outside it as a sibling.
|
|
299
|
+
*/
|
|
300
|
+
#onPointerLeave = (event) => {
|
|
301
|
+
const next = event instanceof MouseEvent ? event.relatedTarget : null;
|
|
302
|
+
if (next instanceof Node && this.#hoverElements.some((el) => el.contains(next))) return;
|
|
303
|
+
this.#hoverTimers.clearAll();
|
|
304
|
+
this.#hoverTimers.set(() => this.#closeAll(), this.hoverDelayValue);
|
|
305
|
+
};
|
|
306
|
+
/** Wires hover open/close on each hover element (opt-in), tracking what was wired. */
|
|
307
|
+
#addHoverListeners() {
|
|
308
|
+
for (const element of this.#hoverElements) {
|
|
309
|
+
element.addEventListener("mouseenter", this.#onPointerEnter);
|
|
310
|
+
element.addEventListener("mouseleave", this.#onPointerLeave);
|
|
311
|
+
this.#hoverWired.add(element);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/** Removes the hover listeners from exactly the elements that were wired. */
|
|
315
|
+
#removeHoverListeners() {
|
|
316
|
+
for (const element of this.#hoverWired) {
|
|
317
|
+
element.removeEventListener("mouseenter", this.#onPointerEnter);
|
|
318
|
+
element.removeEventListener("mouseleave", this.#onPointerLeave);
|
|
319
|
+
}
|
|
320
|
+
this.#hoverWired.clear();
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Rebuilds the hover wiring from the current targets. Target callbacks call
|
|
324
|
+
* this so items added or removed after connect (e.g. a Turbo Stream append)
|
|
325
|
+
* participate in hover; the wired-set removal keeps the rebuild symmetric.
|
|
326
|
+
*/
|
|
327
|
+
#rewireHoverListeners() {
|
|
328
|
+
if (!this.openOnHoverValue) return;
|
|
329
|
+
this.#removeHoverListeners();
|
|
330
|
+
this.#addHoverListeners();
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Elements that participate in hover: each `hoverArea`, plus every trigger and
|
|
334
|
+
* panel **not** wrapped by one. A wrapped trigger/panel must defer to its
|
|
335
|
+
* wrapper — its own mouseleave would otherwise schedule a close while the
|
|
336
|
+
* pointer is still inside the area (mouseenter does not re-fire on the wrapper
|
|
337
|
+
* when moving among its descendants), flickering the panel shut.
|
|
338
|
+
*/
|
|
339
|
+
get #hoverElements() {
|
|
340
|
+
const areas = this.hoverAreaTargets;
|
|
341
|
+
const covered = (element) => areas.some((area) => area.contains(element));
|
|
342
|
+
return [
|
|
343
|
+
...areas,
|
|
344
|
+
...this.triggerTargets.filter((trigger) => !covered(trigger)),
|
|
345
|
+
...this.panelTargets.filter((panel) => !covered(panel))
|
|
346
|
+
];
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Resolves the trigger for a hovered element: the trigger itself, the trigger
|
|
350
|
+
* controlling a hovered panel, or the first trigger contained in a hovered
|
|
351
|
+
* `hoverArea` wrapper.
|
|
352
|
+
*/
|
|
353
|
+
#triggerForHover(element) {
|
|
354
|
+
if (this.triggerTargets.includes(element)) return element;
|
|
355
|
+
if (this.panelTargets.includes(element)) {
|
|
356
|
+
return this.triggerTargets.find((trigger) => this.#panelFor(trigger) === element) ?? null;
|
|
357
|
+
}
|
|
358
|
+
if (this.hoverAreaTargets.includes(element)) {
|
|
359
|
+
return this.triggerTargets.find((trigger) => element.contains(trigger)) ?? null;
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
/** The panel controlled by `trigger` (matched by `aria-controls`/`id`). */
|
|
364
|
+
#panelFor(trigger) {
|
|
365
|
+
const id = trigger.getAttribute("aria-controls");
|
|
366
|
+
return id ? this.panelTargets.find((panel) => panel.id === id) ?? null : null;
|
|
367
|
+
}
|
|
368
|
+
/** Whether `trigger`'s panel is currently expanded. */
|
|
369
|
+
#isExpanded(trigger) {
|
|
370
|
+
return trigger.getAttribute("aria-expanded") === "true";
|
|
371
|
+
}
|
|
372
|
+
/** The trigger whose panel is currently open, if any. */
|
|
373
|
+
get #openTrigger() {
|
|
374
|
+
return this.triggerTargets.find((trigger) => this.#isExpanded(trigger)) ?? null;
|
|
375
|
+
}
|
|
376
|
+
/** Whether any panel is currently open. */
|
|
377
|
+
get #isAnyOpen() {
|
|
378
|
+
return this.#openTrigger !== null;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
export { NavigationMenuController };
|
|
383
|
+
//# sourceMappingURL=navigation_menu_controller.js.map
|
|
384
|
+
//# sourceMappingURL=navigation_menu_controller.js.map
|
|
@@ -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;
|