stimeo-ui 0.14.0 → 0.15.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 +34 -0
- data/dist/cable/index.js +60 -14
- data/dist/controllers/announcer_controller.js +88 -61
- data/dist/controllers/auto_submit_controller.js +40 -5
- data/dist/controllers/breadcrumb_controller.js +29 -3
- data/dist/controllers/calendar_controller.js +20 -3
- data/dist/controllers/checkbox_controller.js +27 -2
- data/dist/controllers/conditional_fields_controller.js +29 -6
- data/dist/controllers/currency_input_controller.js +16 -9
- data/dist/controllers/date_range_picker_controller.js +12 -3
- data/dist/controllers/dirty_form_controller.js +32 -12
- data/dist/controllers/flash_controller.js +145 -43
- data/dist/controllers/frame_loading_controller.js +29 -6
- data/dist/controllers/highlight_controller.js +63 -23
- data/dist/controllers/local_time_controller.js +10 -5
- data/dist/controllers/masonry_controller.js +31 -13
- data/dist/controllers/optimistic_controller.js +28 -4
- data/dist/controllers/overflow_indicator_controller.js +31 -8
- data/dist/controllers/overflow_menu_controller.js +29 -3
- data/dist/controllers/read_more_controller.js +31 -5
- data/dist/controllers/reading_progress_controller.js +65 -19
- data/dist/controllers/relative_time_controller.js +10 -5
- data/dist/controllers/scroll_area_controller.js +70 -26
- data/dist/controllers/scroll_restore_controller.js +37 -16
- data/dist/controllers/scroll_visibility_controller.js +49 -29
- data/dist/controllers/scrollspy_controller.js +65 -26
- data/dist/controllers/smart_sticky_header_controller.js +49 -25
- data/dist/controllers/stick_to_bottom_controller.js +29 -3
- data/dist/controllers/submit_once_controller.js +29 -6
- data/dist/controllers/textarea_autosize_controller.js +29 -3
- data/dist/controllers/toast_controller.js +136 -73
- data/dist/index.js +473 -435
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -26,6 +26,30 @@ function canTakeFocus(element) {
|
|
|
26
26
|
return !inheritsFieldsetDisabled(element);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// src/utils/listener_set.ts
|
|
30
|
+
var ListenerSet = class {
|
|
31
|
+
/** The generation every `add` joins until the next `dispose()`. */
|
|
32
|
+
#abort = new AbortController();
|
|
33
|
+
/**
|
|
34
|
+
* Attaches `handler` to the open generation, exactly as the caller spelled it.
|
|
35
|
+
*
|
|
36
|
+
* The set supplies the signal, so `options` carries everything else the DOM
|
|
37
|
+
* accepts — `capture` included, which has to match at release time and no
|
|
38
|
+
* longer has a second place to drift from.
|
|
39
|
+
*/
|
|
40
|
+
add(target, type, handler, options) {
|
|
41
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Releases every listener of the open generation, synchronously, and opens the
|
|
45
|
+
* next one. Idempotent, and safe before anything has been added.
|
|
46
|
+
*/
|
|
47
|
+
dispose() {
|
|
48
|
+
this.#abort.abort();
|
|
49
|
+
this.#abort = new AbortController();
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
29
53
|
// src/utils/microtask_coalescer.ts
|
|
30
54
|
var MicrotaskCoalescer = class {
|
|
31
55
|
#run;
|
|
@@ -150,6 +174,7 @@ var ConditionalFieldsController = class extends Controller {
|
|
|
150
174
|
#observing = false;
|
|
151
175
|
/** Coalesces one target/morph/mutation batch into one full DOM reconciliation. */
|
|
152
176
|
#reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
|
|
177
|
+
#listeners = new ListenerSet();
|
|
153
178
|
/** Provides the last-resort focus landmark without claiming an authored tabindex. */
|
|
154
179
|
#rootTabindex = new TabindexLoan();
|
|
155
180
|
/** Watches retained targets and controls whose declarative or reflected state changed. */
|
|
@@ -188,9 +213,9 @@ var ConditionalFieldsController = class extends Controller {
|
|
|
188
213
|
subtree: true
|
|
189
214
|
});
|
|
190
215
|
this.#observing = true;
|
|
191
|
-
this.element
|
|
192
|
-
this.element
|
|
193
|
-
this.element
|
|
216
|
+
this.#listeners.add(this.element, "change", this.#onTriggerInput);
|
|
217
|
+
this.#listeners.add(this.element, "input", this.#onTriggerInput);
|
|
218
|
+
this.#listeners.add(this.element, "turbo:morph-element", this.#onMorph);
|
|
194
219
|
document.addEventListener("reset", this.#onReset, true);
|
|
195
220
|
}
|
|
196
221
|
/** Releases all external resources and returns the temporary focus landmark loan. */
|
|
@@ -198,9 +223,7 @@ var ConditionalFieldsController = class extends Controller {
|
|
|
198
223
|
this.#reconcile.cancel();
|
|
199
224
|
this.#observing = false;
|
|
200
225
|
this.#observer.disconnect();
|
|
201
|
-
this.
|
|
202
|
-
this.element.removeEventListener("input", this.#onTriggerInput);
|
|
203
|
-
this.element.removeEventListener("turbo:morph-element", this.#onMorph);
|
|
226
|
+
this.#listeners.dispose();
|
|
204
227
|
document.removeEventListener("reset", this.#onReset, true);
|
|
205
228
|
this.#rootTabindex.returnAll();
|
|
206
229
|
}
|
|
@@ -88,11 +88,17 @@ function construct(factory, locale, options, fallbackLocale) {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// src/utils/locale.ts
|
|
92
|
+
function resolveLocale(element, declared = "") {
|
|
93
|
+
return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
91
96
|
// src/controllers/currency_input_controller.ts
|
|
97
|
+
var FALLBACK_LOCALE = "en-US";
|
|
92
98
|
var CurrencyInputController = class extends Controller {
|
|
93
99
|
static targets = ["display", "field", "srValue"];
|
|
94
100
|
static values = {
|
|
95
|
-
locale: { type: String, default: "
|
|
101
|
+
locale: { type: String, default: "" },
|
|
96
102
|
currency: { type: String, default: "" },
|
|
97
103
|
precision: { type: Number, default: 2 }
|
|
98
104
|
};
|
|
@@ -102,7 +108,7 @@ var CurrencyInputController = class extends Controller {
|
|
|
102
108
|
#lastValue = null;
|
|
103
109
|
#started = false;
|
|
104
110
|
/** Validated mirrors of the Values; the hot path never reads a raw Value. */
|
|
105
|
-
#locale
|
|
111
|
+
#locale;
|
|
106
112
|
#currency = "";
|
|
107
113
|
#precision = 2;
|
|
108
114
|
/** Formatters rebuilt only when a Value changes, never per keystroke. */
|
|
@@ -339,8 +345,9 @@ var CurrencyInputController = class extends Controller {
|
|
|
339
345
|
* formatters from the validated set.
|
|
340
346
|
*/
|
|
341
347
|
#revalidate() {
|
|
342
|
-
const
|
|
343
|
-
|
|
348
|
+
const resolved = resolveLocale(this.element, this.localeValue);
|
|
349
|
+
const usable = intlFormatter(Intl.NumberFormat, resolved, {});
|
|
350
|
+
this.#locale = usable === null ? FALLBACK_LOCALE : resolved;
|
|
344
351
|
const precision = this.precisionValue;
|
|
345
352
|
this.#precision = Number.isInteger(precision) && precision >= 0 && precision <= 100 ? precision : 2;
|
|
346
353
|
this.#currency = "";
|
|
@@ -355,7 +362,7 @@ var CurrencyInputController = class extends Controller {
|
|
|
355
362
|
Intl.NumberFormat,
|
|
356
363
|
this.#locale,
|
|
357
364
|
{ useGrouping: true, maximumFractionDigits: 0 },
|
|
358
|
-
|
|
365
|
+
FALLBACK_LOCALE
|
|
359
366
|
);
|
|
360
367
|
this.#fixed = intlFormatter(
|
|
361
368
|
Intl.NumberFormat,
|
|
@@ -365,15 +372,15 @@ var CurrencyInputController = class extends Controller {
|
|
|
365
372
|
minimumFractionDigits: this.#precision,
|
|
366
373
|
maximumFractionDigits: this.#precision
|
|
367
374
|
},
|
|
368
|
-
|
|
375
|
+
FALLBACK_LOCALE
|
|
369
376
|
);
|
|
370
377
|
this.#accessible = this.#currency ? intlFormatter(
|
|
371
378
|
Intl.NumberFormat,
|
|
372
379
|
this.#locale,
|
|
373
380
|
{ style: "currency", currency: this.#currency },
|
|
374
|
-
|
|
381
|
+
FALLBACK_LOCALE
|
|
375
382
|
) : this.#fixed;
|
|
376
|
-
const parts = intlFormatter(Intl.NumberFormat, this.#locale, {},
|
|
383
|
+
const parts = intlFormatter(Intl.NumberFormat, this.#locale, {}, FALLBACK_LOCALE).formatToParts(
|
|
377
384
|
11111.1
|
|
378
385
|
);
|
|
379
386
|
this.#group = parts.find((p) => p.type === "group")?.value ?? ",";
|
|
@@ -383,7 +390,7 @@ var CurrencyInputController = class extends Controller {
|
|
|
383
390
|
Intl.NumberFormat,
|
|
384
391
|
this.#locale,
|
|
385
392
|
{ useGrouping: false },
|
|
386
|
-
|
|
393
|
+
FALLBACK_LOCALE
|
|
387
394
|
);
|
|
388
395
|
for (let i = 0; i <= 9; i++) {
|
|
389
396
|
const digit = digitFormatter.format(i);
|
|
@@ -114,6 +114,11 @@ function monthLabelFormatter(locale) {
|
|
|
114
114
|
return intlFormatter(Intl.DateTimeFormat, locale, MONTH_LABEL_OPTIONS, "en");
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// src/utils/locale.ts
|
|
118
|
+
function resolveLocale(element, declared = "") {
|
|
119
|
+
return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
117
122
|
// src/utils/microtask_coalescer.ts
|
|
118
123
|
var MicrotaskCoalescer = class {
|
|
119
124
|
#run;
|
|
@@ -224,7 +229,8 @@ var DateRangePickerController = class extends Controller {
|
|
|
224
229
|
// A JSON list read through `parseStringList` rather than Stimulus's `Array`
|
|
225
230
|
// type: that reader throws out of the value observer before any callback
|
|
226
231
|
// runs, so one malformed attribute would stop the picker from connecting.
|
|
227
|
-
disabledDates: { type: String, default: "" }
|
|
232
|
+
disabledDates: { type: String, default: "" },
|
|
233
|
+
locale: { type: String, default: "" }
|
|
228
234
|
};
|
|
229
235
|
static actions = ["applyPreset", "next", "onKeydown", "prev", "previewTo", "selectDate"];
|
|
230
236
|
static events = ["change", "monthchange"];
|
|
@@ -277,6 +283,10 @@ var DateRangePickerController = class extends Controller {
|
|
|
277
283
|
this.#beforeCache.deactivate();
|
|
278
284
|
this.#focusTimer.clearAll();
|
|
279
285
|
}
|
|
286
|
+
/** Repaints the label when application code changes `locale` at runtime. */
|
|
287
|
+
localeValueChanged() {
|
|
288
|
+
this.#repaint.schedule();
|
|
289
|
+
}
|
|
280
290
|
/** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
|
|
281
291
|
minValueChanged() {
|
|
282
292
|
this.#repaint.schedule();
|
|
@@ -465,8 +475,7 @@ var DateRangePickerController = class extends Controller {
|
|
|
465
475
|
const { year, month } = info;
|
|
466
476
|
if (this.#previewDate && !this.#isSelectable(this.#previewDate)) this.#previewDate = "";
|
|
467
477
|
if (this.hasMonthLabelTarget) {
|
|
468
|
-
const
|
|
469
|
-
const formatter = monthLabelFormatter(lang);
|
|
478
|
+
const formatter = monthLabelFormatter(resolveLocale(this.element, this.localeValue));
|
|
470
479
|
this.monthLabelTarget.textContent = formatter.format(new Date(year, month - 1, 1));
|
|
471
480
|
}
|
|
472
481
|
const [rangeStart, rangeEnd] = this.#visualRange();
|
|
@@ -2,6 +2,30 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/dirty_form_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/listener_set.ts
|
|
6
|
+
var ListenerSet = class {
|
|
7
|
+
/** The generation every `add` joins until the next `dispose()`. */
|
|
8
|
+
#abort = new AbortController();
|
|
9
|
+
/**
|
|
10
|
+
* Attaches `handler` to the open generation, exactly as the caller spelled it.
|
|
11
|
+
*
|
|
12
|
+
* The set supplies the signal, so `options` carries everything else the DOM
|
|
13
|
+
* accepts — `capture` included, which has to match at release time and no
|
|
14
|
+
* longer has a second place to drift from.
|
|
15
|
+
*/
|
|
16
|
+
add(target, type, handler, options) {
|
|
17
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Releases every listener of the open generation, synchronously, and opens the
|
|
21
|
+
* next one. Idempotent, and safe before anything has been added.
|
|
22
|
+
*/
|
|
23
|
+
dispose() {
|
|
24
|
+
this.#abort.abort();
|
|
25
|
+
this.#abort = new AbortController();
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
5
29
|
// src/utils/safe_timeout.ts
|
|
6
30
|
var TimerRegistry = class {
|
|
7
31
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -125,6 +149,7 @@ var DirtyFormController = class extends Controller {
|
|
|
125
149
|
#dirty = false;
|
|
126
150
|
#beforeunloadBound = false;
|
|
127
151
|
#timeouts = new SafeTimeout();
|
|
152
|
+
#listeners = new ListenerSet();
|
|
128
153
|
/** Whether the active submission still represents the form's current values. */
|
|
129
154
|
#submitting = false;
|
|
130
155
|
#submitAttempt = null;
|
|
@@ -182,24 +207,19 @@ var DirtyFormController = class extends Controller {
|
|
|
182
207
|
this.#baseline = this.#serialize();
|
|
183
208
|
this.#setDirty(false);
|
|
184
209
|
this.element.removeAttribute("data-dirty");
|
|
185
|
-
this.element
|
|
186
|
-
this.element
|
|
187
|
-
this.element
|
|
188
|
-
this.element
|
|
189
|
-
this.element
|
|
190
|
-
this.element
|
|
210
|
+
this.#listeners.add(this.element, "input", this.#onFieldChange);
|
|
211
|
+
this.#listeners.add(this.element, "change", this.#onFieldChange);
|
|
212
|
+
this.#listeners.add(this.element, "submit", this.#onSubmit);
|
|
213
|
+
this.#listeners.add(this.element, "formdata", this.#onFormData);
|
|
214
|
+
this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
|
|
215
|
+
this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
|
|
191
216
|
this.#unregisterVisit = registerVisitParticipant(this.element.ownerDocument, {
|
|
192
217
|
form: this.element,
|
|
193
218
|
evaluate: (event) => this.#evaluateVisit(event)
|
|
194
219
|
});
|
|
195
220
|
}
|
|
196
221
|
disconnect() {
|
|
197
|
-
this.
|
|
198
|
-
this.element.removeEventListener("change", this.#onFieldChange);
|
|
199
|
-
this.element.removeEventListener("submit", this.#onSubmit);
|
|
200
|
-
this.element.removeEventListener("formdata", this.#onFormData);
|
|
201
|
-
this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
202
|
-
this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
222
|
+
this.#listeners.dispose();
|
|
203
223
|
this.#unregisterVisit?.();
|
|
204
224
|
this.#unregisterVisit = null;
|
|
205
225
|
this.#unbindBeforeUnload();
|
|
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/flash_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/announce.ts
|
|
6
|
+
function announce(message, options = {}) {
|
|
7
|
+
const text = message.trim();
|
|
8
|
+
if (text.length === 0) return;
|
|
9
|
+
window.dispatchEvent(
|
|
10
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
11
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
// src/utils/before_cache_reset.ts
|
|
6
17
|
var BeforeCacheReset = class _BeforeCacheReset {
|
|
7
18
|
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
@@ -84,6 +95,121 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
84
95
|
}
|
|
85
96
|
};
|
|
86
97
|
|
|
98
|
+
// src/utils/keyed_timers.ts
|
|
99
|
+
var KeyedTimers = class {
|
|
100
|
+
#timers = new SafeTimeout();
|
|
101
|
+
/** The pending timer of each key; an entry lives exactly as long as its timer. */
|
|
102
|
+
#pending = /* @__PURE__ */ new Map();
|
|
103
|
+
/**
|
|
104
|
+
* Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
|
|
105
|
+
* pending. The entry is dropped before the callback runs, so the callback sees
|
|
106
|
+
* the key unarmed and may arm it again for the next round.
|
|
107
|
+
*/
|
|
108
|
+
set(key, callback, delay) {
|
|
109
|
+
this.clear(key);
|
|
110
|
+
const id = this.#timers.set(() => {
|
|
111
|
+
this.#pending.delete(key);
|
|
112
|
+
callback();
|
|
113
|
+
}, delay);
|
|
114
|
+
this.#pending.set(key, id);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Cancels `key`'s pending timer, if it has one.
|
|
118
|
+
*
|
|
119
|
+
* A timer id is a positive integer, so `-1` stands for "nothing pending" and
|
|
120
|
+
* the registry ignores an id it does not own — the unarmed case needs no
|
|
121
|
+
* branch of its own, and no other key's timer can be reached from here.
|
|
122
|
+
*/
|
|
123
|
+
clear(key) {
|
|
124
|
+
this.#timers.clear(this.#pending.get(key) ?? -1);
|
|
125
|
+
this.#pending.delete(key);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Cancels every pending timer and forgets every key. Call this from a
|
|
129
|
+
* controller's `disconnect()` so no timer, and no entry, outlives the element.
|
|
130
|
+
*/
|
|
131
|
+
clearAll() {
|
|
132
|
+
this.#timers.clearAll();
|
|
133
|
+
this.#pending.clear();
|
|
134
|
+
}
|
|
135
|
+
/** Whether `key` has a timer pending. */
|
|
136
|
+
has(key) {
|
|
137
|
+
return this.#pending.has(key);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// src/utils/pausable_timers.ts
|
|
142
|
+
var PausableTimers = class {
|
|
143
|
+
#timers = new KeyedTimers();
|
|
144
|
+
/** Every key armed or held; an entry goes as its timer fires, or with the key. */
|
|
145
|
+
#entries = /* @__PURE__ */ new Map();
|
|
146
|
+
/**
|
|
147
|
+
* Arms `callback` after `delay` ms for `key`, replacing whatever `key` had.
|
|
148
|
+
* A held key stays held and banks `delay` for its resume.
|
|
149
|
+
*/
|
|
150
|
+
set(key, callback, delay) {
|
|
151
|
+
const reasons = this.#entries.get(key)?.reasons ?? /* @__PURE__ */ new Set();
|
|
152
|
+
const entry = { callback, startedAt: 0, remaining: delay, reasons };
|
|
153
|
+
this.#entries.set(key, entry);
|
|
154
|
+
if (reasons.size === 0) this.#arm(key, entry);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Holds `key` for `reason` and banks the time left, with a floor of one
|
|
158
|
+
* millisecond. Reports whether this call is the one that stopped a running
|
|
159
|
+
* timer, so the caller can write its held-state hook exactly once.
|
|
160
|
+
*/
|
|
161
|
+
pause(key, reason) {
|
|
162
|
+
const entry = this.#entries.get(key);
|
|
163
|
+
if (entry === void 0) return false;
|
|
164
|
+
entry.reasons.add(reason);
|
|
165
|
+
if (!this.#timers.has(key)) return false;
|
|
166
|
+
this.#timers.clear(key);
|
|
167
|
+
entry.remaining = Math.max(1, entry.remaining - (Date.now() - entry.startedAt));
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Releases `reason` on `key`, arming the banked time again once no reason is
|
|
172
|
+
* left. Reports whether this call is the one that started the timer again.
|
|
173
|
+
*/
|
|
174
|
+
resume(key, reason) {
|
|
175
|
+
const entry = this.#entries.get(key);
|
|
176
|
+
if (entry === void 0) return false;
|
|
177
|
+
entry.reasons.delete(reason);
|
|
178
|
+
if (entry.reasons.size > 0 || this.#timers.has(key)) return false;
|
|
179
|
+
this.#arm(key, entry);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
/** Whether `key` is armed or held — that is, whether this registry drives it at all. */
|
|
183
|
+
tracks(key) {
|
|
184
|
+
return this.#entries.has(key);
|
|
185
|
+
}
|
|
186
|
+
/** Cancels `key`'s timer and drops its hold. */
|
|
187
|
+
clear(key) {
|
|
188
|
+
this.#timers.clear(key);
|
|
189
|
+
this.#entries.delete(key);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Cancels every timer and forgets every key. Call this from a controller's
|
|
193
|
+
* `disconnect()` so neither a timer nor a hold outlives the element.
|
|
194
|
+
*/
|
|
195
|
+
clearAll() {
|
|
196
|
+
this.#timers.clearAll();
|
|
197
|
+
this.#entries.clear();
|
|
198
|
+
}
|
|
199
|
+
/** Starts `entry`'s banked time running for `key`, and drops it as it fires. */
|
|
200
|
+
#arm(key, entry) {
|
|
201
|
+
entry.startedAt = Date.now();
|
|
202
|
+
this.#timers.set(
|
|
203
|
+
key,
|
|
204
|
+
() => {
|
|
205
|
+
this.#entries.delete(key);
|
|
206
|
+
entry.callback();
|
|
207
|
+
},
|
|
208
|
+
entry.remaining
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
87
213
|
// src/utils/transition_completion.ts
|
|
88
214
|
function timeMs(value) {
|
|
89
215
|
const trimmed = value.trim();
|
|
@@ -130,12 +256,13 @@ var FlashController = class extends Controller {
|
|
|
130
256
|
};
|
|
131
257
|
static actions = ["dismiss"];
|
|
132
258
|
static events = ["show", "dismiss", "reconcile"];
|
|
259
|
+
/** Removal timers for the leaving transition; the auto-dismiss ones live below. */
|
|
133
260
|
#timers = new SafeTimeout();
|
|
261
|
+
/** Per-message auto-dismiss, held open while the message is hovered or focused. */
|
|
262
|
+
#dismiss = new PausableTimers();
|
|
134
263
|
#observer = null;
|
|
135
264
|
/** Whether the controller is between `connect()` and `disconnect()`. */
|
|
136
265
|
#connected = false;
|
|
137
|
-
/** Auto-dismiss timer state keyed by message element. */
|
|
138
|
-
#state = /* @__PURE__ */ new Map();
|
|
139
266
|
/** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
|
|
140
267
|
#order = [];
|
|
141
268
|
/**
|
|
@@ -161,8 +288,8 @@ var FlashController = class extends Controller {
|
|
|
161
288
|
this.#beforeCache.deactivate();
|
|
162
289
|
this.#stopObserving();
|
|
163
290
|
this.#timers.clearAll();
|
|
291
|
+
this.#dismiss.clearAll();
|
|
164
292
|
for (const message of this.#order) this.#unbindPause(message);
|
|
165
|
-
this.#state.clear();
|
|
166
293
|
this.#order.length = 0;
|
|
167
294
|
this.#leaving.clear();
|
|
168
295
|
}
|
|
@@ -291,8 +418,7 @@ var FlashController = class extends Controller {
|
|
|
291
418
|
* own role to do the announcing (dynamic inserts). Idempotent per message.
|
|
292
419
|
*/
|
|
293
420
|
#process(message, bridge) {
|
|
294
|
-
if (this.#
|
|
295
|
-
if (this.#leaving.has(message)) return;
|
|
421
|
+
if (this.#order.includes(message) || this.#leaving.has(message)) return;
|
|
296
422
|
const type = message.getAttribute("data-flash-type") ?? "";
|
|
297
423
|
const assertive = ASSERTIVE_TYPES.has(type);
|
|
298
424
|
if (!message.hasAttribute("role")) {
|
|
@@ -303,11 +429,7 @@ var FlashController = class extends Controller {
|
|
|
303
429
|
this.#bindPause(message);
|
|
304
430
|
const text = message.textContent?.trim() ?? "";
|
|
305
431
|
this.dispatch("show", { target: message, detail: { type, message: text } });
|
|
306
|
-
if (bridge
|
|
307
|
-
window.dispatchEvent(
|
|
308
|
-
new CustomEvent("stimeo--announcer:announce", { detail: { message: text, assertive } })
|
|
309
|
-
);
|
|
310
|
-
}
|
|
432
|
+
if (bridge) announce(text, { assertive });
|
|
311
433
|
this.#startTimer(message);
|
|
312
434
|
this.#enforceMax();
|
|
313
435
|
}
|
|
@@ -320,53 +442,33 @@ var FlashController = class extends Controller {
|
|
|
320
442
|
this.#beginDismiss(oldest, "limit");
|
|
321
443
|
}
|
|
322
444
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const id = this.#timers.set(() => this.#beginDismiss(message, "timeout"), duration);
|
|
328
|
-
this.#state.set(message, {
|
|
329
|
-
id,
|
|
330
|
-
startedAt: Date.now(),
|
|
331
|
-
remaining: duration,
|
|
332
|
-
paused: existing?.paused ?? /* @__PURE__ */ new Set()
|
|
333
|
-
});
|
|
445
|
+
/** Arms a message's auto-dismiss; a non-positive `duration` means it never expires. */
|
|
446
|
+
#startTimer(message) {
|
|
447
|
+
if (this.durationValue <= 0) return;
|
|
448
|
+
this.#dismiss.set(message, () => this.#beginDismiss(message, "timeout"), this.durationValue);
|
|
334
449
|
}
|
|
335
450
|
/**
|
|
336
|
-
*
|
|
337
|
-
* Hover and focus are independent reasons
|
|
338
|
-
* first of them
|
|
451
|
+
* Holds a message's auto-dismiss open while it is hovered or focused
|
|
452
|
+
* (WCAG 2.2 2.2.1). Hover and focus are independent reasons, and the registry
|
|
453
|
+
* banks the time left on the first of them and waits for the last.
|
|
339
454
|
*/
|
|
340
455
|
#pause(message, reason) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (timer.id === 0) return;
|
|
345
|
-
this.#timers.clear(timer.id);
|
|
346
|
-
const remaining = Math.max(1, timer.remaining - (Date.now() - timer.startedAt));
|
|
347
|
-
this.#state.set(message, { id: 0, startedAt: 0, remaining, paused: timer.paused });
|
|
348
|
-
}
|
|
349
|
-
/** Resumes a paused message's auto-dismiss with the banked time. */
|
|
456
|
+
this.#dismiss.pause(message, reason);
|
|
457
|
+
}
|
|
458
|
+
/** Releases one reason, resuming the banked time once no reason is left. */
|
|
350
459
|
#resume(message, reason) {
|
|
351
|
-
|
|
352
|
-
if (!timer) return;
|
|
353
|
-
timer.paused.delete(reason);
|
|
354
|
-
if (timer.paused.size > 0) return;
|
|
355
|
-
if (timer.id !== 0) return;
|
|
356
|
-
this.#startTimer(message, timer.remaining);
|
|
460
|
+
this.#dismiss.resume(message, reason);
|
|
357
461
|
}
|
|
358
462
|
/** Releases every per-message resource: timer, stacking slot, pause listeners. */
|
|
359
463
|
#forget(message) {
|
|
360
|
-
|
|
361
|
-
if (timer?.id) this.#timers.clear(timer.id);
|
|
362
|
-
this.#state.delete(message);
|
|
464
|
+
this.#dismiss.clear(message);
|
|
363
465
|
const index = this.#order.indexOf(message);
|
|
364
466
|
if (index !== -1) this.#order.splice(index, 1);
|
|
365
467
|
this.#unbindPause(message);
|
|
366
468
|
}
|
|
367
469
|
/** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
|
|
368
470
|
#beginDismiss(message, reason) {
|
|
369
|
-
if (!this.#
|
|
471
|
+
if (!this.#order.includes(message)) return;
|
|
370
472
|
this.#forget(message);
|
|
371
473
|
this.#leaving.add(message);
|
|
372
474
|
message.setAttribute("data-flash-state", "leaving");
|
|
@@ -103,6 +103,30 @@ var DetachGate = class _DetachGate {
|
|
|
103
103
|
}
|
|
104
104
|
};
|
|
105
105
|
|
|
106
|
+
// src/utils/listener_set.ts
|
|
107
|
+
var ListenerSet = class {
|
|
108
|
+
/** The generation every `add` joins until the next `dispose()`. */
|
|
109
|
+
#abort = new AbortController();
|
|
110
|
+
/**
|
|
111
|
+
* Attaches `handler` to the open generation, exactly as the caller spelled it.
|
|
112
|
+
*
|
|
113
|
+
* The set supplies the signal, so `options` carries everything else the DOM
|
|
114
|
+
* accepts — `capture` included, which has to match at release time and no
|
|
115
|
+
* longer has a second place to drift from.
|
|
116
|
+
*/
|
|
117
|
+
add(target, type, handler, options) {
|
|
118
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Releases every listener of the open generation, synchronously, and opens the
|
|
122
|
+
* next one. Idempotent, and safe before anything has been added.
|
|
123
|
+
*/
|
|
124
|
+
dispose() {
|
|
125
|
+
this.#abort.abort();
|
|
126
|
+
this.#abort = new AbortController();
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
106
130
|
// src/utils/min_duration_floor.ts
|
|
107
131
|
var MinDurationFloor = class {
|
|
108
132
|
#timers;
|
|
@@ -218,6 +242,7 @@ var FrameLoadingController = class extends Controller {
|
|
|
218
242
|
#floor = new MinDurationFloor(this.#timeouts);
|
|
219
243
|
#gate = new DetachGate();
|
|
220
244
|
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
245
|
+
#listeners = new ListenerSet();
|
|
221
246
|
#loading = false;
|
|
222
247
|
/**
|
|
223
248
|
* The optional targets this controller revealed, and the content it marked inert.
|
|
@@ -244,14 +269,12 @@ var FrameLoadingController = class extends Controller {
|
|
|
244
269
|
connect() {
|
|
245
270
|
this.#gate.cancel();
|
|
246
271
|
this.#beforeCache.activate();
|
|
247
|
-
this.element
|
|
248
|
-
this.element
|
|
249
|
-
this.element
|
|
272
|
+
this.#listeners.add(this.element, "turbo:before-fetch-request", this.#onStart);
|
|
273
|
+
this.#listeners.add(this.element, "turbo:frame-load", this.#onEnd);
|
|
274
|
+
this.#listeners.add(this.element, "turbo:fetch-request-error", this.#onEnd);
|
|
250
275
|
}
|
|
251
276
|
disconnect() {
|
|
252
|
-
this.
|
|
253
|
-
this.element.removeEventListener("turbo:frame-load", this.#onEnd);
|
|
254
|
-
this.element.removeEventListener("turbo:fetch-request-error", this.#onEnd);
|
|
277
|
+
this.#listeners.dispose();
|
|
255
278
|
this.#beforeCache.deactivate();
|
|
256
279
|
this.#gate.disconnected(this, () => this.#teardown());
|
|
257
280
|
}
|