stimeo-ui 0.2.0 → 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.
Files changed (124) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/controllers/accordion_controller.d.ts +3 -2
  3. package/dist/controllers/accordion_controller.js.map +1 -1
  4. package/dist/controllers/alert_dialog_controller.d.ts +64 -0
  5. package/dist/controllers/alert_dialog_controller.js +318 -0
  6. package/dist/controllers/alert_dialog_controller.js.map +1 -0
  7. package/dist/controllers/carousel_controller.d.ts +100 -0
  8. package/dist/controllers/carousel_controller.js +272 -0
  9. package/dist/controllers/carousel_controller.js.map +1 -0
  10. package/dist/controllers/clipboard_controller.d.ts +72 -0
  11. package/dist/controllers/clipboard_controller.js +144 -0
  12. package/dist/controllers/clipboard_controller.js.map +1 -0
  13. package/dist/controllers/collapsible_controller.d.ts +70 -0
  14. package/dist/controllers/collapsible_controller.js +327 -0
  15. package/dist/controllers/collapsible_controller.js.map +1 -0
  16. package/dist/controllers/color_picker_controller.d.ts +76 -0
  17. package/dist/controllers/color_picker_controller.js +213 -0
  18. package/dist/controllers/color_picker_controller.js.map +1 -0
  19. package/dist/controllers/count_up_controller.js +8 -1
  20. package/dist/controllers/count_up_controller.js.map +1 -1
  21. package/dist/controllers/currency_input_controller.d.ts +72 -0
  22. package/dist/controllers/currency_input_controller.js +147 -0
  23. package/dist/controllers/currency_input_controller.js.map +1 -0
  24. package/dist/controllers/data_grid_controller.d.ts +71 -0
  25. package/dist/controllers/data_grid_controller.js +168 -0
  26. package/dist/controllers/data_grid_controller.js.map +1 -0
  27. package/dist/controllers/date_range_picker_controller.d.ts +87 -0
  28. package/dist/controllers/date_range_picker_controller.js +417 -0
  29. package/dist/controllers/date_range_picker_controller.js.map +1 -0
  30. package/dist/controllers/dismissible_controller.d.ts +57 -0
  31. package/dist/controllers/dismissible_controller.js +117 -0
  32. package/dist/controllers/dismissible_controller.js.map +1 -0
  33. package/dist/controllers/drawer_controller.d.ts +96 -0
  34. package/dist/controllers/drawer_controller.js +630 -0
  35. package/dist/controllers/drawer_controller.js.map +1 -0
  36. package/dist/controllers/editable_controller.d.ts +73 -0
  37. package/dist/controllers/editable_controller.js +168 -0
  38. package/dist/controllers/editable_controller.js.map +1 -0
  39. package/dist/controllers/file_dropzone_controller.d.ts +92 -0
  40. package/dist/controllers/file_dropzone_controller.js +165 -0
  41. package/dist/controllers/file_dropzone_controller.js.map +1 -0
  42. package/dist/controllers/filter_controller.d.ts +60 -0
  43. package/dist/controllers/filter_controller.js +86 -0
  44. package/dist/controllers/filter_controller.js.map +1 -0
  45. package/dist/controllers/flash_controller.js +36 -5
  46. package/dist/controllers/flash_controller.js.map +1 -1
  47. package/dist/controllers/highlight_controller.js +6 -4
  48. package/dist/controllers/highlight_controller.js.map +1 -1
  49. package/dist/controllers/intersection_controller.js +41 -18
  50. package/dist/controllers/intersection_controller.js.map +1 -1
  51. package/dist/controllers/lazy_frame_controller.js +33 -11
  52. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  53. package/dist/controllers/masonry_controller.d.ts +54 -0
  54. package/dist/controllers/masonry_controller.js +142 -0
  55. package/dist/controllers/masonry_controller.js.map +1 -0
  56. package/dist/controllers/menubar_controller.d.ts +62 -0
  57. package/dist/controllers/menubar_controller.js +433 -0
  58. package/dist/controllers/menubar_controller.js.map +1 -0
  59. package/dist/controllers/multi_select_controller.d.ts +115 -0
  60. package/dist/controllers/multi_select_controller.js +472 -0
  61. package/dist/controllers/multi_select_controller.js.map +1 -0
  62. package/dist/controllers/navigation_menu_controller.d.ts +99 -0
  63. package/dist/controllers/navigation_menu_controller.js +384 -0
  64. package/dist/controllers/navigation_menu_controller.js.map +1 -0
  65. package/dist/controllers/overflow_indicator_controller.d.ts +12 -4
  66. package/dist/controllers/overflow_indicator_controller.js +178 -27
  67. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  68. package/dist/controllers/password_reveal_controller.d.ts +49 -0
  69. package/dist/controllers/password_reveal_controller.js +117 -0
  70. package/dist/controllers/password_reveal_controller.js.map +1 -0
  71. package/dist/controllers/range_slider_controller.d.ts +87 -0
  72. package/dist/controllers/range_slider_controller.js +166 -0
  73. package/dist/controllers/range_slider_controller.js.map +1 -0
  74. package/dist/controllers/read_more_controller.d.ts +52 -0
  75. package/dist/controllers/read_more_controller.js +194 -0
  76. package/dist/controllers/read_more_controller.js.map +1 -0
  77. package/dist/controllers/scroll_area_controller.js +15 -2
  78. package/dist/controllers/scroll_area_controller.js.map +1 -1
  79. package/dist/controllers/scroll_restore_controller.d.ts +54 -0
  80. package/dist/controllers/scroll_restore_controller.js +93 -0
  81. package/dist/controllers/scroll_restore_controller.js.map +1 -0
  82. package/dist/controllers/scroll_visibility_controller.js +8 -4
  83. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  84. package/dist/controllers/scrollspy_controller.js +33 -11
  85. package/dist/controllers/scrollspy_controller.js.map +1 -1
  86. package/dist/controllers/separator_controller.d.ts +54 -0
  87. package/dist/controllers/separator_controller.js +87 -0
  88. package/dist/controllers/separator_controller.js.map +1 -0
  89. package/dist/controllers/sidebar_controller.d.ts +107 -0
  90. package/dist/controllers/sidebar_controller.js +761 -0
  91. package/dist/controllers/sidebar_controller.js.map +1 -0
  92. package/dist/controllers/stepper_controller.d.ts +4 -0
  93. package/dist/controllers/stepper_controller.js +28 -12
  94. package/dist/controllers/stepper_controller.js.map +1 -1
  95. package/dist/controllers/stick_to_bottom_controller.js +8 -4
  96. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  97. package/dist/controllers/sticky_observer_controller.d.ts +17 -4
  98. package/dist/controllers/sticky_observer_controller.js +88 -20
  99. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  100. package/dist/controllers/tabs_controller.d.ts +3 -2
  101. package/dist/controllers/tabs_controller.js.map +1 -1
  102. package/dist/controllers/tags_input_controller.d.ts +91 -0
  103. package/dist/controllers/tags_input_controller.js +275 -0
  104. package/dist/controllers/tags_input_controller.js.map +1 -0
  105. package/dist/controllers/theme_controller.js +20 -10
  106. package/dist/controllers/theme_controller.js.map +1 -1
  107. package/dist/controllers/time_picker_controller.d.ts +67 -0
  108. package/dist/controllers/time_picker_controller.js +212 -0
  109. package/dist/controllers/time_picker_controller.js.map +1 -0
  110. package/dist/controllers/toast_controller.d.ts +1 -9
  111. package/dist/controllers/toast_controller.js +36 -9
  112. package/dist/controllers/toast_controller.js.map +1 -1
  113. package/dist/controllers/transition_controller.d.ts +14 -8
  114. package/dist/controllers/transition_controller.js +153 -38
  115. package/dist/controllers/transition_controller.js.map +1 -1
  116. package/dist/controllers/tree_view_controller.d.ts +54 -0
  117. package/dist/controllers/tree_view_controller.js +275 -0
  118. package/dist/controllers/tree_view_controller.js.map +1 -0
  119. package/dist/index.d.ts +27 -1818
  120. package/dist/index.js +811 -295
  121. package/dist/index.js.map +1 -1
  122. package/dist/inspector/examples.json +2 -2
  123. package/dist/inspector/manifest.json +15 -3
  124. package/package.json +1 -1
@@ -0,0 +1,630 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/drawer_controller.ts
4
+
5
+ // src/utils/escape_layer.ts
6
+ var EscapeLayer = class _EscapeLayer {
7
+ static #registries = /* @__PURE__ */ new WeakMap();
8
+ #ownerDocument = null;
9
+ /** Dismissal callback while active; `null` when inactive. */
10
+ #onDismiss = null;
11
+ /** Live predicate deciding whether the layer claims a press; `null` = always. */
12
+ #claims = null;
13
+ /**
14
+ * Activates this layer at the top of its document's Escape stack, installing
15
+ * the document's shared resolver listener if this is its first layer.
16
+ * Re-activating an already-active layer moves it to the top.
17
+ */
18
+ activate(ownerDocument = document, options) {
19
+ this.deactivate();
20
+ let registry = _EscapeLayer.#registries.get(ownerDocument);
21
+ if (!registry) {
22
+ registry = _EscapeLayer.#createRegistry();
23
+ _EscapeLayer.#registries.set(ownerDocument, registry);
24
+ ownerDocument.addEventListener("keydown", registry.onKeydown);
25
+ }
26
+ registry.stack.push(this);
27
+ this.#ownerDocument = ownerDocument;
28
+ this.#onDismiss = options.onDismiss;
29
+ this.#claims = options.claims ?? null;
30
+ }
31
+ /**
32
+ * Removes this layer from its document's Escape stack, uninstalling the
33
+ * shared listener when the stack empties. Safe to call when inactive.
34
+ */
35
+ deactivate() {
36
+ const ownerDocument = this.#ownerDocument;
37
+ if (!ownerDocument) return;
38
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
39
+ if (registry) {
40
+ const index = registry.stack.lastIndexOf(this);
41
+ if (index >= 0) registry.stack.splice(index, 1);
42
+ if (registry.stack.length === 0) {
43
+ ownerDocument.removeEventListener("keydown", registry.onKeydown);
44
+ _EscapeLayer.#registries.delete(ownerDocument);
45
+ }
46
+ }
47
+ this.#ownerDocument = null;
48
+ this.#onDismiss = null;
49
+ this.#claims = null;
50
+ }
51
+ /**
52
+ * Whether this active layer would own a press right now: it is the topmost
53
+ * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
54
+ * and diagnostics — production dismissal goes through the shared listener.
55
+ */
56
+ get ownsEscape() {
57
+ const ownerDocument = this.#ownerDocument;
58
+ if (!ownerDocument) return false;
59
+ const registry = _EscapeLayer.#registries.get(ownerDocument);
60
+ if (!registry) return false;
61
+ return _EscapeLayer.#resolveOwner(registry.stack) === this;
62
+ }
63
+ /** Builds a document's registry with its shared resolver listener. */
64
+ static #createRegistry() {
65
+ const registry = {
66
+ stack: [],
67
+ onKeydown: (event) => {
68
+ if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
69
+ const owner = _EscapeLayer.#resolveOwner(registry.stack);
70
+ if (!owner) return;
71
+ event.preventDefault();
72
+ owner.#onDismiss?.();
73
+ }
74
+ };
75
+ return registry;
76
+ }
77
+ /** The topmost stack layer whose claims predicate passes, or `null`. */
78
+ static #resolveOwner(stack) {
79
+ for (let index = stack.length - 1; index >= 0; index--) {
80
+ const layer = stack[index];
81
+ if (!layer) continue;
82
+ if (layer.#claims && !layer.#claims()) continue;
83
+ return layer;
84
+ }
85
+ return null;
86
+ }
87
+ };
88
+
89
+ // src/utils/focus_trap.ts
90
+ var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
91
+ var FocusTrap = class {
92
+ /** The element focused before activation, restored on deactivation. */
93
+ #previouslyFocused = null;
94
+ /** The body's inline `overflow` before locking, restored on deactivation. */
95
+ #previousBodyOverflow = "";
96
+ /** Whether scroll was locked this activation (so it is only restored if applied). */
97
+ #scrollLocked = false;
98
+ /** Background siblings made `inert` while active, restored on deactivation. */
99
+ #inertedSiblings = [];
100
+ /** Whether the modal side effects are currently applied. */
101
+ #activeState = false;
102
+ /** Registers the trap on the shared Escape stack while active (see {@link EscapeLayer}). */
103
+ #escapeLayer = new EscapeLayer();
104
+ /** Returns the trapped element; called on every operation for the live target. */
105
+ #getContainer;
106
+ /** Closing/focus hooks; see {@link FocusTrapOptions}. */
107
+ #options;
108
+ /**
109
+ * @param getContainer - Returns the trapped element. Called on every operation
110
+ * so the live target is always used.
111
+ * @param options - Closing/focus hooks; see {@link FocusTrapOptions}.
112
+ */
113
+ constructor(getContainer, options = {}) {
114
+ this.#getContainer = getContainer;
115
+ this.#options = options;
116
+ }
117
+ /** Whether the trap is currently active. */
118
+ get active() {
119
+ return this.#activeState;
120
+ }
121
+ /**
122
+ * Applies the trap: records the current focus, optionally locks background scroll
123
+ * and makes background siblings `inert`, listens for `Tab`/`Escape`, and (unless
124
+ * `autoFocus` is off) moves focus inside. No-ops if already active.
125
+ */
126
+ activate() {
127
+ if (this.#activeState) return;
128
+ this.#activeState = true;
129
+ const active = document.activeElement;
130
+ this.#previouslyFocused = active instanceof HTMLElement && active !== document.body ? active : null;
131
+ if (this.#flag(this.#options.lockScroll, true)) {
132
+ this.#previousBodyOverflow = document.body.style.overflow;
133
+ document.body.style.overflow = "hidden";
134
+ this.#scrollLocked = true;
135
+ }
136
+ if (this.#flag(this.#options.isolate, true)) this.#isolateBackground();
137
+ document.addEventListener("keydown", this.#onKeydown);
138
+ document.addEventListener("turbo:before-cache", this.#onBeforeCache);
139
+ const onEscape = this.#options.onEscape;
140
+ if (onEscape) this.#escapeLayer.activate(document, { onDismiss: () => onEscape() });
141
+ if (this.#flag(this.#options.autoFocus, true)) this.#focusInitial();
142
+ }
143
+ /**
144
+ * Reverts every side effect applied by {@link activate}. No-ops if inactive, so
145
+ * a controller can call it defensively from both `close()` and `disconnect()`.
146
+ *
147
+ * @param restoreFocus - Move focus back to the opener (default `true`). Pass
148
+ * `false` on teardown (`disconnect`), where yanking focus is undesirable.
149
+ */
150
+ deactivate({ restoreFocus = true } = {}) {
151
+ if (!this.#activeState) return;
152
+ this.#activeState = false;
153
+ this.#escapeLayer.deactivate();
154
+ document.removeEventListener("keydown", this.#onKeydown);
155
+ document.removeEventListener("turbo:before-cache", this.#onBeforeCache);
156
+ if (this.#scrollLocked) {
157
+ document.body.style.overflow = this.#previousBodyOverflow;
158
+ this.#scrollLocked = false;
159
+ }
160
+ this.#releaseBackground();
161
+ if (restoreFocus) {
162
+ const target = this.#previouslyFocused ?? this.#options.fallbackFocus?.() ?? null;
163
+ target?.focus();
164
+ }
165
+ }
166
+ /** Resolves a boolean-or-getter option, defaulting when it was not provided. */
167
+ #flag(option, fallback) {
168
+ if (option === void 0) return fallback;
169
+ return typeof option === "function" ? option() : option;
170
+ }
171
+ /**
172
+ * Reverts the side effects just before Turbo caches the page snapshot, so an
173
+ * overlay left open does not bake the scroll lock into `body[style]` — a
174
+ * restored page would feed that locked value back into {@link activate} as the
175
+ * baseline, and closing would then never unlock the page. Markup state stays
176
+ * untouched (restore-open designs reopen against a clean baseline), and focus
177
+ * is left alone mid-navigation. The listener lives only while active.
178
+ */
179
+ #onBeforeCache = () => {
180
+ this.deactivate({ restoreFocus: false });
181
+ };
182
+ /**
183
+ * Handles `Tab` (focus trap) while active. `Escape` dismissal is owned by the
184
+ * shared {@link EscapeLayer} resolver, so Tab trapping stays independent of
185
+ * which layer currently owns Escape.
186
+ */
187
+ #onKeydown = (event) => {
188
+ if (event.key === "Tab") this.#trapTab(event);
189
+ };
190
+ /** Keeps `Tab` focus cycling within the container's focusable elements. */
191
+ #trapTab(event) {
192
+ const focusable = this.#focusableElements();
193
+ if (focusable.length === 0) {
194
+ event.preventDefault();
195
+ return;
196
+ }
197
+ const first = focusable[0];
198
+ const last = focusable[focusable.length - 1];
199
+ const active = document.activeElement;
200
+ if (!(active instanceof Node) || !this.#getContainer().contains(active)) {
201
+ event.preventDefault();
202
+ first?.focus();
203
+ return;
204
+ }
205
+ if (event.shiftKey && active === first) {
206
+ event.preventDefault();
207
+ last?.focus();
208
+ } else if (!event.shiftKey && active === last) {
209
+ event.preventDefault();
210
+ first?.focus();
211
+ }
212
+ }
213
+ /**
214
+ * Marks every element outside the container's subtree as `inert` so background
215
+ * content cannot be focused or reached by assistive technology, honoring the
216
+ * `aria-modal="true"` contract. An element that was *already* `inert` is left
217
+ * untracked so `#releaseBackground` does not wrongly clear it.
218
+ */
219
+ #isolateBackground() {
220
+ const container = this.#getContainer();
221
+ this.#inertedSiblings = [];
222
+ for (const sibling of Array.from(document.body.children)) {
223
+ if (!(sibling instanceof HTMLElement)) continue;
224
+ if (sibling.contains(container) || sibling.inert) continue;
225
+ sibling.inert = true;
226
+ this.#inertedSiblings.push(sibling);
227
+ }
228
+ }
229
+ /** Reverts the `inert` flags applied by `#isolateBackground`. */
230
+ #releaseBackground() {
231
+ for (const sibling of this.#inertedSiblings) {
232
+ sibling.inert = false;
233
+ }
234
+ this.#inertedSiblings = [];
235
+ }
236
+ /** Moves focus to the initial target, the first focusable, or the container. */
237
+ #focusInitial() {
238
+ const preferred = this.#options.initialFocus?.();
239
+ if (preferred) {
240
+ preferred.focus();
241
+ return;
242
+ }
243
+ const focusable = this.#focusableElements();
244
+ if (focusable[0]) {
245
+ focusable[0].focus();
246
+ return;
247
+ }
248
+ const container = this.#getContainer();
249
+ container.tabIndex = -1;
250
+ container.focus();
251
+ }
252
+ /** Collects the container's currently focusable descendants in DOM order. */
253
+ #focusableElements() {
254
+ return Array.from(this.#getContainer().querySelectorAll(FOCUSABLE)).filter(
255
+ (el) => !el.hidden
256
+ );
257
+ }
258
+ };
259
+
260
+ // src/utils/safe_timeout.ts
261
+ var TimerRegistry = class {
262
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
263
+ ids = /* @__PURE__ */ new Set();
264
+ /**
265
+ * Cancels a single tracked timer.
266
+ *
267
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
268
+ * registry), so callers can clear defensively without guarding.
269
+ */
270
+ clear(id) {
271
+ if (this.ids.delete(id)) {
272
+ this.cancel(id);
273
+ }
274
+ }
275
+ /**
276
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
277
+ * to guarantee no timer outlives the element.
278
+ */
279
+ clearAll() {
280
+ for (const id of this.ids) {
281
+ this.cancel(id);
282
+ }
283
+ this.ids.clear();
284
+ }
285
+ /** Number of timers currently tracked (pending). */
286
+ get size() {
287
+ return this.ids.size;
288
+ }
289
+ };
290
+ var SafeTimeout = class extends TimerRegistry {
291
+ /**
292
+ * Schedules `callback` after `delay` ms and returns the timer id.
293
+ *
294
+ * The id is removed from the registry automatically when the timeout fires,
295
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
296
+ */
297
+ set(callback, delay) {
298
+ const id = this.schedule(() => {
299
+ this.ids.delete(id);
300
+ callback();
301
+ }, delay);
302
+ this.ids.add(id);
303
+ return id;
304
+ }
305
+ schedule(callback, delay) {
306
+ return window.setTimeout(callback, delay);
307
+ }
308
+ cancel(id) {
309
+ window.clearTimeout(id);
310
+ }
311
+ };
312
+
313
+ // src/utils/transition_completion.ts
314
+ function timeMs(value) {
315
+ const trimmed = value.trim();
316
+ const amount = Number.parseFloat(trimmed);
317
+ if (!Number.isFinite(amount)) return 0;
318
+ if (trimmed.endsWith("ms")) return amount;
319
+ if (trimmed.endsWith("s")) return amount * 1e3;
320
+ return 0;
321
+ }
322
+ function cssList(value) {
323
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
324
+ }
325
+ function transitionTimings(style) {
326
+ const properties = cssList(style.transitionProperty);
327
+ const durations = cssList(style.transitionDuration).map(timeMs);
328
+ const delays = cssList(style.transitionDelay).map(timeMs);
329
+ const effectiveProperties = properties.length > 0 ? properties : Array.from({ length: Math.max(durations.length, delays.length, 1) }, () => "all");
330
+ const effectiveDurations = durations.length > 0 ? durations : [0];
331
+ const effectiveDelays = delays.length > 0 ? delays : [0];
332
+ return effectiveProperties.filter((property) => property !== "none").map((property, index) => ({
333
+ property,
334
+ totalMs: Math.max(
335
+ 0,
336
+ (effectiveDurations[index % effectiveDurations.length] ?? 0) + (effectiveDelays[index % effectiveDelays.length] ?? 0)
337
+ )
338
+ }));
339
+ }
340
+ function maxTotalMs(timings) {
341
+ return timings.reduce((max, { totalMs }) => Math.max(max, totalMs), 0);
342
+ }
343
+ var TransitionCompletion = class {
344
+ #timers = new SafeTimeout();
345
+ #element = null;
346
+ #complete = null;
347
+ #pendingProperties = null;
348
+ #deadline = 0;
349
+ /**
350
+ * Replaces any prior wait and invokes `complete` synchronously for a 0ms
351
+ * transition (including when `getComputedStyle` is unavailable).
352
+ *
353
+ * With a positive `options.timeoutMs` the synchronous fast-path is skipped and
354
+ * the override replaces the auto-computed fallback (see {@link TransitionWaitOptions}).
355
+ */
356
+ wait(element, complete, options = {}) {
357
+ this.cancel();
358
+ const requested = options.timeoutMs ?? 0;
359
+ const override = Number.isFinite(requested) && requested > 0 ? requested : 0;
360
+ const timings = typeof window.getComputedStyle === "function" ? transitionTimings(window.getComputedStyle(element)) : [];
361
+ const maximum = maxTotalMs(timings);
362
+ if (maximum <= 0 && override <= 0) {
363
+ complete();
364
+ return;
365
+ }
366
+ this.#element = element;
367
+ this.#complete = complete;
368
+ this.#deadline = Date.now() + maximum;
369
+ const activeProperties = this.#activeTransitionProperties(element);
370
+ this.#pendingProperties = activeProperties.size > 0 ? activeProperties : this.#explicitPendingProperties(timings);
371
+ element.addEventListener("transitionend", this.#onTerminal);
372
+ element.addEventListener("transitioncancel", this.#onTerminal);
373
+ this.#timers.set(() => this.#finish(), override > 0 ? override : maximum + 50);
374
+ }
375
+ /** Cancels the pending wait without invoking its completion callback. */
376
+ cancel() {
377
+ this.#complete = null;
378
+ this.#teardown();
379
+ }
380
+ /**
381
+ * Handles terminal events from the observed element only.
382
+ *
383
+ * For explicit property lists, every declared positive-time property must
384
+ * settle. For `all`, no reliable property set exists, so an event can finish
385
+ * only after the computed maximum time; the safety timeout owns the usual path.
386
+ */
387
+ #onTerminal = (event) => {
388
+ if (event.target !== this.#element) return;
389
+ const transitionEvent = event;
390
+ if (transitionEvent.pseudoElement) return;
391
+ if (this.#pendingProperties) {
392
+ const propertyName = transitionEvent.propertyName;
393
+ const active = this.#activeTransitionProperties(this.#element);
394
+ if (active.has(propertyName)) return;
395
+ if (!this.#pendingProperties.delete(propertyName)) return;
396
+ if (this.#pendingProperties.size > 0) return;
397
+ if (active.size > 0) {
398
+ this.#pendingProperties = active;
399
+ return;
400
+ }
401
+ this.#finish();
402
+ return;
403
+ }
404
+ if (Date.now() >= this.#deadline) this.#finish();
405
+ };
406
+ /**
407
+ * Returns active CSS transition properties expanded to the names reported by
408
+ * terminal events. CSS animations and pseudo-element effects are excluded.
409
+ */
410
+ #activeTransitionProperties(element) {
411
+ if (!element || typeof element.getAnimations !== "function") return /* @__PURE__ */ new Set();
412
+ try {
413
+ const properties = element.getAnimations().flatMap((animation) => {
414
+ if (animation.playState === "idle" || animation.playState === "finished") return [];
415
+ const effect = animation.effect;
416
+ if (effect?.pseudoElement) return [];
417
+ const target = effect?.target;
418
+ if (target && target !== element) return [];
419
+ const property = animation.transitionProperty;
420
+ return typeof property === "string" && property.length > 0 ? [property] : [];
421
+ });
422
+ return new Set(properties);
423
+ } catch {
424
+ return /* @__PURE__ */ new Set();
425
+ }
426
+ }
427
+ /** Returns explicit positive-time properties, or `null` for the ambiguous `all`. */
428
+ #explicitPendingProperties(timings) {
429
+ if (timings.some(({ property }) => property === "all")) return null;
430
+ const pending = new Set(
431
+ timings.filter(({ totalMs }) => totalMs > 0).map(({ property }) => property)
432
+ );
433
+ return pending.size > 0 ? pending : null;
434
+ }
435
+ /** Completes exactly once, releasing listeners and the fallback before the callback. */
436
+ #finish() {
437
+ const complete = this.#complete;
438
+ if (!complete) return;
439
+ this.#complete = null;
440
+ this.#teardown();
441
+ complete();
442
+ }
443
+ /** Releases the exact element listeners and timer owned by the current wait. */
444
+ #teardown() {
445
+ this.#timers.clearAll();
446
+ this.#element?.removeEventListener("transitionend", this.#onTerminal);
447
+ this.#element?.removeEventListener("transitioncancel", this.#onTerminal);
448
+ this.#element = null;
449
+ this.#pendingProperties = null;
450
+ this.#deadline = 0;
451
+ }
452
+ };
453
+
454
+ // src/controllers/drawer_controller.ts
455
+ var DrawerController = class extends Controller {
456
+ static targets = ["trigger", "overlay", "panel"];
457
+ static values = {
458
+ placement: { type: String, default: "right" },
459
+ open: { type: Boolean, default: false }
460
+ };
461
+ static actions = ["close", "closeOnBackdrop", "open"];
462
+ /** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
463
+ #activePanel = null;
464
+ /** Owns the modal side effects; Escape closes, focus falls back to the trigger. */
465
+ #trap = new FocusTrap(() => this.#activePanel ?? this.panelTarget, {
466
+ onEscape: () => this.close(),
467
+ fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
468
+ });
469
+ /** Owns the cancellable close-transition wait and its bounded fallback. */
470
+ #transition = new TransitionCompletion();
471
+ /** Distinguishes dynamic target churn from callbacks around controller teardown. */
472
+ #connected = false;
473
+ /**
474
+ * Reflects placement and establishes the initial open/closed state.
475
+ *
476
+ * The DOM is the source of truth on reconnect (Turbo cache restore / morph): a
477
+ * restored snapshot whose panel is already `data-state="open"` stays open
478
+ * rather than being re-derived from the declarative `open` Value (which would
479
+ * close a user-opened drawer). The `open` Value only seeds a genuinely fresh
480
+ * render. We normalize to a clean closed baseline first so {@link open} runs its
481
+ * full reveal + trap activation — the {@link FocusTrap} is a fresh instance
482
+ * after a reconnect and must be re-activated.
483
+ */
484
+ connect() {
485
+ this.#connected = true;
486
+ this.#activePanel = this.hasPanelTarget ? this.panelTarget : null;
487
+ this.#reflectPlacement();
488
+ const shouldOpen = this.#isOpen || this.openValue;
489
+ this.#applyClosedState();
490
+ if (shouldOpen) this.open();
491
+ }
492
+ /** Reverts the modal side effects and pending hide if torn down while open. */
493
+ disconnect() {
494
+ this.#connected = false;
495
+ this.#transition.cancel();
496
+ this.#trap.deactivate({ restoreFocus: false });
497
+ this.#activePanel = null;
498
+ }
499
+ /** Adopts a panel target added by a Turbo morph after the controller connected. */
500
+ panelTargetConnected(panel) {
501
+ if (!this.#connected || this.#activePanel?.isConnected && this.#activePanel !== panel) return;
502
+ this.#adoptPanel(panel);
503
+ }
504
+ /** Closes and releases modal side effects when the actively trapped panel disappears. */
505
+ panelTargetDisconnected(panel) {
506
+ if (panel !== this.#activePanel) return;
507
+ this.#transition.cancel();
508
+ this.#activePanel = null;
509
+ if (!this.#connected) return;
510
+ panel.setAttribute("data-state", "closed");
511
+ panel.hidden = true;
512
+ if (this.hasOverlayTarget) {
513
+ this.overlayTarget.setAttribute("data-state", "closed");
514
+ this.overlayTarget.hidden = true;
515
+ }
516
+ this.openValue = false;
517
+ this.#trap.deactivate();
518
+ if (this.hasPanelTarget) this.#adoptPanel(this.panelTarget);
519
+ }
520
+ /** Keeps `data-placement` in sync if the value changes at runtime. */
521
+ placementValueChanged() {
522
+ this.#reflectPlacement();
523
+ }
524
+ /** Opens the drawer: reveals it, syncs `data-state`, traps focus. */
525
+ open() {
526
+ if (!this.hasPanelTarget || this.#isOpen) return;
527
+ this.#transition.cancel();
528
+ this.#activePanel = this.panelTarget;
529
+ this.panelTarget.hidden = false;
530
+ if (this.hasOverlayTarget) this.overlayTarget.hidden = false;
531
+ void this.panelTarget.offsetWidth;
532
+ this.#setState("open");
533
+ this.openValue = true;
534
+ this.#trap.activate();
535
+ }
536
+ /**
537
+ * Closes the drawer: syncs `data-state` to start the exit transition, then
538
+ * defers both `hidden` *and* the modal teardown (scroll lock / background
539
+ * `inert` / focus restore) until the transition finishes — see
540
+ * `#applyHidden`. This keeps the background inert and focus trapped while
541
+ * the drawer is still visually on screen, preserving the modal contract during
542
+ * the exit animation.
543
+ */
544
+ close() {
545
+ if (!this.hasPanelTarget || !this.#isOpen) return;
546
+ this.openValue = false;
547
+ this.#setState("closed");
548
+ this.#hideAfterTransition();
549
+ }
550
+ /** Closes only when the overlay itself (not its contents) is clicked. */
551
+ closeOnBackdrop(event) {
552
+ if (this.hasOverlayTarget && event.target === this.overlayTarget) this.close();
553
+ }
554
+ /** Writes `data-placement` from the current `placement` value. */
555
+ #reflectPlacement() {
556
+ if (this.hasPanelTarget) this.panelTarget.setAttribute("data-placement", this.#placement);
557
+ }
558
+ /** Reconciles a replacement panel and companion overlay from its explicit DOM state. */
559
+ #adoptPanel(panel) {
560
+ this.#transition.cancel();
561
+ const trapWasActive = this.#trap.active;
562
+ this.#activePanel = panel;
563
+ panel.setAttribute("data-placement", this.#placement);
564
+ if (panel.getAttribute("data-state") === "open") {
565
+ panel.hidden = false;
566
+ if (this.hasOverlayTarget) {
567
+ this.overlayTarget.setAttribute("data-state", "open");
568
+ this.overlayTarget.hidden = false;
569
+ }
570
+ this.openValue = true;
571
+ if (trapWasActive) this.#trap.deactivate({ restoreFocus: false });
572
+ this.#trap.activate();
573
+ return;
574
+ }
575
+ panel.setAttribute("data-state", "closed");
576
+ panel.hidden = true;
577
+ if (this.hasOverlayTarget) {
578
+ this.overlayTarget.setAttribute("data-state", "closed");
579
+ this.overlayTarget.hidden = true;
580
+ }
581
+ this.openValue = false;
582
+ this.#trap.deactivate();
583
+ }
584
+ /** Validated placement (`left`/`right`/`top`/`bottom`), defaulting to `right`. */
585
+ get #placement() {
586
+ const value = this.placementValue;
587
+ return value === "left" || value === "top" || value === "bottom" ? value : "right";
588
+ }
589
+ /** Syncs `data-state` on the panel and overlay together. */
590
+ #setState(state) {
591
+ if (this.hasPanelTarget) this.panelTarget.setAttribute("data-state", state);
592
+ if (this.hasOverlayTarget) this.overlayTarget.setAttribute("data-state", state);
593
+ }
594
+ /** Fully reflects the closed state up front (used on connect when not open). */
595
+ #applyClosedState() {
596
+ this.#setState("closed");
597
+ if (this.hasPanelTarget) this.panelTarget.hidden = true;
598
+ if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
599
+ }
600
+ /**
601
+ * Applies `hidden` once the panel's close transition ends, so the exit slide
602
+ * can play. The shared waiter hides synchronously for 0ms transitions and
603
+ * supplies a bounded fallback when the browser emits no terminal event.
604
+ */
605
+ #hideAfterTransition() {
606
+ const panel = this.panelTarget;
607
+ this.#activePanel = panel;
608
+ this.#transition.wait(panel, () => this.#applyHidden(panel));
609
+ }
610
+ /**
611
+ * Runs once the close transition has finished: applies `hidden` to the panel
612
+ * and overlay, then reverts the modal side effects (scroll lock, background
613
+ * `inert`, keydown listener) and restores focus to the opener. Deferring the
614
+ * {@link FocusTrap} teardown to here — rather than at {@link close} time — keeps
615
+ * the background unreachable and focus trapped for the whole exit animation.
616
+ */
617
+ #applyHidden(panel) {
618
+ panel.hidden = true;
619
+ if (this.hasOverlayTarget) this.overlayTarget.hidden = true;
620
+ this.#trap.deactivate();
621
+ }
622
+ /** Whether the drawer is open (tracked via `data-state`, not `hidden`). */
623
+ get #isOpen() {
624
+ return this.hasPanelTarget && this.panelTarget.getAttribute("data-state") === "open";
625
+ }
626
+ };
627
+
628
+ export { DrawerController };
629
+ //# sourceMappingURL=drawer_controller.js.map
630
+ //# sourceMappingURL=drawer_controller.js.map