stimeo-ui 0.15.0 → 0.17.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 +209 -0
- data/README.md +128 -1
- data/dist/cable/index.js +392 -417
- data/dist/controllers/accordion_controller.js +228 -28
- data/dist/controllers/alert_dialog_controller.js +862 -221
- data/dist/controllers/announcer_controller.js +135 -191
- data/dist/controllers/aspect_ratio_controller.js +0 -10
- data/dist/controllers/auto_submit_controller.js +295 -110
- data/dist/controllers/avatar_controller.js +172 -106
- data/dist/controllers/breadcrumb_controller.js +0 -129
- data/dist/controllers/bulk_select_controller.js +264 -53
- data/dist/controllers/calendar_controller.js +346 -180
- data/dist/controllers/carousel_controller.js +587 -300
- data/dist/controllers/character_counter_controller.js +271 -143
- data/dist/controllers/checkbox_controller.js +77 -75
- data/dist/controllers/clipboard_controller.js +187 -108
- data/dist/controllers/collapsible_controller.js +374 -108
- data/dist/controllers/color_picker_controller.js +183 -127
- data/dist/controllers/combobox_controller.js +426 -109
- data/dist/controllers/command_palette_controller.js +994 -350
- data/dist/controllers/conditional_fields_controller.js +212 -125
- data/dist/controllers/confirm_controller.js +939 -245
- data/dist/controllers/context_menu_controller.js +275 -127
- data/dist/controllers/count_up_controller.js +73 -21
- data/dist/controllers/countdown_controller.js +235 -97
- data/dist/controllers/currency_input_controller.js +241 -118
- data/dist/controllers/data_grid_controller.js +229 -135
- data/dist/controllers/date_range_picker_controller.js +359 -165
- data/dist/controllers/dialog_controller.js +887 -225
- data/dist/controllers/direct_upload_controller.js +80 -142
- data/dist/controllers/dirty_form_controller.js +26 -47
- data/dist/controllers/dismissible_controller.js +175 -20
- data/dist/controllers/drawer_controller.js +963 -336
- data/dist/controllers/dropdown_controller.js +342 -85
- data/dist/controllers/editable_controller.js +182 -58
- data/dist/controllers/empty_state_controller.js +67 -53
- data/dist/controllers/file_dropzone_controller.js +426 -229
- data/dist/controllers/filter_controller.js +78 -36
- data/dist/controllers/flash_controller.js +328 -211
- data/dist/controllers/focus_controller.js +654 -239
- data/dist/controllers/form_field_controller.js +153 -136
- data/dist/controllers/form_validation_controller.js +29 -96
- data/dist/controllers/frame_loading_controller.js +263 -198
- data/dist/controllers/highlight_controller.js +119 -70
- data/dist/controllers/hover_card_controller.js +335 -118
- data/dist/controllers/idle_controller.js +316 -65
- data/dist/controllers/input_mask_controller.js +116 -61
- data/dist/controllers/intersection_controller.js +147 -103
- data/dist/controllers/lazy_frame_controller.js +9 -56
- data/dist/controllers/listbox_controller.js +413 -153
- data/dist/controllers/local_time_controller.js +54 -64
- data/dist/controllers/masonry_controller.js +70 -95
- data/dist/controllers/menu_controller.js +254 -167
- data/dist/controllers/menubar_controller.js +206 -331
- data/dist/controllers/meter_controller.js +159 -71
- data/dist/controllers/multi_select_controller.js +797 -388
- data/dist/controllers/navigation_menu_controller.js +156 -231
- data/dist/controllers/nested_form_controller.js +373 -174
- data/dist/controllers/network_status_controller.js +241 -45
- data/dist/controllers/number_input_controller.js +387 -227
- data/dist/controllers/optimistic_controller.js +28 -75
- data/dist/controllers/otp_controller.js +344 -196
- data/dist/controllers/overflow_indicator_controller.js +167 -93
- data/dist/controllers/overflow_menu_controller.js +408 -277
- data/dist/controllers/pagination_controller.js +342 -120
- data/dist/controllers/password_reveal_controller.js +424 -99
- data/dist/controllers/password_strength_controller.js +99 -153
- data/dist/controllers/persist_controller.js +252 -96
- data/dist/controllers/pointer_drag_controller.js +263 -164
- data/dist/controllers/popover_controller.js +267 -94
- data/dist/controllers/portal_controller.js +209 -72
- data/dist/controllers/preview_guard_controller.js +243 -112
- data/dist/controllers/progress_controller.js +132 -59
- data/dist/controllers/radio_group_controller.js +138 -133
- data/dist/controllers/range_slider_controller.js +245 -112
- data/dist/controllers/rating_controller.js +429 -194
- data/dist/controllers/read_more_controller.js +185 -47
- data/dist/controllers/reading_progress_controller.js +147 -135
- data/dist/controllers/relative_time_controller.js +145 -110
- data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
- data/dist/controllers/resizable_controller.js +294 -118
- data/dist/controllers/roving_controller.js +39 -84
- data/dist/controllers/scroll_area_controller.js +428 -221
- data/dist/controllers/scroll_restore_controller.js +0 -53
- data/dist/controllers/scroll_visibility_controller.js +395 -146
- data/dist/controllers/scrollspy_controller.js +181 -236
- data/dist/controllers/separator_controller.js +288 -132
- data/dist/controllers/sidebar_controller.js +1071 -375
- data/dist/controllers/skeleton_controller.js +248 -92
- data/dist/controllers/slider_controller.js +211 -96
- data/dist/controllers/smart_sticky_header_controller.js +163 -54
- data/dist/controllers/sortable_controller.js +13 -110
- data/dist/controllers/spinner_controller.js +277 -161
- data/dist/controllers/step_indicator_controller.js +107 -53
- data/dist/controllers/stepper_controller.js +171 -48
- data/dist/controllers/stick_to_bottom_controller.js +310 -116
- data/dist/controllers/sticky_observer_controller.js +214 -49
- data/dist/controllers/submit_once_controller.js +294 -192
- data/dist/controllers/switch_controller.js +107 -43
- data/dist/controllers/tabs_controller.js +50 -25
- data/dist/controllers/tags_input_controller.js +290 -149
- data/dist/controllers/textarea_autosize_controller.js +71 -58
- data/dist/controllers/theme_controller.js +148 -80
- data/dist/controllers/time_picker_controller.js +162 -81
- data/dist/controllers/toast_controller.js +365 -193
- data/dist/controllers/toggle_group_controller.js +194 -99
- data/dist/controllers/toolbar_controller.js +92 -90
- data/dist/controllers/tooltip_controller.js +185 -125
- data/dist/controllers/transition_controller.js +123 -115
- data/dist/controllers/tree_view_controller.js +248 -290
- data/dist/index.js +8649 -8572
- data/dist/positioning/index.js +148 -45
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +3 -3
|
@@ -1,19 +1,46 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
|
|
4
|
+
var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
|
|
5
|
+
function stateReasonFor(event) {
|
|
6
|
+
if (!event) return "api";
|
|
7
|
+
if (FOCUS_EVENTS.has(event.type)) return "focus";
|
|
8
|
+
if (POINTER_EVENTS.has(event.type)) return "pointer";
|
|
9
|
+
return "user";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function actionSource(source) {
|
|
13
|
+
if (source instanceof HTMLElement) {
|
|
14
|
+
return { event: null, host: source, origin: source, reason: "api" };
|
|
15
|
+
}
|
|
16
|
+
const event = source ?? null;
|
|
17
|
+
return {
|
|
18
|
+
event,
|
|
19
|
+
host: event?.currentTarget instanceof Element ? event.currentTarget : null,
|
|
20
|
+
origin: event?.target instanceof Element ? event.target : null,
|
|
21
|
+
reason: stateReasonFor(event)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
4
24
|
|
|
5
|
-
// src/utils/logical_scroll.ts
|
|
6
25
|
function isRtl(element) {
|
|
7
26
|
return window.getComputedStyle(element).direction === "rtl";
|
|
8
27
|
}
|
|
9
28
|
|
|
10
|
-
// src/utils/arrow_step.ts
|
|
11
29
|
function isReservedArrowChord(event, allow = []) {
|
|
12
30
|
if (!event.key.startsWith("Arrow")) return false;
|
|
13
31
|
return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
|
|
14
32
|
}
|
|
15
33
|
|
|
16
|
-
|
|
34
|
+
function sharedRegistry(key, create) {
|
|
35
|
+
const symbol = Symbol.for(key);
|
|
36
|
+
const scope = globalThis;
|
|
37
|
+
const existing = scope[symbol];
|
|
38
|
+
if (existing !== void 0) return existing;
|
|
39
|
+
const registry = create();
|
|
40
|
+
Object.defineProperty(globalThis, symbol, { value: registry });
|
|
41
|
+
return registry;
|
|
42
|
+
}
|
|
43
|
+
|
|
17
44
|
function claimsWhileFocusWithin(element) {
|
|
18
45
|
return () => {
|
|
19
46
|
const active = element.ownerDocument.activeElement;
|
|
@@ -21,17 +48,11 @@ function claimsWhileFocusWithin(element) {
|
|
|
21
48
|
};
|
|
22
49
|
}
|
|
23
50
|
var EscapeLayer = class _EscapeLayer {
|
|
24
|
-
static #registries =
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
#claims = null;
|
|
30
|
-
/**
|
|
31
|
-
* Activates this layer at the top of its document's Escape stack, installing
|
|
32
|
-
* the document's shared resolver listener if this is its first layer.
|
|
33
|
-
* Re-activating an already-active layer moves it to the top.
|
|
34
|
-
*/
|
|
51
|
+
static #registries = sharedRegistry(
|
|
52
|
+
"stimeo-ui.escape-layer.registry.v1",
|
|
53
|
+
() => /* @__PURE__ */ new WeakMap()
|
|
54
|
+
);
|
|
55
|
+
#active = null;
|
|
35
56
|
activate(ownerDocument = document, options) {
|
|
36
57
|
this.deactivate();
|
|
37
58
|
let registry = _EscapeLayer.#registries.get(ownerDocument);
|
|
@@ -40,44 +61,31 @@ var EscapeLayer = class _EscapeLayer {
|
|
|
40
61
|
_EscapeLayer.#registries.set(ownerDocument, registry);
|
|
41
62
|
ownerDocument.addEventListener("keydown", registry.onKeydown);
|
|
42
63
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
this.#
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Removes this layer from its document's Escape stack, uninstalling the
|
|
50
|
-
* shared listener when the stack empties. Safe to call when inactive.
|
|
51
|
-
*/
|
|
64
|
+
const entry = { onDismiss: options.onDismiss, claims: options.claims ?? null };
|
|
65
|
+
registry.stack.push(entry);
|
|
66
|
+
this.#active = { document: ownerDocument, entry };
|
|
67
|
+
}
|
|
52
68
|
deactivate() {
|
|
53
|
-
const
|
|
54
|
-
if (!
|
|
55
|
-
const registry = _EscapeLayer.#registries.get(
|
|
69
|
+
const active = this.#active;
|
|
70
|
+
if (!active) return;
|
|
71
|
+
const registry = _EscapeLayer.#registries.get(active.document);
|
|
56
72
|
if (registry) {
|
|
57
|
-
const index = registry.stack.lastIndexOf(
|
|
73
|
+
const index = registry.stack.lastIndexOf(active.entry);
|
|
58
74
|
if (index >= 0) registry.stack.splice(index, 1);
|
|
59
75
|
if (registry.stack.length === 0) {
|
|
60
|
-
|
|
61
|
-
_EscapeLayer.#registries.delete(
|
|
76
|
+
active.document.removeEventListener("keydown", registry.onKeydown);
|
|
77
|
+
_EscapeLayer.#registries.delete(active.document);
|
|
62
78
|
}
|
|
63
79
|
}
|
|
64
|
-
this.#
|
|
65
|
-
|
|
66
|
-
this.#claims = null;
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Whether this active layer would own a press right now: it is the topmost
|
|
70
|
-
* layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests
|
|
71
|
-
* and diagnostics — production dismissal goes through the shared listener.
|
|
72
|
-
*/
|
|
80
|
+
this.#active = null;
|
|
81
|
+
}
|
|
73
82
|
get ownsEscape() {
|
|
74
|
-
const
|
|
75
|
-
if (!
|
|
76
|
-
const registry = _EscapeLayer.#registries.get(
|
|
83
|
+
const active = this.#active;
|
|
84
|
+
if (!active) return false;
|
|
85
|
+
const registry = _EscapeLayer.#registries.get(active.document);
|
|
77
86
|
if (!registry) return false;
|
|
78
|
-
return _EscapeLayer.#resolveOwner(registry.stack) ===
|
|
87
|
+
return _EscapeLayer.#resolveOwner(registry.stack) === active.entry;
|
|
79
88
|
}
|
|
80
|
-
/** Builds a document's registry with its shared resolver listener. */
|
|
81
89
|
static #createRegistry() {
|
|
82
90
|
const registry = {
|
|
83
91
|
stack: [],
|
|
@@ -86,24 +94,22 @@ var EscapeLayer = class _EscapeLayer {
|
|
|
86
94
|
const owner = _EscapeLayer.#resolveOwner(registry.stack);
|
|
87
95
|
if (!owner) return;
|
|
88
96
|
event.preventDefault();
|
|
89
|
-
owner
|
|
97
|
+
owner.onDismiss();
|
|
90
98
|
}
|
|
91
99
|
};
|
|
92
100
|
return registry;
|
|
93
101
|
}
|
|
94
|
-
/** The topmost stack layer whose claims predicate passes, or `null`. */
|
|
95
102
|
static #resolveOwner(stack) {
|
|
96
103
|
for (let index = stack.length - 1; index >= 0; index--) {
|
|
97
104
|
const layer = stack[index];
|
|
98
105
|
if (!layer) continue;
|
|
99
|
-
if (layer
|
|
106
|
+
if (layer.claims && !layer.claims()) continue;
|
|
100
107
|
return layer;
|
|
101
108
|
}
|
|
102
109
|
return null;
|
|
103
110
|
}
|
|
104
111
|
};
|
|
105
112
|
|
|
106
|
-
// src/utils/event_owner.ts
|
|
107
113
|
function ownerIndex(candidates, node) {
|
|
108
114
|
if (!(node instanceof Node)) return -1;
|
|
109
115
|
return candidates.findIndex((candidate) => candidate.contains(node));
|
|
@@ -112,31 +118,78 @@ function ownerOf(candidates, node) {
|
|
|
112
118
|
return candidates[ownerIndex(candidates, node)] ?? null;
|
|
113
119
|
}
|
|
114
120
|
|
|
115
|
-
|
|
121
|
+
var ListenerSet = class {
|
|
122
|
+
#abort = new AbortController();
|
|
123
|
+
add(target, type, handler, options) {
|
|
124
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
125
|
+
}
|
|
126
|
+
dispose() {
|
|
127
|
+
this.#abort.abort();
|
|
128
|
+
this.#abort = new AbortController();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
var MicrotaskCoalescer = class {
|
|
133
|
+
#run;
|
|
134
|
+
#queued = false;
|
|
135
|
+
#active = false;
|
|
136
|
+
#generation = 0;
|
|
137
|
+
constructor(run) {
|
|
138
|
+
this.#run = run;
|
|
139
|
+
}
|
|
140
|
+
activate() {
|
|
141
|
+
this.#active = true;
|
|
142
|
+
}
|
|
143
|
+
cancel() {
|
|
144
|
+
this.#active = false;
|
|
145
|
+
this.#queued = false;
|
|
146
|
+
this.#generation += 1;
|
|
147
|
+
}
|
|
148
|
+
schedule() {
|
|
149
|
+
if (!this.#active || this.#queued) return;
|
|
150
|
+
this.#queued = true;
|
|
151
|
+
const generation = this.#generation;
|
|
152
|
+
queueMicrotask(() => {
|
|
153
|
+
if (generation !== this.#generation || !this.#queued || !this.#active) return;
|
|
154
|
+
this.#queued = false;
|
|
155
|
+
this.#run();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
var MorphRenderWatcher = class {
|
|
161
|
+
#listeners = new ListenerSet();
|
|
162
|
+
#pass;
|
|
163
|
+
#element = null;
|
|
164
|
+
#onMorph = () => this.#pass.schedule();
|
|
165
|
+
constructor(run) {
|
|
166
|
+
this.#pass = new MicrotaskCoalescer(run);
|
|
167
|
+
}
|
|
168
|
+
observe(element) {
|
|
169
|
+
if (this.#element === element) return;
|
|
170
|
+
this.disconnect();
|
|
171
|
+
this.#element = element;
|
|
172
|
+
this.#pass.activate();
|
|
173
|
+
this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
|
|
174
|
+
}
|
|
175
|
+
schedule() {
|
|
176
|
+
this.#pass.schedule();
|
|
177
|
+
}
|
|
178
|
+
disconnect() {
|
|
179
|
+
this.#listeners.dispose();
|
|
180
|
+
this.#pass.cancel();
|
|
181
|
+
this.#element = null;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
116
185
|
var RovingTabindex = class {
|
|
117
|
-
/** Returns the current ordered item elements; called on every operation. */
|
|
118
186
|
#getItems;
|
|
119
|
-
/**
|
|
120
|
-
* @param getItems - Returns the current ordered item elements. Called on every
|
|
121
|
-
* operation so the live target list is always used.
|
|
122
|
-
*/
|
|
123
187
|
constructor(getItems) {
|
|
124
188
|
this.#getItems = getItems;
|
|
125
189
|
}
|
|
126
|
-
/** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
|
|
127
190
|
get activeIndex() {
|
|
128
191
|
return this.#getItems().findIndex((item) => item.tabIndex === 0);
|
|
129
192
|
}
|
|
130
|
-
/**
|
|
131
|
-
* Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
|
|
132
|
-
* other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
|
|
133
|
-
* (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
|
|
134
|
-
* "nothing is currently tabbable".
|
|
135
|
-
*
|
|
136
|
-
* @param index - Position of the item to make tabbable.
|
|
137
|
-
* @param options - Pass `{ focus: true }` to also move DOM focus to that item,
|
|
138
|
-
* and `items` to reuse an event-scoped collection snapshot.
|
|
139
|
-
*/
|
|
140
193
|
setActive(index, options = {}) {
|
|
141
194
|
const { focus = false } = options;
|
|
142
195
|
const items = options.items ?? this.#getItems();
|
|
@@ -147,43 +200,24 @@ var RovingTabindex = class {
|
|
|
147
200
|
}
|
|
148
201
|
};
|
|
149
202
|
|
|
150
|
-
// src/utils/safe_timeout.ts
|
|
151
203
|
var TimerRegistry = class {
|
|
152
|
-
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
153
204
|
ids = /* @__PURE__ */ new Set();
|
|
154
|
-
/**
|
|
155
|
-
* Cancels a single tracked timer.
|
|
156
|
-
*
|
|
157
|
-
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
158
|
-
* registry), so callers can clear defensively without guarding.
|
|
159
|
-
*/
|
|
160
205
|
clear(id) {
|
|
161
206
|
if (this.ids.delete(id)) {
|
|
162
207
|
this.cancel(id);
|
|
163
208
|
}
|
|
164
209
|
}
|
|
165
|
-
/**
|
|
166
|
-
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
167
|
-
* to guarantee no timer outlives the element.
|
|
168
|
-
*/
|
|
169
210
|
clearAll() {
|
|
170
211
|
for (const id of this.ids) {
|
|
171
212
|
this.cancel(id);
|
|
172
213
|
}
|
|
173
214
|
this.ids.clear();
|
|
174
215
|
}
|
|
175
|
-
/** Number of timers currently tracked (pending). */
|
|
176
216
|
get size() {
|
|
177
217
|
return this.ids.size;
|
|
178
218
|
}
|
|
179
219
|
};
|
|
180
220
|
var SafeTimeout = class extends TimerRegistry {
|
|
181
|
-
/**
|
|
182
|
-
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
183
|
-
*
|
|
184
|
-
* The id is removed from the registry automatically when the timeout fires,
|
|
185
|
-
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
186
|
-
*/
|
|
187
221
|
set(callback, delay) {
|
|
188
222
|
const id = this.schedule(() => {
|
|
189
223
|
this.ids.delete(id);
|
|
@@ -200,29 +234,22 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
200
234
|
}
|
|
201
235
|
};
|
|
202
236
|
|
|
203
|
-
|
|
237
|
+
function targetSelector(identifier, name) {
|
|
238
|
+
return `[data-${identifier}-target~="${name}"]`;
|
|
239
|
+
}
|
|
240
|
+
|
|
204
241
|
var TYPEAHEAD_RESET_MS = 500;
|
|
205
242
|
var Typeahead = class {
|
|
206
|
-
/** Timer registry for the pending idle reset; private so `reset()` is the only exit. */
|
|
207
243
|
#timers = new SafeTimeout();
|
|
208
|
-
/** Idle window before the query resets, in milliseconds. */
|
|
209
244
|
#resetMs;
|
|
210
|
-
/** The accumulated lowercase query, empty when idle. */
|
|
211
245
|
#query = "";
|
|
212
|
-
/** Id of the pending reset timer, `0` when none is scheduled. */
|
|
213
246
|
#timerId = 0;
|
|
214
|
-
/** @param options - Overrides for the idle window. */
|
|
215
247
|
constructor({ resetMs = TYPEAHEAD_RESET_MS } = {}) {
|
|
216
248
|
this.#resetMs = resetMs;
|
|
217
249
|
}
|
|
218
|
-
/** The query a search would currently run with; empty while idle. */
|
|
219
250
|
get query() {
|
|
220
251
|
return this.#query;
|
|
221
252
|
}
|
|
222
|
-
/**
|
|
223
|
-
* Folds `key` into the query, restarts the idle window, and returns the query to
|
|
224
|
-
* search with. A repeated character collapses the query to that one character.
|
|
225
|
-
*/
|
|
226
253
|
push(key) {
|
|
227
254
|
const char = key.toLowerCase();
|
|
228
255
|
const repeated = this.#query.length > 0 && [...this.#query].every((c) => c === char);
|
|
@@ -231,7 +258,6 @@ var Typeahead = class {
|
|
|
231
258
|
this.#timerId = this.#timers.set(() => this.reset(), this.#resetMs);
|
|
232
259
|
return this.#query;
|
|
233
260
|
}
|
|
234
|
-
/** Clears the query and cancels the pending idle reset. */
|
|
235
261
|
reset() {
|
|
236
262
|
this.#query = "";
|
|
237
263
|
this.#timers.clear(this.#timerId);
|
|
@@ -258,70 +284,38 @@ function findTypeaheadMatch(items, from, query, label = (item) => typeaheadLabel
|
|
|
258
284
|
return -1;
|
|
259
285
|
}
|
|
260
286
|
|
|
261
|
-
// src/controllers/menubar_controller.ts
|
|
262
287
|
var STATE_ATTRIBUTES = ["disabled", "hidden"];
|
|
263
288
|
var MenubarController = class extends Controller {
|
|
264
289
|
static targets = ["top", "menu", "item"];
|
|
265
290
|
static actions = ["activate", "onItemKeydown", "onTopKeydown", "toggle"];
|
|
266
|
-
|
|
291
|
+
static events = ["close", "open"];
|
|
267
292
|
#roving = new RovingTabindex(() => this.topTargets);
|
|
268
|
-
/** Typeahead query and its idle-reset timer (scoped to the open menu). */
|
|
269
293
|
#typeahead = new Typeahead();
|
|
270
294
|
#timers = new SafeTimeout();
|
|
271
|
-
/** Escape-stack membership while a menu is open; the shared resolver dismisses via it. */
|
|
272
295
|
#escapeLayer = new EscapeLayer();
|
|
273
|
-
/** Whether {@link #escapeLayer} is currently on the stack (see {@link #syncEscapeLayer}). */
|
|
274
296
|
#layerActive = false;
|
|
275
|
-
/** Watches `disabled`/`hidden` under the menubar; `null` while disconnected. */
|
|
276
297
|
#observer = null;
|
|
277
|
-
/**
|
|
278
|
-
* Live between `connect()` and `disconnect()`. Stimulus reports the *initial*
|
|
279
|
-
* targets before `connect()` and re-reports them as disconnected afterwards;
|
|
280
|
-
* gating on this keeps the target callbacks from clobbering the authored Tab
|
|
281
|
-
* stop on mount and from resurrecting one after teardown.
|
|
282
|
-
*/
|
|
283
298
|
#connected = false;
|
|
284
|
-
|
|
299
|
+
#reporting = false;
|
|
285
300
|
#focused = null;
|
|
286
|
-
/**
|
|
287
|
-
* Records where DOM focus sits inside the menubar. `focusin` bubbles, so one
|
|
288
|
-
* listener covers the top items and everything nested in their menus. The
|
|
289
|
-
* record is needed because a top or menu becomes unreachable *before* this
|
|
290
|
-
* controller hears about it, by which point the browser has already reset
|
|
291
|
-
* `document.activeElement`.
|
|
292
|
-
*/
|
|
293
301
|
#onFocusIn = (event) => {
|
|
294
302
|
this.#focused = event.target;
|
|
295
303
|
};
|
|
296
|
-
/**
|
|
297
|
-
* Forgets the record once focus genuinely lands on something else, so a later
|
|
298
|
-
* mutation cannot pull focus back in. A `relatedTarget` of `null` is the case
|
|
299
|
-
* that must be kept: it is what "focus went nowhere" looks like, which is
|
|
300
|
-
* exactly the state the rescue exists for.
|
|
301
|
-
*/
|
|
302
304
|
#onFocusOut = (event) => {
|
|
303
305
|
const next = event.relatedTarget;
|
|
304
306
|
if (next && !this.element.contains(next)) this.#focused = null;
|
|
305
307
|
};
|
|
306
|
-
|
|
307
|
-
* Establishes the closed baseline and the single tab stop: keep an existing tab
|
|
308
|
-
* stop when it is still navigable (so a Turbo cache restore preserves the user's
|
|
309
|
-
* position), else fall back to the first navigable top item.
|
|
310
|
-
*
|
|
311
|
-
* The outside-click listener is registered in the **capture** phase: in the
|
|
312
|
-
* bubble phase an inside handler that removes its own click target detaches the
|
|
313
|
-
* node before `contains()` runs, so an *inside* click would read as outside and
|
|
314
|
-
* close the menu.
|
|
315
|
-
*/
|
|
308
|
+
#morph = new MorphRenderWatcher(() => this.#reconcile());
|
|
316
309
|
connect() {
|
|
317
|
-
this.#
|
|
310
|
+
this.#morph.observe(this.element);
|
|
311
|
+
this.#closeAllMenus("api");
|
|
318
312
|
this.#reconcile();
|
|
319
313
|
this.element.addEventListener("click", this.#onDisabledClickCapture, true);
|
|
320
314
|
this.element.addEventListener("focusin", this.#onFocusIn);
|
|
321
315
|
this.element.addEventListener("focusout", this.#onFocusOut);
|
|
322
316
|
document.addEventListener("click", this.#onOutsideClick, true);
|
|
323
317
|
if (typeof MutationObserver !== "undefined") {
|
|
324
|
-
this.#observer = new MutationObserver(() => this.#
|
|
318
|
+
this.#observer = new MutationObserver(() => this.#morph.schedule());
|
|
325
319
|
this.#observer.observe(this.element, {
|
|
326
320
|
subtree: true,
|
|
327
321
|
childList: true,
|
|
@@ -330,10 +324,12 @@ var MenubarController = class extends Controller {
|
|
|
330
324
|
});
|
|
331
325
|
}
|
|
332
326
|
this.#connected = true;
|
|
327
|
+
this.#reporting = true;
|
|
333
328
|
}
|
|
334
|
-
/** Removes the listeners, stack membership, and any pending timer (typeahead / Tab close). */
|
|
335
329
|
disconnect() {
|
|
330
|
+
this.#morph.disconnect();
|
|
336
331
|
this.#connected = false;
|
|
332
|
+
this.#reporting = false;
|
|
337
333
|
this.#escapeLayer.deactivate();
|
|
338
334
|
this.#layerActive = false;
|
|
339
335
|
this.element.removeEventListener("click", this.#onDisabledClickCapture, true);
|
|
@@ -346,62 +342,39 @@ var MenubarController = class extends Controller {
|
|
|
346
342
|
this.#typeahead.reset();
|
|
347
343
|
this.#timers.clearAll();
|
|
348
344
|
}
|
|
349
|
-
/**
|
|
350
|
-
* A top item added at runtime is dropped out of the Tab sequence first — a fresh
|
|
351
|
-
* `<button>` is tabbable by default, which would leave the menubar with two Tab
|
|
352
|
-
* stops — before the lone stop is re-established.
|
|
353
|
-
*/
|
|
354
345
|
topTargetConnected(top) {
|
|
355
346
|
if (!this.#connected) return;
|
|
356
347
|
top.tabIndex = -1;
|
|
357
348
|
this.#reconcile();
|
|
358
349
|
}
|
|
359
|
-
/**
|
|
360
|
-
* Removing a top item can strand two things: the Tab stop it held, and the menu
|
|
361
|
-
* it owned (which nothing could close afterwards).
|
|
362
|
-
*
|
|
363
|
-
* The departing element is handed back in a neutral state first. A target can
|
|
364
|
-
* leave without leaving the document — a morph that only drops the
|
|
365
|
-
* `data-*-target` token keeps the node — and it would then sit in the page
|
|
366
|
-
* carrying this controller's `tabindex="0"` (a second Tab stop next to the one
|
|
367
|
-
* re-established below) and an `aria-expanded="true"` nothing can collapse.
|
|
368
|
-
* When the node really is gone these writes are harmless no-ops.
|
|
369
|
-
*/
|
|
370
350
|
topTargetDisconnected(top) {
|
|
371
351
|
if (!this.#connected) return;
|
|
372
352
|
top.tabIndex = -1;
|
|
373
353
|
if (this.#isExpanded(top)) top.setAttribute("aria-expanded", "false");
|
|
374
354
|
this.#reconcile();
|
|
375
355
|
}
|
|
376
|
-
/** See {@link MenubarController.menuTargetDisconnected}. */
|
|
377
356
|
menuTargetConnected() {
|
|
378
357
|
if (!this.#connected) return;
|
|
379
358
|
this.#reconcile();
|
|
380
359
|
}
|
|
381
|
-
/**
|
|
382
|
-
* A menu removed while open leaves its owner claiming `aria-expanded="true"` for
|
|
383
|
-
* a popup that no longer exists, and the menubar holding an Escape layer that
|
|
384
|
-
* would swallow presses meant for something else.
|
|
385
|
-
*
|
|
386
|
-
* As with {@link MenubarController.topTargetDisconnected}, the departing menu is
|
|
387
|
-
* closed first: a token-only removal leaves a visible popup in the page that no
|
|
388
|
-
* key and no click can dismiss any more.
|
|
389
|
-
*/
|
|
390
360
|
menuTargetDisconnected(menu) {
|
|
391
361
|
if (!this.#connected) return;
|
|
392
362
|
if (!menu.hidden) menu.hidden = true;
|
|
393
363
|
this.#reconcile();
|
|
394
364
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const top =
|
|
365
|
+
toggle(source) {
|
|
366
|
+
const { event, host, origin, reason } = actionSource(source);
|
|
367
|
+
const top = host?.closest(targetSelector(this.identifier, "top"));
|
|
368
|
+
if (!top || !this.topTargets.includes(top)) return;
|
|
369
|
+
if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
|
|
370
|
+
return;
|
|
371
|
+
const moveFocus = event !== null || this.element.contains(document.activeElement);
|
|
398
372
|
if (this.#isExpanded(top)) {
|
|
399
|
-
this.#closeMenu(top);
|
|
373
|
+
this.#closeMenu(top, reason);
|
|
400
374
|
} else {
|
|
401
|
-
this.#openMenu(top, "first");
|
|
375
|
+
this.#openMenu(top, "first", reason, moveFocus);
|
|
402
376
|
}
|
|
403
377
|
}
|
|
404
|
-
/** Keyboard handling while focus is on a top item. */
|
|
405
378
|
onTopKeydown(event) {
|
|
406
379
|
if (event.defaultPrevented) return;
|
|
407
380
|
if (isReservedArrowChord(event)) return;
|
|
@@ -414,34 +387,33 @@ var MenubarController = class extends Controller {
|
|
|
414
387
|
switch (event.key) {
|
|
415
388
|
case "ArrowRight":
|
|
416
389
|
event.preventDefault();
|
|
417
|
-
this.#gotoTop(tops[(index + step + length) % length], anyOpen);
|
|
390
|
+
this.#gotoTop(tops[(index + step + length) % length], anyOpen, "user");
|
|
418
391
|
break;
|
|
419
392
|
case "ArrowLeft":
|
|
420
393
|
event.preventDefault();
|
|
421
|
-
this.#gotoTop(tops[(index - step + length) % length], anyOpen);
|
|
394
|
+
this.#gotoTop(tops[(index - step + length) % length], anyOpen, "user");
|
|
422
395
|
break;
|
|
423
396
|
case "ArrowDown":
|
|
424
397
|
event.preventDefault();
|
|
425
|
-
this.#openMenu(tops[index], "first");
|
|
398
|
+
this.#openMenu(tops[index], "first", "user");
|
|
426
399
|
break;
|
|
427
400
|
case "ArrowUp":
|
|
428
401
|
event.preventDefault();
|
|
429
|
-
this.#openMenu(tops[index], "last");
|
|
402
|
+
this.#openMenu(tops[index], "last", "user");
|
|
430
403
|
break;
|
|
431
404
|
case "Home":
|
|
432
405
|
event.preventDefault();
|
|
433
|
-
this.#gotoTop(tops[0], anyOpen);
|
|
406
|
+
this.#gotoTop(tops[0], anyOpen, "user");
|
|
434
407
|
break;
|
|
435
408
|
case "End":
|
|
436
409
|
event.preventDefault();
|
|
437
|
-
this.#gotoTop(tops[length - 1], anyOpen);
|
|
410
|
+
this.#gotoTop(tops[length - 1], anyOpen, "user");
|
|
438
411
|
break;
|
|
439
412
|
case "Tab":
|
|
440
413
|
this.#closeMenusSoon();
|
|
441
414
|
break;
|
|
442
415
|
}
|
|
443
416
|
}
|
|
444
|
-
/** Keyboard handling while focus is on a menu item. */
|
|
445
417
|
onItemKeydown(event) {
|
|
446
418
|
if (event.defaultPrevented) return;
|
|
447
419
|
if (isReservedArrowChord(event)) return;
|
|
@@ -487,138 +459,109 @@ var MenubarController = class extends Controller {
|
|
|
487
459
|
break;
|
|
488
460
|
}
|
|
489
461
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const item =
|
|
462
|
+
activate(source) {
|
|
463
|
+
const { event, host, origin } = actionSource(source);
|
|
464
|
+
const item = host?.closest(targetSelector(this.identifier, "item"));
|
|
465
|
+
if (!item || !this.itemTargets.includes(item)) return;
|
|
466
|
+
if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
|
|
467
|
+
return;
|
|
468
|
+
const moveFocus = event !== null || this.element.contains(document.activeElement);
|
|
493
469
|
const menu = item.closest("[role='menu']");
|
|
494
470
|
const top = this.#topFor(menu);
|
|
495
|
-
this.#closeAllMenus();
|
|
496
|
-
this.#focusTop(
|
|
471
|
+
this.#closeAllMenus("select");
|
|
472
|
+
this.#focusTop(
|
|
473
|
+
top,
|
|
474
|
+
moveFocus && (event !== null || this.element.contains(document.activeElement))
|
|
475
|
+
);
|
|
497
476
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
if (!top) return;
|
|
477
|
+
#gotoTop(destination, reopen, reason) {
|
|
478
|
+
if (!destination) return;
|
|
501
479
|
if (reopen) {
|
|
502
|
-
this.#openMenu(
|
|
480
|
+
this.#openMenu(destination, "first", reason);
|
|
503
481
|
} else {
|
|
504
|
-
this.#roving.setActive(this.topTargets.indexOf(
|
|
482
|
+
this.#roving.setActive(this.topTargets.indexOf(destination), { focus: true });
|
|
505
483
|
}
|
|
506
484
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
*
|
|
518
|
-
* The first two still take the roving focus, and any open menu closes: the APG
|
|
519
|
-
* makes closing unconditional for the horizontal move ("closes the submenu,
|
|
520
|
-
* moves focus to the next menubar item, and *optionally* opens that item's
|
|
521
|
-
* submenu"), so only the opening half is skipped. Leaving the old menu open
|
|
522
|
-
* would strand a popup that no longer contains focus.
|
|
523
|
-
*
|
|
524
|
-
* The dangling case instead leaves the open/closed state and focus untouched
|
|
525
|
-
* rather than closing everything and dropping focus to the body — but only on
|
|
526
|
-
* this "open a menu" path. A dangling top is still an ordinary roving
|
|
527
|
-
* destination while nothing is open.
|
|
528
|
-
*/
|
|
529
|
-
#openMenu(top, focus) {
|
|
530
|
-
if (!top) return;
|
|
531
|
-
const menu = this.#menuFor(top);
|
|
532
|
-
if (!menu || this.#isActivationBlocked(top)) {
|
|
533
|
-
if (menu || !top.hasAttribute("aria-controls")) {
|
|
534
|
-
this.#closeAllMenus();
|
|
535
|
-
this.#focusTop(top);
|
|
485
|
+
#openMenu(opener, focus, reason, moveFocus = true) {
|
|
486
|
+
if (!opener) return;
|
|
487
|
+
const menu = this.#menuFor(opener);
|
|
488
|
+
if (!menu || this.#isActivationBlocked(opener)) {
|
|
489
|
+
if (menu || !opener.hasAttribute("aria-controls")) {
|
|
490
|
+
this.#closeAllMenus(reason);
|
|
491
|
+
this.#focusTop(
|
|
492
|
+
opener,
|
|
493
|
+
moveFocus && (reason !== "api" || this.element.contains(document.activeElement))
|
|
494
|
+
);
|
|
536
495
|
}
|
|
537
496
|
return;
|
|
538
497
|
}
|
|
539
498
|
this.#timers.clearAll();
|
|
540
|
-
this.#
|
|
499
|
+
const was = this.#isExpanded(opener);
|
|
500
|
+
this.#closeAllMenus(reason, opener);
|
|
541
501
|
menu.hidden = false;
|
|
542
|
-
|
|
502
|
+
opener.setAttribute("aria-expanded", "true");
|
|
503
|
+
if (!was) this.#report("open", opener, menu, reason);
|
|
504
|
+
if (!this.#isExpanded(opener)) return;
|
|
543
505
|
this.#escapeLayer.activate(document, {
|
|
544
506
|
onDismiss: () => this.#dismissOpenMenu(),
|
|
545
507
|
claims: claimsWhileFocusWithin(this.element)
|
|
546
508
|
});
|
|
547
509
|
this.#layerActive = true;
|
|
548
|
-
this.#roving.setActive(this.topTargets.indexOf(
|
|
510
|
+
this.#roving.setActive(this.topTargets.indexOf(opener));
|
|
549
511
|
const items = this.#itemsIn(menu);
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
* — stamping `aria-expanded="false"` on it would announce a popup it lacks.
|
|
556
|
-
*/
|
|
557
|
-
#closeMenu(top) {
|
|
512
|
+
if (moveFocus && (reason !== "api" || this.element.contains(document.activeElement))) {
|
|
513
|
+
this.#focusAt(items, focus === "first" ? 0 : items.length - 1);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
#closeMenu(top, reason, silent = false) {
|
|
558
517
|
if (!top) return;
|
|
559
518
|
const menu = this.#menuFor(top);
|
|
560
519
|
if (!menu) return;
|
|
520
|
+
const was = this.#isExpanded(top);
|
|
561
521
|
menu.hidden = true;
|
|
562
522
|
top.setAttribute("aria-expanded", "false");
|
|
523
|
+
if (was && !silent) this.#report("close", top, menu, reason);
|
|
563
524
|
this.#syncEscapeLayer();
|
|
564
525
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
for (const top of this.topTargets) this.#closeMenu(top);
|
|
526
|
+
#closeAllMenus(reason, except = null) {
|
|
527
|
+
for (const top of this.topTargets) this.#closeMenu(top, reason, top === except);
|
|
568
528
|
this.#typeahead.reset();
|
|
569
529
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
*/
|
|
530
|
+
#report(name, top, menu, reason) {
|
|
531
|
+
if (!this.#reporting) return;
|
|
532
|
+
const detail = { reason, index: this.topTargets.indexOf(top), menu };
|
|
533
|
+
if (name === "open") this.dispatch("open", { detail, cancelable: false });
|
|
534
|
+
else this.dispatch("close", { detail, cancelable: false });
|
|
535
|
+
}
|
|
577
536
|
#closeMenusSoon() {
|
|
578
537
|
if (!this.#isAnyOpen) return;
|
|
579
538
|
this.#timers.clearAll();
|
|
580
|
-
this.#timers.set(() => this.#closeAllMenus(), 0);
|
|
581
|
-
}
|
|
582
|
-
/**
|
|
583
|
-
* Escape path, invoked by the shared resolver: pressed inside an open menu it
|
|
584
|
-
* closes that menu and returns focus to its top item (the APG behavior);
|
|
585
|
-
* pressed while focus is on a top item (or fell to the body) it closes every
|
|
586
|
-
* menu without moving focus.
|
|
587
|
-
*/
|
|
539
|
+
this.#timers.set(() => this.#closeAllMenus("focus"), 0);
|
|
540
|
+
}
|
|
588
541
|
#dismissOpenMenu() {
|
|
589
542
|
const menu = ownerOf(this.menuTargets, document.activeElement);
|
|
590
543
|
if (menu) {
|
|
591
544
|
const top = this.#topFor(menu);
|
|
592
|
-
this.#closeMenu(top);
|
|
545
|
+
this.#closeMenu(top, "escape");
|
|
593
546
|
this.#focusTop(top);
|
|
594
547
|
return;
|
|
595
548
|
}
|
|
596
|
-
this.#closeAllMenus();
|
|
549
|
+
this.#closeAllMenus("escape");
|
|
597
550
|
}
|
|
598
|
-
/** Opens the menu of the navigable top item `delta` steps from `menu`'s owner. */
|
|
599
551
|
#moveToAdjacentMenu(menu, delta) {
|
|
600
|
-
const
|
|
601
|
-
if (!
|
|
552
|
+
const ownerTop = this.#topFor(menu);
|
|
553
|
+
if (!ownerTop) return;
|
|
602
554
|
const tops = this.#navigableTops;
|
|
603
|
-
const current = tops.indexOf(
|
|
555
|
+
const current = tops.indexOf(ownerTop);
|
|
604
556
|
if (current === -1) return;
|
|
605
557
|
const next = (current + delta + tops.length) % tops.length;
|
|
606
|
-
this.#openMenu(tops[next], "first");
|
|
558
|
+
this.#openMenu(tops[next], "first", "user");
|
|
607
559
|
}
|
|
608
|
-
/**
|
|
609
|
-
* Closes when a click lands outside the controller's element. Focus is left on
|
|
610
|
-
* whatever the user clicked — see the focus-restoration contract in the class
|
|
611
|
-
* docs.
|
|
612
|
-
*/
|
|
613
560
|
#onOutsideClick = (event) => {
|
|
614
|
-
if (this.#isAnyOpen && !this.element.contains(event.target))
|
|
561
|
+
if (this.#isAnyOpen && !this.element.contains(event.target)) {
|
|
562
|
+
this.#closeAllMenus("outside");
|
|
563
|
+
}
|
|
615
564
|
};
|
|
616
|
-
/**
|
|
617
|
-
* Captures clicks so `aria-disabled` top items and commands cannot reach
|
|
618
|
-
* consumer handlers (nor `toggle`/`activate`). Native Enter/Space activation
|
|
619
|
-
* also synthesizes a click and is blocked here; natively `disabled` buttons
|
|
620
|
-
* dispatch no click at all.
|
|
621
|
-
*/
|
|
622
565
|
#onDisabledClickCapture = (event) => {
|
|
623
566
|
const target = event.target;
|
|
624
567
|
if (!(target instanceof Node)) return;
|
|
@@ -629,18 +572,6 @@ var MenubarController = class extends Controller {
|
|
|
629
572
|
event.preventDefault();
|
|
630
573
|
event.stopImmediatePropagation();
|
|
631
574
|
};
|
|
632
|
-
/**
|
|
633
|
-
* Re-derives every piece of state this controller owns from the live targets:
|
|
634
|
-
* expanded flags, menu visibility, the single Tab stop, and Escape-stack
|
|
635
|
-
* membership. Runtime DOM edits can leave those out of step with each other — a
|
|
636
|
-
* top can claim `aria-expanded="true"` after its menu target was removed or after
|
|
637
|
-
* being hidden itself, a menu can stay visible after its owning top was removed,
|
|
638
|
-
* an open pair can appear from a morph with no layer registered for it, and the
|
|
639
|
-
* Tab stop can end up on a now-inert top, on a runtime-added one, or on none at
|
|
640
|
-
* all. The only state carried between calls is the tracked focus record and which side
|
|
641
|
-
* of the Escape stack this layer is on (which the stack itself does not expose), so the outcome is the
|
|
642
|
-
* same whichever mutation arrived and calling it more often than needed is free.
|
|
643
|
-
*/
|
|
644
575
|
#reconcile() {
|
|
645
576
|
for (const top of this.topTargets) {
|
|
646
577
|
const own = this.#menuFor(top);
|
|
@@ -657,20 +588,6 @@ var MenubarController = class extends Controller {
|
|
|
657
588
|
this.#rescueFocus();
|
|
658
589
|
this.#syncEscapeLayer();
|
|
659
590
|
}
|
|
660
|
-
/**
|
|
661
|
-
* Returns DOM focus to the menubar when the element holding it became
|
|
662
|
-
* unreachable — hidden, natively disabled, or removed — and the document had
|
|
663
|
-
* nowhere to put it. The destination is the top item that now owns the Tab
|
|
664
|
-
* stop, which is where `Escape` from a closed menu would have left the user.
|
|
665
|
-
*
|
|
666
|
-
* Focus is only *restored*, never *stolen*: it moves solely when the tracked
|
|
667
|
-
* element can no longer take it **and** focus is either already gone or still
|
|
668
|
-
* sitting on that element. The second half is what makes a `hidden` ancestor
|
|
669
|
-
* work — a browser blurs the element it hides on its next style pass, not when
|
|
670
|
-
* the attribute is written, so waiting to observe `<body>` here would always
|
|
671
|
-
* come too early. Removal is the other side of the same condition: the browser
|
|
672
|
-
* has already fallen back to the body, or left a detached `activeElement`.
|
|
673
|
-
*/
|
|
674
591
|
#rescueFocus() {
|
|
675
592
|
const focused = this.#focused;
|
|
676
593
|
if (!focused) return;
|
|
@@ -686,22 +603,6 @@ var MenubarController = class extends Controller {
|
|
|
686
603
|
const top = index === -1 ? void 0 : this.topTargets[index];
|
|
687
604
|
if (top && top !== focused && !focused.contains(top)) top.focus();
|
|
688
605
|
}
|
|
689
|
-
/**
|
|
690
|
-
* Brings Escape-stack membership back in line with what the DOM now says is
|
|
691
|
-
* open, in both directions.
|
|
692
|
-
*
|
|
693
|
-
* The deactivate half is the common one (something closed). The activate half
|
|
694
|
-
* covers a menu that became open *without* going through {@link #openMenu} —
|
|
695
|
-
* a Turbo morph that patches `aria-expanded` and the menu's `hidden` in place,
|
|
696
|
-
* which is legitimate here because the DOM is this controller's only source of
|
|
697
|
-
* truth. Without it the popup is visible but `Escape` does nothing, since no
|
|
698
|
-
* layer is registered to claim the press.
|
|
699
|
-
*
|
|
700
|
-
* `#layerActive` exists because re-activating an already-active layer moves it
|
|
701
|
-
* to the top of the stack, which would reshuffle nested layers on every
|
|
702
|
-
* unrelated mutation. Registering only on the false→true edge keeps activation
|
|
703
|
-
* ordered by when each layer actually opened.
|
|
704
|
-
*/
|
|
705
606
|
#syncEscapeLayer() {
|
|
706
607
|
const open = this.#isAnyOpen;
|
|
707
608
|
if (open === this.#layerActive) return;
|
|
@@ -715,13 +616,6 @@ var MenubarController = class extends Controller {
|
|
|
715
616
|
}
|
|
716
617
|
this.#layerActive = open;
|
|
717
618
|
}
|
|
718
|
-
/**
|
|
719
|
-
* Re-establishes the single Tab stop: keep the current one while it is still
|
|
720
|
-
* navigable, else hand it to the first navigable top item. Every other top is
|
|
721
|
-
* explicitly removed from the Tab sequence, so a top that arrived tabbable
|
|
722
|
-
* cannot leave two stops behind. No Tab stop at all (`-1`) happens only when
|
|
723
|
-
* every top is inert, and is recovered from as soon as one becomes navigable.
|
|
724
|
-
*/
|
|
725
619
|
#ensureTabStop() {
|
|
726
620
|
const active = this.#roving.activeIndex;
|
|
727
621
|
const activeTop = active === -1 ? void 0 : this.topTargets[active];
|
|
@@ -732,58 +626,40 @@ var MenubarController = class extends Controller {
|
|
|
732
626
|
const first = this.#navigableTops[0];
|
|
733
627
|
this.#roving.setActive(first ? this.topTargets.indexOf(first) : -1);
|
|
734
628
|
}
|
|
735
|
-
/** The menu element controlled by `top` (matched by `aria-controls`/`id`). */
|
|
736
629
|
#menuFor(top) {
|
|
737
630
|
const id = top.getAttribute("aria-controls");
|
|
738
631
|
return id ? this.menuTargets.find((menu) => menu.id === id) ?? null : null;
|
|
739
632
|
}
|
|
740
|
-
/** The top item that controls `menu` (reverse of `#menuFor`). */
|
|
741
633
|
#topFor(menu) {
|
|
742
634
|
if (!menu) return null;
|
|
743
635
|
return this.topTargets.find((top) => top.getAttribute("aria-controls") === menu.id) ?? null;
|
|
744
636
|
}
|
|
745
|
-
/** The navigable item targets that live inside `menu`, in DOM order. */
|
|
746
637
|
#itemsIn(menu) {
|
|
747
638
|
return this.itemTargets.filter((item) => menu.contains(item) && this.#isNavigable(item));
|
|
748
639
|
}
|
|
749
|
-
/** Top items eligible for the roving tab stop and the arrow keys. */
|
|
750
640
|
get #navigableTops() {
|
|
751
641
|
return this.topTargets.filter((top) => this.#isNavigable(top));
|
|
752
642
|
}
|
|
753
|
-
/**
|
|
754
|
-
* Whether `element` can take roving focus: `hidden` and natively `disabled`
|
|
755
|
-
* controls are out of reach and are skipped. `aria-disabled` is deliberately
|
|
756
|
-
* **not** checked — the APG keeps such items focusable so they stay
|
|
757
|
-
* discoverable, and {@link MenubarController.#isActivationBlocked} is what
|
|
758
|
-
* suppresses acting on them. CSS-only visibility is not detectable headlessly
|
|
759
|
-
* and stays the consumer's responsibility.
|
|
760
|
-
*/
|
|
761
643
|
#isNavigable(element) {
|
|
762
644
|
if (element.hasAttribute("hidden")) return false;
|
|
763
645
|
return !element.disabled;
|
|
764
646
|
}
|
|
765
|
-
/** Whether `element` is `aria-disabled`: reachable, but never activated. */
|
|
766
647
|
#isActivationBlocked(element) {
|
|
767
648
|
return element.getAttribute("aria-disabled") === "true";
|
|
768
649
|
}
|
|
769
|
-
/** Moves DOM focus to the item at `index` (no-op if out of range). */
|
|
770
650
|
#focusAt(items, index) {
|
|
771
651
|
items[index]?.focus();
|
|
772
652
|
}
|
|
773
|
-
|
|
774
|
-
#focusTop(top) {
|
|
653
|
+
#focusTop(top, focus = true) {
|
|
775
654
|
if (!top) return;
|
|
776
|
-
this.#roving.setActive(this.topTargets.indexOf(top), { focus
|
|
655
|
+
this.#roving.setActive(this.topTargets.indexOf(top), { focus });
|
|
777
656
|
}
|
|
778
|
-
/** Whether `top`'s menu is currently expanded. */
|
|
779
657
|
#isExpanded(top) {
|
|
780
658
|
return top.getAttribute("aria-expanded") === "true";
|
|
781
659
|
}
|
|
782
|
-
/** Whether any top item's menu is currently open. */
|
|
783
660
|
get #isAnyOpen() {
|
|
784
661
|
return this.topTargets.some((top) => this.#isExpanded(top));
|
|
785
662
|
}
|
|
786
|
-
/** Advances focus to the next item in `items` matching the accumulated query. */
|
|
787
663
|
#typeaheadTo(items, current, key) {
|
|
788
664
|
const index = findTypeaheadMatch(items, current, this.#typeahead.push(key));
|
|
789
665
|
if (index !== -1) items[index]?.focus();
|
|
@@ -791,5 +667,4 @@ var MenubarController = class extends Controller {
|
|
|
791
667
|
};
|
|
792
668
|
|
|
793
669
|
export { MenubarController };
|
|
794
|
-
//# sourceMappingURL=menubar_controller.js.map
|
|
795
670
|
//# sourceMappingURL=menubar_controller.js.map
|