stimeo-ui 0.14.0 → 0.16.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 +212 -0
- data/README.md +120 -0
- data/dist/cable/index.js +123 -29
- data/dist/controllers/accordion_controller.js +98 -9
- data/dist/controllers/announcer_controller.js +96 -62
- data/dist/controllers/auto_submit_controller.js +83 -7
- data/dist/controllers/avatar_controller.js +1 -1
- data/dist/controllers/breadcrumb_controller.js +38 -11
- data/dist/controllers/bulk_select_controller.js +7 -6
- data/dist/controllers/calendar_controller.js +340 -123
- data/dist/controllers/carousel_controller.js +263 -38
- data/dist/controllers/character_counter_controller.js +40 -2
- data/dist/controllers/checkbox_controller.js +81 -12
- data/dist/controllers/clipboard_controller.js +63 -5
- data/dist/controllers/collapsible_controller.js +99 -14
- data/dist/controllers/color_picker_controller.js +80 -34
- data/dist/controllers/combobox_controller.js +106 -16
- data/dist/controllers/command_palette_controller.js +35 -3
- data/dist/controllers/conditional_fields_controller.js +85 -17
- data/dist/controllers/confirm_controller.js +3 -0
- data/dist/controllers/context_menu_controller.js +32 -12
- data/dist/controllers/countdown_controller.js +129 -26
- data/dist/controllers/currency_input_controller.js +221 -67
- data/dist/controllers/data_grid_controller.js +195 -29
- data/dist/controllers/date_range_picker_controller.js +151 -30
- data/dist/controllers/dialog_controller.js +35 -8
- data/dist/controllers/direct_upload_controller.js +22 -4
- data/dist/controllers/dirty_form_controller.js +46 -13
- data/dist/controllers/dismissible_controller.js +1 -0
- data/dist/controllers/drawer_controller.js +54 -19
- data/dist/controllers/dropdown_controller.js +36 -9
- data/dist/controllers/editable_controller.js +34 -0
- data/dist/controllers/file_dropzone_controller.js +144 -51
- data/dist/controllers/filter_controller.js +20 -6
- data/dist/controllers/flash_controller.js +432 -71
- data/dist/controllers/focus_controller.js +1 -0
- data/dist/controllers/form_field_controller.js +7 -5
- data/dist/controllers/form_validation_controller.js +19 -13
- data/dist/controllers/frame_loading_controller.js +45 -8
- data/dist/controllers/highlight_controller.js +82 -25
- data/dist/controllers/hover_card_controller.js +40 -14
- data/dist/controllers/idle_controller.js +90 -5
- data/dist/controllers/input_mask_controller.js +65 -9
- data/dist/controllers/intersection_controller.js +3 -0
- data/dist/controllers/lazy_frame_controller.js +11 -2
- data/dist/controllers/listbox_controller.js +203 -45
- data/dist/controllers/local_time_controller.js +10 -5
- data/dist/controllers/masonry_controller.js +31 -15
- data/dist/controllers/menu_controller.js +45 -16
- data/dist/controllers/menubar_controller.js +58 -24
- data/dist/controllers/meter_controller.js +9 -5
- data/dist/controllers/multi_select_controller.js +278 -104
- data/dist/controllers/navigation_menu_controller.js +48 -15
- data/dist/controllers/nested_form_controller.js +37 -8
- data/dist/controllers/network_status_controller.js +9 -1
- data/dist/controllers/number_input_controller.js +124 -21
- data/dist/controllers/optimistic_controller.js +42 -5
- data/dist/controllers/otp_controller.js +198 -55
- data/dist/controllers/overflow_indicator_controller.js +115 -21
- data/dist/controllers/overflow_menu_controller.js +141 -44
- data/dist/controllers/pagination_controller.js +74 -28
- data/dist/controllers/password_reveal_controller.js +59 -2
- data/dist/controllers/persist_controller.js +30 -8
- data/dist/controllers/pointer_drag_controller.js +131 -52
- data/dist/controllers/popover_controller.js +45 -11
- data/dist/controllers/portal_controller.js +6 -2
- data/dist/controllers/preview_guard_controller.js +16 -1
- data/dist/controllers/progress_controller.js +8 -4
- data/dist/controllers/radio_group_controller.js +42 -17
- data/dist/controllers/range_slider_controller.js +88 -42
- data/dist/controllers/rating_controller.js +39 -15
- data/dist/controllers/read_more_controller.js +100 -7
- data/dist/controllers/reading_progress_controller.js +65 -19
- data/dist/controllers/relative_time_controller.js +10 -5
- data/dist/controllers/resizable_controller.js +82 -22
- data/dist/controllers/scroll_area_controller.js +75 -27
- data/dist/controllers/scroll_restore_controller.js +37 -16
- data/dist/controllers/scroll_visibility_controller.js +49 -30
- data/dist/controllers/scrollspy_controller.js +71 -26
- data/dist/controllers/separator_controller.js +66 -37
- data/dist/controllers/sidebar_controller.js +77 -18
- data/dist/controllers/skeleton_controller.js +6 -1
- data/dist/controllers/slider_controller.js +82 -47
- data/dist/controllers/smart_sticky_header_controller.js +60 -26
- data/dist/controllers/sortable_controller.js +17 -2
- data/dist/controllers/spinner_controller.js +10 -2
- data/dist/controllers/step_indicator_controller.js +18 -17
- data/dist/controllers/stepper_controller.js +101 -19
- data/dist/controllers/stick_to_bottom_controller.js +104 -8
- data/dist/controllers/submit_once_controller.js +45 -9
- data/dist/controllers/switch_controller.js +101 -10
- data/dist/controllers/tabs_controller.js +21 -2
- data/dist/controllers/tags_input_controller.js +209 -59
- data/dist/controllers/textarea_autosize_controller.js +29 -3
- data/dist/controllers/theme_controller.js +64 -14
- data/dist/controllers/time_picker_controller.js +23 -8
- data/dist/controllers/toast_controller.js +451 -105
- data/dist/controllers/toggle_group_controller.js +159 -23
- data/dist/controllers/toolbar_controller.js +32 -0
- data/dist/controllers/tooltip_controller.js +39 -13
- data/dist/controllers/transition_controller.js +4 -0
- data/dist/controllers/tree_view_controller.js +169 -16
- data/dist/index.js +5002 -1911
- data/dist/positioning/index.js +2 -0
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -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. */
|
|
@@ -31,6 +42,15 @@ var BeforeCacheReset = class _BeforeCacheReset {
|
|
|
31
42
|
}
|
|
32
43
|
};
|
|
33
44
|
|
|
45
|
+
// src/utils/event_owner.ts
|
|
46
|
+
function ownerIndex(candidates, node) {
|
|
47
|
+
if (!(node instanceof Node)) return -1;
|
|
48
|
+
return candidates.findIndex((candidate) => candidate.contains(node));
|
|
49
|
+
}
|
|
50
|
+
function ownerOf(candidates, node) {
|
|
51
|
+
return candidates[ownerIndex(candidates, node)] ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
34
54
|
// src/utils/safe_timeout.ts
|
|
35
55
|
var TimerRegistry = class {
|
|
36
56
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -84,6 +104,207 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
84
104
|
}
|
|
85
105
|
};
|
|
86
106
|
|
|
107
|
+
// src/utils/keyed_timers.ts
|
|
108
|
+
var KeyedTimers = class {
|
|
109
|
+
#timers = new SafeTimeout();
|
|
110
|
+
/** The pending timer of each key; an entry lives exactly as long as its timer. */
|
|
111
|
+
#pending = /* @__PURE__ */ new Map();
|
|
112
|
+
/**
|
|
113
|
+
* Arms `callback` after `delay` ms for `key`, cancelling the timer `key` had
|
|
114
|
+
* pending. The entry is dropped before the callback runs, so the callback sees
|
|
115
|
+
* the key unarmed and may arm it again for the next round.
|
|
116
|
+
*/
|
|
117
|
+
set(key, callback, delay) {
|
|
118
|
+
this.clear(key);
|
|
119
|
+
const id = this.#timers.set(() => {
|
|
120
|
+
this.#pending.delete(key);
|
|
121
|
+
callback();
|
|
122
|
+
}, delay);
|
|
123
|
+
this.#pending.set(key, id);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Cancels `key`'s pending timer, if it has one.
|
|
127
|
+
*
|
|
128
|
+
* A timer id is a positive integer, so `-1` stands for "nothing pending" and
|
|
129
|
+
* the registry ignores an id it does not own — the unarmed case needs no
|
|
130
|
+
* branch of its own, and no other key's timer can be reached from here.
|
|
131
|
+
*/
|
|
132
|
+
clear(key) {
|
|
133
|
+
this.#timers.clear(this.#pending.get(key) ?? -1);
|
|
134
|
+
this.#pending.delete(key);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Cancels every pending timer and forgets every key. Call this from a
|
|
138
|
+
* controller's `disconnect()` so no timer, and no entry, outlives the element.
|
|
139
|
+
*/
|
|
140
|
+
clearAll() {
|
|
141
|
+
this.#timers.clearAll();
|
|
142
|
+
this.#pending.clear();
|
|
143
|
+
}
|
|
144
|
+
/** Whether `key` has a timer pending. */
|
|
145
|
+
has(key) {
|
|
146
|
+
return this.#pending.has(key);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// src/utils/listener_set.ts
|
|
151
|
+
var ListenerSet = class {
|
|
152
|
+
/** The generation every `add` joins until the next `dispose()`. */
|
|
153
|
+
#abort = new AbortController();
|
|
154
|
+
/**
|
|
155
|
+
* Attaches `handler` to the open generation, exactly as the caller spelled it.
|
|
156
|
+
*
|
|
157
|
+
* The set supplies the signal, so `options` carries everything else the DOM
|
|
158
|
+
* accepts — `capture` included, which has to match at release time and no
|
|
159
|
+
* longer has a second place to drift from.
|
|
160
|
+
*/
|
|
161
|
+
add(target, type, handler, options) {
|
|
162
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Releases every listener of the open generation, synchronously, and opens the
|
|
166
|
+
* next one. Idempotent, and safe before anything has been added.
|
|
167
|
+
*/
|
|
168
|
+
dispose() {
|
|
169
|
+
this.#abort.abort();
|
|
170
|
+
this.#abort = new AbortController();
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// src/utils/microtask_coalescer.ts
|
|
175
|
+
var MicrotaskCoalescer = class {
|
|
176
|
+
#run;
|
|
177
|
+
#queued = false;
|
|
178
|
+
#active = false;
|
|
179
|
+
#generation = 0;
|
|
180
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
181
|
+
constructor(run) {
|
|
182
|
+
this.#run = run;
|
|
183
|
+
}
|
|
184
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
185
|
+
activate() {
|
|
186
|
+
this.#active = true;
|
|
187
|
+
}
|
|
188
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
189
|
+
cancel() {
|
|
190
|
+
this.#active = false;
|
|
191
|
+
this.#queued = false;
|
|
192
|
+
this.#generation += 1;
|
|
193
|
+
}
|
|
194
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
195
|
+
schedule() {
|
|
196
|
+
if (!this.#active || this.#queued) return;
|
|
197
|
+
this.#queued = true;
|
|
198
|
+
const generation = this.#generation;
|
|
199
|
+
queueMicrotask(() => {
|
|
200
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
201
|
+
this.#queued = false;
|
|
202
|
+
this.#run();
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// src/utils/pausable_timers.ts
|
|
208
|
+
var PausableTimers = class {
|
|
209
|
+
#timers = new KeyedTimers();
|
|
210
|
+
/** Every key armed or held; an entry goes as its timer fires, or with the key. */
|
|
211
|
+
#entries = /* @__PURE__ */ new Map();
|
|
212
|
+
/**
|
|
213
|
+
* Arms `callback` after `delay` ms for `key`, replacing whatever `key` had.
|
|
214
|
+
* A held key stays held and banks `delay` for its resume.
|
|
215
|
+
*/
|
|
216
|
+
set(key, callback, delay) {
|
|
217
|
+
const reasons = this.#entries.get(key)?.reasons ?? /* @__PURE__ */ new Set();
|
|
218
|
+
const entry = { callback, startedAt: 0, remaining: delay, reasons };
|
|
219
|
+
this.#entries.set(key, entry);
|
|
220
|
+
if (reasons.size === 0) this.#arm(key, entry, callback);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Holds `key` for `reason` and banks the time left, with a floor of one
|
|
224
|
+
* millisecond. A key with no timer is held all the same. Reports whether this
|
|
225
|
+
* call is the one that stopped a running timer, so the caller can write its
|
|
226
|
+
* held-state hook exactly once.
|
|
227
|
+
*/
|
|
228
|
+
pause(key, reason) {
|
|
229
|
+
let entry = this.#entries.get(key);
|
|
230
|
+
if (entry === void 0) {
|
|
231
|
+
entry = { callback: null, startedAt: 0, remaining: 0, reasons: /* @__PURE__ */ new Set() };
|
|
232
|
+
this.#entries.set(key, entry);
|
|
233
|
+
}
|
|
234
|
+
entry.reasons.add(reason);
|
|
235
|
+
if (!this.#timers.has(key)) return false;
|
|
236
|
+
this.#timers.clear(key);
|
|
237
|
+
entry.remaining = Math.max(1, entry.remaining - (Date.now() - entry.startedAt));
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Releases `reason` on `key`, arming the banked time again once no reason is
|
|
242
|
+
* left; a key released with no timer is forgotten. Reports whether this call
|
|
243
|
+
* is the one that started the timer again.
|
|
244
|
+
*/
|
|
245
|
+
resume(key, reason) {
|
|
246
|
+
const entry = this.#entries.get(key);
|
|
247
|
+
if (entry === void 0) return false;
|
|
248
|
+
entry.reasons.delete(reason);
|
|
249
|
+
if (entry.reasons.size > 0 || this.#timers.has(key)) return false;
|
|
250
|
+
const { callback } = entry;
|
|
251
|
+
if (callback === null) {
|
|
252
|
+
this.#entries.delete(key);
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
this.#arm(key, entry, callback);
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
/** Whether any reason holds `key`, with or without a timer. */
|
|
259
|
+
isHeld(key) {
|
|
260
|
+
return (this.#entries.get(key)?.reasons.size ?? 0) > 0;
|
|
261
|
+
}
|
|
262
|
+
/** Whether `key` is armed or held — that is, whether this registry drives it at all. */
|
|
263
|
+
tracks(key) {
|
|
264
|
+
return this.#entries.has(key);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Cancels `key`'s timer and keeps its hold, so a release arms nothing; a key
|
|
268
|
+
* nothing holds is forgotten.
|
|
269
|
+
*/
|
|
270
|
+
disarm(key) {
|
|
271
|
+
this.#timers.clear(key);
|
|
272
|
+
const entry = this.#entries.get(key);
|
|
273
|
+
if (entry?.reasons.size) entry.callback = null;
|
|
274
|
+
else this.#entries.delete(key);
|
|
275
|
+
}
|
|
276
|
+
/** Cancels `key`'s timer and drops its hold. */
|
|
277
|
+
clear(key) {
|
|
278
|
+
this.#timers.clear(key);
|
|
279
|
+
this.#entries.delete(key);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Cancels every timer and forgets every key. Call this from a controller's
|
|
283
|
+
* `disconnect()` so neither a timer nor a hold outlives the element.
|
|
284
|
+
*/
|
|
285
|
+
clearAll() {
|
|
286
|
+
this.#timers.clearAll();
|
|
287
|
+
this.#entries.clear();
|
|
288
|
+
}
|
|
289
|
+
/** Starts `entry`'s banked time running for `key`, and drops it as `callback` fires. */
|
|
290
|
+
#arm(key, entry, callback) {
|
|
291
|
+
entry.startedAt = Date.now();
|
|
292
|
+
this.#timers.set(
|
|
293
|
+
key,
|
|
294
|
+
() => {
|
|
295
|
+
this.#entries.delete(key);
|
|
296
|
+
callback();
|
|
297
|
+
},
|
|
298
|
+
entry.remaining
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// src/utils/target_selector.ts
|
|
304
|
+
function targetSelector(identifier, name) {
|
|
305
|
+
return `[data-${identifier}-target~="${name}"]`;
|
|
306
|
+
}
|
|
307
|
+
|
|
87
308
|
// src/utils/transition_completion.ts
|
|
88
309
|
function timeMs(value) {
|
|
89
310
|
const trimmed = value.trim();
|
|
@@ -120,8 +341,12 @@ function maxTransitionTotalMs(style) {
|
|
|
120
341
|
|
|
121
342
|
// src/controllers/flash_controller.ts
|
|
122
343
|
var ASSERTIVE_TYPES = /* @__PURE__ */ new Set(["alert", "error"]);
|
|
123
|
-
var
|
|
344
|
+
var MESSAGE_PART = "message";
|
|
124
345
|
var FlashController = class extends Controller {
|
|
346
|
+
/** Selects the message parts, in the namespace this controller is registered under. */
|
|
347
|
+
get #messageSelector() {
|
|
348
|
+
return targetSelector(this.identifier, MESSAGE_PART);
|
|
349
|
+
}
|
|
125
350
|
static targets = ["region", "message"];
|
|
126
351
|
static values = {
|
|
127
352
|
duration: { type: Number, default: 5e3 },
|
|
@@ -130,12 +355,35 @@ var FlashController = class extends Controller {
|
|
|
130
355
|
};
|
|
131
356
|
static actions = ["dismiss"];
|
|
132
357
|
static events = ["show", "dismiss", "reconcile"];
|
|
358
|
+
/** Removal timers for the leaving transition; the auto-dismiss ones live below. */
|
|
133
359
|
#timers = new SafeTimeout();
|
|
360
|
+
/**
|
|
361
|
+
* Per-message auto-dismiss held open while the message is hovered or focused, and
|
|
362
|
+
* the one record of which messages hover or focus holds, which the cap reads.
|
|
363
|
+
*/
|
|
364
|
+
#dismiss = new PausableTimers();
|
|
365
|
+
/**
|
|
366
|
+
* Auto-dismiss of the messages taken on while `pauseOnHover` is off. It runs on
|
|
367
|
+
* whatever holds the message; the hold itself still keeps the cap away.
|
|
368
|
+
*/
|
|
369
|
+
#fixedDismiss = new KeyedTimers();
|
|
370
|
+
/** Applies the cap again once the event that released a hold has run its course. */
|
|
371
|
+
#reapply = new MicrotaskCoalescer(() => this.#reapplyCap());
|
|
372
|
+
/** Messages the pointer or focus moved into since the cap was last applied again. */
|
|
373
|
+
#spared = [];
|
|
374
|
+
/** The next pointer movement, listened for while a hover hold waits on it. */
|
|
375
|
+
#pointer = new ListenerSet();
|
|
376
|
+
/** Messages whose hover hold waits for the next pointer movement to be confirmed. */
|
|
377
|
+
#awaitingPointer = /* @__PURE__ */ new Set();
|
|
134
378
|
#observer = null;
|
|
135
379
|
/** Whether the controller is between `connect()` and `disconnect()`. */
|
|
136
380
|
#connected = false;
|
|
137
|
-
/**
|
|
138
|
-
|
|
381
|
+
/**
|
|
382
|
+
* The `max` the stack was last held to, or `null` before the first time. `connect()`
|
|
383
|
+
* applies the cap only when `max` is not that one, so a reconnect of the same instance
|
|
384
|
+
* leaves the stack as it was unless `max` changed while it was away.
|
|
385
|
+
*/
|
|
386
|
+
#appliedMax = null;
|
|
139
387
|
/** Messages already processed, in insertion order, to enforce `max` and avoid double work. */
|
|
140
388
|
#order = [];
|
|
141
389
|
/**
|
|
@@ -147,12 +395,28 @@ var FlashController = class extends Controller {
|
|
|
147
395
|
#leaving = /* @__PURE__ */ new Set();
|
|
148
396
|
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
149
397
|
#onEnter = (event) => this.#pause(event.currentTarget, event.type === "focusin" ? "focus" : "hover");
|
|
150
|
-
|
|
398
|
+
/**
|
|
399
|
+
* Releases hover or focus on a message. Focus moving between two of the message's
|
|
400
|
+
* own controls is no release, since `focusout` names a control still inside it; and
|
|
401
|
+
* the message the pointer or focus is moving into is spared when the release
|
|
402
|
+
* applies the cap again, because its own hold only arrives after this event.
|
|
403
|
+
*/
|
|
404
|
+
#onLeave = (event) => {
|
|
405
|
+
const message = event.currentTarget;
|
|
406
|
+
const next = ownerOf(this.#order, event.relatedTarget);
|
|
407
|
+
if (next === message) return;
|
|
408
|
+
this.#resume(message, event.type === "focusout" ? "focus" : "hover", next);
|
|
409
|
+
};
|
|
151
410
|
connect() {
|
|
152
411
|
this.#connected = true;
|
|
412
|
+
this.#reapply.activate();
|
|
153
413
|
for (const message of this.messageTargets) {
|
|
154
414
|
if (this.#owns(message)) this.#process(message, true);
|
|
155
415
|
}
|
|
416
|
+
if (!Object.is(this.#appliedMax, this.maxValue)) {
|
|
417
|
+
this.#appliedMax = this.maxValue;
|
|
418
|
+
this.#enforceMax();
|
|
419
|
+
}
|
|
156
420
|
this.#syncObservation();
|
|
157
421
|
this.#beforeCache.activate();
|
|
158
422
|
}
|
|
@@ -161,8 +425,13 @@ var FlashController = class extends Controller {
|
|
|
161
425
|
this.#beforeCache.deactivate();
|
|
162
426
|
this.#stopObserving();
|
|
163
427
|
this.#timers.clearAll();
|
|
428
|
+
this.#dismiss.clearAll();
|
|
429
|
+
this.#fixedDismiss.clearAll();
|
|
430
|
+
this.#reapply.cancel();
|
|
431
|
+
this.#spared = [];
|
|
432
|
+
this.#pointer.dispose();
|
|
433
|
+
this.#awaitingPointer.clear();
|
|
164
434
|
for (const message of this.#order) this.#unbindPause(message);
|
|
165
|
-
this.#state.clear();
|
|
166
435
|
this.#order.length = 0;
|
|
167
436
|
this.#leaving.clear();
|
|
168
437
|
}
|
|
@@ -192,6 +461,19 @@ var FlashController = class extends Controller {
|
|
|
192
461
|
regionTargetDisconnected() {
|
|
193
462
|
this.#resync();
|
|
194
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Holds the stack to a changed `max`: lowering it below the count dismisses the
|
|
466
|
+
* oldest messages hover and focus do not hold, with reason `limit`, as an arrival
|
|
467
|
+
* past the cap does, while raising it or setting `0` dismisses nothing. Stimulus can
|
|
468
|
+
* also call this ahead of `connect()` — for an attribute that changed while the
|
|
469
|
+
* controller was away, and on every connect for an undeclared `max`, with its default;
|
|
470
|
+
* `connect()` decides then.
|
|
471
|
+
*/
|
|
472
|
+
maxValueChanged() {
|
|
473
|
+
if (!this.#connected) return;
|
|
474
|
+
this.#appliedMax = this.maxValue;
|
|
475
|
+
this.#enforceMax();
|
|
476
|
+
}
|
|
195
477
|
/**
|
|
196
478
|
* Whether this controller owns `message`. Ownership is the current `region`'s
|
|
197
479
|
* subtree: a message target anywhere else in the controller's scope is the
|
|
@@ -210,12 +492,16 @@ var FlashController = class extends Controller {
|
|
|
210
492
|
* scheduled removal are cancelled. A move *within* the region keeps all of them —
|
|
211
493
|
* which is why the element must still be a message to be treated as one: ownership
|
|
212
494
|
* alone reads an in-place attribute rewrite as a move, and a node outside the target
|
|
213
|
-
* set belongs to the consumer, so nothing here may dismiss it.
|
|
495
|
+
* set belongs to the consumer, so nothing here may dismiss it. A move may still have
|
|
496
|
+
* ended a hold, so those are read again.
|
|
214
497
|
*/
|
|
215
498
|
messageTargetDisconnected(message) {
|
|
216
499
|
if (!this.#connected) return;
|
|
217
|
-
const moved = this.#owns(message) && message.matches(
|
|
218
|
-
if (moved)
|
|
500
|
+
const moved = this.#owns(message) && message.matches(this.#messageSelector);
|
|
501
|
+
if (moved) {
|
|
502
|
+
this.#afterMove(message);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
219
505
|
this.#forget(message);
|
|
220
506
|
this.#leaving.delete(message);
|
|
221
507
|
}
|
|
@@ -249,13 +535,11 @@ var FlashController = class extends Controller {
|
|
|
249
535
|
this.#observer = null;
|
|
250
536
|
}
|
|
251
537
|
/**
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
* even if `pauseOnHover` were ever toggled during a message's life.
|
|
538
|
+
* Hover and focus listeners, bound and unbound as a pair so the two sides stay in
|
|
539
|
+
* sync. Every message gets them, whatever `pauseOnHover` says, because the cap reads
|
|
540
|
+
* the holds they record; unbinding is idempotent (a no-op when nothing was bound).
|
|
256
541
|
*/
|
|
257
542
|
#bindPause(message) {
|
|
258
|
-
if (!this.pauseOnHoverValue) return;
|
|
259
543
|
message.addEventListener("mouseenter", this.#onEnter);
|
|
260
544
|
message.addEventListener("mouseleave", this.#onLeave);
|
|
261
545
|
message.addEventListener("focusin", this.#onEnter);
|
|
@@ -270,7 +554,7 @@ var FlashController = class extends Controller {
|
|
|
270
554
|
/** Dismisses the flash whose close control fired the event. */
|
|
271
555
|
dismiss(event) {
|
|
272
556
|
const target = event.currentTarget || event.target;
|
|
273
|
-
const message = target?.closest(
|
|
557
|
+
const message = target?.closest(this.#messageSelector);
|
|
274
558
|
if (message) this.#beginDismiss(message, "user");
|
|
275
559
|
}
|
|
276
560
|
/** Processes messages added after connect (Turbo Stream); their own role announces them. */
|
|
@@ -278,21 +562,25 @@ var FlashController = class extends Controller {
|
|
|
278
562
|
for (const mutation of mutations) {
|
|
279
563
|
for (const node of mutation.addedNodes) {
|
|
280
564
|
if (!(node instanceof HTMLElement)) continue;
|
|
281
|
-
if (node.matches(
|
|
282
|
-
for (const message of node.querySelectorAll(
|
|
565
|
+
if (node.matches(this.#messageSelector)) this.#process(node, false);
|
|
566
|
+
for (const message of node.querySelectorAll(this.#messageSelector)) {
|
|
283
567
|
this.#process(message, false);
|
|
284
568
|
}
|
|
285
569
|
}
|
|
286
570
|
}
|
|
287
571
|
}
|
|
288
572
|
/**
|
|
289
|
-
* Applies role/state, wires pause listeners,
|
|
290
|
-
*
|
|
291
|
-
*
|
|
573
|
+
* Applies role/state, wires pause listeners, takes the holds the message already has,
|
|
574
|
+
* and schedules auto-dismiss. A message `connect()` takes on (`atConnect`) is bridged
|
|
575
|
+
* to the Announcer and meets no cap here, `connect()` holding the stack to it; a later
|
|
576
|
+
* insert announces through its own role and meets the cap as it arrives. Idempotent
|
|
577
|
+
* per message, and a node that has left the region by the time its insertion is
|
|
578
|
+
* reported is not taken on.
|
|
292
579
|
*/
|
|
293
|
-
#process(message,
|
|
294
|
-
if (this.#
|
|
295
|
-
|
|
580
|
+
#process(message, atConnect) {
|
|
581
|
+
if (this.#order.includes(message) || this.#leaving.has(message) || !this.#owns(message)) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
296
584
|
const type = message.getAttribute("data-flash-type") ?? "";
|
|
297
585
|
const assertive = ASSERTIVE_TYPES.has(type);
|
|
298
586
|
if (!message.hasAttribute("role")) {
|
|
@@ -301,72 +589,145 @@ var FlashController = class extends Controller {
|
|
|
301
589
|
message.setAttribute("data-flash-state", "visible");
|
|
302
590
|
this.#order.push(message);
|
|
303
591
|
this.#bindPause(message);
|
|
592
|
+
this.#readHolds(message);
|
|
304
593
|
const text = message.textContent?.trim() ?? "";
|
|
305
594
|
this.dispatch("show", { target: message, detail: { type, message: text } });
|
|
306
|
-
if (
|
|
307
|
-
window.dispatchEvent(
|
|
308
|
-
new CustomEvent("stimeo--announcer:announce", { detail: { message: text, assertive } })
|
|
309
|
-
);
|
|
310
|
-
}
|
|
595
|
+
if (atConnect) announce(text, { assertive });
|
|
311
596
|
this.#startTimer(message);
|
|
312
|
-
this.#enforceMax();
|
|
597
|
+
if (!atConnect) this.#enforceMax([message]);
|
|
313
598
|
}
|
|
314
|
-
/**
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
599
|
+
/**
|
|
600
|
+
* Dismisses the oldest messages with reason `limit` while more than `max` are shown
|
|
601
|
+
* (0 or less, or not a finite number, means no cap), passing over every message hover
|
|
602
|
+
* or focus holds and those in `spared` — the message just taken on, or the ones the
|
|
603
|
+
* pointer or focus moved into. Only messages still in the region count or go: one a
|
|
604
|
+
* script has just taken out stays on the stack until its removal is reported. With
|
|
605
|
+
* nothing else left to go the stack stays over the cap. The loop walks a copy and
|
|
606
|
+
* re-reads the count on every step, because a `dismiss` listener may already have
|
|
607
|
+
* changed both.
|
|
608
|
+
*
|
|
609
|
+
* @stimeoRenderRoot
|
|
610
|
+
*/
|
|
611
|
+
#enforceMax(spared = []) {
|
|
612
|
+
if (!Number.isFinite(this.maxValue) || this.maxValue <= 0) return;
|
|
613
|
+
for (const message of [...this.#order]) {
|
|
614
|
+
if (this.#shownCount() <= this.maxValue) return;
|
|
615
|
+
if (this.#owns(message) && !spared.includes(message) && !this.#dismiss.isHeld(message)) {
|
|
616
|
+
this.#beginDismiss(message, "limit");
|
|
617
|
+
}
|
|
321
618
|
}
|
|
322
619
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
620
|
+
/** How many messages the stack shows: the ones taken on that are still in the region. */
|
|
621
|
+
#shownCount() {
|
|
622
|
+
return this.#order.filter((message) => this.#owns(message)).length;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Arms a message's auto-dismiss; a non-positive `duration` means it never expires.
|
|
626
|
+
* The timer waits out hover and focus when `pauseOnHover` is on, and runs on
|
|
627
|
+
* regardless when it is off.
|
|
628
|
+
*
|
|
629
|
+
* @stimeoRuntimeOnly `duration` is the delay of the one dismissal timer this call arms,
|
|
630
|
+
* and `pauseOnHover` whether hover and focus hold that timer.
|
|
631
|
+
*/
|
|
632
|
+
#startTimer(message) {
|
|
633
|
+
if (this.durationValue <= 0) return;
|
|
634
|
+
const dismiss = () => this.#beginDismiss(message, "timeout");
|
|
635
|
+
if (this.pauseOnHoverValue) this.#dismiss.set(message, dismiss, this.durationValue);
|
|
636
|
+
else this.#fixedDismiss.set(message, dismiss, this.durationValue);
|
|
334
637
|
}
|
|
335
638
|
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
639
|
+
* Records hover or focus on a message. Hover and focus are independent reasons:
|
|
640
|
+
* the registry banks the time left on the first of them and waits for the last
|
|
641
|
+
* (WCAG 2.2 2.2.1), and the cap passes over the message while either is held.
|
|
339
642
|
*/
|
|
340
643
|
#pause(message, reason) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
644
|
+
this.#dismiss.pause(message, reason);
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Releases one reason, resuming the banked time once no reason is left. Releasing
|
|
648
|
+
* the last one applies the cap again, sparing `spare`.
|
|
649
|
+
*/
|
|
650
|
+
#resume(message, reason, spare) {
|
|
651
|
+
const held = this.#dismiss.isHeld(message);
|
|
652
|
+
this.#dismiss.resume(message, reason);
|
|
653
|
+
if (held && !this.#dismiss.isHeld(message)) this.#capLater(spare);
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Applies the cap again once the current event is over, sparing `spare`. A hold can
|
|
657
|
+
* end in a `focusout` the engine fires while it is itself taking the node out for a
|
|
658
|
+
* script's move or removal, and dismissing the message inside that event would pull
|
|
659
|
+
* the node from under the caller's own call.
|
|
660
|
+
*/
|
|
661
|
+
#capLater(spare) {
|
|
662
|
+
if (spare) this.#spared.push(spare);
|
|
663
|
+
this.#reapply.schedule();
|
|
664
|
+
}
|
|
665
|
+
/** Applies the cap again, sparing every message gathered since the last pass. */
|
|
666
|
+
#reapplyCap() {
|
|
667
|
+
const spared = this.#spared;
|
|
668
|
+
this.#spared = [];
|
|
669
|
+
this.#enforceMax(spared);
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Reads the holds of a message that moved within the region. Focus is read at once:
|
|
673
|
+
* an ordinary move ends it with a `focusout` and `moveBefore` keeps it. Where the
|
|
674
|
+
* pointer is, nothing says until it moves again: no `mouseleave` reaches a moved node,
|
|
675
|
+
* and `:hover` can answer from before the move. A message still held waits for that
|
|
676
|
+
* movement.
|
|
677
|
+
*/
|
|
678
|
+
#afterMove(message) {
|
|
679
|
+
if (!message.contains(document.activeElement)) this.#resume(message, "focus", null);
|
|
680
|
+
if (this.#dismiss.isHeld(message)) this.#awaitPointer(message);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Takes the holds a message already has as it is taken on: focus inside it, and the
|
|
684
|
+
* pointer over it by `:hover`. That answer can date from before the message came here,
|
|
685
|
+
* so a hover hold taken on it waits for the next pointer movement to be confirmed.
|
|
686
|
+
*/
|
|
687
|
+
#readHolds(message) {
|
|
688
|
+
if (message.contains(document.activeElement)) this.#pause(message, "focus");
|
|
689
|
+
if (!message.matches(":hover")) return;
|
|
690
|
+
this.#pause(message, "hover");
|
|
691
|
+
this.#awaitPointer(message);
|
|
692
|
+
}
|
|
693
|
+
/** Leaves `message`'s hover hold to be confirmed or let go on the next pointer movement. */
|
|
694
|
+
#awaitPointer(message) {
|
|
695
|
+
this.#awaitingPointer.add(message);
|
|
696
|
+
this.#pointer.add(document, "pointermove", this.#onPointerMove, {
|
|
697
|
+
capture: true,
|
|
698
|
+
passive: true
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Reads `:hover` once the pointer has moved, when the answer is current, and lets go of
|
|
703
|
+
* the hover hold of every waiting message the pointer is not over. Listens once.
|
|
704
|
+
*/
|
|
705
|
+
#onPointerMove = () => {
|
|
706
|
+
this.#pointer.dispose();
|
|
707
|
+
const waiting = [...this.#awaitingPointer];
|
|
708
|
+
this.#awaitingPointer.clear();
|
|
709
|
+
for (const message of waiting) {
|
|
710
|
+
if (!message.matches(":hover")) this.#resume(message, "hover", null);
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
/**
|
|
714
|
+
* Releases every per-message resource: timers, hold, stacking slot, pause listeners,
|
|
715
|
+
* and the wait on the pointer. A message something still held counts as released: the
|
|
716
|
+
* cap is applied again.
|
|
717
|
+
*/
|
|
359
718
|
#forget(message) {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
this.#
|
|
719
|
+
if (this.#dismiss.isHeld(message)) this.#capLater(null);
|
|
720
|
+
this.#dismiss.clear(message);
|
|
721
|
+
this.#fixedDismiss.clear(message);
|
|
363
722
|
const index = this.#order.indexOf(message);
|
|
364
723
|
if (index !== -1) this.#order.splice(index, 1);
|
|
365
724
|
this.#unbindPause(message);
|
|
725
|
+
this.#awaitingPointer.delete(message);
|
|
726
|
+
if (this.#awaitingPointer.size === 0) this.#pointer.dispose();
|
|
366
727
|
}
|
|
367
728
|
/** Marks a message leaving, then removes it after its CSS transition and emits dismiss. */
|
|
368
729
|
#beginDismiss(message, reason) {
|
|
369
|
-
if (!this.#
|
|
730
|
+
if (!this.#order.includes(message)) return;
|
|
370
731
|
this.#forget(message);
|
|
371
732
|
this.#leaving.add(message);
|
|
372
733
|
message.setAttribute("data-flash-state", "leaving");
|
|
@@ -437,6 +437,7 @@ var FocusController = class extends Controller {
|
|
|
437
437
|
this.element.setAttribute("data-focus-trapped", "true");
|
|
438
438
|
this.dispatch("activate", { detail: {} });
|
|
439
439
|
}
|
|
440
|
+
/** @stimeoRuntimeOnly `restore` decides whether releasing this one trap returns focus. */
|
|
440
441
|
#deactivate() {
|
|
441
442
|
if (!this.#active) return;
|
|
442
443
|
this.#active = false;
|
|
@@ -141,15 +141,17 @@ var MicrotaskCoalescer = class {
|
|
|
141
141
|
|
|
142
142
|
// src/controllers/form_field_controller.ts
|
|
143
143
|
var OBSERVED_ATTRIBUTES = ["hidden", "id"];
|
|
144
|
-
var FormFieldController = class
|
|
144
|
+
var FormFieldController = class extends Controller {
|
|
145
145
|
static targets = ["control", "description", "error"];
|
|
146
146
|
static values = {
|
|
147
147
|
focusOnError: { type: Boolean, default: false }
|
|
148
148
|
};
|
|
149
149
|
static actions = ["clearError", "setError"];
|
|
150
150
|
static events = ["validate"];
|
|
151
|
-
/** Root attribute (CSS hook) reflecting the invalid state. */
|
|
152
|
-
|
|
151
|
+
/** Root attribute (CSS hook) reflecting the invalid state, in this controller's namespace. */
|
|
152
|
+
get #invalidAttribute() {
|
|
153
|
+
return `data-${this.identifier}-invalid`;
|
|
154
|
+
}
|
|
153
155
|
/** Collapses one target/morph batch into one silent ARIA reconciliation. */
|
|
154
156
|
#reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
|
|
155
157
|
/** Returns borrowed control ARIA before Turbo snapshots the page. */
|
|
@@ -173,7 +175,7 @@ var FormFieldController = class _FormFieldController extends Controller {
|
|
|
173
175
|
this.#ensureAssociationIds();
|
|
174
176
|
this.#beforeCache.activate();
|
|
175
177
|
if (!this.#initialized) {
|
|
176
|
-
this.#explicitInvalid = this.element.hasAttribute(
|
|
178
|
+
this.#explicitInvalid = this.element.hasAttribute(this.#invalidAttribute) && this.#shownErrors().length === 0;
|
|
177
179
|
this.#initialized = true;
|
|
178
180
|
}
|
|
179
181
|
this.#reconcileDom();
|
|
@@ -270,7 +272,7 @@ var FormFieldController = class _FormFieldController extends Controller {
|
|
|
270
272
|
this.#adoptCurrentControl();
|
|
271
273
|
const shown = this.#shownErrors();
|
|
272
274
|
const invalid = this.#explicitInvalid || shown.length > 0;
|
|
273
|
-
this.element.toggleAttribute(
|
|
275
|
+
this.element.toggleAttribute(this.#invalidAttribute, invalid);
|
|
274
276
|
const control = this.#activeControl;
|
|
275
277
|
if (!control) return;
|
|
276
278
|
this.#ariaInvalid.write(control, invalid ? "true" : "false");
|