stimeo-ui 0.5.0 → 0.6.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 +116 -0
- data/dist/controllers/aspect_ratio_controller.js +19 -11
- data/dist/controllers/avatar_controller.js +195 -40
- data/dist/controllers/carousel_controller.js +85 -9
- data/dist/controllers/checkbox_controller.js +136 -25
- data/dist/controllers/color_picker_controller.js +35 -9
- data/dist/controllers/date_range_picker_controller.js +157 -30
- data/dist/controllers/file_dropzone_controller.js +26 -3
- data/dist/controllers/idle_controller.js +27 -5
- data/dist/controllers/menubar_controller.js +5 -3
- data/dist/controllers/multi_select_controller.js +460 -151
- data/dist/controllers/number_input_controller.js +275 -51
- data/dist/controllers/overflow_menu_controller.js +4 -0
- data/dist/controllers/pagination_controller.js +33 -0
- data/dist/controllers/password_strength_controller.js +20 -2
- data/dist/controllers/persist_controller.js +24 -5
- data/dist/controllers/radio_group_controller.js +540 -56
- data/dist/controllers/rating_controller.js +272 -89
- data/dist/controllers/resizable_controller.js +33 -0
- data/dist/controllers/roving_controller.js +60 -5
- data/dist/controllers/scroll_area_controller.js +154 -22
- data/dist/controllers/scroll_visibility_controller.js +33 -0
- data/dist/controllers/tags_input_controller.js +356 -120
- data/dist/controllers/time_picker_controller.js +296 -107
- data/dist/controllers/toggle_group_controller.js +378 -55
- data/dist/controllers/toolbar_controller.js +5 -3
- data/dist/controllers/tree_view_controller.js +5 -3
- data/dist/index.js +2637 -781
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
|
@@ -13,6 +13,72 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
13
13
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// src/utils/attribute_lease.ts
|
|
17
|
+
var AttributeLease = class {
|
|
18
|
+
#attribute;
|
|
19
|
+
#records = /* @__PURE__ */ new Map();
|
|
20
|
+
/** @param attribute - The attribute whose temporary values this lease owns. */
|
|
21
|
+
constructor(attribute) {
|
|
22
|
+
this.#attribute = attribute;
|
|
23
|
+
}
|
|
24
|
+
/** Writes or removes the leased attribute while preserving its authored value. */
|
|
25
|
+
write(element, value) {
|
|
26
|
+
const existing = this.#records.get(element);
|
|
27
|
+
if (existing) {
|
|
28
|
+
existing.written = value;
|
|
29
|
+
} else {
|
|
30
|
+
this.#records.set(element, {
|
|
31
|
+
original: element.getAttribute(this.#attribute),
|
|
32
|
+
written: value
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (value === null) element.removeAttribute(this.#attribute);
|
|
36
|
+
else element.setAttribute(this.#attribute, value);
|
|
37
|
+
}
|
|
38
|
+
/** Returns one lease without overwriting a value subsequently authored by a consumer. */
|
|
39
|
+
return(element) {
|
|
40
|
+
const record = this.#records.get(element);
|
|
41
|
+
if (!record) return;
|
|
42
|
+
this.#records.delete(element);
|
|
43
|
+
if (element.getAttribute(this.#attribute) !== record.written) return;
|
|
44
|
+
if (record.original === null) element.removeAttribute(this.#attribute);
|
|
45
|
+
else element.setAttribute(this.#attribute, record.original);
|
|
46
|
+
}
|
|
47
|
+
/** Returns every outstanding lease using the same ownership check as {@link return}. */
|
|
48
|
+
returnAll() {
|
|
49
|
+
for (const element of Array.from(this.#records.keys())) this.return(element);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// src/utils/before_cache_reset.ts
|
|
54
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
55
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
56
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
57
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
58
|
+
static #onBeforeCache = () => {
|
|
59
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
60
|
+
};
|
|
61
|
+
#rewind;
|
|
62
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
63
|
+
constructor(rewind) {
|
|
64
|
+
this.#rewind = rewind;
|
|
65
|
+
}
|
|
66
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
67
|
+
activate() {
|
|
68
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
69
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
70
|
+
if (first) {
|
|
71
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
75
|
+
deactivate() {
|
|
76
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
77
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
78
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
16
82
|
// src/utils/microtask_coalescer.ts
|
|
17
83
|
var MicrotaskCoalescer = class {
|
|
18
84
|
#run;
|
|
@@ -68,10 +134,12 @@ var RovingTabindex = class {
|
|
|
68
134
|
* "nothing is currently tabbable".
|
|
69
135
|
*
|
|
70
136
|
* @param index - Position of the item to make tabbable.
|
|
71
|
-
* @param options - Pass `{ focus: true }` to also move DOM focus to that item
|
|
137
|
+
* @param options - Pass `{ focus: true }` to also move DOM focus to that item,
|
|
138
|
+
* and `items` to reuse an event-scoped collection snapshot.
|
|
72
139
|
*/
|
|
73
|
-
setActive(index,
|
|
74
|
-
const
|
|
140
|
+
setActive(index, options = {}) {
|
|
141
|
+
const { focus = false } = options;
|
|
142
|
+
const items = options.items ?? this.#getItems();
|
|
75
143
|
items.forEach((item, i) => {
|
|
76
144
|
item.tabIndex = i === index ? 0 : -1;
|
|
77
145
|
});
|
|
@@ -79,167 +147,282 @@ var RovingTabindex = class {
|
|
|
79
147
|
}
|
|
80
148
|
};
|
|
81
149
|
|
|
150
|
+
// src/utils/tabindex_loan.ts
|
|
151
|
+
var TabindexLoan = class {
|
|
152
|
+
#value;
|
|
153
|
+
#lent = /* @__PURE__ */ new Set();
|
|
154
|
+
/** Returns live loans before Turbo can copy them into its page snapshot. */
|
|
155
|
+
#beforeCache = new BeforeCacheReset(() => this.returnAll());
|
|
156
|
+
/**
|
|
157
|
+
* @param value - the `tabindex` to lend. `"-1"` (the default) is
|
|
158
|
+
* programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
|
|
159
|
+
* which a scroll region with no focusable content of its own needs.
|
|
160
|
+
*/
|
|
161
|
+
constructor(value = "-1") {
|
|
162
|
+
this.#value = value;
|
|
163
|
+
}
|
|
164
|
+
/** Lends `element` the value; no-ops when it already carries a `tabindex`. */
|
|
165
|
+
lend(element) {
|
|
166
|
+
if (element.hasAttribute("tabindex")) return;
|
|
167
|
+
element.setAttribute("tabindex", this.#value);
|
|
168
|
+
this.#lent.add(element);
|
|
169
|
+
this.#beforeCache.activate();
|
|
170
|
+
}
|
|
171
|
+
/** Takes back every loan whose value is still the one that was lent. */
|
|
172
|
+
returnAll() {
|
|
173
|
+
for (const element of this.#lent) {
|
|
174
|
+
if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
|
|
175
|
+
}
|
|
176
|
+
this.#lent.clear();
|
|
177
|
+
this.#beforeCache.deactivate();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
82
181
|
// src/controllers/rating_controller.ts
|
|
83
182
|
var RatingController = class extends Controller {
|
|
84
183
|
static targets = ["symbol", "field"];
|
|
85
184
|
static values = {
|
|
86
185
|
value: { type: Number, default: 0 },
|
|
87
|
-
max: { type: Number, default: 5 },
|
|
88
186
|
clearable: { type: Boolean, default: true },
|
|
89
187
|
readonly: { type: Boolean, default: false }
|
|
90
188
|
};
|
|
91
189
|
static actions = ["endPreview", "onKeydown", "preview", "select"];
|
|
92
|
-
static events = ["change"];
|
|
190
|
+
static events = ["change", "reconcile"];
|
|
93
191
|
#roving = new RovingTabindex(() => this.symbolTargets);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
#
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
this.#apply(this.#clamp(this.valueValue), { focus: false });
|
|
105
|
-
});
|
|
192
|
+
#rootRole = new AttributeLease("role");
|
|
193
|
+
#symbolRole = new AttributeLease("role");
|
|
194
|
+
#symbolAriaHidden = new AttributeLease("aria-hidden");
|
|
195
|
+
#rootTabindex = new TabindexLoan();
|
|
196
|
+
#repaint = new MicrotaskCoalescer(() => this.#reconcileScale());
|
|
197
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
198
|
+
#connected = false;
|
|
199
|
+
#rescuedFocus = false;
|
|
200
|
+
/** Reflects declarative state without announcing an initial user change. */
|
|
106
201
|
connect() {
|
|
107
202
|
this.#repaint.activate();
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
this.#apply(this.#clamp(this.valueValue), { focus: false });
|
|
203
|
+
this.#beforeCache.activate();
|
|
204
|
+
this.#apply(this.#normalize(this.valueValue), { focus: false });
|
|
205
|
+
this.#connected = true;
|
|
113
206
|
}
|
|
114
|
-
/**
|
|
207
|
+
/** Drops a queued reconciliation and hands every borrowed attribute back. */
|
|
115
208
|
disconnect() {
|
|
209
|
+
this.#connected = false;
|
|
116
210
|
this.#repaint.cancel();
|
|
211
|
+
this.#beforeCache.deactivate();
|
|
212
|
+
this.#releaseReadonly();
|
|
213
|
+
}
|
|
214
|
+
/** Removes a runtime-added symbol's authored Tab stop before the batch repaint. */
|
|
215
|
+
symbolTargetConnected(symbol) {
|
|
216
|
+
if (this.#connected === false) return;
|
|
217
|
+
symbol.tabIndex = -1;
|
|
218
|
+
this.#repaint.schedule();
|
|
219
|
+
}
|
|
220
|
+
/** Releases readonly ownership and reconciles the remaining DOM-ordered scale. */
|
|
221
|
+
symbolTargetDisconnected(symbol) {
|
|
222
|
+
this.#symbolRole.return(symbol);
|
|
223
|
+
this.#symbolAriaHidden.return(symbol);
|
|
224
|
+
this.#repaint.schedule();
|
|
225
|
+
}
|
|
226
|
+
/** Reflects into a hidden field added or replaced after connection. */
|
|
227
|
+
fieldTargetConnected() {
|
|
228
|
+
this.#repaint.schedule();
|
|
117
229
|
}
|
|
118
|
-
/**
|
|
230
|
+
/** Reconciles after a hidden field is removed or replaced. */
|
|
231
|
+
fieldTargetDisconnected() {
|
|
232
|
+
this.#repaint.schedule();
|
|
233
|
+
}
|
|
234
|
+
/** Repaints when application code or a Turbo morph changes `value`. */
|
|
119
235
|
valueValueChanged() {
|
|
120
236
|
this.#repaint.schedule();
|
|
121
237
|
}
|
|
122
|
-
/**
|
|
238
|
+
/** Repaints when application code changes whether value 0 is permitted. */
|
|
239
|
+
clearableValueChanged() {
|
|
240
|
+
this.#repaint.schedule();
|
|
241
|
+
}
|
|
242
|
+
/** Repaints when application code enters or leaves the readonly snapshot. */
|
|
243
|
+
readonlyValueChanged() {
|
|
244
|
+
this.#repaint.schedule();
|
|
245
|
+
}
|
|
246
|
+
/** Selects or clears the clicked symbol. Bound via `data-action` (click). */
|
|
123
247
|
select(event) {
|
|
124
248
|
if (this.readonlyValue) return;
|
|
125
|
-
const
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
249
|
+
const ordinal = this.#symbolOrdinal(event.currentTarget);
|
|
250
|
+
if (ordinal === null) return;
|
|
251
|
+
const current = this.#normalize(this.valueValue);
|
|
252
|
+
this.#commit(this.clearableValue && ordinal === current ? 0 : ordinal, {
|
|
253
|
+
focus: ordinal === current
|
|
254
|
+
});
|
|
131
255
|
}
|
|
132
|
-
/** Previews a fill range on hover
|
|
256
|
+
/** Previews a fill range on hover or focus without committing it. */
|
|
133
257
|
preview(event) {
|
|
134
258
|
if (this.readonlyValue) return;
|
|
135
|
-
this.#
|
|
259
|
+
const ordinal = this.#symbolOrdinal(event.currentTarget);
|
|
260
|
+
if (ordinal !== null) this.#setFillRange(ordinal);
|
|
136
261
|
}
|
|
137
|
-
/** Restores the fill range
|
|
262
|
+
/** Restores the fill range after hover or focus leaves a symbol. */
|
|
138
263
|
endPreview() {
|
|
139
264
|
if (this.readonlyValue) return;
|
|
140
|
-
this.#setFillRange(this.valueValue);
|
|
265
|
+
this.#setFillRange(this.#normalize(this.valueValue));
|
|
141
266
|
}
|
|
142
|
-
/** Arrow/Home/End/Space keyboard control, clamped
|
|
267
|
+
/** Arrow/Home/End/Space/Delete keyboard control, clamped without wrapping. */
|
|
143
268
|
onKeydown(event) {
|
|
144
|
-
if (event.defaultPrevented) return;
|
|
145
|
-
|
|
146
|
-
if (this.readonlyValue) return;
|
|
269
|
+
if (event.defaultPrevented || isReservedArrowChord(event) || this.readonlyValue) return;
|
|
270
|
+
const current = this.#normalize(this.valueValue);
|
|
147
271
|
let next = null;
|
|
148
272
|
const rtl = isRtl(this.element);
|
|
149
273
|
switch (event.key) {
|
|
150
274
|
case "ArrowRight":
|
|
151
275
|
case "ArrowUp":
|
|
152
|
-
next =
|
|
276
|
+
next = current + (event.key === "ArrowRight" && rtl ? -1 : 1);
|
|
153
277
|
break;
|
|
154
278
|
case "ArrowLeft":
|
|
155
279
|
case "ArrowDown":
|
|
156
|
-
next =
|
|
280
|
+
next = current - (event.key === "ArrowLeft" && rtl ? -1 : 1);
|
|
157
281
|
break;
|
|
158
282
|
case "Home":
|
|
159
283
|
next = this.#minValue;
|
|
160
284
|
break;
|
|
161
285
|
case "End":
|
|
162
|
-
next = this.
|
|
286
|
+
next = this.symbolTargets.length;
|
|
163
287
|
break;
|
|
164
288
|
case " ":
|
|
165
289
|
case "Enter":
|
|
166
|
-
next = this.#
|
|
290
|
+
next = this.#symbolOrdinal(event.currentTarget);
|
|
291
|
+
break;
|
|
292
|
+
case "Delete":
|
|
293
|
+
case "Backspace":
|
|
294
|
+
if (!this.clearableValue) return;
|
|
295
|
+
next = 0;
|
|
167
296
|
break;
|
|
168
297
|
default:
|
|
169
298
|
return;
|
|
170
299
|
}
|
|
300
|
+
if (next === null) return;
|
|
171
301
|
event.preventDefault();
|
|
172
|
-
this.#
|
|
302
|
+
this.#commit(next, { focus: true });
|
|
173
303
|
}
|
|
174
304
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* initialization mirrors state without emitting an event.
|
|
305
|
+
* Repaints one settled target/Value mutation batch and reports only a value
|
|
306
|
+
* this controller had to normalize.
|
|
178
307
|
*
|
|
179
308
|
* @stimeoRenderRoot
|
|
180
309
|
*/
|
|
181
|
-
#
|
|
310
|
+
#reconcileScale() {
|
|
311
|
+
const requested = this.valueValue;
|
|
312
|
+
const value = this.#normalize(requested);
|
|
313
|
+
this.#apply(value, { focus: false });
|
|
314
|
+
if (!Object.is(value, requested)) {
|
|
315
|
+
this.dispatch("reconcile", { detail: { value } });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/** Applies one user operation and emits only when its committed value changes. */
|
|
319
|
+
#commit(raw, { focus }) {
|
|
320
|
+
const previous = this.#normalize(this.valueValue);
|
|
321
|
+
const value = this.#normalize(raw);
|
|
182
322
|
this.#apply(value, { focus });
|
|
183
|
-
this.dispatch("change", { detail: { value } });
|
|
323
|
+
if (value !== previous) this.dispatch("change", { detail: { value } });
|
|
184
324
|
}
|
|
185
|
-
/**
|
|
186
|
-
* Stores `value`, syncs `aria-checked` and the roving Tab stop, the hidden
|
|
187
|
-
* field, and the fill range — without dispatching `change`. Idempotent and
|
|
188
|
-
* safe on connect (and across Turbo morphing).
|
|
189
|
-
*/
|
|
325
|
+
/** Synchronizes value, ARIA, roving focus, form state, and the visual fill hook. */
|
|
190
326
|
#apply(value, { focus }) {
|
|
191
|
-
this.valueValue = value;
|
|
192
|
-
this.symbolTargets.forEach((symbol) => {
|
|
193
|
-
symbol.setAttribute(
|
|
194
|
-
"aria-checked",
|
|
195
|
-
value > 0 && this.#symbolValue(symbol) === value ? "true" : "false"
|
|
196
|
-
);
|
|
327
|
+
if (!Object.is(this.valueValue, value)) this.valueValue = value;
|
|
328
|
+
this.symbolTargets.forEach((symbol, index) => {
|
|
329
|
+
symbol.setAttribute("aria-checked", value > 0 && index + 1 === value ? "true" : "false");
|
|
197
330
|
});
|
|
198
|
-
this
|
|
331
|
+
if (this.readonlyValue) {
|
|
332
|
+
this.#applyReadonly();
|
|
333
|
+
} else {
|
|
334
|
+
const returning = this.#releaseReadonly();
|
|
335
|
+
this.#roving.setActive(value > 0 ? value - 1 : 0, { focus: focus || returning });
|
|
336
|
+
}
|
|
199
337
|
if (this.hasFieldTarget) this.fieldTarget.value = String(value);
|
|
200
338
|
this.#setFillRange(value);
|
|
201
339
|
}
|
|
202
|
-
/** Marks
|
|
340
|
+
/** Marks the first `range` symbols with the consumer-owned fill hook. */
|
|
203
341
|
#setFillRange(range) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
} else {
|
|
208
|
-
symbol.removeAttribute("data-rating-hover");
|
|
209
|
-
}
|
|
210
|
-
}
|
|
342
|
+
this.symbolTargets.forEach((symbol, index) => {
|
|
343
|
+
symbol.toggleAttribute("data-rating-hover", range > 0 && index < range);
|
|
344
|
+
});
|
|
211
345
|
}
|
|
212
|
-
/**
|
|
346
|
+
/**
|
|
347
|
+
* Temporarily turns the radiogroup into a non-interactive image snapshot.
|
|
348
|
+
*
|
|
349
|
+
* Each lease is returned before it is taken again, so a value the consumer
|
|
350
|
+
* wrote while readonly becomes the value the lease restores on release. The
|
|
351
|
+
* return is a no-op on an attribute still holding this controller's own
|
|
352
|
+
* write, which is the ordinary case.
|
|
353
|
+
*/
|
|
213
354
|
#applyReadonly() {
|
|
214
|
-
|
|
215
|
-
this.
|
|
216
|
-
this.element
|
|
355
|
+
this.#rescueFocus();
|
|
356
|
+
this.#rootRole.return(this.element);
|
|
357
|
+
this.#rootRole.write(this.element, "img");
|
|
217
358
|
for (const symbol of this.symbolTargets) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
symbol
|
|
359
|
+
this.#symbolRole.return(symbol);
|
|
360
|
+
this.#symbolRole.write(symbol, null);
|
|
361
|
+
this.#symbolAriaHidden.return(symbol);
|
|
362
|
+
this.#symbolAriaHidden.write(symbol, "true");
|
|
221
363
|
}
|
|
222
|
-
|
|
223
|
-
|
|
364
|
+
this.#roving.setActive(-1);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Hands every readonly borrowing back before Turbo clones the page.
|
|
368
|
+
*
|
|
369
|
+
* The snapshot is taken while the controller is still connected, so an
|
|
370
|
+
* element left as `role="img"` with hidden symbols is what a restored page
|
|
371
|
+
* connects against — and that markup would be read as the authored one,
|
|
372
|
+
* leaving no way back to the radiogroup. Rewinding first keeps the cached
|
|
373
|
+
* copy identical to what the consumer wrote.
|
|
374
|
+
*/
|
|
375
|
+
#rewindForCache() {
|
|
376
|
+
this.#releaseReadonly();
|
|
377
|
+
const value = this.#normalize(this.valueValue);
|
|
378
|
+
this.#roving.setActive(value > 0 ? value - 1 : 0);
|
|
224
379
|
}
|
|
225
380
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
381
|
+
* Lands focus on the root before the symbols leave the accessibility tree.
|
|
382
|
+
*
|
|
383
|
+
* A symbol holding focus when readonly begins would keep it while losing its
|
|
384
|
+
* role and gaining `aria-hidden`, stranding the user on a node no longer in
|
|
385
|
+
* the tree. The root is the one element that survives the transition named:
|
|
386
|
+
* it carries the consumer's accessible name under `role="img"`.
|
|
230
387
|
*/
|
|
231
|
-
#
|
|
232
|
-
const
|
|
233
|
-
|
|
388
|
+
#rescueFocus() {
|
|
389
|
+
const active = document.activeElement;
|
|
390
|
+
if (!(active instanceof HTMLElement) || active === this.element) return;
|
|
391
|
+
if (!this.element.contains(active)) return;
|
|
392
|
+
this.#rootTabindex.lend(this.element);
|
|
393
|
+
this.element.focus();
|
|
394
|
+
this.#rescuedFocus = true;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Restores authored roles and visibility after leaving readonly mode.
|
|
398
|
+
*
|
|
399
|
+
* @returns whether focus is standing on the root because {@link #rescueFocus}
|
|
400
|
+
* put it there, and therefore belongs back on the Tab stop.
|
|
401
|
+
*/
|
|
402
|
+
#releaseReadonly() {
|
|
403
|
+
const returning = this.#rescuedFocus && document.activeElement === this.element;
|
|
404
|
+
this.#rescuedFocus = false;
|
|
405
|
+
this.#rootRole.return(this.element);
|
|
406
|
+
this.#symbolRole.returnAll();
|
|
407
|
+
this.#symbolAriaHidden.returnAll();
|
|
408
|
+
this.#rootTabindex.returnAll();
|
|
409
|
+
return returning;
|
|
410
|
+
}
|
|
411
|
+
/** Normalizes a raw value to an integer ordinal in the live DOM range. */
|
|
412
|
+
#normalize(raw) {
|
|
413
|
+
const maximum = this.symbolTargets.length;
|
|
414
|
+
const ordinal = Number.isFinite(raw) ? Math.round(raw) : this.#minValue;
|
|
415
|
+
return Math.min(maximum, Math.max(this.#minValue, ordinal));
|
|
234
416
|
}
|
|
235
417
|
/** Lowest selectable value: 0 when clearable, otherwise 1. */
|
|
236
418
|
get #minValue() {
|
|
237
419
|
return this.clearableValue ? 0 : 1;
|
|
238
420
|
}
|
|
239
|
-
/**
|
|
240
|
-
#
|
|
241
|
-
const
|
|
242
|
-
|
|
421
|
+
/** Returns a target's 1-based position, or null when the action host is invalid. */
|
|
422
|
+
#symbolOrdinal(target) {
|
|
423
|
+
const targets = this.symbolTargets;
|
|
424
|
+
const index = targets.indexOf(target);
|
|
425
|
+
return index < 0 ? null : index + 1;
|
|
243
426
|
}
|
|
244
427
|
};
|
|
245
428
|
|
|
@@ -8,10 +8,41 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
8
8
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// src/utils/before_cache_reset.ts
|
|
12
|
+
var BeforeCacheReset = class _BeforeCacheReset {
|
|
13
|
+
/** Every subscribed instance, iterated by the one shared document listener. */
|
|
14
|
+
static #subscribers = /* @__PURE__ */ new Set();
|
|
15
|
+
/** The shared listener; installed while at least one instance is subscribed. */
|
|
16
|
+
static #onBeforeCache = () => {
|
|
17
|
+
for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
|
|
18
|
+
};
|
|
19
|
+
#rewind;
|
|
20
|
+
/** @param rewind - the pass that returns this controller's state to its initial form. */
|
|
21
|
+
constructor(rewind) {
|
|
22
|
+
this.#rewind = rewind;
|
|
23
|
+
}
|
|
24
|
+
/** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
|
|
25
|
+
activate() {
|
|
26
|
+
const first = _BeforeCacheReset.#subscribers.size === 0;
|
|
27
|
+
_BeforeCacheReset.#subscribers.add(this);
|
|
28
|
+
if (first) {
|
|
29
|
+
document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
|
|
33
|
+
deactivate() {
|
|
34
|
+
_BeforeCacheReset.#subscribers.delete(this);
|
|
35
|
+
if (_BeforeCacheReset.#subscribers.size > 0) return;
|
|
36
|
+
document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
11
40
|
// src/utils/tabindex_loan.ts
|
|
12
41
|
var TabindexLoan = class {
|
|
13
42
|
#value;
|
|
14
43
|
#lent = /* @__PURE__ */ new Set();
|
|
44
|
+
/** Returns live loans before Turbo can copy them into its page snapshot. */
|
|
45
|
+
#beforeCache = new BeforeCacheReset(() => this.returnAll());
|
|
15
46
|
/**
|
|
16
47
|
* @param value - the `tabindex` to lend. `"-1"` (the default) is
|
|
17
48
|
* programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
|
|
@@ -25,6 +56,7 @@ var TabindexLoan = class {
|
|
|
25
56
|
if (element.hasAttribute("tabindex")) return;
|
|
26
57
|
element.setAttribute("tabindex", this.#value);
|
|
27
58
|
this.#lent.add(element);
|
|
59
|
+
this.#beforeCache.activate();
|
|
28
60
|
}
|
|
29
61
|
/** Takes back every loan whose value is still the one that was lent. */
|
|
30
62
|
returnAll() {
|
|
@@ -32,6 +64,7 @@ var TabindexLoan = class {
|
|
|
32
64
|
if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
|
|
33
65
|
}
|
|
34
66
|
this.#lent.clear();
|
|
67
|
+
this.#beforeCache.deactivate();
|
|
35
68
|
}
|
|
36
69
|
};
|
|
37
70
|
|
|
@@ -13,6 +13,39 @@ function isReservedArrowChord(event, allow = []) {
|
|
|
13
13
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// src/utils/microtask_coalescer.ts
|
|
17
|
+
var MicrotaskCoalescer = class {
|
|
18
|
+
#run;
|
|
19
|
+
#queued = false;
|
|
20
|
+
#active = false;
|
|
21
|
+
#generation = 0;
|
|
22
|
+
/** @param run - the single reconciliation pass, invoked at most once per batch. */
|
|
23
|
+
constructor(run) {
|
|
24
|
+
this.#run = run;
|
|
25
|
+
}
|
|
26
|
+
/** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
|
|
27
|
+
activate() {
|
|
28
|
+
this.#active = true;
|
|
29
|
+
}
|
|
30
|
+
/** Closes the window and drops any pending pass; call from `disconnect()`. */
|
|
31
|
+
cancel() {
|
|
32
|
+
this.#active = false;
|
|
33
|
+
this.#queued = false;
|
|
34
|
+
this.#generation += 1;
|
|
35
|
+
}
|
|
36
|
+
/** Requests one pass after the batch settles. Idempotent; inert outside the window. */
|
|
37
|
+
schedule() {
|
|
38
|
+
if (!this.#active || this.#queued) return;
|
|
39
|
+
this.#queued = true;
|
|
40
|
+
const generation = this.#generation;
|
|
41
|
+
queueMicrotask(() => {
|
|
42
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
43
|
+
this.#queued = false;
|
|
44
|
+
this.#run();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
16
49
|
// src/utils/roving_tabindex.ts
|
|
17
50
|
var RovingTabindex = class {
|
|
18
51
|
/** Returns the current ordered item elements; called on every operation. */
|
|
@@ -35,10 +68,12 @@ var RovingTabindex = class {
|
|
|
35
68
|
* "nothing is currently tabbable".
|
|
36
69
|
*
|
|
37
70
|
* @param index - Position of the item to make tabbable.
|
|
38
|
-
* @param options - Pass `{ focus: true }` to also move DOM focus to that item
|
|
71
|
+
* @param options - Pass `{ focus: true }` to also move DOM focus to that item,
|
|
72
|
+
* and `items` to reuse an event-scoped collection snapshot.
|
|
39
73
|
*/
|
|
40
|
-
setActive(index,
|
|
41
|
-
const
|
|
74
|
+
setActive(index, options = {}) {
|
|
75
|
+
const { focus = false } = options;
|
|
76
|
+
const items = options.items ?? this.#getItems();
|
|
42
77
|
items.forEach((item, i) => {
|
|
43
78
|
item.tabIndex = i === index ? 0 : -1;
|
|
44
79
|
});
|
|
@@ -62,16 +97,31 @@ var RovingController = class extends Controller {
|
|
|
62
97
|
};
|
|
63
98
|
static events = ["change"];
|
|
64
99
|
#roving = new RovingTabindex(() => this.itemTargets);
|
|
100
|
+
#reconcile = new MicrotaskCoalescer(() => this.#ensureTabStop());
|
|
101
|
+
#connected = false;
|
|
65
102
|
connect() {
|
|
66
|
-
|
|
67
|
-
this.#roving.setActive(active === -1 ? 0 : active);
|
|
103
|
+
this.#ensureTabStop();
|
|
68
104
|
this.element.addEventListener("keydown", this.#onKeydown);
|
|
69
105
|
this.element.addEventListener("focusin", this.#onFocusin);
|
|
106
|
+
this.#connected = true;
|
|
107
|
+
this.#reconcile.activate();
|
|
70
108
|
}
|
|
71
109
|
disconnect() {
|
|
110
|
+
this.#connected = false;
|
|
111
|
+
this.#reconcile.cancel();
|
|
72
112
|
this.element.removeEventListener("keydown", this.#onKeydown);
|
|
73
113
|
this.element.removeEventListener("focusin", this.#onFocusin);
|
|
74
114
|
}
|
|
115
|
+
/** Drops a runtime-added item from the Tab sequence before batch reconciliation. */
|
|
116
|
+
itemTargetConnected(item) {
|
|
117
|
+
if (!this.#connected) return;
|
|
118
|
+
item.tabIndex = -1;
|
|
119
|
+
this.#reconcile.schedule();
|
|
120
|
+
}
|
|
121
|
+
/** Re-establishes the single Tab stop after an item leaves the target set. */
|
|
122
|
+
itemTargetDisconnected() {
|
|
123
|
+
this.#reconcile.schedule();
|
|
124
|
+
}
|
|
75
125
|
/** Arrow keys move focus + the tab stop; Home/End jump to the ends. */
|
|
76
126
|
#onKeydown = (event) => {
|
|
77
127
|
if (event.defaultPrevented) return;
|
|
@@ -125,6 +175,11 @@ var RovingController = class extends Controller {
|
|
|
125
175
|
this.dispatch("change", { detail: { index, item: this.itemTargets[index] } });
|
|
126
176
|
}
|
|
127
177
|
}
|
|
178
|
+
/** Keeps the first existing Tab stop, falling back to the first live item. */
|
|
179
|
+
#ensureTabStop() {
|
|
180
|
+
const active = this.#roving.activeIndex;
|
|
181
|
+
this.#roving.setActive(active === -1 ? 0 : active);
|
|
182
|
+
}
|
|
128
183
|
};
|
|
129
184
|
|
|
130
185
|
export { RovingController };
|