stimeo-ui 0.1.0-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.
- package/CHANGELOG.md +149 -0
- package/README.md +6 -6
- package/dist/controllers/accordion_controller.d.ts +3 -2
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +64 -0
- package/dist/controllers/alert_dialog_controller.js +318 -0
- package/dist/controllers/alert_dialog_controller.js.map +1 -0
- package/dist/controllers/auto_submit_controller.js +56 -21
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +100 -0
- package/dist/controllers/carousel_controller.js +272 -0
- package/dist/controllers/carousel_controller.js.map +1 -0
- package/dist/controllers/character_counter_controller.js +52 -15
- package/dist/controllers/character_counter_controller.js.map +1 -1
- package/dist/controllers/clipboard_controller.d.ts +72 -0
- package/dist/controllers/clipboard_controller.js +144 -0
- package/dist/controllers/clipboard_controller.js.map +1 -0
- package/dist/controllers/collapsible_controller.d.ts +70 -0
- package/dist/controllers/collapsible_controller.js +327 -0
- package/dist/controllers/collapsible_controller.js.map +1 -0
- package/dist/controllers/color_picker_controller.d.ts +76 -0
- package/dist/controllers/color_picker_controller.js +213 -0
- package/dist/controllers/color_picker_controller.js.map +1 -0
- 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/count_up_controller.js +8 -1
- package/dist/controllers/count_up_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/currency_input_controller.d.ts +72 -0
- package/dist/controllers/currency_input_controller.js +147 -0
- package/dist/controllers/currency_input_controller.js.map +1 -0
- package/dist/controllers/data_grid_controller.d.ts +71 -0
- package/dist/controllers/data_grid_controller.js +168 -0
- package/dist/controllers/data_grid_controller.js.map +1 -0
- package/dist/controllers/date_range_picker_controller.d.ts +87 -0
- package/dist/controllers/date_range_picker_controller.js +417 -0
- package/dist/controllers/date_range_picker_controller.js.map +1 -0
- package/dist/controllers/dialog_controller.js +94 -8
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.d.ts +57 -0
- package/dist/controllers/dismissible_controller.js +117 -0
- package/dist/controllers/dismissible_controller.js.map +1 -0
- package/dist/controllers/drawer_controller.d.ts +96 -0
- package/dist/controllers/drawer_controller.js +630 -0
- package/dist/controllers/drawer_controller.js.map +1 -0
- 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/editable_controller.d.ts +73 -0
- package/dist/controllers/editable_controller.js +168 -0
- package/dist/controllers/editable_controller.js.map +1 -0
- package/dist/controllers/file_dropzone_controller.d.ts +92 -0
- package/dist/controllers/file_dropzone_controller.js +165 -0
- package/dist/controllers/file_dropzone_controller.js.map +1 -0
- package/dist/controllers/filter_controller.d.ts +60 -0
- package/dist/controllers/filter_controller.js +86 -0
- package/dist/controllers/filter_controller.js.map +1 -0
- package/dist/controllers/flash_controller.js +36 -5
- package/dist/controllers/flash_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/highlight_controller.js +6 -4
- package/dist/controllers/highlight_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 +41 -18
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +33 -11
- 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/masonry_controller.d.ts +54 -0
- package/dist/controllers/masonry_controller.js +142 -0
- package/dist/controllers/masonry_controller.js.map +1 -0
- 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/menubar_controller.d.ts +62 -0
- package/dist/controllers/menubar_controller.js +433 -0
- package/dist/controllers/menubar_controller.js.map +1 -0
- package/dist/controllers/multi_select_controller.d.ts +115 -0
- package/dist/controllers/multi_select_controller.js +472 -0
- package/dist/controllers/multi_select_controller.js.map +1 -0
- package/dist/controllers/navigation_menu_controller.d.ts +99 -0
- package/dist/controllers/navigation_menu_controller.js +384 -0
- package/dist/controllers/navigation_menu_controller.js.map +1 -0
- package/dist/controllers/otp_controller.js +62 -22
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +12 -4
- package/dist/controllers/overflow_indicator_controller.js +178 -27
- package/dist/controllers/overflow_indicator_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/password_reveal_controller.d.ts +49 -0
- package/dist/controllers/password_reveal_controller.js +117 -0
- package/dist/controllers/password_reveal_controller.js.map +1 -0
- 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/range_slider_controller.d.ts +87 -0
- package/dist/controllers/range_slider_controller.js +166 -0
- package/dist/controllers/range_slider_controller.js.map +1 -0
- package/dist/controllers/read_more_controller.d.ts +52 -0
- package/dist/controllers/read_more_controller.js +194 -0
- package/dist/controllers/read_more_controller.js.map +1 -0
- package/dist/controllers/scroll_area_controller.js +15 -2
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.d.ts +54 -0
- package/dist/controllers/scroll_restore_controller.js +93 -0
- package/dist/controllers/scroll_restore_controller.js.map +1 -0
- package/dist/controllers/scroll_visibility_controller.js +8 -4
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.js +33 -11
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +54 -0
- package/dist/controllers/separator_controller.js +87 -0
- package/dist/controllers/separator_controller.js.map +1 -0
- package/dist/controllers/sidebar_controller.d.ts +107 -0
- package/dist/controllers/sidebar_controller.js +761 -0
- package/dist/controllers/sidebar_controller.js.map +1 -0
- package/dist/controllers/stepper_controller.d.ts +4 -0
- package/dist/controllers/stepper_controller.js +28 -12
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.js +8 -4
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.d.ts +17 -4
- package/dist/controllers/sticky_observer_controller.js +88 -20
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +3 -3
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +91 -0
- package/dist/controllers/tags_input_controller.js +275 -0
- package/dist/controllers/tags_input_controller.js.map +1 -0
- package/dist/controllers/theme_controller.js +20 -10
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +67 -0
- package/dist/controllers/time_picker_controller.js +212 -0
- package/dist/controllers/time_picker_controller.js.map +1 -0
- package/dist/controllers/toast_controller.d.ts +8 -18
- package/dist/controllers/toast_controller.js +144 -38
- 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/controllers/transition_controller.d.ts +14 -8
- package/dist/controllers/transition_controller.js +153 -38
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +54 -0
- package/dist/controllers/tree_view_controller.js +275 -0
- package/dist/controllers/tree_view_controller.js.map +1 -0
- package/dist/index.d.ts +27 -1788
- package/dist/index.js +1644 -643
- 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 +8 -8
- package/dist/inspector/manifest.json +30 -5
- package/package.json +7 -7
|
@@ -2,6 +2,90 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/tooltip_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/escape_layer.ts
|
|
6
|
+
var EscapeLayer = class _EscapeLayer {
|
|
7
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
8
|
+
#ownerDocument = null;
|
|
9
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
10
|
+
#onDismiss = null;
|
|
11
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
12
|
+
#claims = null;
|
|
13
|
+
/**
|
|
14
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
15
|
+
* the document's shared resolver listener if this is its first layer.
|
|
16
|
+
* Re-activating an already-active layer moves it to the top.
|
|
17
|
+
*/
|
|
18
|
+
activate(ownerDocument = document, options) {
|
|
19
|
+
this.deactivate();
|
|
20
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
21
|
+
if (!registry) {
|
|
22
|
+
registry = _EscapeLayer.#createRegistry();
|
|
23
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
24
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
25
|
+
}
|
|
26
|
+
registry.stack.push(this);
|
|
27
|
+
this.#ownerDocument = ownerDocument;
|
|
28
|
+
this.#onDismiss = options.onDismiss;
|
|
29
|
+
this.#claims = options.claims ?? null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
33
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
34
|
+
*/
|
|
35
|
+
deactivate() {
|
|
36
|
+
const ownerDocument = this.#ownerDocument;
|
|
37
|
+
if (!ownerDocument) return;
|
|
38
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
39
|
+
if (registry) {
|
|
40
|
+
const index = registry.stack.lastIndexOf(this);
|
|
41
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
42
|
+
if (registry.stack.length === 0) {
|
|
43
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
44
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this.#ownerDocument = null;
|
|
48
|
+
this.#onDismiss = null;
|
|
49
|
+
this.#claims = null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
53
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
54
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
55
|
+
*/
|
|
56
|
+
get ownsEscape() {
|
|
57
|
+
const ownerDocument = this.#ownerDocument;
|
|
58
|
+
if (!ownerDocument) return false;
|
|
59
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
60
|
+
if (!registry) return false;
|
|
61
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
62
|
+
}
|
|
63
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
64
|
+
static #createRegistry() {
|
|
65
|
+
const registry = {
|
|
66
|
+
stack: [],
|
|
67
|
+
onKeydown: (event) => {
|
|
68
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
69
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
70
|
+
if (!owner) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
owner.#onDismiss?.();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
return registry;
|
|
76
|
+
}
|
|
77
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
78
|
+
static #resolveOwner(stack) {
|
|
79
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
80
|
+
const layer = stack[index];
|
|
81
|
+
if (!layer) continue;
|
|
82
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
83
|
+
return layer;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
5
89
|
// src/utils/safe_timeout.ts
|
|
6
90
|
var TimerRegistry = class {
|
|
7
91
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -88,27 +172,39 @@ var TooltipController = class extends Controller {
|
|
|
88
172
|
hideDelay: { type: Number, default: 0 },
|
|
89
173
|
closeOnScroll: { type: Boolean, default: false }
|
|
90
174
|
};
|
|
91
|
-
static actions = ["hide", "
|
|
92
|
-
/**
|
|
175
|
+
static actions = ["hide", "show"];
|
|
176
|
+
/** Registry whose pending timers are cancelled individually with their guard ids. */
|
|
93
177
|
#timers = new SafeTimeout();
|
|
94
|
-
/**
|
|
178
|
+
/** Escape-stack membership while shown; the shared resolver dismisses via it. */
|
|
179
|
+
#escapeLayer = new EscapeLayer();
|
|
180
|
+
/** The id of the currently pending show timer, if any. */
|
|
95
181
|
#pendingShow = null;
|
|
182
|
+
/** The id of the currently pending hide timer, if any. */
|
|
96
183
|
#pendingHide = null;
|
|
184
|
+
/** Whether focus or the pointer currently requires the tooltip to persist. */
|
|
185
|
+
#focusActive = false;
|
|
186
|
+
#pointerActive = false;
|
|
97
187
|
/** Cleanup for the dismiss-on-scroll listeners while shown, or `null`. */
|
|
98
188
|
#stopScrollDismiss = null;
|
|
99
|
-
/** Starts hidden. */
|
|
189
|
+
/** Starts hidden with no stale timer or interaction state from a prior connection. */
|
|
100
190
|
connect() {
|
|
191
|
+
this.#cancelShow();
|
|
192
|
+
this.#cancelHide();
|
|
193
|
+
this.#resetInteractionState();
|
|
101
194
|
this.#conceal();
|
|
102
195
|
}
|
|
103
|
-
/** Clears timers
|
|
196
|
+
/** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */
|
|
104
197
|
disconnect() {
|
|
105
|
-
this.#
|
|
106
|
-
|
|
198
|
+
this.#cancelShow();
|
|
199
|
+
this.#cancelHide();
|
|
200
|
+
this.#resetInteractionState();
|
|
201
|
+
this.#escapeLayer.deactivate();
|
|
107
202
|
this.#stopScrollDismiss?.();
|
|
108
203
|
this.#stopScrollDismiss = null;
|
|
109
204
|
}
|
|
110
|
-
/** Shows
|
|
111
|
-
show() {
|
|
205
|
+
/** Shows after `showDelay`, recording the focus/pointer reason supplied by an action event. */
|
|
206
|
+
show(event) {
|
|
207
|
+
this.#activateInteraction(event);
|
|
112
208
|
this.#cancelHide();
|
|
113
209
|
if (this.#isVisible || this.#pendingShow !== null) return;
|
|
114
210
|
if (this.showDelayValue <= 0) {
|
|
@@ -120,8 +216,13 @@ var TooltipController = class extends Controller {
|
|
|
120
216
|
this.#reveal();
|
|
121
217
|
}, this.showDelayValue);
|
|
122
218
|
}
|
|
123
|
-
/** Hides
|
|
124
|
-
hide() {
|
|
219
|
+
/** Hides after `hideDelay` once no focus/pointer reason remains; eventless calls are explicit. */
|
|
220
|
+
hide(event) {
|
|
221
|
+
const interactionEnded = this.#deactivateInteraction(event);
|
|
222
|
+
if (interactionEnded && this.#hasActiveInteraction) {
|
|
223
|
+
this.#cancelHide();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
125
226
|
this.#cancelShow();
|
|
126
227
|
if (!this.#isVisible || this.#pendingHide !== null) return;
|
|
127
228
|
if (this.hideDelayValue <= 0) {
|
|
@@ -133,52 +234,57 @@ var TooltipController = class extends Controller {
|
|
|
133
234
|
this.#conceal();
|
|
134
235
|
}, this.hideDelayValue);
|
|
135
236
|
}
|
|
136
|
-
/**
|
|
137
|
-
* Dismisses the tooltip on `Escape` from the trigger. The authoritative
|
|
138
|
-
* dismissal path is the document-level listener (added while shown) so a
|
|
139
|
-
* hover-triggered tooltip is dismissible regardless of focus; this handler
|
|
140
|
-
* keeps the documented `keydown->#onKeydown` binding meaningful too.
|
|
141
|
-
*/
|
|
142
|
-
onKeydown(event) {
|
|
143
|
-
if (event.key === "Escape" && this.#isVisible) {
|
|
144
|
-
event.preventDefault();
|
|
145
|
-
this.#cancelShow();
|
|
146
|
-
this.#cancelHide();
|
|
147
|
-
this.#conceal();
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
/** Reveals the content and starts watching for a dismissing `Escape`/scroll. */
|
|
237
|
+
/** Reveals the content and joins the Escape stack / starts the scroll watcher. */
|
|
151
238
|
#reveal() {
|
|
152
239
|
if (!this.hasContentTarget) return;
|
|
153
240
|
this.contentTarget.hidden = false;
|
|
154
241
|
this.contentTarget.setAttribute("data-state", "open");
|
|
155
|
-
|
|
242
|
+
this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });
|
|
156
243
|
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
157
|
-
this.#stopScrollDismiss = observeScrollDismiss(this.element, () =>
|
|
158
|
-
this.#cancelShow();
|
|
159
|
-
this.#cancelHide();
|
|
160
|
-
this.#conceal();
|
|
161
|
-
});
|
|
244
|
+
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());
|
|
162
245
|
}
|
|
163
246
|
}
|
|
164
|
-
/** Hides the content and stops
|
|
247
|
+
/** Hides the content and leaves the Escape stack / stops the scroll watcher. */
|
|
165
248
|
#conceal() {
|
|
166
|
-
|
|
249
|
+
this.#escapeLayer.deactivate();
|
|
167
250
|
this.#stopScrollDismiss?.();
|
|
168
251
|
this.#stopScrollDismiss = null;
|
|
169
252
|
if (!this.hasContentTarget) return;
|
|
170
253
|
this.contentTarget.hidden = true;
|
|
171
254
|
this.contentTarget.setAttribute("data-state", "closed");
|
|
172
255
|
}
|
|
173
|
-
/**
|
|
174
|
-
#
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
256
|
+
/** Cancels pending timers and conceals immediately (shared Escape / scroll path). */
|
|
257
|
+
#dismiss() {
|
|
258
|
+
this.#cancelShow();
|
|
259
|
+
this.#cancelHide();
|
|
260
|
+
this.#conceal();
|
|
261
|
+
}
|
|
262
|
+
/** Records the modality whose enter/focus event requires the tooltip to stay visible. */
|
|
263
|
+
#activateInteraction(event) {
|
|
264
|
+
if (event?.type === "mouseenter") this.#pointerActive = true;
|
|
265
|
+
if (event?.type === "focusin") this.#focusActive = true;
|
|
266
|
+
}
|
|
267
|
+
/** Clears a modality on leave/blur and reports whether the event represented such a change. */
|
|
268
|
+
#deactivateInteraction(event) {
|
|
269
|
+
if (event?.type === "mouseleave") {
|
|
270
|
+
this.#pointerActive = false;
|
|
271
|
+
return true;
|
|
180
272
|
}
|
|
181
|
-
|
|
273
|
+
if (event?.type === "focusout") {
|
|
274
|
+
this.#focusActive = false;
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
/** Discards interaction reasons at a lifecycle boundary. */
|
|
280
|
+
#resetInteractionState() {
|
|
281
|
+
this.#focusActive = false;
|
|
282
|
+
this.#pointerActive = false;
|
|
283
|
+
}
|
|
284
|
+
/** Whether focus or pointer presence still requires a persistent tooltip. */
|
|
285
|
+
get #hasActiveInteraction() {
|
|
286
|
+
return this.#focusActive || this.#pointerActive;
|
|
287
|
+
}
|
|
182
288
|
/** Cancels any pending show timer. */
|
|
183
289
|
#cancelShow() {
|
|
184
290
|
if (this.#pendingShow !== null) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/utils/scroll_dismiss.ts","../../src/controllers/tooltip_controller.ts"],"names":[],"mappings":"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","file":"tooltip_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","/**\n * Invokes `onScroll` when the page — or any scrollable ancestor of `element` —\n * scrolls, returning a cleanup that detaches every listener.\n *\n * `scroll` events do not bubble, and a single capture-phase listener on\n * `window`/`document` is unreliable for descendant scroll containers — so this\n * attaches a `scroll` listener directly to each scroll-parent ancestor (walking\n * `parentElement` and checking `overflow`) plus the window. That mirrors the\n * *intent* of `@floating-ui/dom`'s `autoUpdate` scroll tracking; it is a\n * deliberately simple resolver and does not cross shadow roots. This is precisely\n * the case a consumer cannot cover with `data-action` alone, which is why the\n * surface controllers own it.\n *\n * The listeners are `passive` (the handler never calls `preventDefault`), so they\n * never delay scrolling. Pass the surface's anchor/root so the relevant scroll\n * parents are found even when the floating element is portaled elsewhere.\n */\nexport function observeScrollDismiss(element: Element, onScroll: () => void): () => void {\n const targets: Array<Element | Window> = [...scrollParents(element), window];\n const handler = (): void => onScroll();\n for (const target of targets) {\n target.addEventListener(\"scroll\", handler, { passive: true });\n }\n return () => {\n for (const target of targets) target.removeEventListener(\"scroll\", handler);\n };\n}\n\n/** The scrollable ancestors of `element`, nearest first (window excluded). */\nfunction scrollParents(element: Element): Element[] {\n const parents: Element[] = [];\n let node = element.parentElement;\n while (node) {\n if (isScrollable(node)) parents.push(node);\n node = node.parentElement;\n }\n return parents;\n}\n\n/** Whether `element` is a scroll container (any axis `auto`/`scroll`/`overlay`). */\nfunction isScrollable(element: Element): boolean {\n const style = getComputedStyle(element);\n return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\nimport { observeScrollDismiss } from \"../utils/scroll_dismiss\";\n\n/**\n * Headless, accessible **tooltip** behavior.\n *\n * Markup contract (identifier: `stimeo--tooltip`):\n * <span data-controller=\"stimeo--tooltip\">\n * <button data-stimeo--tooltip-target=\"trigger\" aria-describedby=\"tip\"\n * data-action=\"mouseenter->stimeo--tooltip#show\n * mouseleave->stimeo--tooltip#hide\n * focusin->stimeo--tooltip#show\n * focusout->stimeo--tooltip#hide\n * keydown->stimeo--tooltip#onKeydown\">Save</button>\n * <span id=\"tip\" role=\"tooltip\" data-stimeo--tooltip-target=\"content\"\n * data-action=\"mouseenter->stimeo--tooltip#show\n * mouseleave->stimeo--tooltip#hide\" hidden>…</span>\n * </span>\n *\n * Implements the WAI-ARIA APG **Tooltip** pattern and WCAG 2.2 SC 1.4.13\n * (hoverable / dismissible / persistent). The tooltip never receives focus and\n * holds no interactive content — for that use `stimeo--hover-card` or\n * `stimeo--popover`. The `aria-describedby` association is declared in the\n * consumer's markup; this controller only toggles visibility.\n *\n * @remarks\n * Behavior only — placement is the consumer's CSS (static) or the opt-in\n * `stimeo-ui/positioning` module (dynamic); this controller never imports it.\n *\n * Behavior provided:\n * - Show on `mouseenter`/`focusin`, hide on `mouseleave`/`focusout`, each gated by\n * `showDelay`/`hideDelay` to prevent flicker.\n * - **Hoverable bridge**: binding show/hide on the content too means moving the\n * pointer from trigger into the tooltip cancels the pending hide, so it stays up.\n * - **Dismissible**: while shown, `Escape` is watched at the `document` level so it\n * dismisses even when a hover (not focus) triggered it and focus is elsewhere.\n * - Visibility flips `hidden` and `data-state` (`open`/`closed`); the\n * `aria-describedby` reference is always preserved.\n * - Opt-in **dismiss on scroll** (`closeOnScroll`): while shown, scrolling a tracked\n * scroll-parent ancestor (or the window) hides the tooltip — the Radix / floating-ui\n * convention, useful for focus-triggered tooltips that a pointer-leave cannot\n * dismiss. Off by default.\n */\nexport class TooltipController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"content\"];\n static override values = {\n showDelay: { type: Number, default: 0 },\n hideDelay: { type: Number, default: 0 },\n closeOnScroll: { type: Boolean, default: false },\n };\n static actions = [\"hide\", \"onKeydown\", \"show\"] as const;\n\n declare readonly contentTarget: HTMLElement;\n declare readonly hasContentTarget: boolean;\n declare readonly showDelayValue: number;\n declare readonly hideDelayValue: number;\n declare readonly closeOnScrollValue: boolean;\n\n /** Pending show/hide timers, torn down together on disconnect. */\n readonly #timers = new SafeTimeout();\n /** The id of the currently pending show or hide timer, if any. */\n #pendingShow: number | null = null;\n #pendingHide: number | null = null;\n /** Cleanup for the dismiss-on-scroll listeners while shown, or `null`. */\n #stopScrollDismiss: (() => void) | null = null;\n\n /** Starts hidden. */\n override connect(): void {\n this.#conceal();\n }\n\n /** Clears timers and the document `Escape` / scroll listeners so nothing outlives the element. */\n override disconnect(): void {\n this.#timers.clearAll();\n document.removeEventListener(\"keydown\", this.#onDocumentKeydown);\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n }\n\n /** Shows the tooltip, after `showDelay` ms (or immediately at 0). Cancels a pending hide. */\n show(): void {\n this.#cancelHide();\n if (this.#isVisible || this.#pendingShow !== null) return;\n if (this.showDelayValue <= 0) {\n this.#reveal();\n return;\n }\n this.#pendingShow = this.#timers.set(() => {\n this.#pendingShow = null;\n this.#reveal();\n }, this.showDelayValue);\n }\n\n /** Hides the tooltip, after `hideDelay` ms (or immediately at 0). Cancels a pending show. */\n hide(): void {\n this.#cancelShow();\n if (!this.#isVisible || this.#pendingHide !== null) return;\n if (this.hideDelayValue <= 0) {\n this.#conceal();\n return;\n }\n this.#pendingHide = this.#timers.set(() => {\n this.#pendingHide = null;\n this.#conceal();\n }, this.hideDelayValue);\n }\n\n /**\n * Dismisses the tooltip on `Escape` from the trigger. The authoritative\n * dismissal path is the document-level listener (added while shown) so a\n * hover-triggered tooltip is dismissible regardless of focus; this handler\n * keeps the documented `keydown->#onKeydown` binding meaningful too.\n */\n onKeydown(event: KeyboardEvent): void {\n if (event.key === \"Escape\" && this.#isVisible) {\n event.preventDefault();\n this.#cancelShow();\n this.#cancelHide();\n this.#conceal();\n }\n }\n\n /** Reveals the content and starts watching for a dismissing `Escape`/scroll. */\n #reveal(): void {\n if (!this.hasContentTarget) return;\n this.contentTarget.hidden = false;\n this.contentTarget.setAttribute(\"data-state\", \"open\");\n document.addEventListener(\"keydown\", this.#onDocumentKeydown);\n if (this.closeOnScrollValue && !this.#stopScrollDismiss) {\n this.#stopScrollDismiss = observeScrollDismiss(this.element, () => {\n this.#cancelShow();\n this.#cancelHide();\n this.#conceal();\n });\n }\n }\n\n /** Hides the content and stops watching for `Escape`/scroll. */\n #conceal(): void {\n // Release listeners first, unconditionally: if the content target was removed\n // from the DOM while shown, an early return would leak the document keydown\n // and scroll-dismiss listeners.\n document.removeEventListener(\"keydown\", this.#onDocumentKeydown);\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n if (!this.hasContentTarget) return;\n this.contentTarget.hidden = true;\n this.contentTarget.setAttribute(\"data-state\", \"closed\");\n }\n\n /** Document-level `Escape` watcher (active only while shown). */\n readonly #onDocumentKeydown = (event: KeyboardEvent): void => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n this.#cancelShow();\n this.#cancelHide();\n this.#conceal();\n }\n };\n\n /** Cancels any pending show timer. */\n #cancelShow(): void {\n if (this.#pendingShow !== null) {\n this.#timers.clear(this.#pendingShow);\n this.#pendingShow = null;\n }\n }\n\n /** Cancels any pending hide timer. */\n #cancelHide(): void {\n if (this.#pendingHide !== null) {\n this.#timers.clear(this.#pendingHide);\n this.#pendingHide = null;\n }\n }\n\n /** Whether the tooltip is currently shown. */\n get #isVisible(): boolean {\n return this.hasContentTarget && !this.contentTarget.hidden;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/escape_layer.ts","../../src/utils/safe_timeout.ts","../../src/utils/scroll_dismiss.ts","../../src/controllers/tooltip_controller.ts"],"names":[],"mappings":"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","file":"tooltip_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","/**\n * Invokes `onScroll` when the page — or any scrollable ancestor of `element` —\n * scrolls, returning a cleanup that detaches every listener.\n *\n * `scroll` events do not bubble, and a single capture-phase listener on\n * `window`/`document` is unreliable for descendant scroll containers — so this\n * attaches a `scroll` listener directly to each scroll-parent ancestor (walking\n * `parentElement` and checking `overflow`) plus the window. That mirrors the\n * *intent* of `@floating-ui/dom`'s `autoUpdate` scroll tracking; it is a\n * deliberately simple resolver and does not cross shadow roots. This is precisely\n * the case a consumer cannot cover with `data-action` alone, which is why the\n * surface controllers own it.\n *\n * The listeners are `passive` (the handler never calls `preventDefault`), so they\n * never delay scrolling. Pass the surface's anchor/root so the relevant scroll\n * parents are found even when the floating element is portaled elsewhere.\n */\nexport function observeScrollDismiss(element: Element, onScroll: () => void): () => void {\n const targets: Array<Element | Window> = [...scrollParents(element), window];\n const handler = (): void => onScroll();\n for (const target of targets) {\n target.addEventListener(\"scroll\", handler, { passive: true });\n }\n return () => {\n for (const target of targets) target.removeEventListener(\"scroll\", handler);\n };\n}\n\n/** The scrollable ancestors of `element`, nearest first (window excluded). */\nfunction scrollParents(element: Element): Element[] {\n const parents: Element[] = [];\n let node = element.parentElement;\n while (node) {\n if (isScrollable(node)) parents.push(node);\n node = node.parentElement;\n }\n return parents;\n}\n\n/** Whether `element` is a scroll container (any axis `auto`/`scroll`/`overlay`). */\nfunction isScrollable(element: Element): boolean {\n const style = getComputedStyle(element);\n return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { EscapeLayer } from \"../utils/escape_layer\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\nimport { observeScrollDismiss } from \"../utils/scroll_dismiss\";\n\n/**\n * Headless, accessible **tooltip** behavior.\n *\n * Markup contract (identifier: `stimeo--tooltip`):\n * <span data-controller=\"stimeo--tooltip\">\n * <button data-stimeo--tooltip-target=\"trigger\" aria-describedby=\"tip\"\n * data-action=\"mouseenter->stimeo--tooltip#show\n * mouseleave->stimeo--tooltip#hide\n * focusin->stimeo--tooltip#show\n * focusout->stimeo--tooltip#hide\">Save</button>\n * <span id=\"tip\" role=\"tooltip\" data-stimeo--tooltip-target=\"content\"\n * data-action=\"mouseenter->stimeo--tooltip#show\n * mouseleave->stimeo--tooltip#hide\" hidden>…</span>\n * </span>\n *\n * Implements the WAI-ARIA APG **Tooltip** pattern and WCAG 2.2 SC 1.4.13\n * (hoverable / dismissible / persistent). The tooltip never receives focus and\n * holds no interactive content — for that use `stimeo--hover-card` or\n * `stimeo--popover`. The `aria-describedby` association is declared in the\n * consumer's markup; this controller only toggles visibility.\n *\n * @remarks\n * Behavior only — placement is the consumer's CSS (static) or the opt-in\n * `stimeo-ui/positioning` module (dynamic); this controller never imports it.\n *\n * Behavior provided:\n * - Show on `mouseenter`/`focusin`, hide on `mouseleave`/`focusout`, each gated by\n * `showDelay`/`hideDelay` to prevent flicker.\n * - **Hoverable bridge**: binding show/hide on the content too means moving the\n * pointer from trigger into the tooltip cancels the pending hide, so it stays up.\n * - **Persistent across input modalities**: focus and pointer presence are tracked\n * separately, so leaving one does not hide while the other still requires the hint.\n * - **Dismissible**: while shown, the tooltip joins the shared {@link EscapeLayer}\n * stack, so `Escape` dismisses it even when a hover (not focus) triggered it and\n * focus is elsewhere. The resolver ignores an Escape already consumed by an\n * inner handler and lets the most recently shown layer own the press, so one\n * keypress closes exactly one layer (the shared layered-Escape contract).\n * - Visibility flips `hidden` and `data-state` (`open`/`closed`); the\n * `aria-describedby` reference is always preserved.\n * - Opt-in **dismiss on scroll** (`closeOnScroll`): while shown, scrolling a tracked\n * scroll-parent ancestor (or the window) hides the tooltip — the Radix / floating-ui\n * convention, useful for focus-triggered tooltips that a pointer-leave cannot\n * dismiss. Off by default.\n */\nexport class TooltipController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"content\"];\n static override values = {\n showDelay: { type: Number, default: 0 },\n hideDelay: { type: Number, default: 0 },\n closeOnScroll: { type: Boolean, default: false },\n };\n static actions = [\"hide\", \"show\"] as const;\n\n declare readonly contentTarget: HTMLElement;\n declare readonly hasContentTarget: boolean;\n declare readonly showDelayValue: number;\n declare readonly hideDelayValue: number;\n declare readonly closeOnScrollValue: boolean;\n\n /** Registry whose pending timers are cancelled individually with their guard ids. */\n readonly #timers = new SafeTimeout();\n /** Escape-stack membership while shown; the shared resolver dismisses via it. */\n readonly #escapeLayer = new EscapeLayer();\n /** The id of the currently pending show timer, if any. */\n #pendingShow: number | null = null;\n /** The id of the currently pending hide timer, if any. */\n #pendingHide: number | null = null;\n /** Whether focus or the pointer currently requires the tooltip to persist. */\n #focusActive = false;\n #pointerActive = false;\n /** Cleanup for the dismiss-on-scroll listeners while shown, or `null`. */\n #stopScrollDismiss: (() => void) | null = null;\n\n /** Starts hidden with no stale timer or interaction state from a prior connection. */\n override connect(): void {\n this.#cancelShow();\n this.#cancelHide();\n this.#resetInteractionState();\n this.#conceal();\n }\n\n /** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */\n override disconnect(): void {\n this.#cancelShow();\n this.#cancelHide();\n this.#resetInteractionState();\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n }\n\n /** Shows after `showDelay`, recording the focus/pointer reason supplied by an action event. */\n show(event?: Event): void {\n this.#activateInteraction(event);\n this.#cancelHide();\n if (this.#isVisible || this.#pendingShow !== null) return;\n if (this.showDelayValue <= 0) {\n this.#reveal();\n return;\n }\n this.#pendingShow = this.#timers.set(() => {\n this.#pendingShow = null;\n this.#reveal();\n }, this.showDelayValue);\n }\n\n /** Hides after `hideDelay` once no focus/pointer reason remains; eventless calls are explicit. */\n hide(event?: Event): void {\n const interactionEnded = this.#deactivateInteraction(event);\n if (interactionEnded && this.#hasActiveInteraction) {\n this.#cancelHide();\n return;\n }\n this.#cancelShow();\n if (!this.#isVisible || this.#pendingHide !== null) return;\n if (this.hideDelayValue <= 0) {\n this.#conceal();\n return;\n }\n this.#pendingHide = this.#timers.set(() => {\n this.#pendingHide = null;\n this.#conceal();\n }, this.hideDelayValue);\n }\n\n /** Reveals the content and joins the Escape stack / starts the scroll watcher. */\n #reveal(): void {\n if (!this.hasContentTarget) return;\n this.contentTarget.hidden = false;\n this.contentTarget.setAttribute(\"data-state\", \"open\");\n // No claims predicate: a shown hover hint is dismissible regardless of\n // where focus sits (WCAG 2.2 SC 1.4.13), so it always claims while shown.\n this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });\n if (this.closeOnScrollValue && !this.#stopScrollDismiss) {\n this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());\n }\n }\n\n /** Hides the content and leaves the Escape stack / stops the scroll watcher. */\n #conceal(): void {\n // Release the layer and observers first, unconditionally: if the content\n // target was removed from the DOM while shown, an early return would leak\n // the stack entry and the scroll-dismiss listeners.\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n if (!this.hasContentTarget) return;\n this.contentTarget.hidden = true;\n this.contentTarget.setAttribute(\"data-state\", \"closed\");\n }\n\n /** Cancels pending timers and conceals immediately (shared Escape / scroll path). */\n #dismiss(): void {\n this.#cancelShow();\n this.#cancelHide();\n this.#conceal();\n }\n\n /** Records the modality whose enter/focus event requires the tooltip to stay visible. */\n #activateInteraction(event?: Event): void {\n if (event?.type === \"mouseenter\") this.#pointerActive = true;\n if (event?.type === \"focusin\") this.#focusActive = true;\n }\n\n /** Clears a modality on leave/blur and reports whether the event represented such a change. */\n #deactivateInteraction(event?: Event): boolean {\n if (event?.type === \"mouseleave\") {\n this.#pointerActive = false;\n return true;\n }\n if (event?.type === \"focusout\") {\n this.#focusActive = false;\n return true;\n }\n return false;\n }\n\n /** Discards interaction reasons at a lifecycle boundary. */\n #resetInteractionState(): void {\n this.#focusActive = false;\n this.#pointerActive = false;\n }\n\n /** Whether focus or pointer presence still requires a persistent tooltip. */\n get #hasActiveInteraction(): boolean {\n return this.#focusActive || this.#pointerActive;\n }\n\n /** Cancels any pending show timer. */\n #cancelShow(): void {\n if (this.#pendingShow !== null) {\n this.#timers.clear(this.#pendingShow);\n this.#pendingShow = null;\n }\n }\n\n /** Cancels any pending hide timer. */\n #cancelHide(): void {\n if (this.#pendingHide !== null) {\n this.#timers.clear(this.#pendingHide);\n this.#pendingHide = null;\n }\n }\n\n /** Whether the tooltip is currently shown. */\n get #isVisible(): boolean {\n return this.hasContentTarget && !this.contentTarget.hidden;\n }\n}\n"]}
|
|
@@ -17,20 +17,26 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
17
17
|
* data-stimeo--transition-leave-to-value="opacity-0" hidden>…</div>
|
|
18
18
|
*
|
|
19
19
|
* `enter()` unhides the element, applies `enter` + `enterFrom`, then on the next frame
|
|
20
|
-
* swaps `enterFrom` → `enterTo` so the CSS transition runs, and
|
|
21
|
-
*
|
|
20
|
+
* swaps `enterFrom` → `enterTo` so the CSS transition runs, and settles to `entered`
|
|
21
|
+
* once the transition completes. `leave()` mirrors it and re-applies `hidden`.
|
|
22
22
|
* `toggle()` reverses the current direction. The element carries `data-transition-state`
|
|
23
23
|
* (`entering` / `entered` / `leaving` / `left`) and `entered` / `left` events fire on
|
|
24
24
|
* completion.
|
|
25
25
|
*
|
|
26
26
|
* @remarks
|
|
27
27
|
* Behavior only — the animation itself is the consumer's CSS; this controls *when* the
|
|
28
|
-
* stage classes are applied.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* `
|
|
32
|
-
*
|
|
33
|
-
*
|
|
28
|
+
* stage classes are applied. Completion is owned by the shared
|
|
29
|
+
* {@link TransitionCompletion}: every declared transition property must settle
|
|
30
|
+
* (`transitionend` / `transitioncancel`, pseudo-element events excluded) with a
|
|
31
|
+
* `max(duration + delay) + 50ms` bounded fallback, and a computed 0ms transition
|
|
32
|
+
* settles synchronously at the staging frame. A positive `timeout` Value replaces the
|
|
33
|
+
* fallback verbatim and keeps the wait armed even for a computed 0ms transition.
|
|
34
|
+
* Under `prefers-reduced-motion: reduce` it switches instantly (no staging). An
|
|
35
|
+
* interrupting call cancels the in-flight transition and starts the new one. State
|
|
36
|
+
* lives solely in `hidden` / `data-transition-state`, and `connect()` reconciles to a
|
|
37
|
+
* stable state (stripping any half-applied stage classes from a Turbo cache); the
|
|
38
|
+
* terminal-event listeners, rAF, and fallback timer are released on `disconnect()`
|
|
39
|
+
* (Turbo navigation included).
|
|
34
40
|
*/
|
|
35
41
|
declare class TransitionController extends Controller<HTMLElement> {
|
|
36
42
|
#private;
|
|
@@ -2,6 +2,11 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/transition_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/reduced_motion.ts
|
|
6
|
+
function prefersReducedMotion() {
|
|
7
|
+
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
// src/utils/safe_timeout.ts
|
|
6
11
|
var TimerRegistry = class {
|
|
7
12
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -55,14 +60,149 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
55
60
|
}
|
|
56
61
|
};
|
|
57
62
|
|
|
63
|
+
// src/utils/transition_completion.ts
|
|
64
|
+
function timeMs(value) {
|
|
65
|
+
const trimmed = value.trim();
|
|
66
|
+
const amount = Number.parseFloat(trimmed);
|
|
67
|
+
if (!Number.isFinite(amount)) return 0;
|
|
68
|
+
if (trimmed.endsWith("ms")) return amount;
|
|
69
|
+
if (trimmed.endsWith("s")) return amount * 1e3;
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
function cssList(value) {
|
|
73
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
function transitionTimings(style) {
|
|
76
|
+
const properties = cssList(style.transitionProperty);
|
|
77
|
+
const durations = cssList(style.transitionDuration).map(timeMs);
|
|
78
|
+
const delays = cssList(style.transitionDelay).map(timeMs);
|
|
79
|
+
const effectiveProperties = properties.length > 0 ? properties : Array.from({ length: Math.max(durations.length, delays.length, 1) }, () => "all");
|
|
80
|
+
const effectiveDurations = durations.length > 0 ? durations : [0];
|
|
81
|
+
const effectiveDelays = delays.length > 0 ? delays : [0];
|
|
82
|
+
return effectiveProperties.filter((property) => property !== "none").map((property, index) => ({
|
|
83
|
+
property,
|
|
84
|
+
totalMs: Math.max(
|
|
85
|
+
0,
|
|
86
|
+
(effectiveDurations[index % effectiveDurations.length] ?? 0) + (effectiveDelays[index % effectiveDelays.length] ?? 0)
|
|
87
|
+
)
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
function maxTotalMs(timings) {
|
|
91
|
+
return timings.reduce((max, { totalMs }) => Math.max(max, totalMs), 0);
|
|
92
|
+
}
|
|
93
|
+
var TransitionCompletion = class {
|
|
94
|
+
#timers = new SafeTimeout();
|
|
95
|
+
#element = null;
|
|
96
|
+
#complete = null;
|
|
97
|
+
#pendingProperties = null;
|
|
98
|
+
#deadline = 0;
|
|
99
|
+
/**
|
|
100
|
+
* Replaces any prior wait and invokes `complete` synchronously for a 0ms
|
|
101
|
+
* transition (including when `getComputedStyle` is unavailable).
|
|
102
|
+
*
|
|
103
|
+
* With a positive `options.timeoutMs` the synchronous fast-path is skipped and
|
|
104
|
+
* the override replaces the auto-computed fallback (see {@link TransitionWaitOptions}).
|
|
105
|
+
*/
|
|
106
|
+
wait(element, complete, options = {}) {
|
|
107
|
+
this.cancel();
|
|
108
|
+
const requested = options.timeoutMs ?? 0;
|
|
109
|
+
const override = Number.isFinite(requested) && requested > 0 ? requested : 0;
|
|
110
|
+
const timings = typeof window.getComputedStyle === "function" ? transitionTimings(window.getComputedStyle(element)) : [];
|
|
111
|
+
const maximum = maxTotalMs(timings);
|
|
112
|
+
if (maximum <= 0 && override <= 0) {
|
|
113
|
+
complete();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
this.#element = element;
|
|
117
|
+
this.#complete = complete;
|
|
118
|
+
this.#deadline = Date.now() + maximum;
|
|
119
|
+
const activeProperties = this.#activeTransitionProperties(element);
|
|
120
|
+
this.#pendingProperties = activeProperties.size > 0 ? activeProperties : this.#explicitPendingProperties(timings);
|
|
121
|
+
element.addEventListener("transitionend", this.#onTerminal);
|
|
122
|
+
element.addEventListener("transitioncancel", this.#onTerminal);
|
|
123
|
+
this.#timers.set(() => this.#finish(), override > 0 ? override : maximum + 50);
|
|
124
|
+
}
|
|
125
|
+
/** Cancels the pending wait without invoking its completion callback. */
|
|
126
|
+
cancel() {
|
|
127
|
+
this.#complete = null;
|
|
128
|
+
this.#teardown();
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Handles terminal events from the observed element only.
|
|
132
|
+
*
|
|
133
|
+
* For explicit property lists, every declared positive-time property must
|
|
134
|
+
* settle. For `all`, no reliable property set exists, so an event can finish
|
|
135
|
+
* only after the computed maximum time; the safety timeout owns the usual path.
|
|
136
|
+
*/
|
|
137
|
+
#onTerminal = (event) => {
|
|
138
|
+
if (event.target !== this.#element) return;
|
|
139
|
+
const transitionEvent = event;
|
|
140
|
+
if (transitionEvent.pseudoElement) return;
|
|
141
|
+
if (this.#pendingProperties) {
|
|
142
|
+
const propertyName = transitionEvent.propertyName;
|
|
143
|
+
const active = this.#activeTransitionProperties(this.#element);
|
|
144
|
+
if (active.has(propertyName)) return;
|
|
145
|
+
if (!this.#pendingProperties.delete(propertyName)) return;
|
|
146
|
+
if (this.#pendingProperties.size > 0) return;
|
|
147
|
+
if (active.size > 0) {
|
|
148
|
+
this.#pendingProperties = active;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this.#finish();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (Date.now() >= this.#deadline) this.#finish();
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* Returns active CSS transition properties expanded to the names reported by
|
|
158
|
+
* terminal events. CSS animations and pseudo-element effects are excluded.
|
|
159
|
+
*/
|
|
160
|
+
#activeTransitionProperties(element) {
|
|
161
|
+
if (!element || typeof element.getAnimations !== "function") return /* @__PURE__ */ new Set();
|
|
162
|
+
try {
|
|
163
|
+
const properties = element.getAnimations().flatMap((animation) => {
|
|
164
|
+
if (animation.playState === "idle" || animation.playState === "finished") return [];
|
|
165
|
+
const effect = animation.effect;
|
|
166
|
+
if (effect?.pseudoElement) return [];
|
|
167
|
+
const target = effect?.target;
|
|
168
|
+
if (target && target !== element) return [];
|
|
169
|
+
const property = animation.transitionProperty;
|
|
170
|
+
return typeof property === "string" && property.length > 0 ? [property] : [];
|
|
171
|
+
});
|
|
172
|
+
return new Set(properties);
|
|
173
|
+
} catch {
|
|
174
|
+
return /* @__PURE__ */ new Set();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Returns explicit positive-time properties, or `null` for the ambiguous `all`. */
|
|
178
|
+
#explicitPendingProperties(timings) {
|
|
179
|
+
if (timings.some(({ property }) => property === "all")) return null;
|
|
180
|
+
const pending = new Set(
|
|
181
|
+
timings.filter(({ totalMs }) => totalMs > 0).map(({ property }) => property)
|
|
182
|
+
);
|
|
183
|
+
return pending.size > 0 ? pending : null;
|
|
184
|
+
}
|
|
185
|
+
/** Completes exactly once, releasing listeners and the fallback before the callback. */
|
|
186
|
+
#finish() {
|
|
187
|
+
const complete = this.#complete;
|
|
188
|
+
if (!complete) return;
|
|
189
|
+
this.#complete = null;
|
|
190
|
+
this.#teardown();
|
|
191
|
+
complete();
|
|
192
|
+
}
|
|
193
|
+
/** Releases the exact element listeners and timer owned by the current wait. */
|
|
194
|
+
#teardown() {
|
|
195
|
+
this.#timers.clearAll();
|
|
196
|
+
this.#element?.removeEventListener("transitionend", this.#onTerminal);
|
|
197
|
+
this.#element?.removeEventListener("transitioncancel", this.#onTerminal);
|
|
198
|
+
this.#element = null;
|
|
199
|
+
this.#pendingProperties = null;
|
|
200
|
+
this.#deadline = 0;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
58
204
|
// src/controllers/transition_controller.ts
|
|
59
205
|
var tokensOf = (value) => value.split(/\s+/).filter(Boolean);
|
|
60
|
-
var firstTimeMs = (value) => {
|
|
61
|
-
const first = value.split(",")[0]?.trim() ?? "";
|
|
62
|
-
const amount = Number.parseFloat(first);
|
|
63
|
-
if (Number.isNaN(amount)) return 0;
|
|
64
|
-
return first.endsWith("ms") ? amount : amount * 1e3;
|
|
65
|
-
};
|
|
66
206
|
var TransitionController = class extends Controller {
|
|
67
207
|
static values = {
|
|
68
208
|
enter: { type: String, default: "" },
|
|
@@ -75,9 +215,9 @@ var TransitionController = class extends Controller {
|
|
|
75
215
|
};
|
|
76
216
|
static actions = ["enter", "leave", "toggle"];
|
|
77
217
|
static events = ["entered", "left"];
|
|
78
|
-
|
|
218
|
+
/** Owns the cancellable completion wait (terminal events + bounded fallback). */
|
|
219
|
+
#transition = new TransitionCompletion();
|
|
79
220
|
#rafId = null;
|
|
80
|
-
#endListener = null;
|
|
81
221
|
connect() {
|
|
82
222
|
this.#strip();
|
|
83
223
|
this.element.setAttribute("data-transition-state", this.element.hidden ? "left" : "entered");
|
|
@@ -104,7 +244,7 @@ var TransitionController = class extends Controller {
|
|
|
104
244
|
const isEnter = kind === "enter";
|
|
105
245
|
if (isEnter) this.element.hidden = false;
|
|
106
246
|
this.element.setAttribute("data-transition-state", isEnter ? "entering" : "leaving");
|
|
107
|
-
if (
|
|
247
|
+
if (prefersReducedMotion()) {
|
|
108
248
|
this.#finish(kind);
|
|
109
249
|
return;
|
|
110
250
|
}
|
|
@@ -116,12 +256,13 @@ var TransitionController = class extends Controller {
|
|
|
116
256
|
this.#rafId = null;
|
|
117
257
|
this.#remove(from);
|
|
118
258
|
this.#add(to);
|
|
119
|
-
this.#
|
|
259
|
+
this.#transition.wait(this.element, () => this.#finish(kind), {
|
|
260
|
+
timeoutMs: this.timeoutValue
|
|
261
|
+
});
|
|
120
262
|
});
|
|
121
263
|
}
|
|
122
264
|
/** Settles the element into the completed state, clearing the stage classes. */
|
|
123
265
|
#finish(kind) {
|
|
124
|
-
this.#cleanupEnd();
|
|
125
266
|
this.#strip();
|
|
126
267
|
if (kind === "enter") {
|
|
127
268
|
this.element.setAttribute("data-transition-state", "entered");
|
|
@@ -132,29 +273,13 @@ var TransitionController = class extends Controller {
|
|
|
132
273
|
this.dispatch("left", { detail: {} });
|
|
133
274
|
}
|
|
134
275
|
}
|
|
135
|
-
/** Resolves on the element's own `transitionend`, with a safety timeout fallback. */
|
|
136
|
-
#awaitEnd(done) {
|
|
137
|
-
this.#endListener = (event) => {
|
|
138
|
-
if (event.target === this.element) done();
|
|
139
|
-
};
|
|
140
|
-
this.element.addEventListener("transitionend", this.#endListener);
|
|
141
|
-
const ms = this.timeoutValue > 0 ? this.timeoutValue : this.#duration();
|
|
142
|
-
this.#timers.set(done, ms);
|
|
143
|
-
}
|
|
144
|
-
#cleanupEnd() {
|
|
145
|
-
if (this.#endListener) {
|
|
146
|
-
this.element.removeEventListener("transitionend", this.#endListener);
|
|
147
|
-
this.#endListener = null;
|
|
148
|
-
}
|
|
149
|
-
this.#timers.clearAll();
|
|
150
|
-
}
|
|
151
276
|
/** Cancels any in-flight transition (interruption / teardown). */
|
|
152
277
|
#cancel() {
|
|
153
278
|
if (this.#rafId !== null) {
|
|
154
279
|
this.#cancelRaf(this.#rafId);
|
|
155
280
|
this.#rafId = null;
|
|
156
281
|
}
|
|
157
|
-
this.#
|
|
282
|
+
this.#transition.cancel();
|
|
158
283
|
this.#strip();
|
|
159
284
|
}
|
|
160
285
|
#add(...lists) {
|
|
@@ -176,13 +301,6 @@ var TransitionController = class extends Controller {
|
|
|
176
301
|
this.leaveToValue
|
|
177
302
|
);
|
|
178
303
|
}
|
|
179
|
-
/** Auto-computed safety duration (transition time + delay, with a small buffer). */
|
|
180
|
-
#duration() {
|
|
181
|
-
if (typeof window.getComputedStyle !== "function") return 0;
|
|
182
|
-
const style = window.getComputedStyle(this.element);
|
|
183
|
-
const total = firstTimeMs(style.transitionDuration) + firstTimeMs(style.transitionDelay);
|
|
184
|
-
return total > 0 ? total + 50 : 0;
|
|
185
|
-
}
|
|
186
304
|
#raf(callback) {
|
|
187
305
|
if (typeof window.requestAnimationFrame === "function") {
|
|
188
306
|
return window.requestAnimationFrame(() => callback());
|
|
@@ -193,9 +311,6 @@ var TransitionController = class extends Controller {
|
|
|
193
311
|
if (typeof window.cancelAnimationFrame === "function") window.cancelAnimationFrame(id);
|
|
194
312
|
else window.clearTimeout(id);
|
|
195
313
|
}
|
|
196
|
-
#prefersReducedMotion() {
|
|
197
|
-
return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
198
|
-
}
|
|
199
314
|
};
|
|
200
315
|
|
|
201
316
|
export { TransitionController };
|