stimeo-ui 0.1.0.pre.beta.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +90 -0
- data/README.md +6 -6
- data/dist/controllers/auto_submit_controller.js +56 -21
- data/dist/controllers/character_counter_controller.js +52 -15
- data/dist/controllers/combobox_controller.js +64 -3
- data/dist/controllers/command_palette_controller.js +243 -39
- data/dist/controllers/confirm_controller.js +94 -8
- data/dist/controllers/context_menu_controller.js +191 -13
- data/dist/controllers/countdown_controller.js +2 -2
- data/dist/controllers/dialog_controller.js +94 -8
- data/dist/controllers/dropdown_controller.js +139 -16
- data/dist/controllers/focus_controller.js +94 -8
- data/dist/controllers/form_validation_controller.js +2 -2
- data/dist/controllers/hover_card_controller.js +103 -27
- data/dist/controllers/listbox_controller.js +1 -0
- data/dist/controllers/menu_controller.js +181 -8
- data/dist/controllers/otp_controller.js +62 -22
- data/dist/controllers/pointer_drag_controller.js +3 -3
- data/dist/controllers/popover_controller.js +118 -27
- data/dist/controllers/toast_controller.js +108 -29
- data/dist/controllers/tooltip_controller.js +148 -42
- data/dist/index.js +833 -348
- data/lib/stimeo/ui/version.rb +5 -4
- metadata +2 -2
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/otp_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/composition_tracker.ts
|
|
6
|
+
var CompositionTracker = class {
|
|
7
|
+
#observedTargets = /* @__PURE__ */ new Set();
|
|
8
|
+
#activeTargets = /* @__PURE__ */ new Set();
|
|
9
|
+
#onStart;
|
|
10
|
+
#onEnd;
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.#onStart = options.onStart;
|
|
13
|
+
this.#onEnd = options.onEnd;
|
|
14
|
+
}
|
|
15
|
+
/** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
|
|
16
|
+
observe(target) {
|
|
17
|
+
if (this.#observedTargets.has(target)) return;
|
|
18
|
+
target.addEventListener("compositionstart", this.#handleStart);
|
|
19
|
+
target.addEventListener("compositionend", this.#handleEnd);
|
|
20
|
+
this.#observedTargets.add(target);
|
|
21
|
+
}
|
|
22
|
+
/** Stops tracking one target and clears any active composition it owned. */
|
|
23
|
+
unobserve(target) {
|
|
24
|
+
if (!this.#observedTargets.delete(target)) return;
|
|
25
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
26
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
27
|
+
this.#activeTargets.delete(target);
|
|
28
|
+
}
|
|
29
|
+
/** Releases every listener and clears state so reconnect starts cleanly. */
|
|
30
|
+
disconnect() {
|
|
31
|
+
for (const target of this.#observedTargets) {
|
|
32
|
+
target.removeEventListener("compositionstart", this.#handleStart);
|
|
33
|
+
target.removeEventListener("compositionend", this.#handleEnd);
|
|
34
|
+
}
|
|
35
|
+
this.#observedTargets.clear();
|
|
36
|
+
this.#activeTargets.clear();
|
|
37
|
+
}
|
|
38
|
+
/** True when lifecycle tracking or the current event reports composition. */
|
|
39
|
+
isComposing(event) {
|
|
40
|
+
return this.#activeTargets.size > 0 || event?.isComposing === true;
|
|
41
|
+
}
|
|
42
|
+
#handleStart = (event) => {
|
|
43
|
+
if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
|
|
44
|
+
this.#onStart?.(event);
|
|
45
|
+
};
|
|
46
|
+
#handleEnd = (event) => {
|
|
47
|
+
if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
|
|
48
|
+
this.#onEnd?.(event);
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
3
52
|
// src/controllers/otp_controller.ts
|
|
4
53
|
var OtpController = class extends Controller {
|
|
5
54
|
static targets = ["field", "value", "error"];
|
|
@@ -9,21 +58,24 @@ var OtpController = class extends Controller {
|
|
|
9
58
|
};
|
|
10
59
|
static actions = ["onInput", "onKeydown", "onPaste"];
|
|
11
60
|
static events = ["change", "complete", "invalid"];
|
|
12
|
-
|
|
61
|
+
/** Owns IME lifecycle state across every digit field. */
|
|
62
|
+
#composition = new CompositionTracker({
|
|
63
|
+
onEnd: (event) => {
|
|
64
|
+
const input = event.currentTarget;
|
|
65
|
+
if (input) this.#handleInputValidation(input);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
13
68
|
connect() {
|
|
14
69
|
for (const field of this.fieldTargets) {
|
|
15
70
|
field.addEventListener("focus", this.#onFieldFocus);
|
|
16
|
-
|
|
17
|
-
field.addEventListener("compositionend", this.#onCompositionEnd);
|
|
71
|
+
this.#composition.observe(field);
|
|
18
72
|
}
|
|
19
73
|
}
|
|
20
74
|
disconnect() {
|
|
21
75
|
for (const field of this.fieldTargets) {
|
|
22
76
|
field.removeEventListener("focus", this.#onFieldFocus);
|
|
23
|
-
field.removeEventListener("compositionstart", this.#onCompositionStart);
|
|
24
|
-
field.removeEventListener("compositionend", this.#onCompositionEnd);
|
|
25
77
|
}
|
|
26
|
-
this.#
|
|
78
|
+
this.#composition.disconnect();
|
|
27
79
|
}
|
|
28
80
|
/**
|
|
29
81
|
* Stimulus lifecycle callback when a new field target enters the DOM.
|
|
@@ -31,21 +83,18 @@ var OtpController = class extends Controller {
|
|
|
31
83
|
*/
|
|
32
84
|
fieldTargetConnected(element) {
|
|
33
85
|
element.addEventListener("focus", this.#onFieldFocus);
|
|
34
|
-
|
|
35
|
-
element.addEventListener("compositionend", this.#onCompositionEnd);
|
|
86
|
+
this.#composition.observe(element);
|
|
36
87
|
}
|
|
37
88
|
/** Removes focus listeners when fields are dropped. */
|
|
38
89
|
fieldTargetDisconnected(element) {
|
|
39
90
|
element.removeEventListener("focus", this.#onFieldFocus);
|
|
40
|
-
|
|
41
|
-
element.removeEventListener("compositionend", this.#onCompositionEnd);
|
|
42
|
-
this.#isComposing.delete(element);
|
|
91
|
+
this.#composition.unobserve(element);
|
|
43
92
|
}
|
|
44
93
|
/** Handles keystroke inputs and advances focus to the next field. */
|
|
45
94
|
onInput(event) {
|
|
46
95
|
const input = event.currentTarget;
|
|
47
96
|
if (!input) return;
|
|
48
|
-
if (this.#isComposing
|
|
97
|
+
if (this.#composition.isComposing(event)) return;
|
|
49
98
|
this.#handleInputValidation(input);
|
|
50
99
|
}
|
|
51
100
|
/** Handles Backspace retreating, arrows, and home/end navigation. */
|
|
@@ -54,7 +103,7 @@ var OtpController = class extends Controller {
|
|
|
54
103
|
if (!input) return;
|
|
55
104
|
const index = this.fieldTargets.indexOf(input);
|
|
56
105
|
if (index === -1) return;
|
|
57
|
-
if (this.#isComposing
|
|
106
|
+
if (this.#composition.isComposing(event)) return;
|
|
58
107
|
switch (event.key) {
|
|
59
108
|
case "Backspace":
|
|
60
109
|
if (!input.value) {
|
|
@@ -136,15 +185,6 @@ var OtpController = class extends Controller {
|
|
|
136
185
|
input.select();
|
|
137
186
|
}
|
|
138
187
|
};
|
|
139
|
-
#onCompositionStart = (event) => {
|
|
140
|
-
const input = event.currentTarget;
|
|
141
|
-
this.#isComposing.set(input, true);
|
|
142
|
-
};
|
|
143
|
-
#onCompositionEnd = (event) => {
|
|
144
|
-
const input = event.currentTarget;
|
|
145
|
-
this.#isComposing.set(input, false);
|
|
146
|
-
this.#handleInputValidation(input);
|
|
147
|
-
};
|
|
148
188
|
#handleInputValidation(input) {
|
|
149
189
|
const index = this.fieldTargets.indexOf(input);
|
|
150
190
|
if (index === -1) return;
|
|
@@ -187,6 +187,7 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
187
187
|
const handle = this.#handleFor(event.target);
|
|
188
188
|
if (!handle) return;
|
|
189
189
|
if (event.key === "Escape") {
|
|
190
|
+
if (event.defaultPrevented || event.isComposing) return;
|
|
190
191
|
if (this.#pointer?.started) {
|
|
191
192
|
const { pointerType } = this.#pointer;
|
|
192
193
|
this.#teardownSessions();
|
|
@@ -299,9 +300,8 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
299
300
|
}
|
|
300
301
|
/**
|
|
301
302
|
* Silently tears down whatever session is live (disconnect / disabled /
|
|
302
|
-
* Escape). Composed from the two single-session teardowns so
|
|
303
|
-
*
|
|
304
|
-
* the capture-release fix).
|
|
303
|
+
* Escape). Composed from the two single-session teardowns so pointer and
|
|
304
|
+
* keyboard cleanup cannot diverge as either path evolves.
|
|
305
305
|
*/
|
|
306
306
|
#teardownSessions() {
|
|
307
307
|
this.#endPointerSession();
|
|
@@ -2,6 +2,96 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/popover_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/escape_layer.ts
|
|
6
|
+
function claimsWhileFocusWithin(element) {
|
|
7
|
+
return () => {
|
|
8
|
+
const active = element.ownerDocument.activeElement;
|
|
9
|
+
return active === null || active === element.ownerDocument.body || element.contains(active);
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
var EscapeLayer = class _EscapeLayer {
|
|
13
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
14
|
+
#ownerDocument = null;
|
|
15
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
16
|
+
#onDismiss = null;
|
|
17
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
18
|
+
#claims = null;
|
|
19
|
+
/**
|
|
20
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
21
|
+
* the document's shared resolver listener if this is its first layer.
|
|
22
|
+
* Re-activating an already-active layer moves it to the top.
|
|
23
|
+
*/
|
|
24
|
+
activate(ownerDocument = document, options) {
|
|
25
|
+
this.deactivate();
|
|
26
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
27
|
+
if (!registry) {
|
|
28
|
+
registry = _EscapeLayer.#createRegistry();
|
|
29
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
30
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
31
|
+
}
|
|
32
|
+
registry.stack.push(this);
|
|
33
|
+
this.#ownerDocument = ownerDocument;
|
|
34
|
+
this.#onDismiss = options.onDismiss;
|
|
35
|
+
this.#claims = options.claims ?? null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
39
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
40
|
+
*/
|
|
41
|
+
deactivate() {
|
|
42
|
+
const ownerDocument = this.#ownerDocument;
|
|
43
|
+
if (!ownerDocument) return;
|
|
44
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
45
|
+
if (registry) {
|
|
46
|
+
const index = registry.stack.lastIndexOf(this);
|
|
47
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
48
|
+
if (registry.stack.length === 0) {
|
|
49
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
50
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
this.#ownerDocument = null;
|
|
54
|
+
this.#onDismiss = null;
|
|
55
|
+
this.#claims = null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
59
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
60
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
61
|
+
*/
|
|
62
|
+
get ownsEscape() {
|
|
63
|
+
const ownerDocument = this.#ownerDocument;
|
|
64
|
+
if (!ownerDocument) return false;
|
|
65
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
66
|
+
if (!registry) return false;
|
|
67
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
68
|
+
}
|
|
69
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
70
|
+
static #createRegistry() {
|
|
71
|
+
const registry = {
|
|
72
|
+
stack: [],
|
|
73
|
+
onKeydown: (event) => {
|
|
74
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
75
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
76
|
+
if (!owner) return;
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
owner.#onDismiss?.();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
return registry;
|
|
82
|
+
}
|
|
83
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
84
|
+
static #resolveOwner(stack) {
|
|
85
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
86
|
+
const layer = stack[index];
|
|
87
|
+
if (!layer) continue;
|
|
88
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
89
|
+
return layer;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
5
95
|
// src/utils/scroll_dismiss.ts
|
|
6
96
|
function observeScrollDismiss(element, onScroll) {
|
|
7
97
|
const targets = [...scrollParents(element), window];
|
|
@@ -36,24 +126,26 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
36
126
|
static actions = ["close", "open", "toggle"];
|
|
37
127
|
/** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
|
|
38
128
|
#stopScrollDismiss = null;
|
|
129
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
130
|
+
#escapeLayer = new EscapeLayer();
|
|
39
131
|
/** Selector for natively focusable elements used to find the first one. */
|
|
40
132
|
static #FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
41
|
-
/** Starts closed and registers the
|
|
133
|
+
/** Starts closed and registers the standing dismissal listeners. */
|
|
42
134
|
connect() {
|
|
43
135
|
this.close();
|
|
44
136
|
document.addEventListener("click", this.#onOutsideClick);
|
|
45
|
-
|
|
137
|
+
this.element.addEventListener("focusout", this.#onFocusOut);
|
|
46
138
|
}
|
|
47
139
|
/**
|
|
48
|
-
* Removes
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
140
|
+
* Removes every standing listener registered in {@link connect} plus any active
|
|
141
|
+
* dismiss-on-scroll observers. `removeEventListener` is a no-op when it was
|
|
142
|
+
* never added, so this is safe in the closed state too — no listener outlives
|
|
143
|
+
* the element after a Turbo navigation.
|
|
52
144
|
*/
|
|
53
145
|
disconnect() {
|
|
146
|
+
this.#escapeLayer.deactivate();
|
|
54
147
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
55
|
-
|
|
56
|
-
if (this.hasPanelTarget) this.panelTarget.removeEventListener("focusout", this.#onFocusOut);
|
|
148
|
+
this.element.removeEventListener("focusout", this.#onFocusOut);
|
|
57
149
|
this.#stopScrollDismiss?.();
|
|
58
150
|
this.#stopScrollDismiss = null;
|
|
59
151
|
}
|
|
@@ -70,7 +162,10 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
70
162
|
if (!this.hasPanelTarget || this.#isOpen) return;
|
|
71
163
|
this.panelTarget.hidden = false;
|
|
72
164
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
73
|
-
this.
|
|
165
|
+
this.#escapeLayer.activate(document, {
|
|
166
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
167
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
168
|
+
});
|
|
74
169
|
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
75
170
|
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.close());
|
|
76
171
|
}
|
|
@@ -78,11 +173,10 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
78
173
|
}
|
|
79
174
|
/** Closes the panel and reflects the collapsed state. Bound via `data-action`. */
|
|
80
175
|
close() {
|
|
81
|
-
|
|
82
|
-
this.panelTarget.removeEventListener("focusout", this.#onFocusOut);
|
|
176
|
+
this.#escapeLayer.deactivate();
|
|
83
177
|
this.#stopScrollDismiss?.();
|
|
84
178
|
this.#stopScrollDismiss = null;
|
|
85
|
-
this.panelTarget.hidden = true;
|
|
179
|
+
if (this.hasPanelTarget) this.panelTarget.hidden = true;
|
|
86
180
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
87
181
|
}
|
|
88
182
|
/** Moves focus to the first focusable element in the panel, or the panel itself. */
|
|
@@ -95,31 +189,28 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
95
189
|
if (!this.panelTarget.hasAttribute("tabindex")) this.panelTarget.tabIndex = -1;
|
|
96
190
|
this.panelTarget.focus();
|
|
97
191
|
}
|
|
98
|
-
/** Closes and restores focus to the trigger
|
|
192
|
+
/** Closes and restores focus to the trigger for explicit keyboard dismissal. */
|
|
99
193
|
#closeAndRestore() {
|
|
100
194
|
this.close();
|
|
101
195
|
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
102
196
|
}
|
|
103
|
-
/** Closes
|
|
197
|
+
/** Closes without moving focus when a click lands outside the controller element. */
|
|
104
198
|
#onOutsideClick = (event) => {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
/** Closes (restoring focus) on `Escape` while open. */
|
|
108
|
-
#onKeydown = (event) => {
|
|
109
|
-
if (event.key === "Escape" && this.#isOpen) {
|
|
110
|
-
event.preventDefault();
|
|
111
|
-
this.#closeAndRestore();
|
|
112
|
-
}
|
|
199
|
+
const target = event.target;
|
|
200
|
+
if (this.#isOpen && target instanceof Node && !this.element.contains(target)) this.close();
|
|
113
201
|
};
|
|
114
202
|
/**
|
|
115
|
-
* Closes when focus leaves the
|
|
116
|
-
* (e.g.
|
|
117
|
-
* destination is kept, which is the modeless
|
|
118
|
-
*
|
|
203
|
+
* Closes when focus leaves the controller for a known external destination
|
|
204
|
+
* (e.g. forward Tab past the panel or reverse Tab past the trigger). Focus is
|
|
205
|
+
* not restored — the natural destination is kept, which is the modeless
|
|
206
|
+
* contract. A null/non-Node destination is indeterminate: browsers use it for
|
|
207
|
+
* clicks on non-focusable content and window deactivation, so the later outside
|
|
208
|
+
* click handler decides pointer dismissal instead.
|
|
119
209
|
*/
|
|
120
210
|
#onFocusOut = (event) => {
|
|
211
|
+
if (!this.#isOpen) return;
|
|
121
212
|
const next = event.relatedTarget;
|
|
122
|
-
if (next
|
|
213
|
+
if (!(next instanceof Node) || this.element.contains(next)) return;
|
|
123
214
|
this.close();
|
|
124
215
|
};
|
|
125
216
|
/** Whether the panel is currently visible. */
|
|
@@ -56,6 +56,7 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
56
56
|
};
|
|
57
57
|
|
|
58
58
|
// src/controllers/toast_controller.ts
|
|
59
|
+
var DELEGATED_EVENTS = ["click", "focusin", "focusout", "keydown", "mouseover", "mouseout"];
|
|
59
60
|
var ToastController = class extends Controller {
|
|
60
61
|
static targets = ["list", "template", "item"];
|
|
61
62
|
static values = {
|
|
@@ -76,34 +77,57 @@ var ToastController = class extends Controller {
|
|
|
76
77
|
* Tracked so {@link disconnect} can cancel any that have not fired, preventing a
|
|
77
78
|
* detached element from being mutated after it leaves the DOM (Turbo).
|
|
78
79
|
*/
|
|
79
|
-
#rafHandles = /* @__PURE__ */ new
|
|
80
|
+
#rafHandles = /* @__PURE__ */ new Map();
|
|
80
81
|
/** Track active timeouts mapped by each toast element for safe cancellation. */
|
|
81
82
|
#activeTimeouts = /* @__PURE__ */ new Map();
|
|
82
83
|
/** Track active pause reasons (hover/focus) per toast for WCAG 2.2.1 pause/resume. */
|
|
83
84
|
#pauseReasons = /* @__PURE__ */ new Map();
|
|
85
|
+
/** The stable list that owns delegated listeners for dynamically added items. */
|
|
86
|
+
#delegatedList = null;
|
|
84
87
|
connect() {
|
|
88
|
+
this.#connectDelegatedEvents();
|
|
85
89
|
this.enforceMaxLimit();
|
|
86
90
|
for (const item of this.itemTargets) {
|
|
87
|
-
if (!this.#activeTimeouts.has(item)) {
|
|
91
|
+
if (!this.#activeTimeouts.has(item) && item.dataset.state !== "leaving") {
|
|
88
92
|
this.#startTimer(item);
|
|
89
93
|
}
|
|
90
94
|
}
|
|
91
95
|
}
|
|
92
96
|
disconnect() {
|
|
97
|
+
this.#disconnectDelegatedEvents();
|
|
93
98
|
this.#timers.clearAll();
|
|
94
|
-
for (const handle of this.#rafHandles) {
|
|
99
|
+
for (const handle of this.#rafHandles.values()) {
|
|
95
100
|
window.cancelAnimationFrame(handle);
|
|
96
101
|
}
|
|
97
102
|
this.#rafHandles.clear();
|
|
98
103
|
this.#activeTimeouts.clear();
|
|
99
104
|
this.#pauseReasons.clear();
|
|
100
105
|
}
|
|
106
|
+
/** Rebinds delegated interaction when Turbo replaces the list target in place. */
|
|
107
|
+
listTargetConnected(element) {
|
|
108
|
+
if (this.#delegatedList !== element) this.#connectDelegatedEvents(element);
|
|
109
|
+
}
|
|
110
|
+
/** Releases delegation only when the removed target is its current owner. */
|
|
111
|
+
listTargetDisconnected(element) {
|
|
112
|
+
if (this.#delegatedList === element) this.#disconnectDelegatedEvents();
|
|
113
|
+
}
|
|
101
114
|
durationValueChanged() {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
115
|
+
for (const item of this.itemTargets) {
|
|
116
|
+
if (item.dataset.state === "leaving") continue;
|
|
117
|
+
const pauseReasons = this.#pauseReasons.get(item);
|
|
118
|
+
this.#clearTimer(item);
|
|
119
|
+
if (this.durationValue <= 0) {
|
|
120
|
+
item.removeAttribute("data-paused");
|
|
121
|
+
} else if (pauseReasons && pauseReasons.size > 0) {
|
|
122
|
+
this.#pauseReasons.set(item, pauseReasons);
|
|
123
|
+
this.#activeTimeouts.set(item, {
|
|
124
|
+
id: 0,
|
|
125
|
+
startedAt: 0,
|
|
126
|
+
remaining: this.durationValue
|
|
127
|
+
});
|
|
128
|
+
item.setAttribute("data-paused", "true");
|
|
129
|
+
} else {
|
|
130
|
+
this.#startTimer(item);
|
|
107
131
|
}
|
|
108
132
|
}
|
|
109
133
|
}
|
|
@@ -117,17 +141,21 @@ var ToastController = class extends Controller {
|
|
|
117
141
|
*/
|
|
118
142
|
itemTargetConnected(element) {
|
|
119
143
|
this.enforceMaxLimit();
|
|
144
|
+
if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
|
|
120
145
|
this.#startTimer(element);
|
|
121
146
|
element.setAttribute("data-state", "entering");
|
|
147
|
+
this.#cancelAnimation(element);
|
|
122
148
|
const handle = window.requestAnimationFrame(() => {
|
|
123
|
-
this.#rafHandles.delete(
|
|
149
|
+
this.#rafHandles.delete(element);
|
|
150
|
+
if (element.parentNode !== this.listTarget || element.dataset.state === "leaving") return;
|
|
124
151
|
element.setAttribute("data-state", "visible");
|
|
125
152
|
});
|
|
126
|
-
this.#rafHandles.
|
|
153
|
+
this.#rafHandles.set(element, handle);
|
|
127
154
|
}
|
|
128
155
|
/** Clears any active timer when a toast is removed from the DOM. */
|
|
129
156
|
itemTargetDisconnected(element) {
|
|
130
157
|
this.#clearTimer(element);
|
|
158
|
+
this.#cancelAnimation(element);
|
|
131
159
|
}
|
|
132
160
|
/**
|
|
133
161
|
* Shows a new toast. Accepts its content from either a Stimulus action param
|
|
@@ -150,10 +178,9 @@ var ToastController = class extends Controller {
|
|
|
150
178
|
const item = clone.querySelector("[data-stimeo--toast-target='item']");
|
|
151
179
|
if (!item) return;
|
|
152
180
|
const bodySlot = item.querySelector("[data-toast-slot='body']");
|
|
153
|
-
if (bodySlot)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
item.setAttribute("role", this.#readField(event, "type") === "alert" ? "alert" : "status");
|
|
181
|
+
if (!bodySlot) return;
|
|
182
|
+
bodySlot.textContent = body;
|
|
183
|
+
bodySlot.setAttribute("role", this.#readField(event, "type") === "alert" ? "alert" : "status");
|
|
157
184
|
this.listTarget.appendChild(item);
|
|
158
185
|
this.dispatch("show", { detail: { item } });
|
|
159
186
|
}
|
|
@@ -175,18 +202,15 @@ var ToastController = class extends Controller {
|
|
|
175
202
|
}
|
|
176
203
|
/** Dismisses the toast that contained the trigger. */
|
|
177
204
|
dismiss(event) {
|
|
178
|
-
const
|
|
179
|
-
if (!target) return;
|
|
180
|
-
const item = target.closest("[data-stimeo--toast-target='item']");
|
|
205
|
+
const item = this.#itemFromEvent(event);
|
|
181
206
|
if (!item) return;
|
|
182
207
|
this.#removeWithTransition(item, "user");
|
|
183
208
|
}
|
|
184
209
|
/** Dismisses the focused toast when Escape is pressed. */
|
|
185
210
|
onKeydown(event) {
|
|
186
211
|
if (event.key === "Escape") {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const item = target.closest("[data-stimeo--toast-target='item']");
|
|
212
|
+
if (event.defaultPrevented || event.isComposing) return;
|
|
213
|
+
const item = this.#itemFromEvent(event);
|
|
190
214
|
if (!item) return;
|
|
191
215
|
event.preventDefault();
|
|
192
216
|
this.#removeWithTransition(item, "user");
|
|
@@ -212,6 +236,10 @@ var ToastController = class extends Controller {
|
|
|
212
236
|
this.#timers.clear(timeout.id);
|
|
213
237
|
const elapsed = Date.now() - timeout.startedAt;
|
|
214
238
|
const remaining = Math.max(0, timeout.remaining - elapsed);
|
|
239
|
+
if (remaining <= 0) {
|
|
240
|
+
this.#removeWithTransition(item, "timeout");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
215
243
|
this.#activeTimeouts.set(item, { id: 0, startedAt: 0, remaining });
|
|
216
244
|
item.setAttribute("data-paused", "true");
|
|
217
245
|
}
|
|
@@ -223,14 +251,17 @@ var ToastController = class extends Controller {
|
|
|
223
251
|
reasons.delete(this.#pauseReason(event));
|
|
224
252
|
if (reasons.size > 0) return;
|
|
225
253
|
const timeout = this.#activeTimeouts.get(item);
|
|
226
|
-
if (!timeout
|
|
254
|
+
if (!timeout) return;
|
|
255
|
+
if (timeout.id !== 0 || timeout.remaining <= 0) return;
|
|
227
256
|
item.removeAttribute("data-paused");
|
|
228
257
|
this.#startTimer(item, timeout.remaining);
|
|
229
258
|
}
|
|
230
259
|
/** Resolves the toast item element a pause/resume event targets. */
|
|
231
260
|
#itemFromEvent(event) {
|
|
232
|
-
const target = event.
|
|
233
|
-
|
|
261
|
+
const target = event.target instanceof Element ? event.target : event.currentTarget;
|
|
262
|
+
if (!(target instanceof Element) || !this.hasListTarget) return null;
|
|
263
|
+
const item = target.closest("[data-stimeo--toast-target='item']");
|
|
264
|
+
return item && this.listTarget.contains(item) ? item : null;
|
|
234
265
|
}
|
|
235
266
|
/** Classifies a pause/resume event as a hover or focus reason. */
|
|
236
267
|
#pauseReason(event) {
|
|
@@ -246,7 +277,9 @@ var ToastController = class extends Controller {
|
|
|
246
277
|
return reasons;
|
|
247
278
|
}
|
|
248
279
|
#startTimer(element, duration = this.durationValue) {
|
|
249
|
-
if (duration <= 0)
|
|
280
|
+
if (duration <= 0 || element.dataset.state === "leaving" || element.parentNode !== this.listTarget) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
250
283
|
this.#clearTimer(element);
|
|
251
284
|
const id = this.#timers.set(() => {
|
|
252
285
|
this.#removeWithTransition(element, "timeout");
|
|
@@ -262,12 +295,13 @@ var ToastController = class extends Controller {
|
|
|
262
295
|
this.#pauseReasons.delete(element);
|
|
263
296
|
}
|
|
264
297
|
#removeWithTransition(element, reason) {
|
|
298
|
+
if (element.dataset.state === "leaving" || element.parentNode !== this.listTarget) return;
|
|
265
299
|
this.#clearTimer(element);
|
|
300
|
+
this.#cancelAnimation(element);
|
|
266
301
|
element.setAttribute("data-state", "leaving");
|
|
267
302
|
const finalize = () => {
|
|
268
|
-
if (element.parentNode
|
|
269
|
-
|
|
270
|
-
}
|
|
303
|
+
if (element.parentNode !== this.listTarget) return;
|
|
304
|
+
this.listTarget.removeChild(element);
|
|
271
305
|
this.dispatch("dismiss", { detail: { item: element, reason } });
|
|
272
306
|
};
|
|
273
307
|
const transitions = window.getComputedStyle(element).transitionDuration;
|
|
@@ -289,14 +323,59 @@ var ToastController = class extends Controller {
|
|
|
289
323
|
*/
|
|
290
324
|
enforceMaxLimit() {
|
|
291
325
|
const currentItems = this.itemTargets;
|
|
292
|
-
|
|
293
|
-
|
|
326
|
+
const max = Math.max(0, this.maxValue);
|
|
327
|
+
if (currentItems.length > max) {
|
|
328
|
+
const excessCount = currentItems.length - max;
|
|
294
329
|
for (let i = 0; i < excessCount; i++) {
|
|
295
330
|
const oldest = currentItems[i];
|
|
296
331
|
if (oldest) this.#removeWithTransition(oldest, "timeout");
|
|
297
332
|
}
|
|
298
333
|
}
|
|
299
334
|
}
|
|
335
|
+
/** Wires stable-container delegation so newly appended items work immediately. */
|
|
336
|
+
#connectDelegatedEvents(list = this.hasListTarget ? this.listTarget : null) {
|
|
337
|
+
this.#disconnectDelegatedEvents();
|
|
338
|
+
if (!list) return;
|
|
339
|
+
this.#delegatedList = list;
|
|
340
|
+
for (const type of DELEGATED_EVENTS) {
|
|
341
|
+
this.#delegatedList.addEventListener(type, this.#onListEvent);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/** Releases every delegated listener from the exact list that owns it. */
|
|
345
|
+
#disconnectDelegatedEvents() {
|
|
346
|
+
if (!this.#delegatedList) return;
|
|
347
|
+
for (const type of DELEGATED_EVENTS) {
|
|
348
|
+
this.#delegatedList.removeEventListener(type, this.#onListEvent);
|
|
349
|
+
}
|
|
350
|
+
this.#delegatedList = null;
|
|
351
|
+
}
|
|
352
|
+
/** Routes every delegated item interaction from the stable list. */
|
|
353
|
+
#onListEvent = (event) => {
|
|
354
|
+
if (event.type === "click") {
|
|
355
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
356
|
+
const trigger = target?.closest("[data-toast-dismiss]");
|
|
357
|
+
if (trigger && this.#delegatedList?.contains(trigger)) this.dismiss(event);
|
|
358
|
+
} else if (event.type === "keydown") {
|
|
359
|
+
this.onKeydown(event);
|
|
360
|
+
} else if (this.#crossesItemBoundary(event)) {
|
|
361
|
+
if (event.type === "focusin" || event.type === "mouseover") this.pause(event);
|
|
362
|
+
else this.resume(event);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
/** Whether a bubbling focus/pointer event enters or leaves a toast boundary. */
|
|
366
|
+
#crossesItemBoundary(event) {
|
|
367
|
+
const item = this.#itemFromEvent(event);
|
|
368
|
+
if (!item) return false;
|
|
369
|
+
const related = "relatedTarget" in event ? event.relatedTarget : null;
|
|
370
|
+
return !(related instanceof Node && item.contains(related));
|
|
371
|
+
}
|
|
372
|
+
/** Cancels the pending entering-to-visible frame owned by one item. */
|
|
373
|
+
#cancelAnimation(element) {
|
|
374
|
+
const handle = this.#rafHandles.get(element);
|
|
375
|
+
if (handle === void 0) return;
|
|
376
|
+
window.cancelAnimationFrame(handle);
|
|
377
|
+
this.#rafHandles.delete(element);
|
|
378
|
+
}
|
|
300
379
|
};
|
|
301
380
|
function cssTimeToMs(value) {
|
|
302
381
|
const first = value.split(",")[0]?.trim() ?? "";
|