stimeo-ui 0.2.0 → 0.2.1
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.
- package/CHANGELOG.md +59 -0
- package/dist/controllers/accordion_controller.d.ts +3 -2
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +64 -0
- package/dist/controllers/alert_dialog_controller.js +318 -0
- package/dist/controllers/alert_dialog_controller.js.map +1 -0
- package/dist/controllers/carousel_controller.d.ts +100 -0
- package/dist/controllers/carousel_controller.js +272 -0
- package/dist/controllers/carousel_controller.js.map +1 -0
- package/dist/controllers/clipboard_controller.d.ts +72 -0
- package/dist/controllers/clipboard_controller.js +144 -0
- package/dist/controllers/clipboard_controller.js.map +1 -0
- package/dist/controllers/collapsible_controller.d.ts +70 -0
- package/dist/controllers/collapsible_controller.js +327 -0
- package/dist/controllers/collapsible_controller.js.map +1 -0
- package/dist/controllers/color_picker_controller.d.ts +76 -0
- package/dist/controllers/color_picker_controller.js +213 -0
- package/dist/controllers/color_picker_controller.js.map +1 -0
- package/dist/controllers/count_up_controller.js +8 -1
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +72 -0
- package/dist/controllers/currency_input_controller.js +147 -0
- package/dist/controllers/currency_input_controller.js.map +1 -0
- package/dist/controllers/data_grid_controller.d.ts +71 -0
- package/dist/controllers/data_grid_controller.js +168 -0
- package/dist/controllers/data_grid_controller.js.map +1 -0
- package/dist/controllers/date_range_picker_controller.d.ts +87 -0
- package/dist/controllers/date_range_picker_controller.js +417 -0
- package/dist/controllers/date_range_picker_controller.js.map +1 -0
- package/dist/controllers/dismissible_controller.d.ts +57 -0
- package/dist/controllers/dismissible_controller.js +117 -0
- package/dist/controllers/dismissible_controller.js.map +1 -0
- package/dist/controllers/drawer_controller.d.ts +96 -0
- package/dist/controllers/drawer_controller.js +630 -0
- package/dist/controllers/drawer_controller.js.map +1 -0
- package/dist/controllers/editable_controller.d.ts +73 -0
- package/dist/controllers/editable_controller.js +168 -0
- package/dist/controllers/editable_controller.js.map +1 -0
- package/dist/controllers/file_dropzone_controller.d.ts +92 -0
- package/dist/controllers/file_dropzone_controller.js +165 -0
- package/dist/controllers/file_dropzone_controller.js.map +1 -0
- package/dist/controllers/filter_controller.d.ts +60 -0
- package/dist/controllers/filter_controller.js +86 -0
- package/dist/controllers/filter_controller.js.map +1 -0
- package/dist/controllers/flash_controller.js +36 -5
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.js +6 -4
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js +41 -18
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +33 -11
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +54 -0
- package/dist/controllers/masonry_controller.js +142 -0
- package/dist/controllers/masonry_controller.js.map +1 -0
- package/dist/controllers/menubar_controller.d.ts +62 -0
- package/dist/controllers/menubar_controller.js +433 -0
- package/dist/controllers/menubar_controller.js.map +1 -0
- package/dist/controllers/multi_select_controller.d.ts +115 -0
- package/dist/controllers/multi_select_controller.js +472 -0
- package/dist/controllers/multi_select_controller.js.map +1 -0
- package/dist/controllers/navigation_menu_controller.d.ts +99 -0
- package/dist/controllers/navigation_menu_controller.js +384 -0
- package/dist/controllers/navigation_menu_controller.js.map +1 -0
- package/dist/controllers/overflow_indicator_controller.d.ts +12 -4
- package/dist/controllers/overflow_indicator_controller.js +178 -27
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +49 -0
- package/dist/controllers/password_reveal_controller.js +117 -0
- package/dist/controllers/password_reveal_controller.js.map +1 -0
- package/dist/controllers/range_slider_controller.d.ts +87 -0
- package/dist/controllers/range_slider_controller.js +166 -0
- package/dist/controllers/range_slider_controller.js.map +1 -0
- package/dist/controllers/read_more_controller.d.ts +52 -0
- package/dist/controllers/read_more_controller.js +194 -0
- package/dist/controllers/read_more_controller.js.map +1 -0
- package/dist/controllers/scroll_area_controller.js +15 -2
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.d.ts +54 -0
- package/dist/controllers/scroll_restore_controller.js +93 -0
- package/dist/controllers/scroll_restore_controller.js.map +1 -0
- package/dist/controllers/scroll_visibility_controller.js +8 -4
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.js +33 -11
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +54 -0
- package/dist/controllers/separator_controller.js +87 -0
- package/dist/controllers/separator_controller.js.map +1 -0
- package/dist/controllers/sidebar_controller.d.ts +107 -0
- package/dist/controllers/sidebar_controller.js +761 -0
- package/dist/controllers/sidebar_controller.js.map +1 -0
- package/dist/controllers/stepper_controller.d.ts +4 -0
- package/dist/controllers/stepper_controller.js +28 -12
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.js +8 -4
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.d.ts +17 -4
- package/dist/controllers/sticky_observer_controller.js +88 -20
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +3 -2
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +91 -0
- package/dist/controllers/tags_input_controller.js +275 -0
- package/dist/controllers/tags_input_controller.js.map +1 -0
- package/dist/controllers/theme_controller.js +20 -10
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +67 -0
- package/dist/controllers/time_picker_controller.js +212 -0
- package/dist/controllers/time_picker_controller.js.map +1 -0
- package/dist/controllers/toast_controller.d.ts +1 -9
- package/dist/controllers/toast_controller.js +36 -9
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +14 -8
- package/dist/controllers/transition_controller.js +153 -38
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +54 -0
- package/dist/controllers/tree_view_controller.js +275 -0
- package/dist/controllers/tree_view_controller.js.map +1 -0
- package/dist/index.d.ts +27 -1818
- package/dist/index.js +811 -295
- package/dist/index.js.map +1 -1
- package/dist/inspector/examples.json +2 -2
- package/dist/inspector/manifest.json +15 -3
- package/package.json +1 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Headless, accessible **menubar** behavior.
|
|
5
|
+
*
|
|
6
|
+
* Markup contract (identifier: `stimeo--menubar`):
|
|
7
|
+
* <div data-controller="stimeo--menubar" role="menubar" aria-label="Main">
|
|
8
|
+
* <button role="menuitem" aria-haspopup="menu" aria-expanded="false"
|
|
9
|
+
* aria-controls="m-file" data-stimeo--menubar-target="top"
|
|
10
|
+
* data-action="click->stimeo--menubar#toggle
|
|
11
|
+
* keydown->stimeo--menubar#onTopKeydown">File</button>
|
|
12
|
+
* <ul id="m-file" role="menu" data-stimeo--menubar-target="menu" hidden>
|
|
13
|
+
* <li role="none">
|
|
14
|
+
* <button role="menuitem" tabindex="-1" data-stimeo--menubar-target="item"
|
|
15
|
+
* data-action="click->stimeo--menubar#activate
|
|
16
|
+
* keydown->stimeo--menubar#onItemKeydown">New</button>
|
|
17
|
+
* </li>
|
|
18
|
+
* </ul>
|
|
19
|
+
* <!-- more top items + menus -->
|
|
20
|
+
* </div>
|
|
21
|
+
*
|
|
22
|
+
* Implements the WAI-ARIA APG **Menubar** pattern (single level): the top items
|
|
23
|
+
* form one Tab stop via roving tabindex and the arrow keys move between them;
|
|
24
|
+
* `ArrowDown`/`Enter`/`Space` open a menu (`ArrowUp` opens it at the last item),
|
|
25
|
+
* the arrow keys then move within the menu, and pressing `ArrowLeft`/`ArrowRight`
|
|
26
|
+
* while a menu is open jumps to the adjacent top menu. `Escape` closes and
|
|
27
|
+
* returns focus to the owning top item — while a menu is open the menubar is a
|
|
28
|
+
* layer on the shared {@link EscapeLayer} stack, claiming a press only while
|
|
29
|
+
* focus is inside the controller or fell to the body, so one keypress closes
|
|
30
|
+
* exactly one layer (the shared layered-Escape contract). `Tab` and an outside
|
|
31
|
+
* click close.
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* Behavior only. Each top item↔menu pair is linked by `aria-controls`/`id` (not by
|
|
35
|
+
* position), so the markup order is free. Menu placement and viewport-edge
|
|
36
|
+
* collision are out of scope — static placement is the consumer's CSS, and dynamic
|
|
37
|
+
* placement is delegated to the opt-in `stimeo-ui/positioning` module (never
|
|
38
|
+
* imported here, keeping the core zero-dependency). Roving mechanics across the top
|
|
39
|
+
* items are delegated to {@link RovingTabindex}.
|
|
40
|
+
*/
|
|
41
|
+
declare class MenubarController extends Controller<HTMLElement> {
|
|
42
|
+
#private;
|
|
43
|
+
static targets: string[];
|
|
44
|
+
static actions: readonly ["activate", "onItemKeydown", "onTopKeydown", "toggle"];
|
|
45
|
+
readonly topTargets: HTMLElement[];
|
|
46
|
+
readonly menuTargets: HTMLElement[];
|
|
47
|
+
readonly itemTargets: HTMLElement[];
|
|
48
|
+
/** Establishes the single tab stop and the closed baseline. */
|
|
49
|
+
connect(): void;
|
|
50
|
+
/** Removes the document listener, stack membership, and any pending typeahead timer. */
|
|
51
|
+
disconnect(): void;
|
|
52
|
+
/** Toggles a top item's menu. Bound via `data-action` (click on the top item). */
|
|
53
|
+
toggle(event: Event): void;
|
|
54
|
+
/** Keyboard handling while focus is on a top item. */
|
|
55
|
+
onTopKeydown(event: KeyboardEvent): void;
|
|
56
|
+
/** Keyboard handling while focus is on a menu item. */
|
|
57
|
+
onItemKeydown(event: KeyboardEvent): void;
|
|
58
|
+
/** Closes the owning menu after an item is activated and refocuses its top. */
|
|
59
|
+
activate(event: Event): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { MenubarController };
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus';
|
|
2
|
+
|
|
3
|
+
// src/controllers/menubar_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/escape_layer.ts
|
|
6
|
+
function claimsWhileFocusWithin(element) {
|
|
7
|
+
return () => {
|
|
8
|
+
const active = element.ownerDocument.activeElement;
|
|
9
|
+
return active === null || active === element.ownerDocument.body || element.contains(active);
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
var EscapeLayer = class _EscapeLayer {
|
|
13
|
+
static #registries = /* @__PURE__ */ new WeakMap();
|
|
14
|
+
#ownerDocument = null;
|
|
15
|
+
/** Dismissal callback while active; `null` when inactive. */
|
|
16
|
+
#onDismiss = null;
|
|
17
|
+
/** Live predicate deciding whether the layer claims a press; `null` = always. */
|
|
18
|
+
#claims = null;
|
|
19
|
+
/**
|
|
20
|
+
* Activates this layer at the top of its document's Escape stack, installing
|
|
21
|
+
* the document's shared resolver listener if this is its first layer.
|
|
22
|
+
* Re-activating an already-active layer moves it to the top.
|
|
23
|
+
*/
|
|
24
|
+
activate(ownerDocument = document, options) {
|
|
25
|
+
this.deactivate();
|
|
26
|
+
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
27
|
+
if (!registry) {
|
|
28
|
+
registry = _EscapeLayer.#createRegistry();
|
|
29
|
+
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
30
|
+
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
31
|
+
}
|
|
32
|
+
registry.stack.push(this);
|
|
33
|
+
this.#ownerDocument = ownerDocument;
|
|
34
|
+
this.#onDismiss = options.onDismiss;
|
|
35
|
+
this.#claims = options.claims ?? null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Removes this layer from its document's Escape stack, uninstalling the
|
|
39
|
+
* shared listener when the stack empties. Safe to call when inactive.
|
|
40
|
+
*/
|
|
41
|
+
deactivate() {
|
|
42
|
+
const ownerDocument = this.#ownerDocument;
|
|
43
|
+
if (!ownerDocument) return;
|
|
44
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
45
|
+
if (registry) {
|
|
46
|
+
const index = registry.stack.lastIndexOf(this);
|
|
47
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
48
|
+
if (registry.stack.length === 0) {
|
|
49
|
+
ownerDocument.removeEventListener("keydown", registry.onKeydown);
|
|
50
|
+
_EscapeLayer.#registries.delete(ownerDocument);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
this.#ownerDocument = null;
|
|
54
|
+
this.#onDismiss = null;
|
|
55
|
+
this.#claims = null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether this active layer would own a press right now: it is the topmost
|
|
59
|
+
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
60
|
+
* and diagnostics — production dismissal goes through the shared listener.
|
|
61
|
+
*/
|
|
62
|
+
get ownsEscape() {
|
|
63
|
+
const ownerDocument = this.#ownerDocument;
|
|
64
|
+
if (!ownerDocument) return false;
|
|
65
|
+
const registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
66
|
+
if (!registry) return false;
|
|
67
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === this;
|
|
68
|
+
}
|
|
69
|
+
/** Builds a document's registry with its shared resolver listener. */
|
|
70
|
+
static #createRegistry() {
|
|
71
|
+
const registry = {
|
|
72
|
+
stack: [],
|
|
73
|
+
onKeydown: (event) => {
|
|
74
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
75
|
+
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
76
|
+
if (!owner) return;
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
owner.#onDismiss?.();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
return registry;
|
|
82
|
+
}
|
|
83
|
+
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
84
|
+
static #resolveOwner(stack) {
|
|
85
|
+
for (let index = stack.length - 1; index >= 0; index--) {
|
|
86
|
+
const layer = stack[index];
|
|
87
|
+
if (!layer) continue;
|
|
88
|
+
if (layer.#claims && !layer.#claims()) continue;
|
|
89
|
+
return layer;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// src/utils/roving_tabindex.ts
|
|
96
|
+
var RovingTabindex = class {
|
|
97
|
+
/** Returns the current ordered item elements; called on every operation. */
|
|
98
|
+
#getItems;
|
|
99
|
+
/**
|
|
100
|
+
* @param getItems - Returns the current ordered item elements. Called on every
|
|
101
|
+
* operation so the live target list is always used.
|
|
102
|
+
*/
|
|
103
|
+
constructor(getItems) {
|
|
104
|
+
this.#getItems = getItems;
|
|
105
|
+
}
|
|
106
|
+
/** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
|
|
107
|
+
get activeIndex() {
|
|
108
|
+
return this.#getItems().findIndex((item) => item.tabIndex === 0);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
|
|
112
|
+
* other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
|
|
113
|
+
* (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
|
|
114
|
+
* "nothing is currently tabbable".
|
|
115
|
+
*
|
|
116
|
+
* @param index - Position of the item to make tabbable.
|
|
117
|
+
* @param options - Pass `{ focus: true }` to also move DOM focus to that item.
|
|
118
|
+
*/
|
|
119
|
+
setActive(index, { focus = false } = {}) {
|
|
120
|
+
const items = this.#getItems();
|
|
121
|
+
items.forEach((item, i) => {
|
|
122
|
+
item.tabIndex = i === index ? 0 : -1;
|
|
123
|
+
});
|
|
124
|
+
if (focus) items[index]?.focus();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// src/utils/safe_timeout.ts
|
|
129
|
+
var TimerRegistry = class {
|
|
130
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
131
|
+
ids = /* @__PURE__ */ new Set();
|
|
132
|
+
/**
|
|
133
|
+
* Cancels a single tracked timer.
|
|
134
|
+
*
|
|
135
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
136
|
+
* registry), so callers can clear defensively without guarding.
|
|
137
|
+
*/
|
|
138
|
+
clear(id) {
|
|
139
|
+
if (this.ids.delete(id)) {
|
|
140
|
+
this.cancel(id);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
145
|
+
* to guarantee no timer outlives the element.
|
|
146
|
+
*/
|
|
147
|
+
clearAll() {
|
|
148
|
+
for (const id of this.ids) {
|
|
149
|
+
this.cancel(id);
|
|
150
|
+
}
|
|
151
|
+
this.ids.clear();
|
|
152
|
+
}
|
|
153
|
+
/** Number of timers currently tracked (pending). */
|
|
154
|
+
get size() {
|
|
155
|
+
return this.ids.size;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
var SafeTimeout = class extends TimerRegistry {
|
|
159
|
+
/**
|
|
160
|
+
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
161
|
+
*
|
|
162
|
+
* The id is removed from the registry automatically when the timeout fires,
|
|
163
|
+
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
164
|
+
*/
|
|
165
|
+
set(callback, delay) {
|
|
166
|
+
const id = this.schedule(() => {
|
|
167
|
+
this.ids.delete(id);
|
|
168
|
+
callback();
|
|
169
|
+
}, delay);
|
|
170
|
+
this.ids.add(id);
|
|
171
|
+
return id;
|
|
172
|
+
}
|
|
173
|
+
schedule(callback, delay) {
|
|
174
|
+
return window.setTimeout(callback, delay);
|
|
175
|
+
}
|
|
176
|
+
cancel(id) {
|
|
177
|
+
window.clearTimeout(id);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// src/controllers/menubar_controller.ts
|
|
182
|
+
var TYPEAHEAD_RESET_MS = 500;
|
|
183
|
+
var MenubarController = class extends Controller {
|
|
184
|
+
static targets = ["top", "menu", "item"];
|
|
185
|
+
static actions = ["activate", "onItemKeydown", "onTopKeydown", "toggle"];
|
|
186
|
+
/** Roving tabindex across the top-level menuitems (one Tab stop). */
|
|
187
|
+
#roving = new RovingTabindex(() => this.topTargets);
|
|
188
|
+
/** Typeahead buffer and its idle-reset timer (scoped to the open menu). */
|
|
189
|
+
#typeahead = "";
|
|
190
|
+
#typeaheadId = 0;
|
|
191
|
+
#timers = new SafeTimeout();
|
|
192
|
+
/** Escape-stack membership while a menu is open; the shared resolver dismisses via it. */
|
|
193
|
+
#escapeLayer = new EscapeLayer();
|
|
194
|
+
/** Establishes the single tab stop and the closed baseline. */
|
|
195
|
+
connect() {
|
|
196
|
+
const active = this.#roving.activeIndex;
|
|
197
|
+
this.#roving.setActive(active === -1 ? 0 : active);
|
|
198
|
+
this.#closeAllMenus();
|
|
199
|
+
document.addEventListener("click", this.#onOutsideClick);
|
|
200
|
+
}
|
|
201
|
+
/** Removes the document listener, stack membership, and any pending typeahead timer. */
|
|
202
|
+
disconnect() {
|
|
203
|
+
this.#escapeLayer.deactivate();
|
|
204
|
+
document.removeEventListener("click", this.#onOutsideClick);
|
|
205
|
+
this.#timers.clearAll();
|
|
206
|
+
}
|
|
207
|
+
/** Toggles a top item's menu. Bound via `data-action` (click on the top item). */
|
|
208
|
+
toggle(event) {
|
|
209
|
+
const top = event.currentTarget;
|
|
210
|
+
if (this.#isExpanded(top)) {
|
|
211
|
+
this.#closeMenu(top);
|
|
212
|
+
} else {
|
|
213
|
+
this.#openMenu(top, "first");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Keyboard handling while focus is on a top item. */
|
|
217
|
+
onTopKeydown(event) {
|
|
218
|
+
const tops = this.topTargets;
|
|
219
|
+
const index = tops.indexOf(event.currentTarget);
|
|
220
|
+
if (index === -1) return;
|
|
221
|
+
const length = tops.length;
|
|
222
|
+
const anyOpen = this.#isAnyOpen;
|
|
223
|
+
switch (event.key) {
|
|
224
|
+
case "ArrowRight":
|
|
225
|
+
event.preventDefault();
|
|
226
|
+
this.#gotoTop((index + 1) % length, anyOpen);
|
|
227
|
+
break;
|
|
228
|
+
case "ArrowLeft":
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
this.#gotoTop((index - 1 + length) % length, anyOpen);
|
|
231
|
+
break;
|
|
232
|
+
case "ArrowDown":
|
|
233
|
+
event.preventDefault();
|
|
234
|
+
this.#openMenu(tops[index], "first");
|
|
235
|
+
break;
|
|
236
|
+
case "ArrowUp":
|
|
237
|
+
event.preventDefault();
|
|
238
|
+
this.#openMenu(tops[index], "last");
|
|
239
|
+
break;
|
|
240
|
+
case "Home":
|
|
241
|
+
event.preventDefault();
|
|
242
|
+
this.#gotoTop(0, anyOpen);
|
|
243
|
+
break;
|
|
244
|
+
case "End":
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
this.#gotoTop(length - 1, anyOpen);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** Keyboard handling while focus is on a menu item. */
|
|
251
|
+
onItemKeydown(event) {
|
|
252
|
+
const item = event.currentTarget;
|
|
253
|
+
const menu = item.closest("[role='menu']");
|
|
254
|
+
if (!menu) return;
|
|
255
|
+
const items = this.#itemsIn(menu);
|
|
256
|
+
const index = items.indexOf(item);
|
|
257
|
+
const length = items.length;
|
|
258
|
+
switch (event.key) {
|
|
259
|
+
case "ArrowDown":
|
|
260
|
+
event.preventDefault();
|
|
261
|
+
this.#focusAt(items, (index + 1) % length);
|
|
262
|
+
break;
|
|
263
|
+
case "ArrowUp":
|
|
264
|
+
event.preventDefault();
|
|
265
|
+
this.#focusAt(items, (index - 1 + length) % length);
|
|
266
|
+
break;
|
|
267
|
+
case "Home":
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
this.#focusAt(items, 0);
|
|
270
|
+
break;
|
|
271
|
+
case "End":
|
|
272
|
+
event.preventDefault();
|
|
273
|
+
this.#focusAt(items, length - 1);
|
|
274
|
+
break;
|
|
275
|
+
case "ArrowRight":
|
|
276
|
+
event.preventDefault();
|
|
277
|
+
this.#moveToAdjacentMenu(menu, 1);
|
|
278
|
+
break;
|
|
279
|
+
case "ArrowLeft":
|
|
280
|
+
event.preventDefault();
|
|
281
|
+
this.#moveToAdjacentMenu(menu, -1);
|
|
282
|
+
break;
|
|
283
|
+
case "Tab":
|
|
284
|
+
this.#closeAllMenus();
|
|
285
|
+
break;
|
|
286
|
+
default:
|
|
287
|
+
if (this.#isTypeaheadKey(event)) {
|
|
288
|
+
event.preventDefault();
|
|
289
|
+
this.#typeaheadTo(items, index, event.key);
|
|
290
|
+
}
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** Closes the owning menu after an item is activated and refocuses its top. */
|
|
295
|
+
activate(event) {
|
|
296
|
+
const item = event.currentTarget;
|
|
297
|
+
const menu = item.closest("[role='menu']");
|
|
298
|
+
const top = this.#topFor(menu);
|
|
299
|
+
this.#closeAllMenus();
|
|
300
|
+
this.#focusTop(top);
|
|
301
|
+
}
|
|
302
|
+
/** Moves the roving focus to top `index`, opening its menu when one was open. */
|
|
303
|
+
#gotoTop(index, reopen) {
|
|
304
|
+
const top = this.topTargets[index];
|
|
305
|
+
if (!top) return;
|
|
306
|
+
if (reopen) {
|
|
307
|
+
this.#openMenu(top, "first");
|
|
308
|
+
} else {
|
|
309
|
+
this.#roving.setActive(index, { focus: true });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** Opens `top`'s menu (closing others) and focuses its first/last item. */
|
|
313
|
+
#openMenu(top, focus) {
|
|
314
|
+
if (!top) return;
|
|
315
|
+
this.#closeAllMenus();
|
|
316
|
+
const menu = this.#menuFor(top);
|
|
317
|
+
if (!menu) return;
|
|
318
|
+
menu.hidden = false;
|
|
319
|
+
top.setAttribute("aria-expanded", "true");
|
|
320
|
+
this.#escapeLayer.activate(document, {
|
|
321
|
+
onDismiss: () => this.#dismissOpenMenu(),
|
|
322
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
323
|
+
});
|
|
324
|
+
this.#roving.setActive(this.topTargets.indexOf(top));
|
|
325
|
+
const items = this.#itemsIn(menu);
|
|
326
|
+
this.#focusAt(items, focus === "first" ? 0 : items.length - 1);
|
|
327
|
+
}
|
|
328
|
+
/** Hides `top`'s menu and reflects the collapsed state. */
|
|
329
|
+
#closeMenu(top) {
|
|
330
|
+
if (!top) return;
|
|
331
|
+
const menu = this.#menuFor(top);
|
|
332
|
+
if (menu) menu.hidden = true;
|
|
333
|
+
top.setAttribute("aria-expanded", "false");
|
|
334
|
+
if (!this.#isAnyOpen) this.#escapeLayer.deactivate();
|
|
335
|
+
}
|
|
336
|
+
/** Closes every menu and resets the typeahead buffer. */
|
|
337
|
+
#closeAllMenus() {
|
|
338
|
+
for (const top of this.topTargets) this.#closeMenu(top);
|
|
339
|
+
this.#resetTypeahead();
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Escape path, invoked by the shared resolver: pressed inside an open menu it
|
|
343
|
+
* closes that menu and returns focus to its top item (the APG behavior);
|
|
344
|
+
* pressed while focus is on a top item (or fell to the body) it closes every
|
|
345
|
+
* menu without moving focus.
|
|
346
|
+
*/
|
|
347
|
+
#dismissOpenMenu() {
|
|
348
|
+
const active = document.activeElement;
|
|
349
|
+
const menu = this.menuTargets.find((candidate) => candidate.contains(active));
|
|
350
|
+
if (menu) {
|
|
351
|
+
const top = this.#topFor(menu);
|
|
352
|
+
this.#closeMenu(top);
|
|
353
|
+
this.#focusTop(top);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
this.#closeAllMenus();
|
|
357
|
+
}
|
|
358
|
+
/** Opens the menu of the top item `delta` steps from the one owning `menu`. */
|
|
359
|
+
#moveToAdjacentMenu(menu, delta) {
|
|
360
|
+
const top = this.#topFor(menu);
|
|
361
|
+
if (!top) return;
|
|
362
|
+
const tops = this.topTargets;
|
|
363
|
+
const next = (tops.indexOf(top) + delta + tops.length) % tops.length;
|
|
364
|
+
this.#openMenu(tops[next], "first");
|
|
365
|
+
}
|
|
366
|
+
/** Closes when a click lands outside the controller's element. */
|
|
367
|
+
#onOutsideClick = (event) => {
|
|
368
|
+
if (this.#isAnyOpen && !this.element.contains(event.target)) this.#closeAllMenus();
|
|
369
|
+
};
|
|
370
|
+
/** The menu element controlled by `top` (matched by `aria-controls`/`id`). */
|
|
371
|
+
#menuFor(top) {
|
|
372
|
+
const id = top.getAttribute("aria-controls");
|
|
373
|
+
return id ? this.menuTargets.find((menu) => menu.id === id) ?? null : null;
|
|
374
|
+
}
|
|
375
|
+
/** The top item that controls `menu` (reverse of `#menuFor`). */
|
|
376
|
+
#topFor(menu) {
|
|
377
|
+
if (!menu) return null;
|
|
378
|
+
return this.topTargets.find((top) => top.getAttribute("aria-controls") === menu.id) ?? null;
|
|
379
|
+
}
|
|
380
|
+
/** The item targets that live inside `menu`, in DOM order. */
|
|
381
|
+
#itemsIn(menu) {
|
|
382
|
+
return this.itemTargets.filter((item) => menu.contains(item));
|
|
383
|
+
}
|
|
384
|
+
/** Moves DOM focus to the item at `index` (no-op if out of range). */
|
|
385
|
+
#focusAt(items, index) {
|
|
386
|
+
items[index]?.focus();
|
|
387
|
+
}
|
|
388
|
+
/** Returns roving focus to a top item (and makes it the single tab stop). */
|
|
389
|
+
#focusTop(top) {
|
|
390
|
+
if (!top) return;
|
|
391
|
+
this.#roving.setActive(this.topTargets.indexOf(top), { focus: true });
|
|
392
|
+
}
|
|
393
|
+
/** Whether `top`'s menu is currently expanded. */
|
|
394
|
+
#isExpanded(top) {
|
|
395
|
+
return top.getAttribute("aria-expanded") === "true";
|
|
396
|
+
}
|
|
397
|
+
/** Whether any top item's menu is currently open. */
|
|
398
|
+
get #isAnyOpen() {
|
|
399
|
+
return this.topTargets.some((top) => this.#isExpanded(top));
|
|
400
|
+
}
|
|
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. */
|
|
410
|
+
#typeaheadTo(items, current, key) {
|
|
411
|
+
this.#typeahead += key.toLowerCase();
|
|
412
|
+
this.#timers.clear(this.#typeaheadId);
|
|
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);
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
export { MenubarController };
|
|
432
|
+
//# sourceMappingURL=menubar_controller.js.map
|
|
433
|
+
//# sourceMappingURL=menubar_controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/utils/escape_layer.ts","../../src/utils/roving_tabindex.ts","../../src/utils/safe_timeout.ts","../../src/controllers/menubar_controller.ts"],"names":[],"mappings":"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","file":"menubar_controller.js","sourcesContent":["/**\n * Single resolver for layered Escape dismissal.\n *\n * Every Escape-dismissable overlay layer (modal focus traps, disclosure\n * overlays like dropdown/popover/menu, and hover-triggered transient layers)\n * registers here while it is open. One document-level listener per document\n * resolves each press to exactly one owner and invokes that layer's\n * {@link EscapeLayerOptions.onDismiss} — controllers never listen for a\n * dismissing Escape themselves.\n *\n * @remarks\n * Each document owns an activation-ordered stack. The owner of a press is the\n * **topmost layer whose {@link EscapeLayerOptions.claims} passes**; a layer\n * that declines is transparent, so a background overlay opened behind a modal\n * never blocks it. Because a layer opened from within another is necessarily\n * activated later, LIFO order is also inner-first for nested layers — no DOM\n * inspection is needed.\n *\n * The shared listener runs in the document bubble phase and honors\n * `event.defaultPrevented`, so an element-level widget handler that consumes\n * Escape first (an editor cancelling its edit, a combobox closing its list)\n * always wins over every registered layer — the deepest-first half of the\n * shared layered-Escape contract. A keydown that is part of an IME composition\n * (`event.isComposing`) cancels the composition, never a layer, and is ignored\n * here for every layer at once.\n *\n * A WeakMap keeps documents collectible; the listener is installed only while\n * a document's stack is non-empty, and controller lifecycle hooks guarantee\n * that disconnected layers never remain registered.\n */\n\n/** Behavior a layer registers when it activates. */\nexport interface EscapeLayerOptions {\n /**\n * Dismisses the layer. Called by the shared resolver when this layer owns a\n * press; the resolver has already consumed the event (`preventDefault()`),\n * so the callback only needs to close and place focus per the widget's\n * contract.\n */\n onDismiss: () => void;\n /**\n * Whether the layer claims the current press. Evaluated per press, so it can\n * depend on live state (e.g. \"focus is inside me or fell to the body\"). A\n * declining layer is skipped and the next layer down is consulted; omitting\n * it means the layer always claims while active.\n */\n claims?: () => boolean;\n}\n\n/** A document's stack plus the one shared listener bound to it. */\ninterface EscapeLayerRegistry {\n stack: EscapeLayer[];\n onKeydown: (event: KeyboardEvent) => void;\n}\n\n/**\n * Claims predicate shared by the click-opened disclosure overlays (dropdown /\n * popover / navigation-menu / menu / context-menu / menubar): the layer claims\n * a press while focus is inside `element`, or after focus fell to the body —\n * a click on non-focusable overlay content blurs to `<body>`, and Escape must\n * still close the overlay (the \"body-focus rescue\"). A press made after focus\n * moved to another interactive element is declined, so closing never yanks\n * focus away from where the user deliberately went.\n */\nexport function claimsWhileFocusWithin(element: Element): () => boolean {\n return () => {\n const active = element.ownerDocument.activeElement;\n return active === null || active === element.ownerDocument.body || element.contains(active);\n };\n}\n\nexport class EscapeLayer {\n static readonly #registries = new WeakMap<Document, EscapeLayerRegistry>();\n\n #ownerDocument: Document | null = null;\n /** Dismissal callback while active; `null` when inactive. */\n #onDismiss: (() => void) | null = null;\n /** Live predicate deciding whether the layer claims a press; `null` = always. */\n #claims: (() => boolean) | null = null;\n\n /**\n * Activates this layer at the top of its document's Escape stack, installing\n * the document's shared resolver listener if this is its first layer.\n * Re-activating an already-active layer moves it to the top.\n */\n activate(ownerDocument: Document = document, options: EscapeLayerOptions): void {\n this.deactivate();\n let registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) {\n registry = EscapeLayer.#createRegistry();\n EscapeLayer.#registries.set(ownerDocument, registry);\n ownerDocument.addEventListener(\"keydown\", registry.onKeydown);\n }\n registry.stack.push(this);\n this.#ownerDocument = ownerDocument;\n this.#onDismiss = options.onDismiss;\n this.#claims = options.claims ?? null;\n }\n\n /**\n * Removes this layer from its document's Escape stack, uninstalling the\n * shared listener when the stack empties. Safe to call when inactive.\n */\n deactivate(): void {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return;\n\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (registry) {\n const index = registry.stack.lastIndexOf(this);\n if (index >= 0) registry.stack.splice(index, 1);\n if (registry.stack.length === 0) {\n ownerDocument.removeEventListener(\"keydown\", registry.onKeydown);\n EscapeLayer.#registries.delete(ownerDocument);\n }\n }\n this.#ownerDocument = null;\n this.#onDismiss = null;\n this.#claims = null;\n }\n\n /**\n * Whether this active layer would own a press right now: it is the topmost\n * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests\n * and diagnostics — production dismissal goes through the shared listener.\n */\n get ownsEscape(): boolean {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return false;\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) return false;\n return EscapeLayer.#resolveOwner(registry.stack) === this;\n }\n\n /** Builds a document's registry with its shared resolver listener. */\n static #createRegistry(): EscapeLayerRegistry {\n const registry: EscapeLayerRegistry = {\n stack: [],\n onKeydown: (event: KeyboardEvent): void => {\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n const owner = EscapeLayer.#resolveOwner(registry.stack);\n if (!owner) return;\n event.preventDefault();\n owner.#onDismiss?.();\n },\n };\n return registry;\n }\n\n /** The topmost stack layer whose claims predicate passes, or `null`. */\n static #resolveOwner(stack: EscapeLayer[]): EscapeLayer | null {\n for (let index = stack.length - 1; index >= 0; index--) {\n const layer = stack[index];\n if (!layer) continue;\n if (layer.#claims && !layer.#claims()) continue;\n return layer;\n }\n return null;\n }\n}\n","/**\n * Low-level roving-tabindex primitive shared by composite-widget controllers.\n *\n * The APG roving-tabindex pattern keeps a composite widget a single Tab stop:\n * exactly one item is in the Tab sequence (`tabindex=\"0\"`) while the rest are\n * removed from it (`tabindex=\"-1\"`), and the arrow keys move both DOM focus and\n * that single tabbable position together. {@link RovingTabindex} owns *only* that\n * mechanical bookkeeping — \"which one item is tabbable, and move focus there\".\n *\n * It is intentionally **policy-free**. Orientation, wrapping vs. clamping,\n * selection-follows-focus, typeahead, and `Home`/`End` semantics differ per APG\n * pattern (Radio Group, Toolbar, Rating, …); folding them into one helper would\n * flatten those widgets to a lowest common denominator and lose each pattern's\n * correctness. Those decisions therefore stay in each controller, which calls\n * {@link RovingTabindex.setActive} with an index it computed itself (optionally\n * via the pure {@link rovingMove} helper).\n *\n * @remarks\n * Items are read lazily through a getter so a controller can add or remove\n * targets (Stimulus re-scans the DOM) without re-wiring this helper.\n */\nexport class RovingTabindex {\n /** Returns the current ordered item elements; called on every operation. */\n readonly #getItems: () => HTMLElement[];\n\n /**\n * @param getItems - Returns the current ordered item elements. Called on every\n * operation so the live target list is always used.\n */\n constructor(getItems: () => HTMLElement[]) {\n this.#getItems = getItems;\n }\n\n /** Index of the currently tabbable item (`tabindex=\"0\"`), or `-1` if none. */\n get activeIndex(): number {\n return this.#getItems().findIndex((item) => item.tabIndex === 0);\n }\n\n /**\n * Makes exactly the item at `index` tabbable (`tabindex=\"0\"`) and removes every\n * other item from the Tab sequence (`tabindex=\"-1\"`). An out-of-range `index`\n * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express\n * \"nothing is currently tabbable\".\n *\n * @param index - Position of the item to make tabbable.\n * @param options - Pass `{ focus: true }` to also move DOM focus to that item.\n */\n setActive(index: number, { focus = false }: { focus?: boolean } = {}): void {\n const items = this.#getItems();\n items.forEach((item, i) => {\n item.tabIndex = i === index ? 0 : -1;\n });\n if (focus) items[index]?.focus();\n }\n}\n\n/** Edge behavior for {@link rovingMove}: cycle past the ends, or stop at them. */\nexport type RovingWrap = \"wrap\" | \"clamp\";\n\n/**\n * Pure helper that resolves the target index for a one-step directional move.\n *\n * Keyboard/orientation mapping stays in the caller: it decides that a key means\n * `delta` `+1` (next) or `-1` (previous) and whether the widget should `\"wrap\"`\n * (Radio Group, Toolbar) or `\"clamp\"` at the ends.\n *\n * @param current - The index focus is moving from.\n * @param length - Number of items in the set.\n * @param delta - `+1` to move to the next item, `-1` for the previous.\n * @param wrap - `\"wrap\"` cycles around the ends; `\"clamp\"` stops at them.\n * @returns The resolved index, or `-1` when there are no items.\n */\nexport function rovingMove(\n current: number,\n length: number,\n delta: number,\n wrap: RovingWrap,\n): number {\n if (length === 0) return -1;\n const next = current + delta;\n if (wrap === \"wrap\") return (next + length) % length;\n return Math.min(length - 1, Math.max(0, next));\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { claimsWhileFocusWithin, EscapeLayer } from \"../utils/escape_layer\";\nimport { RovingTabindex } from \"../utils/roving_tabindex\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/** Where to land focus when a menu opens. */\ntype OpenFocus = \"first\" | \"last\";\n\n/** Milliseconds of idle after which the typeahead buffer resets. */\nconst TYPEAHEAD_RESET_MS = 500;\n\n/**\n * Headless, accessible **menubar** behavior.\n *\n * Markup contract (identifier: `stimeo--menubar`):\n * <div data-controller=\"stimeo--menubar\" role=\"menubar\" aria-label=\"Main\">\n * <button role=\"menuitem\" aria-haspopup=\"menu\" aria-expanded=\"false\"\n * aria-controls=\"m-file\" data-stimeo--menubar-target=\"top\"\n * data-action=\"click->stimeo--menubar#toggle\n * keydown->stimeo--menubar#onTopKeydown\">File</button>\n * <ul id=\"m-file\" role=\"menu\" data-stimeo--menubar-target=\"menu\" hidden>\n * <li role=\"none\">\n * <button role=\"menuitem\" tabindex=\"-1\" data-stimeo--menubar-target=\"item\"\n * data-action=\"click->stimeo--menubar#activate\n * keydown->stimeo--menubar#onItemKeydown\">New</button>\n * </li>\n * </ul>\n * <!-- more top items + menus -->\n * </div>\n *\n * Implements the WAI-ARIA APG **Menubar** pattern (single level): the top items\n * form one Tab stop via roving tabindex and the arrow keys move between them;\n * `ArrowDown`/`Enter`/`Space` open a menu (`ArrowUp` opens it at the last item),\n * the arrow keys then move within the menu, and pressing `ArrowLeft`/`ArrowRight`\n * while a menu is open jumps to the adjacent top menu. `Escape` closes and\n * returns focus to the owning top item — while a menu is open the menubar is a\n * layer on the shared {@link EscapeLayer} stack, claiming a press only while\n * focus is inside the controller or fell to the body, so one keypress closes\n * exactly one layer (the shared layered-Escape contract). `Tab` and an outside\n * click close.\n *\n * @remarks\n * Behavior only. Each top item↔menu pair is linked by `aria-controls`/`id` (not by\n * position), so the markup order is free. Menu placement and viewport-edge\n * collision are out of scope — static placement is the consumer's CSS, and dynamic\n * placement is delegated to the opt-in `stimeo-ui/positioning` module (never\n * imported here, keeping the core zero-dependency). Roving mechanics across the top\n * items are delegated to {@link RovingTabindex}.\n */\nexport class MenubarController extends Controller<HTMLElement> {\n static override targets = [\"top\", \"menu\", \"item\"];\n static actions = [\"activate\", \"onItemKeydown\", \"onTopKeydown\", \"toggle\"] as const;\n\n declare readonly topTargets: HTMLElement[];\n declare readonly menuTargets: HTMLElement[];\n declare readonly itemTargets: HTMLElement[];\n\n /** Roving tabindex across the top-level menuitems (one Tab stop). */\n readonly #roving = new RovingTabindex(() => this.topTargets);\n /** Typeahead buffer and its idle-reset timer (scoped to the open menu). */\n #typeahead = \"\";\n #typeaheadId = 0;\n readonly #timers = new SafeTimeout();\n\n /** Escape-stack membership while a menu is open; the shared resolver dismisses via it. */\n readonly #escapeLayer = new EscapeLayer();\n\n /** Establishes the single tab stop and the closed baseline. */\n override connect(): void {\n const active = this.#roving.activeIndex;\n this.#roving.setActive(active === -1 ? 0 : active);\n this.#closeAllMenus();\n document.addEventListener(\"click\", this.#onOutsideClick);\n }\n\n /** Removes the document listener, stack membership, and any pending typeahead timer. */\n override disconnect(): void {\n this.#escapeLayer.deactivate();\n document.removeEventListener(\"click\", this.#onOutsideClick);\n this.#timers.clearAll();\n }\n\n /** Toggles a top item's menu. Bound via `data-action` (click on the top item). */\n toggle(event: Event): void {\n const top = event.currentTarget as HTMLElement;\n if (this.#isExpanded(top)) {\n this.#closeMenu(top);\n } else {\n this.#openMenu(top, \"first\");\n }\n }\n\n /** Keyboard handling while focus is on a top item. */\n onTopKeydown(event: KeyboardEvent): void {\n const tops = this.topTargets;\n const index = tops.indexOf(event.currentTarget as HTMLElement);\n if (index === -1) return;\n const length = tops.length;\n // Whether a menu is currently open governs whether horizontal moves *open*\n // the adjacent menu or merely move the roving focus between top items.\n const anyOpen = this.#isAnyOpen;\n\n switch (event.key) {\n case \"ArrowRight\":\n event.preventDefault();\n this.#gotoTop((index + 1) % length, anyOpen);\n break;\n case \"ArrowLeft\":\n event.preventDefault();\n this.#gotoTop((index - 1 + length) % length, anyOpen);\n break;\n case \"ArrowDown\":\n event.preventDefault();\n this.#openMenu(tops[index], \"first\");\n break;\n case \"ArrowUp\":\n event.preventDefault();\n this.#openMenu(tops[index], \"last\");\n break;\n case \"Home\":\n event.preventDefault();\n this.#gotoTop(0, anyOpen);\n break;\n case \"End\":\n event.preventDefault();\n this.#gotoTop(length - 1, anyOpen);\n break;\n default:\n break;\n }\n // Enter/Space are intentionally left to the native button click, which runs\n // toggle() (open + focus first item) — handling them here would double-fire.\n // Escape is owned by the shared EscapeLayer resolver (see #dismissOpenMenu).\n }\n\n /** Keyboard handling while focus is on a menu item. */\n onItemKeydown(event: KeyboardEvent): void {\n const item = event.currentTarget as HTMLElement;\n const menu = item.closest<HTMLElement>(\"[role='menu']\");\n if (!menu) return;\n const items = this.#itemsIn(menu);\n const index = items.indexOf(item);\n const length = items.length;\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n this.#focusAt(items, (index + 1) % length);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n this.#focusAt(items, (index - 1 + length) % length);\n break;\n case \"Home\":\n event.preventDefault();\n this.#focusAt(items, 0);\n break;\n case \"End\":\n event.preventDefault();\n this.#focusAt(items, length - 1);\n break;\n case \"ArrowRight\":\n event.preventDefault();\n this.#moveToAdjacentMenu(menu, 1);\n break;\n case \"ArrowLeft\":\n event.preventDefault();\n this.#moveToAdjacentMenu(menu, -1);\n break;\n case \"Tab\":\n // Per APG, Tab closes the menubar but lets focus move on naturally.\n this.#closeAllMenus();\n break;\n default:\n if (this.#isTypeaheadKey(event)) {\n event.preventDefault();\n this.#typeaheadTo(items, index, event.key);\n }\n break;\n }\n // Enter/Space activate the item via its native button click → activate().\n }\n\n /** Closes the owning menu after an item is activated and refocuses its top. */\n activate(event: Event): void {\n const item = event.currentTarget as HTMLElement;\n const menu = item.closest<HTMLElement>(\"[role='menu']\");\n const top = this.#topFor(menu);\n this.#closeAllMenus();\n this.#focusTop(top);\n }\n\n /** Moves the roving focus to top `index`, opening its menu when one was open. */\n #gotoTop(index: number, reopen: boolean): void {\n const top = this.topTargets[index];\n if (!top) return;\n if (reopen) {\n this.#openMenu(top, \"first\");\n } else {\n this.#roving.setActive(index, { focus: true });\n }\n }\n\n /** Opens `top`'s menu (closing others) and focuses its first/last item. */\n #openMenu(top: HTMLElement | null | undefined, focus: OpenFocus): void {\n if (!top) return;\n this.#closeAllMenus();\n const menu = this.#menuFor(top);\n if (!menu) return;\n menu.hidden = false;\n top.setAttribute(\"aria-expanded\", \"true\");\n this.#escapeLayer.activate(document, {\n onDismiss: () => this.#dismissOpenMenu(),\n claims: claimsWhileFocusWithin(this.element),\n });\n this.#roving.setActive(this.topTargets.indexOf(top));\n const items = this.#itemsIn(menu);\n this.#focusAt(items, focus === \"first\" ? 0 : items.length - 1);\n }\n\n /** Hides `top`'s menu and reflects the collapsed state. */\n #closeMenu(top: HTMLElement | null): void {\n if (!top) return;\n const menu = this.#menuFor(top);\n if (menu) menu.hidden = true;\n top.setAttribute(\"aria-expanded\", \"false\");\n if (!this.#isAnyOpen) this.#escapeLayer.deactivate();\n }\n\n /** Closes every menu and resets the typeahead buffer. */\n #closeAllMenus(): void {\n for (const top of this.topTargets) this.#closeMenu(top);\n this.#resetTypeahead();\n }\n\n /**\n * Escape path, invoked by the shared resolver: pressed inside an open menu it\n * closes that menu and returns focus to its top item (the APG behavior);\n * pressed while focus is on a top item (or fell to the body) it closes every\n * menu without moving focus.\n */\n #dismissOpenMenu(): void {\n const active = document.activeElement;\n const menu = this.menuTargets.find((candidate) => candidate.contains(active));\n if (menu) {\n const top = this.#topFor(menu);\n this.#closeMenu(top);\n this.#focusTop(top);\n return;\n }\n this.#closeAllMenus();\n }\n\n /** Opens the menu of the top item `delta` steps from the one owning `menu`. */\n #moveToAdjacentMenu(menu: HTMLElement, delta: number): void {\n const top = this.#topFor(menu);\n if (!top) return;\n const tops = this.topTargets;\n const next = (tops.indexOf(top) + delta + tops.length) % tops.length;\n this.#openMenu(tops[next], \"first\");\n }\n\n /** Closes when a click lands outside the controller's element. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (this.#isAnyOpen && !this.element.contains(event.target as Node)) this.#closeAllMenus();\n };\n\n /** The menu element controlled by `top` (matched by `aria-controls`/`id`). */\n #menuFor(top: HTMLElement): HTMLElement | null {\n const id = top.getAttribute(\"aria-controls\");\n // Resolve against this controller's own menu targets (not a global id lookup)\n // so it stays scoped to this menubar instance.\n return id ? (this.menuTargets.find((menu) => menu.id === id) ?? null) : null;\n }\n\n /** The top item that controls `menu` (reverse of `#menuFor`). */\n #topFor(menu: HTMLElement | null): HTMLElement | null {\n if (!menu) return null;\n return this.topTargets.find((top) => top.getAttribute(\"aria-controls\") === menu.id) ?? null;\n }\n\n /** The item targets that live inside `menu`, in DOM order. */\n #itemsIn(menu: HTMLElement): HTMLElement[] {\n return this.itemTargets.filter((item) => menu.contains(item));\n }\n\n /** Moves DOM focus to the item at `index` (no-op if out of range). */\n #focusAt(items: HTMLElement[], index: number): void {\n items[index]?.focus();\n }\n\n /** Returns roving focus to a top item (and makes it the single tab stop). */\n #focusTop(top: HTMLElement | null): void {\n if (!top) return;\n this.#roving.setActive(this.topTargets.indexOf(top), { focus: true });\n }\n\n /** Whether `top`'s menu is currently expanded. */\n #isExpanded(top: HTMLElement): boolean {\n return top.getAttribute(\"aria-expanded\") === \"true\";\n }\n\n /** Whether any top item's menu is currently open. */\n get #isAnyOpen(): boolean {\n return this.topTargets.some((top) => this.#isExpanded(top));\n }\n\n /**\n * Whether the event is a single printable character usable for typeahead.\n * `Space` is excluded: on a `<button>` menuitem it natively activates the item\n * (Enter/Space → click), so swallowing it for typeahead would break activation.\n */\n #isTypeaheadKey(event: KeyboardEvent): boolean {\n return (\n event.key.length === 1 &&\n event.key !== \" \" &&\n !event.ctrlKey &&\n !event.metaKey &&\n !event.altKey\n );\n }\n\n /** Advances focus to the next item in `items` matching the accumulated buffer. */\n #typeaheadTo(items: HTMLElement[], current: number, key: string): void {\n this.#typeahead += key.toLowerCase();\n this.#timers.clear(this.#typeaheadId);\n this.#typeaheadId = this.#timers.set(() => this.#resetTypeahead(), TYPEAHEAD_RESET_MS);\n\n const count = items.length;\n // Start the search just after the current item so repeated presses cycle.\n for (let step = 1; step <= count; step++) {\n const candidate = items[(current + step) % count];\n const label = (candidate?.textContent ?? \"\").trim().toLowerCase();\n if (label.startsWith(this.#typeahead)) {\n candidate?.focus();\n return;\n }\n }\n }\n\n /** Clears the typeahead buffer and its timer. */\n #resetTypeahead(): void {\n this.#typeahead = \"\";\n this.#timers.clear(this.#typeaheadId);\n }\n}\n"]}
|