stimeo-ui 0.2.1 → 0.3.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 +104 -0
- data/dist/controllers/accordion_controller.js +10 -0
- data/dist/controllers/breadcrumb_controller.js +225 -13
- data/dist/controllers/calendar_controller.js +89 -22
- data/dist/controllers/carousel_controller.js +47 -6
- data/dist/controllers/collapsible_controller.js +2 -2
- data/dist/controllers/color_picker_controller.js +46 -7
- data/dist/controllers/combobox_controller.js +162 -23
- data/dist/controllers/command_palette_controller.js +194 -17
- data/dist/controllers/context_menu_controller.js +32 -10
- data/dist/controllers/data_grid_controller.js +82 -4
- data/dist/controllers/date_range_picker_controller.js +27 -3
- data/dist/controllers/editable_controller.js +1 -0
- data/dist/controllers/form_validation_controller.js +1 -1
- data/dist/controllers/intersection_controller.js +36 -11
- data/dist/controllers/lazy_frame_controller.js +31 -10
- data/dist/controllers/listbox_controller.js +257 -53
- data/dist/controllers/local_time_controller.js +2 -2
- data/dist/controllers/menu_controller.js +104 -17
- data/dist/controllers/menubar_controller.js +415 -63
- data/dist/controllers/multi_select_controller.js +312 -29
- data/dist/controllers/navigation_menu_controller.js +154 -27
- data/dist/controllers/number_input_controller.js +7 -0
- data/dist/controllers/otp_controller.js +18 -1
- data/dist/controllers/overflow_indicator_controller.js +81 -13
- data/dist/controllers/overflow_menu_controller.js +381 -57
- data/dist/controllers/pagination_controller.js +163 -32
- data/dist/controllers/persist_controller.js +6 -6
- data/dist/controllers/pointer_drag_controller.js +9 -1
- data/dist/controllers/popover_controller.js +2 -2
- data/dist/controllers/radio_group_controller.js +22 -3
- data/dist/controllers/range_slider_controller.js +32 -6
- data/dist/controllers/rating_controller.js +16 -2
- data/dist/controllers/read_more_controller.js +63 -19
- data/dist/controllers/resizable_controller.js +65 -1
- data/dist/controllers/roving_controller.js +17 -2
- data/dist/controllers/scroll_area_controller.js +86 -12
- data/dist/controllers/scroll_restore_controller.js +1 -1
- data/dist/controllers/scroll_visibility_controller.js +33 -3
- data/dist/controllers/scrollspy_controller.js +346 -73
- data/dist/controllers/separator_controller.js +9 -0
- data/dist/controllers/skeleton_controller.js +1 -1
- data/dist/controllers/slider_controller.js +32 -6
- data/dist/controllers/sortable_controller.js +34 -3
- data/dist/controllers/spinner_controller.js +1 -1
- data/dist/controllers/stick_to_bottom_controller.js +2 -1
- data/dist/controllers/sticky_observer_controller.js +32 -11
- data/dist/controllers/switch_controller.js +1 -0
- data/dist/controllers/tabs_controller.js +26 -3
- data/dist/controllers/tags_input_controller.js +22 -2
- data/dist/controllers/theme_controller.js +22 -3
- data/dist/controllers/time_picker_controller.js +20 -1
- data/dist/controllers/toast_controller.js +4 -5
- data/dist/controllers/toggle_group_controller.js +23 -2
- data/dist/controllers/toolbar_controller.js +230 -31
- data/dist/controllers/tree_view_controller.js +467 -51
- data/dist/index.js +3514 -689
- data/lib/stimeo/ui/version.rb +2 -3
- metadata +2 -2
|
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/navigation_menu_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function isReservedArrowChord(event, allow = []) {
|
|
12
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
13
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
// src/utils/escape_layer.ts
|
|
6
17
|
function claimsWhileFocusWithin(element) {
|
|
7
18
|
return () => {
|
|
@@ -157,27 +168,52 @@ var NavigationMenuController = class extends Controller {
|
|
|
157
168
|
#hoverTimers = new SafeTimeout();
|
|
158
169
|
/**
|
|
159
170
|
* Elements currently carrying hover listeners. Removal always mirrors this
|
|
160
|
-
* set (not a recomputed target snapshot), so
|
|
161
|
-
*
|
|
171
|
+
* set (not a recomputed target snapshot), so a rebuild driven by target churn
|
|
172
|
+
* can never strand a listener on an element that stopped being a target.
|
|
162
173
|
*/
|
|
163
174
|
#hoverWired = /* @__PURE__ */ new Set();
|
|
175
|
+
/**
|
|
176
|
+
* The trigger whose hover region the pointer currently occupies, if any. Only
|
|
177
|
+
* the click semantics under `openOnHover` read it (see {@link toggle}).
|
|
178
|
+
*/
|
|
179
|
+
#hoveredTrigger = null;
|
|
180
|
+
/**
|
|
181
|
+
* Whether the controller is between `connect()` and `disconnect()`. Stimulus
|
|
182
|
+
* disconnects the controller **before** disconnecting its targets, so the
|
|
183
|
+
* target callbacks below also fire during teardown; without this flag their
|
|
184
|
+
* rebuild would re-wire hover listeners onto an already-disconnected nav.
|
|
185
|
+
*/
|
|
186
|
+
#connected = false;
|
|
164
187
|
/** Escape-stack membership while any panel is open; the shared resolver dismisses via it. */
|
|
165
188
|
#escapeLayer = new EscapeLayer();
|
|
166
189
|
/** Establishes the closed baseline and the dismissal listeners. */
|
|
167
190
|
connect() {
|
|
191
|
+
this.#connected = true;
|
|
168
192
|
this.#closeAll();
|
|
169
|
-
document.addEventListener("click", this.#onOutsideClick);
|
|
193
|
+
document.addEventListener("click", this.#onOutsideClick, true);
|
|
170
194
|
this.element.addEventListener("focusout", this.#onFocusOut);
|
|
171
195
|
if (this.openOnHoverValue) this.#addHoverListeners();
|
|
172
196
|
}
|
|
173
|
-
/** Removes every listener, pending hover timer, and stack membership
|
|
197
|
+
/** Removes every listener, pending hover timer, and stack membership taken while connected. */
|
|
174
198
|
disconnect() {
|
|
199
|
+
this.#connected = false;
|
|
175
200
|
this.#escapeLayer.deactivate();
|
|
176
|
-
document.removeEventListener("click", this.#onOutsideClick);
|
|
201
|
+
document.removeEventListener("click", this.#onOutsideClick, true);
|
|
177
202
|
this.element.removeEventListener("focusout", this.#onFocusOut);
|
|
178
203
|
this.#removeHoverListeners();
|
|
179
204
|
this.#hoverTimers.clearAll();
|
|
180
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Follows a runtime flip of `openOnHover` (a morph or a scripted attribute
|
|
208
|
+
* change) by wiring or unwiring the hover listeners in place. Stimulus also
|
|
209
|
+
* fires this once before `connect()`, which the `#connected` guard skips —
|
|
210
|
+
* `connect()` owns the initial wiring.
|
|
211
|
+
*/
|
|
212
|
+
openOnHoverValueChanged() {
|
|
213
|
+
if (!this.#connected) return;
|
|
214
|
+
if (this.openOnHoverValue) this.#addHoverListeners();
|
|
215
|
+
else this.#removeHoverListeners();
|
|
216
|
+
}
|
|
181
217
|
/** Re-wires hover listeners when a target is added after connect (Turbo Streams etc.). */
|
|
182
218
|
triggerTargetConnected() {
|
|
183
219
|
this.#rewireHoverListeners();
|
|
@@ -202,31 +238,65 @@ var NavigationMenuController = class extends Controller {
|
|
|
202
238
|
hoverAreaTargetDisconnected() {
|
|
203
239
|
this.#rewireHoverListeners();
|
|
204
240
|
}
|
|
205
|
-
/**
|
|
241
|
+
/**
|
|
242
|
+
* Toggles a trigger's panel (single-open). Bound via `data-action` (click).
|
|
243
|
+
*
|
|
244
|
+
* An explicit activation always wins over a scheduled hover open/close, so any
|
|
245
|
+
* pending hover timer is dropped first — otherwise a close scheduled just
|
|
246
|
+
* before the click would fire into the freshly opened panel.
|
|
247
|
+
*
|
|
248
|
+
* Under `openOnHover`, closing a panel the pointer itself is holding open is
|
|
249
|
+
* refused: the pointer stays put, `mouseenter` does not re-fire, and the panel
|
|
250
|
+
* would be unopenable until the pointer left and came back. Keyboard
|
|
251
|
+
* activation (pointer elsewhere) still toggles it closed, and `Escape` and
|
|
252
|
+
* outside clicks dismiss either way.
|
|
253
|
+
*/
|
|
206
254
|
toggle(event) {
|
|
207
255
|
const trigger = event.currentTarget;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
} else {
|
|
256
|
+
this.#hoverTimers.clearAll();
|
|
257
|
+
if (!this.#isExpanded(trigger)) {
|
|
211
258
|
this.#openPanel(trigger);
|
|
259
|
+
return;
|
|
212
260
|
}
|
|
261
|
+
if (this.openOnHoverValue && this.#hoveredTrigger === trigger) return;
|
|
262
|
+
this.#closePanel(trigger);
|
|
213
263
|
}
|
|
214
|
-
/**
|
|
264
|
+
/**
|
|
265
|
+
* `ArrowLeft`/`ArrowRight` move focus between triggers (keeping Tab order).
|
|
266
|
+
*
|
|
267
|
+
* Only navigable triggers are destinations, so a `hidden` or disabled item
|
|
268
|
+
* never swallows the press. A modified arrow belongs to the browser or OS
|
|
269
|
+
* (`Alt+←`/`Alt+→` is history back/forward), and with no destination at all
|
|
270
|
+
* (a single-trigger nav) the press is left to the page — `preventDefault()` is
|
|
271
|
+
* called only when focus actually moves, keeping the scroll-suppression
|
|
272
|
+
* contract honest.
|
|
273
|
+
*/
|
|
215
274
|
onTriggerKeydown(event) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
if (
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
275
|
+
if (event.defaultPrevented) return;
|
|
276
|
+
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
|
|
277
|
+
if (isReservedArrowChord(event)) return;
|
|
278
|
+
const current = event.currentTarget;
|
|
279
|
+
const triggers = this.triggerTargets.filter(
|
|
280
|
+
(trigger) => trigger === current || this.#isNavigable(trigger)
|
|
281
|
+
);
|
|
282
|
+
const index = triggers.indexOf(current);
|
|
283
|
+
if (index === -1 || triggers.length < 2) return;
|
|
284
|
+
event.preventDefault();
|
|
285
|
+
const forward = isRtl(this.element) ? "ArrowLeft" : "ArrowRight";
|
|
286
|
+
const step = event.key === forward ? 1 : -1;
|
|
287
|
+
triggers[(index + step + triggers.length) % triggers.length]?.focus();
|
|
227
288
|
}
|
|
228
|
-
/**
|
|
289
|
+
/**
|
|
290
|
+
* Opens `trigger`'s panel, closing any other open panel first. Re-opening an
|
|
291
|
+
* already-open panel only re-asserts the Escape layer: the close/open
|
|
292
|
+
* round-trip would rewrite `aria-expanded` and `hidden` for no state change.
|
|
293
|
+
*/
|
|
229
294
|
#openPanel(trigger) {
|
|
295
|
+
if (trigger.getAttribute("aria-disabled") === "true") return;
|
|
296
|
+
if (this.#isExpanded(trigger)) {
|
|
297
|
+
this.#syncEscapeLayer();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
230
300
|
this.#closeAll();
|
|
231
301
|
const panel = this.#panelFor(trigger);
|
|
232
302
|
if (!panel) return;
|
|
@@ -241,10 +311,30 @@ var NavigationMenuController = class extends Controller {
|
|
|
241
311
|
trigger.setAttribute("aria-expanded", "false");
|
|
242
312
|
this.#syncEscapeLayer();
|
|
243
313
|
}
|
|
244
|
-
/**
|
|
314
|
+
/**
|
|
315
|
+
* Closes every open panel and normalizes `aria-expanded="false"` on **all**
|
|
316
|
+
* triggers, so a trigger whose panel was hidden by other means cannot keep
|
|
317
|
+
* advertising an open panel.
|
|
318
|
+
*/
|
|
245
319
|
#closeAll() {
|
|
246
320
|
for (const trigger of this.triggerTargets) this.#closePanel(trigger);
|
|
247
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* Whether a trigger can receive arrow-key focus: not `hidden`, not a natively
|
|
324
|
+
* `disabled` control (mirrors `toolbar`). CSS-only visibility cannot be
|
|
325
|
+
* detected headlessly and stays the consumer's responsibility.
|
|
326
|
+
*
|
|
327
|
+
* **`aria-disabled="true"` stays reachable.** APG separates the attributes by
|
|
328
|
+
* intent: `disabled` is for controls a neighbour makes inferable, while
|
|
329
|
+
* `aria-disabled` marks one that must remain *discoverable* — and a nav
|
|
330
|
+
* section the user cannot even arrow to is a section they cannot learn exists.
|
|
331
|
+
* Opening is suppressed separately, so the trigger announces itself and does
|
|
332
|
+
* nothing.
|
|
333
|
+
*/
|
|
334
|
+
#isNavigable(trigger) {
|
|
335
|
+
if (trigger.hasAttribute("hidden")) return false;
|
|
336
|
+
return !trigger.disabled;
|
|
337
|
+
}
|
|
248
338
|
/**
|
|
249
339
|
* Aligns Escape-stack membership with the open state: joins (or re-asserts to
|
|
250
340
|
* the top) while a panel is open, leaves once none is. Re-asserting when the
|
|
@@ -260,10 +350,15 @@ var NavigationMenuController = class extends Controller {
|
|
|
260
350
|
this.#escapeLayer.deactivate();
|
|
261
351
|
}
|
|
262
352
|
}
|
|
263
|
-
/**
|
|
353
|
+
/**
|
|
354
|
+
* Closes any open panel and returns focus to its trigger (Escape path). A
|
|
355
|
+
* pending hover open would otherwise re-open the panel the user just
|
|
356
|
+
* dismissed, so the reservation is dropped with it.
|
|
357
|
+
*/
|
|
264
358
|
#closeAndRestore() {
|
|
265
359
|
const open = this.#openTrigger;
|
|
266
360
|
if (!open) return;
|
|
361
|
+
this.#hoverTimers.clearAll();
|
|
267
362
|
this.#closePanel(open);
|
|
268
363
|
open.focus();
|
|
269
364
|
}
|
|
@@ -288,8 +383,9 @@ var NavigationMenuController = class extends Controller {
|
|
|
288
383
|
#onPointerEnter = (event) => {
|
|
289
384
|
const trigger = this.#triggerForHover(event.currentTarget);
|
|
290
385
|
if (!trigger) return;
|
|
386
|
+
this.#hoveredTrigger = trigger;
|
|
291
387
|
this.#hoverTimers.clearAll();
|
|
292
|
-
this.#hoverTimers.set(() => this.#
|
|
388
|
+
this.#hoverTimers.set(() => this.#openFromHover(trigger), this.hoverDelayValue);
|
|
293
389
|
};
|
|
294
390
|
/**
|
|
295
391
|
* Closes the open panel after the hover delay (hover mode) — unless the
|
|
@@ -300,9 +396,37 @@ var NavigationMenuController = class extends Controller {
|
|
|
300
396
|
#onPointerLeave = (event) => {
|
|
301
397
|
const next = event instanceof MouseEvent ? event.relatedTarget : null;
|
|
302
398
|
if (next instanceof Node && this.#hoverElements.some((el) => el.contains(next))) return;
|
|
399
|
+
this.#hoveredTrigger = null;
|
|
303
400
|
this.#hoverTimers.clearAll();
|
|
304
|
-
this.#hoverTimers.set(() => this.#
|
|
401
|
+
this.#hoverTimers.set(() => this.#closeFromHover(), this.hoverDelayValue);
|
|
305
402
|
};
|
|
403
|
+
/**
|
|
404
|
+
* Hover-driven open. Switching panels would hide the outgoing one, so focus
|
|
405
|
+
* standing inside it is handed back to its trigger first: pointer movement
|
|
406
|
+
* must never send focus to the body (the keyboard user would lose their
|
|
407
|
+
* place). Focus outside the closing panel is left alone.
|
|
408
|
+
*/
|
|
409
|
+
#openFromHover(trigger) {
|
|
410
|
+
if (trigger.getAttribute("aria-disabled") === "true") return;
|
|
411
|
+
const open = this.#openTrigger;
|
|
412
|
+
if (open && open !== trigger && this.#holdsFocus(this.#panelFor(open))) open.focus();
|
|
413
|
+
this.#openPanel(trigger);
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Hover-driven close: skipped while focus sits inside the panel it would hide,
|
|
417
|
+
* for the same no-focus-loss reason (cf. `hover_card`'s delayed close). The
|
|
418
|
+
* panel then stays open until `Escape`, an outside click, or focus leaving the
|
|
419
|
+
* nav dismisses it — all of which restore or keep focus deliberately.
|
|
420
|
+
*/
|
|
421
|
+
#closeFromHover() {
|
|
422
|
+
const open = this.#openTrigger;
|
|
423
|
+
if (open && this.#holdsFocus(this.#panelFor(open))) return;
|
|
424
|
+
this.#closeAll();
|
|
425
|
+
}
|
|
426
|
+
/** Whether `panel` exists and currently contains the focused element. */
|
|
427
|
+
#holdsFocus(panel) {
|
|
428
|
+
return panel?.contains(document.activeElement) ?? false;
|
|
429
|
+
}
|
|
306
430
|
/** Wires hover open/close on each hover element (opt-in), tracking what was wired. */
|
|
307
431
|
#addHoverListeners() {
|
|
308
432
|
for (const element of this.#hoverElements) {
|
|
@@ -318,14 +442,17 @@ var NavigationMenuController = class extends Controller {
|
|
|
318
442
|
element.removeEventListener("mouseleave", this.#onPointerLeave);
|
|
319
443
|
}
|
|
320
444
|
this.#hoverWired.clear();
|
|
445
|
+
this.#hoveredTrigger = null;
|
|
321
446
|
}
|
|
322
447
|
/**
|
|
323
448
|
* Rebuilds the hover wiring from the current targets. Target callbacks call
|
|
324
449
|
* this so items added or removed after connect (e.g. a Turbo Stream append)
|
|
325
450
|
* participate in hover; the wired-set removal keeps the rebuild symmetric.
|
|
451
|
+
* Stimulus fires those callbacks during teardown too, hence the `#connected`
|
|
452
|
+
* guard — a rebuild after `disconnect()` would resurrect the listeners.
|
|
326
453
|
*/
|
|
327
454
|
#rewireHoverListeners() {
|
|
328
|
-
if (!this.openOnHoverValue) return;
|
|
455
|
+
if (!this.#connected || !this.openOnHoverValue) return;
|
|
329
456
|
this.#removeHoverListeners();
|
|
330
457
|
this.#addHoverListeners();
|
|
331
458
|
}
|
|
@@ -2,6 +2,12 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/number_input_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/arrow_step.ts
|
|
6
|
+
function isReservedArrowChord(event, allow = []) {
|
|
7
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
8
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
// src/utils/safe_timeout.ts
|
|
6
12
|
var TimerRegistry = class {
|
|
7
13
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -148,6 +154,7 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
148
154
|
}
|
|
149
155
|
/** Keyboard stepping per the APG spinbutton model. */
|
|
150
156
|
onKeydown(event) {
|
|
157
|
+
if (isReservedArrowChord(event)) return;
|
|
151
158
|
const page = this.pageStepValue > 0 ? this.pageStepValue : this.stepValue * 10;
|
|
152
159
|
let next = null;
|
|
153
160
|
switch (event.key) {
|
|
@@ -2,6 +2,22 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/otp_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/logical_scroll.ts
|
|
6
|
+
function isRtl(element) {
|
|
7
|
+
return window.getComputedStyle(element).direction === "rtl";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/arrow_step.ts
|
|
11
|
+
function logicalArrowKey(key, element) {
|
|
12
|
+
if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
|
|
13
|
+
if (!isRtl(element)) return key;
|
|
14
|
+
return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
|
|
15
|
+
}
|
|
16
|
+
function isReservedArrowChord(event, allow = []) {
|
|
17
|
+
if (!event.key.startsWith("Arrow")) return false;
|
|
18
|
+
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
19
|
+
}
|
|
20
|
+
|
|
5
21
|
// src/utils/composition_tracker.ts
|
|
6
22
|
var CompositionTracker = class {
|
|
7
23
|
#observedTargets = /* @__PURE__ */ new Set();
|
|
@@ -99,12 +115,13 @@ var OtpController = class extends Controller {
|
|
|
99
115
|
}
|
|
100
116
|
/** Handles Backspace retreating, arrows, and home/end navigation. */
|
|
101
117
|
onKeydown(event) {
|
|
118
|
+
if (isReservedArrowChord(event)) return;
|
|
102
119
|
const input = event.currentTarget;
|
|
103
120
|
if (!input) return;
|
|
104
121
|
const index = this.fieldTargets.indexOf(input);
|
|
105
122
|
if (index === -1) return;
|
|
106
123
|
if (this.#composition.isComposing(event)) return;
|
|
107
|
-
switch (event.key) {
|
|
124
|
+
switch (logicalArrowKey(event.key, this.element)) {
|
|
108
125
|
case "Backspace":
|
|
109
126
|
if (!input.value) {
|
|
110
127
|
if (index > 0) {
|
|
@@ -2,6 +2,61 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/overflow_indicator_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/blur_deferral.ts
|
|
6
|
+
var BlurDeferral = class {
|
|
7
|
+
/** Elements currently holding an update back, mapped to their `blur` listener. */
|
|
8
|
+
#pending = /* @__PURE__ */ new Map();
|
|
9
|
+
/** Called after a pending element blurs and has been detached. */
|
|
10
|
+
#onRelease;
|
|
11
|
+
/** @param onRelease - Invoked once `element` actually blurs; never on `release`. */
|
|
12
|
+
constructor(onRelease) {
|
|
13
|
+
this.#onRelease = onRelease;
|
|
14
|
+
}
|
|
15
|
+
/** Number of elements currently holding an update back. */
|
|
16
|
+
get size() {
|
|
17
|
+
return this.#pending.size;
|
|
18
|
+
}
|
|
19
|
+
/** Snapshot of the pending elements, safe to iterate while releasing them. */
|
|
20
|
+
get elements() {
|
|
21
|
+
return [...this.#pending.keys()];
|
|
22
|
+
}
|
|
23
|
+
/** Whether `element` is currently holding an update back. */
|
|
24
|
+
has(element) {
|
|
25
|
+
return this.#pending.has(element);
|
|
26
|
+
}
|
|
27
|
+
/** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */
|
|
28
|
+
defer(element) {
|
|
29
|
+
if (this.#pending.has(element)) return;
|
|
30
|
+
const onBlur = () => {
|
|
31
|
+
this.#detach(element);
|
|
32
|
+
this.#onRelease(element);
|
|
33
|
+
};
|
|
34
|
+
this.#pending.set(element, onBlur);
|
|
35
|
+
element.addEventListener("blur", onBlur);
|
|
36
|
+
}
|
|
37
|
+
/** Defers `element` as the only pending entry, cancelling any others. */
|
|
38
|
+
deferOnly(element) {
|
|
39
|
+
for (const pending of this.elements) {
|
|
40
|
+
if (pending !== element) this.#detach(pending);
|
|
41
|
+
}
|
|
42
|
+
this.defer(element);
|
|
43
|
+
}
|
|
44
|
+
/** Cancels `element`'s deferral without completing it; no-ops when not pending. */
|
|
45
|
+
release(element) {
|
|
46
|
+
this.#detach(element);
|
|
47
|
+
}
|
|
48
|
+
/** Cancels every deferral without completing any of them. */
|
|
49
|
+
releaseAll() {
|
|
50
|
+
for (const element of this.elements) this.#detach(element);
|
|
51
|
+
}
|
|
52
|
+
/** Removes the `blur` listener for `element` and forgets it. */
|
|
53
|
+
#detach(element) {
|
|
54
|
+
const onBlur = this.#pending.get(element);
|
|
55
|
+
if (onBlur) element.removeEventListener("blur", onBlur);
|
|
56
|
+
this.#pending.delete(element);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
5
60
|
// src/utils/layout_observer.ts
|
|
6
61
|
var LayoutObserver = class {
|
|
7
62
|
#callback;
|
|
@@ -97,7 +152,16 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
97
152
|
#observedViewport = null;
|
|
98
153
|
#observedContent = /* @__PURE__ */ new Set();
|
|
99
154
|
#mutationObserver = null;
|
|
100
|
-
|
|
155
|
+
/**
|
|
156
|
+
* Boundary buttons whose native `disabled` is held back while they hold focus.
|
|
157
|
+
*
|
|
158
|
+
* Completing the deferral is what actually disables the button, so the release
|
|
159
|
+
* callback — unlike {@link #cancelPendingButtonDisable} — applies it.
|
|
160
|
+
*/
|
|
161
|
+
#pendingButtonDisables = new BlurDeferral((button) => {
|
|
162
|
+
this.#restorePendingMarkers(button);
|
|
163
|
+
if (!button.disabled) this.#disableButton(button);
|
|
164
|
+
});
|
|
101
165
|
/** Last reported room, so `change` fires only on transitions. */
|
|
102
166
|
#state = null;
|
|
103
167
|
connect() {
|
|
@@ -155,7 +219,7 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
155
219
|
const page = horizontal ? vp.clientWidth : vp.clientHeight;
|
|
156
220
|
const logicalDelta = direction === "start" ? -page : page;
|
|
157
221
|
const delta = physicalScrollDelta(vp, horizontal, logicalDelta);
|
|
158
|
-
const behavior = prefersReducedMotion() ? "
|
|
222
|
+
const behavior = prefersReducedMotion() ? "instant" : "smooth";
|
|
159
223
|
if (horizontal) {
|
|
160
224
|
vp.scrollBy({ left: delta, behavior });
|
|
161
225
|
} else {
|
|
@@ -164,7 +228,7 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
164
228
|
}
|
|
165
229
|
/** Mirrors remaining room onto any direction buttons by toggling `disabled`. */
|
|
166
230
|
#syncButtons(start, end) {
|
|
167
|
-
for (const button of
|
|
231
|
+
for (const button of this.#pendingButtonDisables.elements) {
|
|
168
232
|
if (!button.isConnected || button.closest("[data-controller~='stimeo--overflow-indicator']") !== this.element) {
|
|
169
233
|
this.#cancelPendingButtonDisable(button);
|
|
170
234
|
}
|
|
@@ -216,21 +280,25 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
216
280
|
button.getAttribute("aria-disabled") ?? ""
|
|
217
281
|
);
|
|
218
282
|
button.setAttribute("aria-disabled", "true");
|
|
219
|
-
|
|
220
|
-
this.#cancelPendingButtonDisable(button);
|
|
221
|
-
if (!button.disabled) this.#disableButton(button);
|
|
222
|
-
};
|
|
223
|
-
this.#pendingButtonDisables.set(button, { onBlur });
|
|
224
|
-
button.addEventListener("blur", onBlur);
|
|
283
|
+
this.#pendingButtonDisables.defer(button);
|
|
225
284
|
}
|
|
226
285
|
#disableButton(button) {
|
|
227
286
|
button.disabled = true;
|
|
228
287
|
button.setAttribute("data-overflow-indicator-disabled", "");
|
|
229
288
|
}
|
|
289
|
+
/** Cancels a pending disable without applying it, restoring the displaced state. */
|
|
230
290
|
#cancelPendingButtonDisable(button) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
291
|
+
this.#pendingButtonDisables.release(button);
|
|
292
|
+
this.#restorePendingMarkers(button);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Drops the pending marker and gives back the `aria-disabled` it displaced.
|
|
296
|
+
*
|
|
297
|
+
* Reads the displaced value from the DOM rather than memory: a Turbo snapshot is
|
|
298
|
+
* cloned before `disconnect()` runs, so a restored page can arrive with markers
|
|
299
|
+
* this instance never wrote.
|
|
300
|
+
*/
|
|
301
|
+
#restorePendingMarkers(button) {
|
|
234
302
|
button.removeAttribute("data-overflow-indicator-pending-disabled");
|
|
235
303
|
const displaced = button.getAttribute("data-overflow-indicator-aria-disabled");
|
|
236
304
|
if (displaced !== null) {
|
|
@@ -242,7 +310,7 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
242
310
|
}
|
|
243
311
|
}
|
|
244
312
|
#clearPendingButtonDisables() {
|
|
245
|
-
for (const button of
|
|
313
|
+
for (const button of this.#pendingButtonDisables.elements) {
|
|
246
314
|
this.#cancelPendingButtonDisable(button);
|
|
247
315
|
}
|
|
248
316
|
}
|