stimeo-ui 0.1.0.pre.beta.3 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +149 -0
- data/README.md +6 -6
- data/dist/controllers/alert_dialog_controller.js +318 -0
- data/dist/controllers/auto_submit_controller.js +56 -21
- data/dist/controllers/carousel_controller.js +272 -0
- data/dist/controllers/character_counter_controller.js +52 -15
- data/dist/controllers/clipboard_controller.js +144 -0
- data/dist/controllers/collapsible_controller.js +327 -0
- data/dist/controllers/color_picker_controller.js +213 -0
- data/dist/controllers/combobox_controller.js +64 -3
- data/dist/controllers/command_palette_controller.js +243 -39
- data/dist/controllers/confirm_controller.js +94 -8
- data/dist/controllers/context_menu_controller.js +191 -13
- data/dist/controllers/count_up_controller.js +8 -1
- data/dist/controllers/countdown_controller.js +2 -2
- data/dist/controllers/currency_input_controller.js +147 -0
- data/dist/controllers/data_grid_controller.js +168 -0
- data/dist/controllers/date_range_picker_controller.js +417 -0
- data/dist/controllers/dialog_controller.js +94 -8
- data/dist/controllers/dismissible_controller.js +117 -0
- data/dist/controllers/drawer_controller.js +630 -0
- data/dist/controllers/dropdown_controller.js +139 -16
- data/dist/controllers/editable_controller.js +168 -0
- data/dist/controllers/file_dropzone_controller.js +165 -0
- data/dist/controllers/filter_controller.js +86 -0
- data/dist/controllers/flash_controller.js +36 -5
- data/dist/controllers/focus_controller.js +94 -8
- data/dist/controllers/form_validation_controller.js +2 -2
- data/dist/controllers/highlight_controller.js +6 -4
- data/dist/controllers/hover_card_controller.js +103 -27
- data/dist/controllers/intersection_controller.js +41 -18
- data/dist/controllers/lazy_frame_controller.js +33 -11
- data/dist/controllers/listbox_controller.js +1 -0
- data/dist/controllers/masonry_controller.js +142 -0
- data/dist/controllers/menu_controller.js +181 -8
- data/dist/controllers/menubar_controller.js +433 -0
- data/dist/controllers/multi_select_controller.js +472 -0
- data/dist/controllers/navigation_menu_controller.js +384 -0
- data/dist/controllers/otp_controller.js +62 -22
- data/dist/controllers/overflow_indicator_controller.js +178 -27
- data/dist/controllers/password_reveal_controller.js +117 -0
- data/dist/controllers/pointer_drag_controller.js +3 -3
- data/dist/controllers/popover_controller.js +118 -27
- data/dist/controllers/range_slider_controller.js +166 -0
- data/dist/controllers/read_more_controller.js +194 -0
- data/dist/controllers/scroll_area_controller.js +15 -2
- data/dist/controllers/scroll_restore_controller.js +93 -0
- data/dist/controllers/scroll_visibility_controller.js +8 -4
- data/dist/controllers/scrollspy_controller.js +33 -11
- data/dist/controllers/separator_controller.js +87 -0
- data/dist/controllers/sidebar_controller.js +761 -0
- data/dist/controllers/stepper_controller.js +28 -12
- data/dist/controllers/stick_to_bottom_controller.js +8 -4
- data/dist/controllers/sticky_observer_controller.js +88 -20
- data/dist/controllers/tags_input_controller.js +275 -0
- data/dist/controllers/theme_controller.js +20 -10
- data/dist/controllers/time_picker_controller.js +212 -0
- data/dist/controllers/toast_controller.js +144 -38
- data/dist/controllers/tooltip_controller.js +148 -42
- data/dist/controllers/transition_controller.js +153 -38
- data/dist/controllers/tree_view_controller.js +275 -0
- data/dist/index.js +1644 -643
- data/lib/stimeo/ui/version.rb +5 -4
- metadata +28 -2
|
@@ -1,5 +1,150 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
+
// src/controllers/menu_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/safe_timeout.ts
|
|
96
|
+
var TimerRegistry = class {
|
|
97
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
98
|
+
ids = /* @__PURE__ */ new Set();
|
|
99
|
+
/**
|
|
100
|
+
* Cancels a single tracked timer.
|
|
101
|
+
*
|
|
102
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
103
|
+
* registry), so callers can clear defensively without guarding.
|
|
104
|
+
*/
|
|
105
|
+
clear(id) {
|
|
106
|
+
if (this.ids.delete(id)) {
|
|
107
|
+
this.cancel(id);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
112
|
+
* to guarantee no timer outlives the element.
|
|
113
|
+
*/
|
|
114
|
+
clearAll() {
|
|
115
|
+
for (const id of this.ids) {
|
|
116
|
+
this.cancel(id);
|
|
117
|
+
}
|
|
118
|
+
this.ids.clear();
|
|
119
|
+
}
|
|
120
|
+
/** Number of timers currently tracked (pending). */
|
|
121
|
+
get size() {
|
|
122
|
+
return this.ids.size;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
var SafeTimeout = class extends TimerRegistry {
|
|
126
|
+
/**
|
|
127
|
+
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
128
|
+
*
|
|
129
|
+
* The id is removed from the registry automatically when the timeout fires,
|
|
130
|
+
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
131
|
+
*/
|
|
132
|
+
set(callback, delay) {
|
|
133
|
+
const id = this.schedule(() => {
|
|
134
|
+
this.ids.delete(id);
|
|
135
|
+
callback();
|
|
136
|
+
}, delay);
|
|
137
|
+
this.ids.add(id);
|
|
138
|
+
return id;
|
|
139
|
+
}
|
|
140
|
+
schedule(callback, delay) {
|
|
141
|
+
return window.setTimeout(callback, delay);
|
|
142
|
+
}
|
|
143
|
+
cancel(id) {
|
|
144
|
+
window.clearTimeout(id);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
3
148
|
// src/controllers/menu_controller.ts
|
|
4
149
|
var MenuController = class extends Controller {
|
|
5
150
|
static targets = ["trigger", "menu", "item"];
|
|
@@ -11,13 +156,20 @@ var MenuController = class extends Controller {
|
|
|
11
156
|
"open",
|
|
12
157
|
"toggle"
|
|
13
158
|
];
|
|
14
|
-
|
|
159
|
+
#timers = new SafeTimeout();
|
|
160
|
+
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
161
|
+
#escapeLayer = new EscapeLayer();
|
|
162
|
+
/** Starts closed and registers delegated listeners. */
|
|
15
163
|
connect() {
|
|
16
164
|
this.close();
|
|
165
|
+
this.element.addEventListener("click", this.#onItemClickCapture, true);
|
|
17
166
|
document.addEventListener("click", this.#onOutsideClick);
|
|
18
167
|
}
|
|
19
|
-
/**
|
|
168
|
+
/** Releases the listeners, stack membership, and any pending Tab-close task. */
|
|
20
169
|
disconnect() {
|
|
170
|
+
this.#timers.clearAll();
|
|
171
|
+
this.#escapeLayer.deactivate();
|
|
172
|
+
this.element.removeEventListener("click", this.#onItemClickCapture, true);
|
|
21
173
|
document.removeEventListener("click", this.#onOutsideClick);
|
|
22
174
|
}
|
|
23
175
|
/** Toggles the menu open/closed. Bound via `data-action` (click). */
|
|
@@ -31,12 +183,19 @@ var MenuController = class extends Controller {
|
|
|
31
183
|
}
|
|
32
184
|
/** Opens the menu and reflects the expanded state on the trigger. */
|
|
33
185
|
open() {
|
|
186
|
+
this.#timers.clearAll();
|
|
34
187
|
if (!this.hasMenuTarget) return;
|
|
188
|
+
this.#escapeLayer.activate(document, {
|
|
189
|
+
onDismiss: () => this.#closeAndRestore(),
|
|
190
|
+
claims: claimsWhileFocusWithin(this.element)
|
|
191
|
+
});
|
|
35
192
|
this.menuTarget.hidden = false;
|
|
36
193
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
37
194
|
}
|
|
38
195
|
/** Closes the menu and reflects the collapsed state on the trigger. */
|
|
39
196
|
close() {
|
|
197
|
+
this.#timers.clearAll();
|
|
198
|
+
this.#escapeLayer.deactivate();
|
|
40
199
|
if (!this.hasMenuTarget) return;
|
|
41
200
|
this.menuTarget.hidden = true;
|
|
42
201
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
@@ -81,18 +240,18 @@ var MenuController = class extends Controller {
|
|
|
81
240
|
event.preventDefault();
|
|
82
241
|
items[items.length - 1]?.focus();
|
|
83
242
|
break;
|
|
84
|
-
case "Escape":
|
|
85
|
-
event.preventDefault();
|
|
86
|
-
this.close();
|
|
87
|
-
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
88
|
-
break;
|
|
89
243
|
case "Tab":
|
|
90
|
-
this.
|
|
244
|
+
this.#timers.clearAll();
|
|
245
|
+
this.#timers.set(() => this.close(), 0);
|
|
91
246
|
break;
|
|
92
247
|
}
|
|
93
248
|
}
|
|
94
249
|
/** Closes the menu after an item is activated. Bound via `data-action`. */
|
|
95
250
|
activate() {
|
|
251
|
+
this.#closeAndRestore();
|
|
252
|
+
}
|
|
253
|
+
/** Closes and returns focus to the trigger (Escape / item-activation path). */
|
|
254
|
+
#closeAndRestore() {
|
|
96
255
|
this.close();
|
|
97
256
|
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
98
257
|
}
|
|
@@ -100,6 +259,20 @@ var MenuController = class extends Controller {
|
|
|
100
259
|
#onOutsideClick = (event) => {
|
|
101
260
|
if (this.#isOpen && !this.element.contains(event.target)) this.close();
|
|
102
261
|
};
|
|
262
|
+
/**
|
|
263
|
+
* Captures clicks so `aria-disabled` commands cannot reach consumer handlers.
|
|
264
|
+
* Native Enter/Space activation also synthesizes a click and is blocked here.
|
|
265
|
+
*/
|
|
266
|
+
#onItemClickCapture = (event) => {
|
|
267
|
+
const target = event.target;
|
|
268
|
+
if (!(target instanceof Node)) return;
|
|
269
|
+
const disabled = this.itemTargets.some(
|
|
270
|
+
(item) => item.getAttribute("aria-disabled") === "true" && item.contains(target)
|
|
271
|
+
);
|
|
272
|
+
if (!disabled) return;
|
|
273
|
+
event.preventDefault();
|
|
274
|
+
event.stopImmediatePropagation();
|
|
275
|
+
};
|
|
103
276
|
/** Moves focus to the first navigable item (no-op if none). */
|
|
104
277
|
#focusFirst() {
|
|
105
278
|
this.#navigableItems[0]?.focus();
|
|
@@ -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
|