@wpmoo/ui 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,274 @@
1
+ const instances = new WeakMap();
2
+
3
+ function pointerRect(event) {
4
+ const { clientX: x, clientY: y } = event;
5
+ return { top: y, left: x, right: x, bottom: y, width: 0, height: 0, x, y };
6
+ }
7
+
8
+ function isDisabledTrigger(trigger) {
9
+ return Boolean(trigger?.disabled || trigger?.getAttribute?.("aria-disabled") === "true");
10
+ }
11
+
12
+ function menuItemFromEvent(event) {
13
+ const target = event.target;
14
+ const item = target.closest?.(".dropdown-item");
15
+ if (item) {
16
+ return item;
17
+ }
18
+ return target.closest?.("li")?.querySelector(":scope > .dropdown-item") || null;
19
+ }
20
+
21
+ export default class ContextMenu {
22
+ static getInstance(element) {
23
+ return element?.nodeType === 1 ? instances.get(element) || null : null;
24
+ }
25
+
26
+ static getOrCreateInstance(element, config = {}) {
27
+ return ContextMenu.getInstance(element) || new ContextMenu(element, config);
28
+ }
29
+
30
+ constructor(element, config = {}) {
31
+ if (element?.nodeType !== 1 || !element.matches(".context-menu")) {
32
+ throw new TypeError("ContextMenu requires a .context-menu root element.");
33
+ }
34
+ const existing = instances.get(element);
35
+ if (existing) {
36
+ return existing;
37
+ }
38
+
39
+ this._element = element;
40
+ this._document = element.ownerDocument;
41
+ this._window = this._document.defaultView;
42
+ this._menu = element.querySelector(":scope > .context-menu-menu");
43
+ this._fallback = element.querySelector(":scope > [data-context-menu-fallback]");
44
+ if (!this._menu) {
45
+ throw new TypeError("ContextMenu requires a .context-menu-menu element.");
46
+ }
47
+ if (!this._fallback) {
48
+ throw new TypeError("ContextMenu requires an explicit fallback trigger.");
49
+ }
50
+
51
+ const Dropdown = this._bootstrap("Dropdown");
52
+ if (!Dropdown) {
53
+ throw new TypeError("ContextMenu requires bootstrap.Dropdown (bootstrap.bundle.js).");
54
+ }
55
+
56
+ this._config = { ...config };
57
+ this._listeners = [];
58
+ this._invoker = null;
59
+ this._pointerRect = null;
60
+ this._openMethod = null;
61
+ this._reference = {
62
+ getBoundingClientRect: () => this._currentRect(),
63
+ contextElement: this._fallback,
64
+ };
65
+ this._dropdown = new Dropdown(this._fallback, {
66
+ reference: this._reference,
67
+ autoClose: "outside",
68
+ });
69
+
70
+ instances.set(element, this);
71
+ this._bindEvents();
72
+ }
73
+
74
+ dispose() {
75
+ this._listeners.forEach(({ target, type, handler, options }) => {
76
+ target.removeEventListener(type, handler, options);
77
+ });
78
+ this._listeners = [];
79
+ this._dropdown.dispose();
80
+ this._syncExpanded(false);
81
+ this._invoker = null;
82
+ this._pointerRect = null;
83
+ instances.delete(this._element);
84
+ }
85
+
86
+ show(anchor = this._fallback) {
87
+ this._open(anchor, null);
88
+ }
89
+
90
+ hide() {
91
+ this._dropdown.hide();
92
+ }
93
+
94
+ _listen(target, type, handler, options) {
95
+ target.addEventListener(type, handler, options);
96
+ this._listeners.push({ target, type, handler, options });
97
+ }
98
+
99
+ _bootstrap(name) {
100
+ return this._window.bootstrap?.[name] || null;
101
+ }
102
+
103
+ _currentRect() {
104
+ if (this._pointerRect) {
105
+ return this._pointerRect;
106
+ }
107
+ return (this._invoker || this._fallback).getBoundingClientRect();
108
+ }
109
+
110
+ _trigger(name, cancelable = false) {
111
+ return this._element.dispatchEvent(
112
+ new this._window.CustomEvent(`${name}.moo.context-menu`, {
113
+ bubbles: true,
114
+ cancelable,
115
+ })
116
+ );
117
+ }
118
+
119
+ _triggers() {
120
+ return Array.from(this._element.querySelectorAll(":scope > .context-menu-trigger"));
121
+ }
122
+
123
+ _syncExpanded(expanded) {
124
+ this._triggers().forEach((trigger) => {
125
+ trigger.setAttribute("aria-expanded", String(expanded));
126
+ });
127
+ }
128
+
129
+ _menuItems() {
130
+ return Array.from(this._menu.querySelectorAll(".dropdown-item")).filter(
131
+ (item) => !isDisabledTrigger(item) && !item.classList.contains("disabled")
132
+ );
133
+ }
134
+
135
+ _open(trigger, pointerEvent, openMethod) {
136
+ if (isDisabledTrigger(trigger)) {
137
+ return;
138
+ }
139
+ if (this._menu.classList.contains("show")) {
140
+ this._dropdown.hide();
141
+ }
142
+ this._invoker = trigger;
143
+ this._pointerRect = pointerEvent ? pointerRect(pointerEvent) : null;
144
+ this._openMethod = openMethod;
145
+ this._dropdown.show();
146
+ }
147
+
148
+ _handleContextMenu(event) {
149
+ const trigger = event.target.closest(".context-menu-trigger");
150
+ if (!trigger || !this._element.contains(trigger)) {
151
+ return;
152
+ }
153
+ event.preventDefault();
154
+ this._open(trigger, event, "pointer");
155
+ }
156
+
157
+ _handleKeydown(event) {
158
+ const trigger = event.target.closest(".context-menu-trigger");
159
+ if (!trigger || !this._element.contains(trigger)) {
160
+ return;
161
+ }
162
+ const isShiftF10 = event.shiftKey && event.key === "F10";
163
+ const isContextMenuKey = event.key === "ContextMenu";
164
+ if (!isShiftF10 && !isContextMenuKey) {
165
+ return;
166
+ }
167
+ event.preventDefault();
168
+ this._open(trigger, null, "keyboard");
169
+ }
170
+
171
+ _handleFallbackClick() {
172
+ this._invoker = this._fallback;
173
+ this._pointerRect = null;
174
+ this._openMethod = "pointer";
175
+ }
176
+
177
+ _handleMenuClick(event) {
178
+ const item = menuItemFromEvent(event);
179
+ if (!item) {
180
+ return;
181
+ }
182
+ const persistentItem = item.matches(".dropdown-item-check, [data-context-menu-persistent]");
183
+ const disabledItem =
184
+ item.matches('.dropdown-item.disabled, .dropdown-item[aria-disabled="true"], .dropdown-item:disabled') ||
185
+ isDisabledTrigger(item);
186
+ if (disabledItem) {
187
+ event.preventDefault();
188
+ event.stopPropagation();
189
+ return;
190
+ }
191
+ // Radio-grouped toggle buttons reuse the dropdown_toggle_item() button
192
+ // Bootstrap's own roving arrow-key focus already picks up (it only ever
193
+ // matches real .dropdown-item elements); Moo adds only the missing
194
+ // mutual-exclusivity Bootstrap's independent button-toggle plugin does
195
+ // not provide.
196
+ const radioButton = item.matches("[data-context-menu-radio-group]") ? item : null;
197
+ if (radioButton) {
198
+ event.preventDefault();
199
+ event.stopPropagation();
200
+ const group = radioButton.dataset.contextMenuRadioGroup;
201
+ this._menu
202
+ .querySelectorAll(`[data-context-menu-radio-group="${group}"]`)
203
+ .forEach((button) => {
204
+ const checked = button === radioButton;
205
+ button.classList.toggle("active", checked);
206
+ button.setAttribute("aria-pressed", String(checked));
207
+ });
208
+ return;
209
+ }
210
+ if (persistentItem) {
211
+ return;
212
+ }
213
+ this._dropdown.hide();
214
+ }
215
+
216
+ _bindEvents() {
217
+ this._listen(this._element, "contextmenu", (event) => this._handleContextMenu(event));
218
+ this._listen(this._element, "keydown", (event) => this._handleKeydown(event));
219
+ this._listen(this._fallback, "click", () => this._handleFallbackClick());
220
+ this._listen(this._menu, "click", (event) => this._handleMenuClick(event));
221
+ this._listen(this._fallback, "show.bs.dropdown", (event) => {
222
+ if (!this._trigger("show", true)) {
223
+ event.preventDefault();
224
+ }
225
+ });
226
+ this._listen(this._fallback, "shown.bs.dropdown", () => {
227
+ this._syncExpanded(true);
228
+ // Bootstrap's own show() never focuses an item either -- only its
229
+ // Up/Down-key handler does, since a pointer-driven open (right-click,
230
+ // or a click on the fallback trigger) should let :hover carry the
231
+ // highlight instead of a focus ring competing with it. A keyboard-
232
+ // driven open (Shift+F10/ContextMenu key) still lands on the first
233
+ // item, matching that same native arrow-key-open convention.
234
+ if (this._openMethod === "keyboard") {
235
+ this._menuItems()[0]?.focus();
236
+ } else {
237
+ this._menu.focus();
238
+ }
239
+ this._trigger("shown");
240
+ });
241
+ this._listen(this._fallback, "hide.bs.dropdown", (event) => {
242
+ if (!this._trigger("hide", true)) {
243
+ event.preventDefault();
244
+ }
245
+ });
246
+ this._listen(this._fallback, "hidden.bs.dropdown", () => {
247
+ this._syncExpanded(false);
248
+ const invoker = this._invoker;
249
+ this._pointerRect = null;
250
+ this._invoker = null;
251
+ // A native Bootstrap click on the fallback trigger bypasses _open()
252
+ // entirely (Bootstrap's own capture-phase click-to-toggle handler
253
+ // calls show() directly), so _handleFallbackClick's bubble-phase
254
+ // "click" listener always runs too late to set _openMethod before
255
+ // shown.bs.dropdown reads it. Resetting it here, back to the neutral
256
+ // pointer default, is what keeps that path correct rather than
257
+ // inheriting whatever a prior keyboard-triggered open last set.
258
+ this._openMethod = null;
259
+ this._trigger("hidden");
260
+ // Bootstrap's own dataApiKeydownHandler (js/src/dropdown.js) intercepts
261
+ // Escape in the document capture phase and hardcodes
262
+ // `getToggleButton.focus()` (our fallback trigger) after hide().
263
+ // Nothing downstream of a capture-phase stopPropagation() can observe
264
+ // that keydown to redirect focus, so correct it in a microtask, which
265
+ // still runs before the next paint: if focus landed on the fallback
266
+ // but a different trigger invoked this menu, return focus there.
267
+ queueMicrotask(() => {
268
+ if (invoker && invoker !== this._fallback && this._document.activeElement === this._fallback) {
269
+ invoker.focus();
270
+ }
271
+ });
272
+ });
273
+ }
274
+ }