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