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/menubar_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 () => {
|
|
@@ -178,32 +189,198 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
178
189
|
}
|
|
179
190
|
};
|
|
180
191
|
|
|
181
|
-
// src/
|
|
192
|
+
// src/utils/typeahead.ts
|
|
182
193
|
var TYPEAHEAD_RESET_MS = 500;
|
|
194
|
+
var Typeahead = class {
|
|
195
|
+
/** Timer registry for the pending idle reset; private so `reset()` is the only exit. */
|
|
196
|
+
#timers = new SafeTimeout();
|
|
197
|
+
/** Idle window before the query resets, in milliseconds. */
|
|
198
|
+
#resetMs;
|
|
199
|
+
/** The accumulated lowercase query, empty when idle. */
|
|
200
|
+
#query = "";
|
|
201
|
+
/** Id of the pending reset timer, `0` when none is scheduled. */
|
|
202
|
+
#timerId = 0;
|
|
203
|
+
/** @param options - Overrides for the idle window. */
|
|
204
|
+
constructor({ resetMs = TYPEAHEAD_RESET_MS } = {}) {
|
|
205
|
+
this.#resetMs = resetMs;
|
|
206
|
+
}
|
|
207
|
+
/** The query a search would currently run with; empty while idle. */
|
|
208
|
+
get query() {
|
|
209
|
+
return this.#query;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Folds `key` into the query, restarts the idle window, and returns the query to
|
|
213
|
+
* search with. A repeated character collapses the query to that one character.
|
|
214
|
+
*/
|
|
215
|
+
push(key) {
|
|
216
|
+
const char = key.toLowerCase();
|
|
217
|
+
const repeated = this.#query.length > 0 && [...this.#query].every((c) => c === char);
|
|
218
|
+
this.#query = repeated ? char : this.#query + char;
|
|
219
|
+
this.#timers.clear(this.#timerId);
|
|
220
|
+
this.#timerId = this.#timers.set(() => this.reset(), this.#resetMs);
|
|
221
|
+
return this.#query;
|
|
222
|
+
}
|
|
223
|
+
/** Clears the query and cancels the pending idle reset. */
|
|
224
|
+
reset() {
|
|
225
|
+
this.#query = "";
|
|
226
|
+
this.#timers.clear(this.#timerId);
|
|
227
|
+
this.#timerId = 0;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
function isTypeaheadKey(event) {
|
|
231
|
+
return event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey && !event.isComposing;
|
|
232
|
+
}
|
|
233
|
+
function typeaheadLabel(element, fallbackText) {
|
|
234
|
+
const label = element.getAttribute("aria-label")?.trim();
|
|
235
|
+
if (label) return label.toLowerCase();
|
|
236
|
+
const text = element.textContent ?? "";
|
|
237
|
+
return text.trim().toLowerCase();
|
|
238
|
+
}
|
|
239
|
+
function findTypeaheadMatch(items, from, query, label = (item) => typeaheadLabel(item)) {
|
|
240
|
+
if (query === "") return -1;
|
|
241
|
+
const count = items.length;
|
|
242
|
+
for (let step = 1; step <= count; step += 1) {
|
|
243
|
+
const index = ((from + step) % count + count) % count;
|
|
244
|
+
const candidate = items[index];
|
|
245
|
+
if (candidate && label(candidate).startsWith(query)) return index;
|
|
246
|
+
}
|
|
247
|
+
return -1;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/controllers/menubar_controller.ts
|
|
251
|
+
var STATE_ATTRIBUTES = ["disabled", "hidden"];
|
|
183
252
|
var MenubarController = class extends Controller {
|
|
184
253
|
static targets = ["top", "menu", "item"];
|
|
185
254
|
static actions = ["activate", "onItemKeydown", "onTopKeydown", "toggle"];
|
|
186
255
|
/** Roving tabindex across the top-level menuitems (one Tab stop). */
|
|
187
256
|
#roving = new RovingTabindex(() => this.topTargets);
|
|
188
|
-
/** Typeahead
|
|
189
|
-
#typeahead =
|
|
190
|
-
#typeaheadId = 0;
|
|
257
|
+
/** Typeahead query and its idle-reset timer (scoped to the open menu). */
|
|
258
|
+
#typeahead = new Typeahead();
|
|
191
259
|
#timers = new SafeTimeout();
|
|
192
260
|
/** Escape-stack membership while a menu is open; the shared resolver dismisses via it. */
|
|
193
261
|
#escapeLayer = new EscapeLayer();
|
|
194
|
-
/**
|
|
262
|
+
/** Whether {@link #escapeLayer} is currently on the stack (see {@link #syncEscapeLayer}). */
|
|
263
|
+
#layerActive = false;
|
|
264
|
+
/** Watches `disabled`/`hidden` under the menubar; `null` while disconnected. */
|
|
265
|
+
#observer = null;
|
|
266
|
+
/**
|
|
267
|
+
* Live between `connect()` and `disconnect()`. Stimulus reports the *initial*
|
|
268
|
+
* targets before `connect()` and re-reports them as disconnected afterwards;
|
|
269
|
+
* gating on this keeps the target callbacks from clobbering the authored Tab
|
|
270
|
+
* stop on mount and from resurrecting one after teardown.
|
|
271
|
+
*/
|
|
272
|
+
#connected = false;
|
|
273
|
+
/** The element inside the menubar that last took DOM focus, if any. */
|
|
274
|
+
#focused = null;
|
|
275
|
+
/**
|
|
276
|
+
* Records where DOM focus sits inside the menubar. `focusin` bubbles, so one
|
|
277
|
+
* listener covers the top items and everything nested in their menus. The
|
|
278
|
+
* record is needed because a top or menu becomes unreachable *before* this
|
|
279
|
+
* controller hears about it, by which point the browser has already reset
|
|
280
|
+
* `document.activeElement`.
|
|
281
|
+
*/
|
|
282
|
+
#onFocusIn = (event) => {
|
|
283
|
+
this.#focused = event.target;
|
|
284
|
+
};
|
|
285
|
+
/**
|
|
286
|
+
* Forgets the record once focus genuinely lands on something else, so a later
|
|
287
|
+
* mutation cannot pull focus back in. A `relatedTarget` of `null` is the case
|
|
288
|
+
* that must be kept: it is what "focus went nowhere" looks like, which is
|
|
289
|
+
* exactly the state the rescue exists for.
|
|
290
|
+
*/
|
|
291
|
+
#onFocusOut = (event) => {
|
|
292
|
+
const next = event.relatedTarget;
|
|
293
|
+
if (next && !this.element.contains(next)) this.#focused = null;
|
|
294
|
+
};
|
|
295
|
+
/**
|
|
296
|
+
* Establishes the closed baseline and the single tab stop: keep an existing tab
|
|
297
|
+
* stop when it is still navigable (so a Turbo cache restore preserves the user's
|
|
298
|
+
* position), else fall back to the first navigable top item.
|
|
299
|
+
*
|
|
300
|
+
* The outside-click listener is registered in the **capture** phase: in the
|
|
301
|
+
* bubble phase an inside handler that removes its own click target detaches the
|
|
302
|
+
* node before `contains()` runs, so an *inside* click would read as outside and
|
|
303
|
+
* close the menu.
|
|
304
|
+
*/
|
|
195
305
|
connect() {
|
|
196
|
-
const active = this.#roving.activeIndex;
|
|
197
|
-
this.#roving.setActive(active === -1 ? 0 : active);
|
|
198
306
|
this.#closeAllMenus();
|
|
199
|
-
|
|
307
|
+
this.#reconcile();
|
|
308
|
+
this.element.addEventListener("click", this.#onDisabledClickCapture, true);
|
|
309
|
+
this.element.addEventListener("focusin", this.#onFocusIn);
|
|
310
|
+
this.element.addEventListener("focusout", this.#onFocusOut);
|
|
311
|
+
document.addEventListener("click", this.#onOutsideClick, true);
|
|
312
|
+
if (typeof MutationObserver !== "undefined") {
|
|
313
|
+
this.#observer = new MutationObserver(() => this.#reconcile());
|
|
314
|
+
this.#observer.observe(this.element, {
|
|
315
|
+
subtree: true,
|
|
316
|
+
childList: true,
|
|
317
|
+
attributes: true,
|
|
318
|
+
attributeFilter: STATE_ATTRIBUTES
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
this.#connected = true;
|
|
200
322
|
}
|
|
201
|
-
/** Removes the
|
|
323
|
+
/** Removes the listeners, stack membership, and any pending timer (typeahead / Tab close). */
|
|
202
324
|
disconnect() {
|
|
325
|
+
this.#connected = false;
|
|
203
326
|
this.#escapeLayer.deactivate();
|
|
204
|
-
|
|
327
|
+
this.#layerActive = false;
|
|
328
|
+
this.element.removeEventListener("click", this.#onDisabledClickCapture, true);
|
|
329
|
+
this.element.removeEventListener("focusin", this.#onFocusIn);
|
|
330
|
+
this.element.removeEventListener("focusout", this.#onFocusOut);
|
|
331
|
+
document.removeEventListener("click", this.#onOutsideClick, true);
|
|
332
|
+
this.#focused = null;
|
|
333
|
+
this.#observer?.disconnect();
|
|
334
|
+
this.#observer = null;
|
|
335
|
+
this.#typeahead.reset();
|
|
205
336
|
this.#timers.clearAll();
|
|
206
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* A top item added at runtime is dropped out of the Tab sequence first — a fresh
|
|
340
|
+
* `<button>` is tabbable by default, which would leave the menubar with two Tab
|
|
341
|
+
* stops — before the lone stop is re-established.
|
|
342
|
+
*/
|
|
343
|
+
topTargetConnected(top) {
|
|
344
|
+
if (!this.#connected) return;
|
|
345
|
+
top.tabIndex = -1;
|
|
346
|
+
this.#reconcile();
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Removing a top item can strand two things: the Tab stop it held, and the menu
|
|
350
|
+
* it owned (which nothing could close afterwards).
|
|
351
|
+
*
|
|
352
|
+
* The departing element is handed back in a neutral state first. A target can
|
|
353
|
+
* leave without leaving the document — a morph that only drops the
|
|
354
|
+
* `data-*-target` token keeps the node — and it would then sit in the page
|
|
355
|
+
* carrying this controller's `tabindex="0"` (a second Tab stop next to the one
|
|
356
|
+
* re-established below) and an `aria-expanded="true"` nothing can collapse.
|
|
357
|
+
* When the node really is gone these writes are harmless no-ops.
|
|
358
|
+
*/
|
|
359
|
+
topTargetDisconnected(top) {
|
|
360
|
+
if (!this.#connected) return;
|
|
361
|
+
top.tabIndex = -1;
|
|
362
|
+
if (this.#isExpanded(top)) top.setAttribute("aria-expanded", "false");
|
|
363
|
+
this.#reconcile();
|
|
364
|
+
}
|
|
365
|
+
/** See {@link MenubarController.menuTargetDisconnected}. */
|
|
366
|
+
menuTargetConnected() {
|
|
367
|
+
if (!this.#connected) return;
|
|
368
|
+
this.#reconcile();
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* A menu removed while open leaves its owner claiming `aria-expanded="true"` for
|
|
372
|
+
* a popup that no longer exists, and the menubar holding an Escape layer that
|
|
373
|
+
* would swallow presses meant for something else.
|
|
374
|
+
*
|
|
375
|
+
* As with {@link MenubarController.topTargetDisconnected}, the departing menu is
|
|
376
|
+
* closed first: a token-only removal leaves a visible popup in the page that no
|
|
377
|
+
* key and no click can dismiss any more.
|
|
378
|
+
*/
|
|
379
|
+
menuTargetDisconnected(menu) {
|
|
380
|
+
if (!this.#connected) return;
|
|
381
|
+
if (!menu.hidden) menu.hidden = true;
|
|
382
|
+
this.#reconcile();
|
|
383
|
+
}
|
|
207
384
|
/** Toggles a top item's menu. Bound via `data-action` (click on the top item). */
|
|
208
385
|
toggle(event) {
|
|
209
386
|
const top = event.currentTarget;
|
|
@@ -215,19 +392,22 @@ var MenubarController = class extends Controller {
|
|
|
215
392
|
}
|
|
216
393
|
/** Keyboard handling while focus is on a top item. */
|
|
217
394
|
onTopKeydown(event) {
|
|
218
|
-
|
|
395
|
+
if (event.defaultPrevented) return;
|
|
396
|
+
if (isReservedArrowChord(event)) return;
|
|
397
|
+
const tops = this.#navigableTops;
|
|
219
398
|
const index = tops.indexOf(event.currentTarget);
|
|
220
399
|
if (index === -1) return;
|
|
221
400
|
const length = tops.length;
|
|
222
401
|
const anyOpen = this.#isAnyOpen;
|
|
402
|
+
const step = isRtl(this.element) ? -1 : 1;
|
|
223
403
|
switch (event.key) {
|
|
224
404
|
case "ArrowRight":
|
|
225
405
|
event.preventDefault();
|
|
226
|
-
this.#gotoTop((index +
|
|
406
|
+
this.#gotoTop(tops[(index + step + length) % length], anyOpen);
|
|
227
407
|
break;
|
|
228
408
|
case "ArrowLeft":
|
|
229
409
|
event.preventDefault();
|
|
230
|
-
this.#gotoTop((index -
|
|
410
|
+
this.#gotoTop(tops[(index - step + length) % length], anyOpen);
|
|
231
411
|
break;
|
|
232
412
|
case "ArrowDown":
|
|
233
413
|
event.preventDefault();
|
|
@@ -239,16 +419,21 @@ var MenubarController = class extends Controller {
|
|
|
239
419
|
break;
|
|
240
420
|
case "Home":
|
|
241
421
|
event.preventDefault();
|
|
242
|
-
this.#gotoTop(0, anyOpen);
|
|
422
|
+
this.#gotoTop(tops[0], anyOpen);
|
|
243
423
|
break;
|
|
244
424
|
case "End":
|
|
245
425
|
event.preventDefault();
|
|
246
|
-
this.#gotoTop(length - 1, anyOpen);
|
|
426
|
+
this.#gotoTop(tops[length - 1], anyOpen);
|
|
427
|
+
break;
|
|
428
|
+
case "Tab":
|
|
429
|
+
this.#closeMenusSoon();
|
|
247
430
|
break;
|
|
248
431
|
}
|
|
249
432
|
}
|
|
250
433
|
/** Keyboard handling while focus is on a menu item. */
|
|
251
434
|
onItemKeydown(event) {
|
|
435
|
+
if (event.defaultPrevented) return;
|
|
436
|
+
if (isReservedArrowChord(event)) return;
|
|
252
437
|
const item = event.currentTarget;
|
|
253
438
|
const menu = item.closest("[role='menu']");
|
|
254
439
|
if (!menu) return;
|
|
@@ -274,17 +459,17 @@ var MenubarController = class extends Controller {
|
|
|
274
459
|
break;
|
|
275
460
|
case "ArrowRight":
|
|
276
461
|
event.preventDefault();
|
|
277
|
-
this.#moveToAdjacentMenu(menu, 1);
|
|
462
|
+
this.#moveToAdjacentMenu(menu, isRtl(this.element) ? -1 : 1);
|
|
278
463
|
break;
|
|
279
464
|
case "ArrowLeft":
|
|
280
465
|
event.preventDefault();
|
|
281
|
-
this.#moveToAdjacentMenu(menu, -1);
|
|
466
|
+
this.#moveToAdjacentMenu(menu, isRtl(this.element) ? 1 : -1);
|
|
282
467
|
break;
|
|
283
468
|
case "Tab":
|
|
284
|
-
this.#
|
|
469
|
+
this.#closeMenusSoon();
|
|
285
470
|
break;
|
|
286
471
|
default:
|
|
287
|
-
if (
|
|
472
|
+
if (isTypeaheadKey(event)) {
|
|
288
473
|
event.preventDefault();
|
|
289
474
|
this.#typeaheadTo(items, index, event.key);
|
|
290
475
|
}
|
|
@@ -299,44 +484,89 @@ var MenubarController = class extends Controller {
|
|
|
299
484
|
this.#closeAllMenus();
|
|
300
485
|
this.#focusTop(top);
|
|
301
486
|
}
|
|
302
|
-
/** Moves the roving focus to top
|
|
303
|
-
#gotoTop(
|
|
304
|
-
const top = this.topTargets[index];
|
|
487
|
+
/** Moves the roving focus to `top`, opening its menu when one was open. */
|
|
488
|
+
#gotoTop(top, reopen) {
|
|
305
489
|
if (!top) return;
|
|
306
490
|
if (reopen) {
|
|
307
491
|
this.#openMenu(top, "first");
|
|
308
492
|
} else {
|
|
309
|
-
this.#roving.setActive(
|
|
493
|
+
this.#roving.setActive(this.topTargets.indexOf(top), { focus: true });
|
|
310
494
|
}
|
|
311
495
|
}
|
|
312
|
-
/**
|
|
496
|
+
/**
|
|
497
|
+
* Opens `top`'s menu (closing others) and focuses its first/last item.
|
|
498
|
+
*
|
|
499
|
+
* Three kinds of top item never open a menu:
|
|
500
|
+
* - **`aria-disabled`** — focusable but never activated, and opening a popup is
|
|
501
|
+
* activation.
|
|
502
|
+
* - **plain command** (no `aria-controls` at all) — a legitimate top item that
|
|
503
|
+
* simply has no popup.
|
|
504
|
+
* - **dangling `aria-controls`** (names a menu that is not a target) — broken
|
|
505
|
+
* markup.
|
|
506
|
+
*
|
|
507
|
+
* The first two still take the roving focus, and any open menu closes: the APG
|
|
508
|
+
* makes closing unconditional for the horizontal move ("closes the submenu,
|
|
509
|
+
* moves focus to the next menubar item, and *optionally* opens that item's
|
|
510
|
+
* submenu"), so only the opening half is skipped. Leaving the old menu open
|
|
511
|
+
* would strand a popup that no longer contains focus.
|
|
512
|
+
*
|
|
513
|
+
* The dangling case instead leaves the open/closed state and focus untouched
|
|
514
|
+
* rather than closing everything and dropping focus to the body — but only on
|
|
515
|
+
* this "open a menu" path. A dangling top is still an ordinary roving
|
|
516
|
+
* destination while nothing is open.
|
|
517
|
+
*/
|
|
313
518
|
#openMenu(top, focus) {
|
|
314
519
|
if (!top) return;
|
|
315
|
-
this.#closeAllMenus();
|
|
316
520
|
const menu = this.#menuFor(top);
|
|
317
|
-
if (!menu)
|
|
521
|
+
if (!menu || this.#isActivationBlocked(top)) {
|
|
522
|
+
if (menu || !top.hasAttribute("aria-controls")) {
|
|
523
|
+
this.#closeAllMenus();
|
|
524
|
+
this.#focusTop(top);
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
this.#timers.clearAll();
|
|
529
|
+
this.#closeAllMenus();
|
|
318
530
|
menu.hidden = false;
|
|
319
531
|
top.setAttribute("aria-expanded", "true");
|
|
320
532
|
this.#escapeLayer.activate(document, {
|
|
321
533
|
onDismiss: () => this.#dismissOpenMenu(),
|
|
322
534
|
claims: claimsWhileFocusWithin(this.element)
|
|
323
535
|
});
|
|
536
|
+
this.#layerActive = true;
|
|
324
537
|
this.#roving.setActive(this.topTargets.indexOf(top));
|
|
325
538
|
const items = this.#itemsIn(menu);
|
|
326
539
|
this.#focusAt(items, focus === "first" ? 0 : items.length - 1);
|
|
327
540
|
}
|
|
328
|
-
/**
|
|
541
|
+
/**
|
|
542
|
+
* Hides `top`'s menu and reflects the collapsed state. A top item that controls
|
|
543
|
+
* no menu (a plain command in an otherwise popup-bearing menubar) is left alone
|
|
544
|
+
* — stamping `aria-expanded="false"` on it would announce a popup it lacks.
|
|
545
|
+
*/
|
|
329
546
|
#closeMenu(top) {
|
|
330
547
|
if (!top) return;
|
|
331
548
|
const menu = this.#menuFor(top);
|
|
332
|
-
if (menu)
|
|
549
|
+
if (!menu) return;
|
|
550
|
+
menu.hidden = true;
|
|
333
551
|
top.setAttribute("aria-expanded", "false");
|
|
334
|
-
|
|
552
|
+
this.#syncEscapeLayer();
|
|
335
553
|
}
|
|
336
|
-
/** Closes every menu and resets the typeahead
|
|
554
|
+
/** Closes every menu and resets the typeahead query. */
|
|
337
555
|
#closeAllMenus() {
|
|
338
556
|
for (const top of this.topTargets) this.#closeMenu(top);
|
|
339
|
-
this.#
|
|
557
|
+
this.#typeahead.reset();
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Closes every menu on the next task instead of synchronously.
|
|
561
|
+
*
|
|
562
|
+
* Used by `Tab`: closing right away removes the focused element before the
|
|
563
|
+
* browser performs its own Tab move, which can restart traversal at the
|
|
564
|
+
* document head and lose the user's place in the Tab order.
|
|
565
|
+
*/
|
|
566
|
+
#closeMenusSoon() {
|
|
567
|
+
if (!this.#isAnyOpen) return;
|
|
568
|
+
this.#timers.clearAll();
|
|
569
|
+
this.#timers.set(() => this.#closeAllMenus(), 0);
|
|
340
570
|
}
|
|
341
571
|
/**
|
|
342
572
|
* Escape path, invoked by the shared resolver: pressed inside an open menu it
|
|
@@ -355,18 +585,143 @@ var MenubarController = class extends Controller {
|
|
|
355
585
|
}
|
|
356
586
|
this.#closeAllMenus();
|
|
357
587
|
}
|
|
358
|
-
/** Opens the menu of the top item `delta` steps from
|
|
588
|
+
/** Opens the menu of the navigable top item `delta` steps from `menu`'s owner. */
|
|
359
589
|
#moveToAdjacentMenu(menu, delta) {
|
|
360
590
|
const top = this.#topFor(menu);
|
|
361
591
|
if (!top) return;
|
|
362
|
-
const tops = this
|
|
363
|
-
const
|
|
592
|
+
const tops = this.#navigableTops;
|
|
593
|
+
const current = tops.indexOf(top);
|
|
594
|
+
if (current === -1) return;
|
|
595
|
+
const next = (current + delta + tops.length) % tops.length;
|
|
364
596
|
this.#openMenu(tops[next], "first");
|
|
365
597
|
}
|
|
366
|
-
/**
|
|
598
|
+
/**
|
|
599
|
+
* Closes when a click lands outside the controller's element. Focus is left on
|
|
600
|
+
* whatever the user clicked — see the focus-restoration contract in the class
|
|
601
|
+
* docs.
|
|
602
|
+
*/
|
|
367
603
|
#onOutsideClick = (event) => {
|
|
368
604
|
if (this.#isAnyOpen && !this.element.contains(event.target)) this.#closeAllMenus();
|
|
369
605
|
};
|
|
606
|
+
/**
|
|
607
|
+
* Captures clicks so `aria-disabled` top items and commands cannot reach
|
|
608
|
+
* consumer handlers (nor `toggle`/`activate`). Native Enter/Space activation
|
|
609
|
+
* also synthesizes a click and is blocked here; natively `disabled` buttons
|
|
610
|
+
* dispatch no click at all.
|
|
611
|
+
*/
|
|
612
|
+
#onDisabledClickCapture = (event) => {
|
|
613
|
+
const target = event.target;
|
|
614
|
+
if (!(target instanceof Node)) return;
|
|
615
|
+
const blocked = [...this.topTargets, ...this.itemTargets].some(
|
|
616
|
+
(element) => this.#isActivationBlocked(element) && element.contains(target)
|
|
617
|
+
);
|
|
618
|
+
if (!blocked) return;
|
|
619
|
+
event.preventDefault();
|
|
620
|
+
event.stopImmediatePropagation();
|
|
621
|
+
};
|
|
622
|
+
/**
|
|
623
|
+
* Re-derives every piece of state this controller owns from the live targets:
|
|
624
|
+
* expanded flags, menu visibility, the single Tab stop, and Escape-stack
|
|
625
|
+
* membership. Runtime DOM edits can leave those out of step with each other — a
|
|
626
|
+
* top can claim `aria-expanded="true"` after its menu target was removed or after
|
|
627
|
+
* being hidden itself, a menu can stay visible after its owning top was removed,
|
|
628
|
+
* an open pair can appear from a morph with no layer registered for it, and the
|
|
629
|
+
* Tab stop can end up on a now-inert top, on a runtime-added one, or on none at
|
|
630
|
+
* all. Nothing is remembered between calls (except which side of the Escape stack
|
|
631
|
+
* this layer is on, which the stack itself does not expose), so the outcome is the
|
|
632
|
+
* same whichever mutation arrived and calling it more often than needed is free.
|
|
633
|
+
*/
|
|
634
|
+
#reconcile() {
|
|
635
|
+
for (const top of this.topTargets) {
|
|
636
|
+
const own = this.#menuFor(top);
|
|
637
|
+
if (this.#isExpanded(top) && (!own || own.hidden || !this.#isNavigable(top))) {
|
|
638
|
+
top.setAttribute("aria-expanded", "false");
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
for (const menu of this.menuTargets) {
|
|
642
|
+
if (menu.hidden) continue;
|
|
643
|
+
const owner = this.#topFor(menu);
|
|
644
|
+
if (!owner || !this.#isExpanded(owner)) menu.hidden = true;
|
|
645
|
+
}
|
|
646
|
+
this.#ensureTabStop();
|
|
647
|
+
this.#rescueFocus();
|
|
648
|
+
this.#syncEscapeLayer();
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Returns DOM focus to the menubar when the element holding it became
|
|
652
|
+
* unreachable — hidden, natively disabled, or removed — and the document had
|
|
653
|
+
* nowhere to put it. The destination is the top item that now owns the Tab
|
|
654
|
+
* stop, which is where `Escape` from a closed menu would have left the user.
|
|
655
|
+
*
|
|
656
|
+
* Focus is only *restored*, never *stolen*: it moves solely when the tracked
|
|
657
|
+
* element can no longer take it **and** focus is either already gone or still
|
|
658
|
+
* sitting on that element. The second half is what makes a `hidden` ancestor
|
|
659
|
+
* work — a browser blurs the element it hides on its next style pass, not when
|
|
660
|
+
* the attribute is written, so waiting to observe `<body>` here would always
|
|
661
|
+
* come too early. Removal is the other side of the same condition: the browser
|
|
662
|
+
* has already fallen back to the body, or left a detached `activeElement`.
|
|
663
|
+
*/
|
|
664
|
+
#rescueFocus() {
|
|
665
|
+
const focused = this.#focused;
|
|
666
|
+
if (!focused) return;
|
|
667
|
+
const owner = this.topTargets.find(
|
|
668
|
+
(top2) => top2 === focused || this.#menuFor(top2)?.contains(focused)
|
|
669
|
+
);
|
|
670
|
+
if (focused.isConnected && owner && this.#isNavigable(owner)) return;
|
|
671
|
+
const doc = this.element.ownerDocument;
|
|
672
|
+
const active = doc.activeElement;
|
|
673
|
+
const lost = active === null || active === doc.body || active === doc.documentElement || !active.isConnected || active === focused || focused.contains(active);
|
|
674
|
+
if (!lost) return;
|
|
675
|
+
const index = this.#roving.activeIndex;
|
|
676
|
+
const top = index === -1 ? void 0 : this.topTargets[index];
|
|
677
|
+
if (top && top !== focused && !focused.contains(top)) top.focus();
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Brings Escape-stack membership back in line with what the DOM now says is
|
|
681
|
+
* open, in both directions.
|
|
682
|
+
*
|
|
683
|
+
* The deactivate half is the common one (something closed). The activate half
|
|
684
|
+
* covers a menu that became open *without* going through {@link #openMenu} —
|
|
685
|
+
* a Turbo morph that patches `aria-expanded` and the menu's `hidden` in place,
|
|
686
|
+
* which is legitimate here because the DOM is this controller's only source of
|
|
687
|
+
* truth. Without it the popup is visible but `Escape` does nothing, since no
|
|
688
|
+
* layer is registered to claim the press.
|
|
689
|
+
*
|
|
690
|
+
* `#layerActive` exists because re-activating an already-active layer moves it
|
|
691
|
+
* to the top of the stack, which would reshuffle nested layers on every
|
|
692
|
+
* unrelated mutation. Registering only on the false→true edge keeps activation
|
|
693
|
+
* ordered by when each layer actually opened.
|
|
694
|
+
*/
|
|
695
|
+
#syncEscapeLayer() {
|
|
696
|
+
const open = this.#isAnyOpen;
|
|
697
|
+
if (open === this.#layerActive) return;
|
|
698
|
+
if (open) {
|
|
699
|
+
this.#escapeLayer.activate(document, {
|
|
700
|
+
onDismiss: () => this.#dismissOpenMenu(),
|
|
701
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
702
|
+
});
|
|
703
|
+
} else {
|
|
704
|
+
this.#escapeLayer.deactivate();
|
|
705
|
+
}
|
|
706
|
+
this.#layerActive = open;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* Re-establishes the single Tab stop: keep the current one while it is still
|
|
710
|
+
* navigable, else hand it to the first navigable top item. Every other top is
|
|
711
|
+
* explicitly removed from the Tab sequence, so a top that arrived tabbable
|
|
712
|
+
* cannot leave two stops behind. No Tab stop at all (`-1`) happens only when
|
|
713
|
+
* every top is inert, and is recovered from as soon as one becomes navigable.
|
|
714
|
+
*/
|
|
715
|
+
#ensureTabStop() {
|
|
716
|
+
const active = this.#roving.activeIndex;
|
|
717
|
+
const activeTop = active === -1 ? void 0 : this.topTargets[active];
|
|
718
|
+
if (activeTop && this.#isNavigable(activeTop)) {
|
|
719
|
+
this.#roving.setActive(active);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const first = this.#navigableTops[0];
|
|
723
|
+
this.#roving.setActive(first ? this.topTargets.indexOf(first) : -1);
|
|
724
|
+
}
|
|
370
725
|
/** The menu element controlled by `top` (matched by `aria-controls`/`id`). */
|
|
371
726
|
#menuFor(top) {
|
|
372
727
|
const id = top.getAttribute("aria-controls");
|
|
@@ -377,9 +732,29 @@ var MenubarController = class extends Controller {
|
|
|
377
732
|
if (!menu) return null;
|
|
378
733
|
return this.topTargets.find((top) => top.getAttribute("aria-controls") === menu.id) ?? null;
|
|
379
734
|
}
|
|
380
|
-
/** The item targets that live inside `menu`, in DOM order. */
|
|
735
|
+
/** The navigable item targets that live inside `menu`, in DOM order. */
|
|
381
736
|
#itemsIn(menu) {
|
|
382
|
-
return this.itemTargets.filter((item) => menu.contains(item));
|
|
737
|
+
return this.itemTargets.filter((item) => menu.contains(item) && this.#isNavigable(item));
|
|
738
|
+
}
|
|
739
|
+
/** Top items eligible for the roving tab stop and the arrow keys. */
|
|
740
|
+
get #navigableTops() {
|
|
741
|
+
return this.topTargets.filter((top) => this.#isNavigable(top));
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Whether `element` can take roving focus: `hidden` and natively `disabled`
|
|
745
|
+
* controls are out of reach and are skipped. `aria-disabled` is deliberately
|
|
746
|
+
* **not** checked — the APG keeps such items focusable so they stay
|
|
747
|
+
* discoverable, and {@link MenubarController.#isActivationBlocked} is what
|
|
748
|
+
* suppresses acting on them. CSS-only visibility is not detectable headlessly
|
|
749
|
+
* and stays the consumer's responsibility.
|
|
750
|
+
*/
|
|
751
|
+
#isNavigable(element) {
|
|
752
|
+
if (element.hasAttribute("hidden")) return false;
|
|
753
|
+
return !element.disabled;
|
|
754
|
+
}
|
|
755
|
+
/** Whether `element` is `aria-disabled`: reachable, but never activated. */
|
|
756
|
+
#isActivationBlocked(element) {
|
|
757
|
+
return element.getAttribute("aria-disabled") === "true";
|
|
383
758
|
}
|
|
384
759
|
/** Moves DOM focus to the item at `index` (no-op if out of range). */
|
|
385
760
|
#focusAt(items, index) {
|
|
@@ -398,33 +773,10 @@ var MenubarController = class extends Controller {
|
|
|
398
773
|
get #isAnyOpen() {
|
|
399
774
|
return this.topTargets.some((top) => this.#isExpanded(top));
|
|
400
775
|
}
|
|
401
|
-
/**
|
|
402
|
-
* Whether the event is a single printable character usable for typeahead.
|
|
403
|
-
* `Space` is excluded: on a `<button>` menuitem it natively activates the item
|
|
404
|
-
* (Enter/Space → click), so swallowing it for typeahead would break activation.
|
|
405
|
-
*/
|
|
406
|
-
#isTypeaheadKey(event) {
|
|
407
|
-
return event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
408
|
-
}
|
|
409
|
-
/** Advances focus to the next item in `items` matching the accumulated buffer. */
|
|
776
|
+
/** Advances focus to the next item in `items` matching the accumulated query. */
|
|
410
777
|
#typeaheadTo(items, current, key) {
|
|
411
|
-
this.#typeahead
|
|
412
|
-
|
|
413
|
-
this.#typeaheadId = this.#timers.set(() => this.#resetTypeahead(), TYPEAHEAD_RESET_MS);
|
|
414
|
-
const count = items.length;
|
|
415
|
-
for (let step = 1; step <= count; step++) {
|
|
416
|
-
const candidate = items[(current + step) % count];
|
|
417
|
-
const label = (candidate?.textContent ?? "").trim().toLowerCase();
|
|
418
|
-
if (label.startsWith(this.#typeahead)) {
|
|
419
|
-
candidate?.focus();
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
/** Clears the typeahead buffer and its timer. */
|
|
425
|
-
#resetTypeahead() {
|
|
426
|
-
this.#typeahead = "";
|
|
427
|
-
this.#timers.clear(this.#typeaheadId);
|
|
778
|
+
const index = findTypeaheadMatch(items, current, this.#typeahead.push(key));
|
|
779
|
+
if (index !== -1) items[index]?.focus();
|
|
428
780
|
}
|
|
429
781
|
};
|
|
430
782
|
|