stimeo-ui 0.6.0 → 0.7.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 +62 -0
- data/dist/controllers/alert_dialog_controller.js +75 -4
- data/dist/controllers/avatar_controller.js +47 -5
- data/dist/controllers/character_counter_controller.js +338 -63
- data/dist/controllers/command_palette_controller.js +75 -4
- data/dist/controllers/conditional_fields_controller.js +345 -51
- data/dist/controllers/confirm_controller.js +75 -4
- data/dist/controllers/dialog_controller.js +75 -4
- data/dist/controllers/direct_upload_controller.js +201 -45
- data/dist/controllers/dirty_form_controller.js +192 -29
- data/dist/controllers/dismissible_controller.js +83 -18
- data/dist/controllers/drawer_controller.js +75 -4
- data/dist/controllers/focus_controller.js +75 -4
- data/dist/controllers/form_field_controller.js +280 -62
- data/dist/controllers/form_validation_controller.js +208 -83
- data/dist/controllers/number_input_controller.js +47 -5
- data/dist/controllers/overflow_menu_controller.js +2 -1
- data/dist/controllers/pagination_controller.js +2 -1
- data/dist/controllers/persist_controller.js +432 -122
- data/dist/controllers/popover_controller.js +77 -4
- data/dist/controllers/rating_controller.js +9 -5
- data/dist/controllers/scroll_area_controller.js +462 -162
- data/dist/controllers/separator_controller.js +354 -38
- data/dist/controllers/sidebar_controller.js +83 -10
- data/dist/controllers/submit_once_controller.js +399 -121
- data/dist/controllers/theme_controller.js +8 -6
- data/dist/controllers/tree_view_controller.js +2 -1
- data/dist/index.js +2387 -861
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -2,6 +2,93 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/character_counter_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
|
+
function fillTemplate(template, values) {
|
|
16
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
17
|
+
const replacement = values[name];
|
|
18
|
+
return replacement === void 0 ? match : String(replacement);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/utils/attribute_lease.ts
|
|
23
|
+
var AttributeLease = class {
|
|
24
|
+
#attribute;
|
|
25
|
+
#records = /* @__PURE__ */ new Map();
|
|
26
|
+
/** @param attribute - The attribute whose temporary values this lease owns. */
|
|
27
|
+
constructor(attribute) {
|
|
28
|
+
this.#attribute = attribute;
|
|
29
|
+
}
|
|
30
|
+
/** Writes or removes the leased attribute while preserving its authored value. */
|
|
31
|
+
write(element, value) {
|
|
32
|
+
const existing = this.#records.get(element);
|
|
33
|
+
if (existing) {
|
|
34
|
+
existing.written = value;
|
|
35
|
+
} else {
|
|
36
|
+
this.#records.set(element, {
|
|
37
|
+
original: element.getAttribute(this.#attribute),
|
|
38
|
+
written: value
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
this.#reflect(element, value);
|
|
42
|
+
}
|
|
43
|
+
/** Returns one lease without overwriting a value subsequently authored by a consumer. */
|
|
44
|
+
return(element) {
|
|
45
|
+
const record = this.#records.get(element);
|
|
46
|
+
if (!record) return;
|
|
47
|
+
this.#records.delete(element);
|
|
48
|
+
const stillOwned = element.getAttribute(this.#attribute) === record.written;
|
|
49
|
+
if (stillOwned) this.#reflect(element, record.original);
|
|
50
|
+
}
|
|
51
|
+
/** Reflects only a real value transition, avoiding self-triggered mutation work. */
|
|
52
|
+
#reflect(element, value) {
|
|
53
|
+
if (element.getAttribute(this.#attribute) === value) return;
|
|
54
|
+
if (value === null) element.removeAttribute(this.#attribute);
|
|
55
|
+
else element.setAttribute(this.#attribute, value);
|
|
56
|
+
}
|
|
57
|
+
/** Returns every outstanding lease using the same ownership check as {@link return}. */
|
|
58
|
+
returnAll() {
|
|
59
|
+
for (const element of Array.from(this.#records.keys())) this.return(element);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// src/utils/before_cache_reset.ts
|
|
64
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
65
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
66
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
67
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
68
|
+
static #onBeforeCache = () => {
|
|
69
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
70
|
+
};
|
|
71
|
+
#rewind;
|
|
72
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
73
|
+
constructor(rewind) {
|
|
74
|
+
this.#rewind = rewind;
|
|
75
|
+
}
|
|
76
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
77
|
+
activate() {
|
|
78
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
79
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
80
|
+
if (first) {
|
|
81
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
85
|
+
deactivate() {
|
|
86
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
87
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
88
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
5
92
|
// src/utils/composition_tracker.ts
|
|
6
93
|
var CompositionTracker = class {
|
|
7
94
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
@@ -49,6 +136,39 @@ var CompositionTracker = class {
|
|
|
49
136
|
};
|
|
50
137
|
};
|
|
51
138
|
|
|
139
|
+
// src/utils/microtask_coalescer.ts
|
|
140
|
+
var MicrotaskCoalescer = class {
|
|
141
|
+
#run;
|
|
142
|
+
#queued = false;
|
|
143
|
+
#active = false;
|
|
144
|
+
#generation = 0;
|
|
145
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
146
|
+
constructor(run) {
|
|
147
|
+
this.#run = run;
|
|
148
|
+
}
|
|
149
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
150
|
+
activate() {
|
|
151
|
+
this.#active = true;
|
|
152
|
+
}
|
|
153
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
154
|
+
cancel() {
|
|
155
|
+
this.#active = false;
|
|
156
|
+
this.#queued = false;
|
|
157
|
+
this.#generation += 1;
|
|
158
|
+
}
|
|
159
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
160
|
+
schedule() {
|
|
161
|
+
if (!this.#active || this.#queued) return;
|
|
162
|
+
this.#queued = true;
|
|
163
|
+
const generation = this.#generation;
|
|
164
|
+
queueMicrotask(() => {
|
|
165
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
166
|
+
this.#queued = false;
|
|
167
|
+
this.#run();
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
52
172
|
// src/utils/safe_timeout.ts
|
|
53
173
|
var TimerRegistry = class {
|
|
54
174
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -103,110 +223,265 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
103
223
|
};
|
|
104
224
|
|
|
105
225
|
// src/controllers/character_counter_controller.ts
|
|
106
|
-
var ORIGINAL_INVALID = "data-character-counter-original-invalid";
|
|
107
226
|
var CharacterCounterController = class _CharacterCounterController extends Controller {
|
|
108
227
|
static targets = ["input", "output"];
|
|
109
228
|
static values = {
|
|
110
229
|
max: { type: Number, default: 0 },
|
|
111
230
|
warnAt: { type: Number, default: 0 },
|
|
112
|
-
mode: { type: String, default: "remaining" }
|
|
231
|
+
mode: { type: String, default: "remaining" },
|
|
232
|
+
announceText: { type: String, default: "" }
|
|
113
233
|
};
|
|
114
|
-
static events = ["change"];
|
|
115
|
-
/** Delay (ms) before
|
|
234
|
+
static events = ["change", "reconcile"];
|
|
235
|
+
/** Delay (ms) before one settled count is sent to the shared announcer. */
|
|
116
236
|
static #announceDelay = 200;
|
|
117
237
|
#timeouts = new SafeTimeout();
|
|
238
|
+
#ariaInvalid = new AttributeLease("aria-invalid");
|
|
239
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
240
|
+
#repaint = new MicrotaskCoalescer(() => this.#reconcile(true));
|
|
241
|
+
#composition = new CompositionTracker({
|
|
242
|
+
onEnd: (event) => this.#commitFrom(event.currentTarget)
|
|
243
|
+
});
|
|
118
244
|
#announceId = null;
|
|
119
|
-
|
|
120
|
-
#
|
|
245
|
+
#boundField = null;
|
|
246
|
+
#lastLength = null;
|
|
247
|
+
#lastDetail = null;
|
|
248
|
+
#reflectedOver = null;
|
|
121
249
|
#onInput = (event) => {
|
|
250
|
+
const field = this.#boundField;
|
|
251
|
+
if (!field || event.currentTarget !== field || this.#field !== field) return;
|
|
122
252
|
if (this.#composition.isComposing(event)) return;
|
|
123
|
-
this.#
|
|
253
|
+
this.#commit(field);
|
|
124
254
|
};
|
|
255
|
+
/** Reflects the current DOM state and opens the mutation-reconciliation window. */
|
|
125
256
|
connect() {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
this.#composition.observe(field);
|
|
130
|
-
this.#update({ announce: false });
|
|
257
|
+
this.#repaint.activate();
|
|
258
|
+
this.#beforeCache.activate();
|
|
259
|
+
this.#reconcile(false);
|
|
131
260
|
}
|
|
261
|
+
/** Releases listeners, timers, borrowed ARIA, and controller-owned state hooks. */
|
|
132
262
|
disconnect() {
|
|
133
|
-
|
|
134
|
-
|
|
263
|
+
this.#repaint.cancel();
|
|
264
|
+
this.#beforeCache.deactivate();
|
|
265
|
+
this.#cancelAnnouncement();
|
|
266
|
+
this.#bindField(null);
|
|
135
267
|
this.#composition.disconnect();
|
|
136
|
-
this.#
|
|
137
|
-
this.#
|
|
268
|
+
this.#clearStateHooks();
|
|
269
|
+
this.#lastLength = null;
|
|
270
|
+
this.#lastDetail = null;
|
|
271
|
+
}
|
|
272
|
+
/** Rebinds and repaints after an input target is added or replaced at runtime. */
|
|
273
|
+
inputTargetConnected() {
|
|
274
|
+
this.#repaint.schedule();
|
|
275
|
+
}
|
|
276
|
+
/** Releases and repaints after an input target is removed or replaced at runtime. */
|
|
277
|
+
inputTargetDisconnected() {
|
|
278
|
+
this.#repaint.schedule();
|
|
279
|
+
}
|
|
280
|
+
/** Initializes a display target inserted after the controller connected. */
|
|
281
|
+
outputTargetConnected() {
|
|
282
|
+
this.#repaint.schedule();
|
|
283
|
+
}
|
|
284
|
+
/** Reconciles the optional display target set after a removal or replacement. */
|
|
285
|
+
outputTargetDisconnected() {
|
|
286
|
+
this.#repaint.schedule();
|
|
287
|
+
}
|
|
288
|
+
/** Repaints when application code or a Turbo morph changes `max`. */
|
|
289
|
+
maxValueChanged() {
|
|
290
|
+
this.#repaint.schedule();
|
|
291
|
+
}
|
|
292
|
+
/** Repaints when application code or a Turbo morph changes `warnAt`. */
|
|
293
|
+
warnAtValueChanged() {
|
|
294
|
+
this.#repaint.schedule();
|
|
295
|
+
}
|
|
296
|
+
/** Repaints when application code or a Turbo morph changes `mode`. */
|
|
297
|
+
modeValueChanged() {
|
|
298
|
+
this.#repaint.schedule();
|
|
299
|
+
}
|
|
300
|
+
/** Cancels a pending old message when its consumer-authored template changes. */
|
|
301
|
+
announceTextValueChanged() {
|
|
302
|
+
this.#repaint.schedule();
|
|
303
|
+
}
|
|
304
|
+
/** Rebinds one mutation batch and reports a changed controller-derived state. */
|
|
305
|
+
#reconcile(report) {
|
|
306
|
+
const previous = this.#lastDetail;
|
|
307
|
+
const owedField = this.#announceId === null ? null : this.#boundField;
|
|
308
|
+
this.#cancelAnnouncement();
|
|
309
|
+
this.#bindField(this.#field);
|
|
310
|
+
if (this.#composition.isComposing()) return;
|
|
311
|
+
const field = this.#boundField;
|
|
312
|
+
if (!field) {
|
|
313
|
+
this.#renderEmpty();
|
|
314
|
+
this.#lastLength = null;
|
|
315
|
+
this.#lastDetail = null;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const reading = this.#render(field);
|
|
319
|
+
const detail = this.#detail(reading);
|
|
320
|
+
this.#lastLength = reading.length;
|
|
321
|
+
this.#lastDetail = detail;
|
|
322
|
+
if (owedField === field) this.#scheduleAnnouncement(reading);
|
|
323
|
+
if (report && previous && this.#detailsDiffer(previous, detail)) {
|
|
324
|
+
this.dispatch("reconcile", { detail });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** Commits a composition only when it came from the field still owned here. */
|
|
328
|
+
#commitFrom(target) {
|
|
329
|
+
const field = this.#boundField;
|
|
330
|
+
if (!field || target !== field || this.#field !== field) return;
|
|
331
|
+
this.#commit(field);
|
|
332
|
+
}
|
|
333
|
+
/** Reflects one confirmed user input and reports only a real length transition. */
|
|
334
|
+
#commit(field) {
|
|
335
|
+
const reading = this.#render(field);
|
|
336
|
+
const previousLength = this.#lastLength;
|
|
337
|
+
const previousDetail = this.#lastDetail;
|
|
338
|
+
const detail = this.#detail(reading);
|
|
339
|
+
this.#lastLength = reading.length;
|
|
340
|
+
this.#lastDetail = detail;
|
|
341
|
+
if (previousLength === reading.length) {
|
|
342
|
+
if (previousDetail && this.#detailsDiffer(previousDetail, detail)) {
|
|
343
|
+
this.dispatch("reconcile", { detail });
|
|
344
|
+
}
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
this.dispatch("change", { detail });
|
|
348
|
+
this.#scheduleAnnouncement(reading);
|
|
349
|
+
}
|
|
350
|
+
/** Selects the public event state from the richer internal reading. */
|
|
351
|
+
#detail(reading) {
|
|
352
|
+
return {
|
|
353
|
+
length: reading.length,
|
|
354
|
+
remaining: reading.remaining,
|
|
355
|
+
over: reading.over
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/** Compares exactly the state carried by `change` and `reconcile`. */
|
|
359
|
+
#detailsDiffer(left, right) {
|
|
360
|
+
return left.length !== right.length || left.remaining !== right.remaining || left.over !== right.over;
|
|
138
361
|
}
|
|
139
362
|
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
363
|
+
* Synchronizes visible count, state hooks, and temporary validation ARIA.
|
|
364
|
+
*
|
|
365
|
+
* @stimeoRenderRoot
|
|
143
366
|
*/
|
|
144
|
-
#
|
|
145
|
-
const field = this.#field;
|
|
146
|
-
if (!field) return;
|
|
367
|
+
#render(field) {
|
|
147
368
|
const length = field.value.length;
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
const
|
|
369
|
+
const max = this.#normalizeCount(this.maxValue);
|
|
370
|
+
const warnAt = this.#normalizeCount(this.warnAtValue);
|
|
371
|
+
const remaining = max > 0 ? max - length : null;
|
|
372
|
+
const over = remaining !== null && remaining < 0;
|
|
373
|
+
const near = remaining !== null && warnAt > 0 && !over && remaining <= warnAt;
|
|
152
374
|
this.#toggle("data-over-limit", over);
|
|
153
375
|
this.#toggle("data-near-limit", near);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
376
|
+
this.#reflectInvalid(field, over);
|
|
377
|
+
const text = this.#format(length, remaining, max);
|
|
378
|
+
this.#writeOutput(text);
|
|
379
|
+
return { length, remaining, over, max, text };
|
|
380
|
+
}
|
|
381
|
+
/** Clears derived output when the declarative input set has no usable field. */
|
|
382
|
+
#renderEmpty() {
|
|
383
|
+
this.#ariaInvalid.returnAll();
|
|
384
|
+
this.#reflectedOver = null;
|
|
385
|
+
this.#clearStateHooks();
|
|
386
|
+
this.#writeOutput("");
|
|
387
|
+
}
|
|
388
|
+
/** Replaces the observed field symmetrically, returning state from the old one. */
|
|
389
|
+
#bindField(field) {
|
|
390
|
+
if (field === this.#boundField) return;
|
|
391
|
+
const previous = this.#boundField;
|
|
392
|
+
if (previous) {
|
|
393
|
+
previous.removeEventListener("input", this.#onInput);
|
|
394
|
+
this.#composition.unobserve(previous);
|
|
395
|
+
this.#ariaInvalid.return(previous);
|
|
172
396
|
}
|
|
173
|
-
this
|
|
174
|
-
|
|
175
|
-
this.#
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
397
|
+
this.#boundField = field;
|
|
398
|
+
this.#lastLength = null;
|
|
399
|
+
this.#reflectedOver = null;
|
|
400
|
+
if (!field) return;
|
|
401
|
+
field.addEventListener("input", this.#onInput);
|
|
402
|
+
this.#composition.observe(field);
|
|
403
|
+
}
|
|
404
|
+
/** Leases `aria-invalid` only on the edge into over-limit, then returns it. */
|
|
405
|
+
#reflectInvalid(field, over) {
|
|
406
|
+
if (this.#reflectedOver === over) return;
|
|
407
|
+
this.#reflectedOver = over;
|
|
408
|
+
if (over) this.#ariaInvalid.write(field, "true");
|
|
409
|
+
else this.#ariaInvalid.return(field);
|
|
179
410
|
}
|
|
180
|
-
/** Builds the count
|
|
181
|
-
#format(length, remaining) {
|
|
411
|
+
/** Builds the visible count for the normalized display mode. */
|
|
412
|
+
#format(length, remaining, max) {
|
|
182
413
|
if (remaining === null) return String(length);
|
|
183
|
-
switch (this
|
|
414
|
+
switch (this.#mode) {
|
|
184
415
|
case "used":
|
|
185
416
|
return String(length);
|
|
186
417
|
case "both":
|
|
187
|
-
return `${length}/${
|
|
418
|
+
return `${length}/${max}`;
|
|
188
419
|
default:
|
|
189
420
|
return String(remaining);
|
|
190
421
|
}
|
|
191
422
|
}
|
|
423
|
+
/** Debounces one i18n-neutral message into the shared polite announcer. */
|
|
424
|
+
#scheduleAnnouncement(reading) {
|
|
425
|
+
this.#cancelAnnouncement();
|
|
426
|
+
const message = fillTemplate(this.announceTextValue, {
|
|
427
|
+
count: reading.text,
|
|
428
|
+
length: reading.length,
|
|
429
|
+
remaining: reading.remaining ?? "",
|
|
430
|
+
max: reading.max,
|
|
431
|
+
over: String(reading.over)
|
|
432
|
+
});
|
|
433
|
+
if (message.trim().length === 0) return;
|
|
434
|
+
this.#announceId = this.#timeouts.set(() => {
|
|
435
|
+
announce(message);
|
|
436
|
+
this.#announceId = null;
|
|
437
|
+
}, _CharacterCounterController.#announceDelay);
|
|
438
|
+
}
|
|
439
|
+
/** Cancels the one outstanding announcement without touching visible output. */
|
|
440
|
+
#cancelAnnouncement() {
|
|
441
|
+
if (this.#announceId !== null) this.#timeouts.clear(this.#announceId);
|
|
442
|
+
this.#announceId = null;
|
|
443
|
+
}
|
|
444
|
+
/** Writes the optional display only when its text actually changed. */
|
|
192
445
|
#writeOutput(text) {
|
|
193
|
-
if (this.hasOutputTarget
|
|
446
|
+
if (!this.hasOutputTarget || this.outputTarget.textContent === text) return;
|
|
447
|
+
this.outputTarget.textContent = text;
|
|
194
448
|
}
|
|
195
|
-
/**
|
|
449
|
+
/** Reflects a presence-style state hook without redundant attribute writes. */
|
|
196
450
|
#toggle(name, on) {
|
|
197
451
|
if (on) {
|
|
198
|
-
this.element.setAttribute(name, "true");
|
|
199
|
-
} else {
|
|
452
|
+
if (this.element.getAttribute(name) !== "true") this.element.setAttribute(name, "true");
|
|
453
|
+
} else if (this.element.hasAttribute(name)) {
|
|
200
454
|
this.element.removeAttribute(name);
|
|
201
455
|
}
|
|
202
456
|
}
|
|
203
|
-
/**
|
|
457
|
+
/** Removes both state hooks owned by this controller. */
|
|
458
|
+
#clearStateHooks() {
|
|
459
|
+
this.#toggle("data-over-limit", false);
|
|
460
|
+
this.#toggle("data-near-limit", false);
|
|
461
|
+
}
|
|
462
|
+
/** Returns borrowed ARIA and transient hooks before Turbo snapshots the page. */
|
|
463
|
+
#rewindForCache() {
|
|
464
|
+
this.#cancelAnnouncement();
|
|
465
|
+
this.#ariaInvalid.returnAll();
|
|
466
|
+
this.#reflectedOver = null;
|
|
467
|
+
this.#clearStateHooks();
|
|
468
|
+
}
|
|
469
|
+
/** Converts a public count Value into a finite non-negative integer. */
|
|
470
|
+
#normalizeCount(raw) {
|
|
471
|
+
if (!Number.isFinite(raw)) return 0;
|
|
472
|
+
return Math.max(0, Math.trunc(raw));
|
|
473
|
+
}
|
|
474
|
+
/** Falls back to `remaining` when an authored display mode is unknown. */
|
|
475
|
+
get #mode() {
|
|
476
|
+
if (this.modeValue === "used" || this.modeValue === "both") return this.modeValue;
|
|
477
|
+
return "remaining";
|
|
478
|
+
}
|
|
479
|
+
/** The watched field: the first target, or a directly controlled form field. */
|
|
204
480
|
get #field() {
|
|
205
481
|
if (this.hasInputTarget) return this.inputTarget;
|
|
206
|
-
const
|
|
207
|
-
if (
|
|
208
|
-
return
|
|
209
|
-
}
|
|
482
|
+
const element = this.element;
|
|
483
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)
|
|
484
|
+
return element;
|
|
210
485
|
return null;
|
|
211
486
|
}
|
|
212
487
|
};
|
|
@@ -180,8 +180,81 @@ var EscapeLayer = class _EscapeLayer {
|
|
|
180
180
|
}
|
|
181
181
|
};
|
|
182
182
|
|
|
183
|
+
// src/utils/focus_candidate.ts
|
|
184
|
+
function inheritsFieldsetDisabled(control) {
|
|
185
|
+
let fieldset = control.closest("fieldset[disabled]");
|
|
186
|
+
while (fieldset) {
|
|
187
|
+
const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
|
|
188
|
+
if (!legend?.contains(control)) return true;
|
|
189
|
+
fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
function canTakeFocus(element) {
|
|
194
|
+
if (element.closest("[hidden], [inert]")) return false;
|
|
195
|
+
if (element instanceof HTMLInputElement && element.type === "hidden") return false;
|
|
196
|
+
if (!("disabled" in element)) return true;
|
|
197
|
+
if (element.disabled) return false;
|
|
198
|
+
return !inheritsFieldsetDisabled(element);
|
|
199
|
+
}
|
|
200
|
+
var TAB_STOP_CANDIDATE_SELECTOR = [
|
|
201
|
+
"a[href]",
|
|
202
|
+
"area[href]",
|
|
203
|
+
"button",
|
|
204
|
+
"input",
|
|
205
|
+
"select",
|
|
206
|
+
"textarea",
|
|
207
|
+
"summary",
|
|
208
|
+
"iframe",
|
|
209
|
+
"audio[controls]",
|
|
210
|
+
"video[controls]",
|
|
211
|
+
"[tabindex]",
|
|
212
|
+
"[contenteditable]"
|
|
213
|
+
].join(",");
|
|
214
|
+
function isRenderedForFocus(element) {
|
|
215
|
+
const check = element.checkVisibility;
|
|
216
|
+
return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
|
|
217
|
+
}
|
|
218
|
+
function authoredTabindex(element) {
|
|
219
|
+
const value = element.getAttribute("tabindex");
|
|
220
|
+
if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
|
|
221
|
+
return Number(value);
|
|
222
|
+
}
|
|
223
|
+
function hasNativeTabStop(element) {
|
|
224
|
+
if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
|
|
225
|
+
return element.hasAttribute("href");
|
|
226
|
+
}
|
|
227
|
+
if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
if (element instanceof HTMLInputElement) return element.type !== "hidden";
|
|
231
|
+
if (element instanceof HTMLIFrameElement) return true;
|
|
232
|
+
if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
|
|
233
|
+
return element.hasAttribute("controls");
|
|
234
|
+
}
|
|
235
|
+
if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
|
|
236
|
+
const details = element.parentElement;
|
|
237
|
+
return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
function hasEditableTabStop(element) {
|
|
242
|
+
const value = element.getAttribute("contenteditable")?.toLowerCase();
|
|
243
|
+
return value === "" || value === "true" || value === "plaintext-only";
|
|
244
|
+
}
|
|
245
|
+
function isTabStop(element) {
|
|
246
|
+
if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
|
|
247
|
+
const tabindex = authoredTabindex(element);
|
|
248
|
+
if (tabindex !== null) return tabindex >= 0;
|
|
249
|
+
return hasNativeTabStop(element) || hasEditableTabStop(element);
|
|
250
|
+
}
|
|
251
|
+
function tabStopsWithin(root) {
|
|
252
|
+
return Array.from(root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)).filter(
|
|
253
|
+
isTabStop
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
183
257
|
// src/utils/focus_trap.ts
|
|
184
|
-
var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
185
258
|
var FocusTrap = class {
|
|
186
259
|
/** The element focused before activation, restored on deactivation. */
|
|
187
260
|
#previouslyFocused = null;
|
|
@@ -343,9 +416,7 @@ var FocusTrap = class {
|
|
|
343
416
|
}
|
|
344
417
|
/** Collects the container's currently focusable descendants in DOM order. */
|
|
345
418
|
#focusableElements() {
|
|
346
|
-
return
|
|
347
|
-
(el) => !el.hidden
|
|
348
|
-
);
|
|
419
|
+
return tabStopsWithin(this.#getContainer());
|
|
349
420
|
}
|
|
350
421
|
};
|
|
351
422
|
|