openproject-primer_view_components 0.68.0 → 0.69.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 +6 -0
- data/app/assets/javascripts/components/primer/alpha/action_menu/action_menu_element.d.ts +5 -0
- data/app/assets/javascripts/components/primer/alpha/action_menu/action_menu_focus_zone_stack.d.ts +17 -0
- data/app/assets/javascripts/primer_view_components.js +1 -1
- data/app/assets/javascripts/primer_view_components.js.map +1 -1
- data/app/components/primer/alpha/action_menu/action_menu_element.d.ts +5 -0
- data/app/components/primer/alpha/action_menu/action_menu_element.js +111 -16
- data/app/components/primer/alpha/action_menu/action_menu_element.ts +136 -23
- data/app/components/primer/alpha/action_menu/action_menu_focus_zone_stack.d.ts +17 -0
- data/app/components/primer/alpha/action_menu/action_menu_focus_zone_stack.js +62 -0
- data/app/components/primer/alpha/action_menu/action_menu_focus_zone_stack.ts +67 -0
- data/app/components/primer/alpha/action_menu/list.rb +3 -1
- data/app/components/primer/alpha/action_menu/list_wrapper.rb +31 -0
- data/app/components/primer/alpha/action_menu/menu.html.erb +24 -0
- data/app/components/primer/alpha/action_menu/menu.rb +136 -0
- data/app/components/primer/alpha/action_menu/primary_menu.rb +86 -0
- data/app/components/primer/alpha/action_menu/sub_menu.rb +74 -0
- data/app/components/primer/alpha/action_menu/sub_menu_item.html.erb +5 -0
- data/app/components/primer/alpha/action_menu/sub_menu_item.rb +54 -0
- data/app/components/primer/alpha/action_menu.html.erb +1 -26
- data/app/components/primer/alpha/action_menu.rb +44 -118
- data/app/components/primer/alpha/select_panel.rb +3 -3
- data/lib/primer/view_components/version.rb +1 -1
- data/previews/primer/alpha/action_menu_preview/multiple_select_form.html.erb +13 -4
- data/previews/primer/alpha/action_menu_preview/opens_dialog.html.erb +20 -11
- data/previews/primer/alpha/action_menu_preview/single_select_form_items.html.erb +13 -2
- data/previews/primer/alpha/action_menu_preview/sub_menus.html.erb +19 -0
- data/previews/primer/alpha/action_menu_preview/with_actions.html.erb +20 -11
- data/previews/primer/alpha/action_menu_preview/with_deferred_content.html.erb +24 -0
- data/previews/primer/alpha/action_menu_preview.rb +93 -29
- data/static/arguments.json +169 -68
- data/static/audited_at.json +4 -0
- data/static/constants.json +27 -7
- data/static/info_arch.json +797 -199
- data/static/previews.json +13 -0
- data/static/statuses.json +4 -0
- metadata +14 -2
@@ -11,6 +11,10 @@ export declare class ActionMenuElement extends HTMLElement {
|
|
11
11
|
#private;
|
12
12
|
includeFragment: IncludeFragmentElement;
|
13
13
|
overlay: AnchoredPositionElement;
|
14
|
+
list: HTMLElement;
|
15
|
+
static validItemRoles: string[];
|
16
|
+
static validSelectors: string[];
|
17
|
+
static menuItemSelectors: string[];
|
14
18
|
get selectVariant(): SelectVariant;
|
15
19
|
set selectVariant(variant: SelectVariant);
|
16
20
|
get dynamicLabelPrefix(): string;
|
@@ -18,6 +22,7 @@ export declare class ActionMenuElement extends HTMLElement {
|
|
18
22
|
get dynamicLabel(): boolean;
|
19
23
|
set dynamicLabel(value: boolean);
|
20
24
|
get popoverElement(): HTMLElement | null;
|
25
|
+
get childPopoverElements(): HTMLElement[];
|
21
26
|
get invokerElement(): HTMLButtonElement | null;
|
22
27
|
get invokerLabel(): HTMLElement | null;
|
23
28
|
get selectedItems(): SelectedItem[];
|
@@ -15,13 +15,14 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
|
|
15
15
|
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
16
16
|
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
17
17
|
};
|
18
|
-
var _ActionMenuElement_instances, _ActionMenuElement_abortController, _ActionMenuElement_originalLabel, _ActionMenuElement_inputName, _ActionMenuElement_invokerBeingClicked, _ActionMenuElement_intersectionObserver, _ActionMenuElement_softDisableItems, _ActionMenuElement_potentiallyDisallowActivation, _ActionMenuElement_isAnchorActivationViaSpace, _ActionMenuElement_isActivation, _ActionMenuElement_handleInvokerActivated, _ActionMenuElement_handleDialogItemActivated, _ActionMenuElement_handleItemActivated, _ActionMenuElement_handleIncludeFragmentReplaced, _ActionMenuElement_handleFocusOut, _ActionMenuElement_show, _ActionMenuElement_hide, _ActionMenuElement_isOpen, _ActionMenuElement_setDynamicLabel, _ActionMenuElement_updateInput, _ActionMenuElement_firstItem_get;
|
18
|
+
var _ActionMenuElement_instances, _a, _ActionMenuElement_abortController, _ActionMenuElement_originalLabel, _ActionMenuElement_inputName, _ActionMenuElement_invokerBeingClicked, _ActionMenuElement_intersectionObserver, _ActionMenuElement_focusZoneStack, _ActionMenuElement_updateLevels, _ActionMenuElement_softDisableItems, _ActionMenuElement_potentiallyDisallowActivation, _ActionMenuElement_isAnchorActivationViaSpace, _ActionMenuElement_isClipboardActivationViaKeyboard, _ActionMenuElement_isActivation, _ActionMenuElement_handleItemKeyboardEvent, _ActionMenuElement_handleToggleEvent, _ActionMenuElement_handleInvokerActivated, _ActionMenuElement_handleDialogItemActivated, _ActionMenuElement_handleItemActivated, _ActionMenuElement_handleIncludeFragmentReplaced, _ActionMenuElement_handleFocusOut, _ActionMenuElement_show, _ActionMenuElement_hide, _ActionMenuElement_isOpen, _ActionMenuElement_setDynamicLabel, _ActionMenuElement_updateInput, _ActionMenuElement_firstItem_get, _ActionMenuElement_subMenuForItem, _ActionMenuElement_itemForSubMenu;
|
19
|
+
var ActionMenuElement_1;
|
19
20
|
import { controller, target } from '@github/catalyst';
|
20
21
|
import '@oddbird/popover-polyfill';
|
21
22
|
import { observeMutationsUntilConditionMet } from '../../utils';
|
22
|
-
|
23
|
-
|
24
|
-
let ActionMenuElement = class ActionMenuElement extends HTMLElement {
|
23
|
+
import { ActionMenuFocusZoneStack } from './action_menu_focus_zone_stack';
|
24
|
+
import { ClipboardCopyElement } from '@github/clipboard-copy-element';
|
25
|
+
let ActionMenuElement = ActionMenuElement_1 = _a = class ActionMenuElement extends HTMLElement {
|
25
26
|
constructor() {
|
26
27
|
super(...arguments);
|
27
28
|
_ActionMenuElement_instances.add(this);
|
@@ -30,6 +31,7 @@ let ActionMenuElement = class ActionMenuElement extends HTMLElement {
|
|
30
31
|
_ActionMenuElement_inputName.set(this, '');
|
31
32
|
_ActionMenuElement_invokerBeingClicked.set(this, false);
|
32
33
|
_ActionMenuElement_intersectionObserver.set(this, void 0);
|
34
|
+
_ActionMenuElement_focusZoneStack.set(this, void 0);
|
33
35
|
}
|
34
36
|
get selectVariant() {
|
35
37
|
return this.getAttribute('data-select-variant');
|
@@ -60,6 +62,10 @@ let ActionMenuElement = class ActionMenuElement extends HTMLElement {
|
|
60
62
|
get popoverElement() {
|
61
63
|
return this.invokerElement?.popoverTargetElement || null;
|
62
64
|
}
|
65
|
+
// i.e. sub-menus
|
66
|
+
get childPopoverElements() {
|
67
|
+
return Array.from(this.overlay.querySelectorAll('anchored-position'));
|
68
|
+
}
|
63
69
|
get invokerElement() {
|
64
70
|
const id = this.querySelector('[role=menu]')?.id;
|
65
71
|
if (!id)
|
@@ -96,7 +102,7 @@ let ActionMenuElement = class ActionMenuElement extends HTMLElement {
|
|
96
102
|
this.addEventListener('mouseover', this, { signal });
|
97
103
|
this.addEventListener('focusout', this, { signal });
|
98
104
|
this.addEventListener('mousedown', this, { signal });
|
99
|
-
this.
|
105
|
+
this.addEventListener('toggle', this, { signal, capture: true });
|
100
106
|
__classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_setDynamicLabel).call(this);
|
101
107
|
__classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_updateInput).call(this);
|
102
108
|
__classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_softDisableItems).call(this);
|
@@ -134,6 +140,10 @@ let ActionMenuElement = class ActionMenuElement extends HTMLElement {
|
|
134
140
|
if (!this.includeFragment) {
|
135
141
|
this.setAttribute('data-ready', 'true');
|
136
142
|
}
|
143
|
+
const levelObserver = new MutationObserver(() => __classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_updateLevels).call(this));
|
144
|
+
levelObserver.observe(this, { childList: true, subtree: true });
|
145
|
+
__classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_updateLevels).call(this);
|
146
|
+
__classPrivateFieldSet(this, _ActionMenuElement_focusZoneStack, new ActionMenuFocusZoneStack(), "f");
|
137
147
|
}
|
138
148
|
disconnectedCallback() {
|
139
149
|
__classPrivateFieldGet(this, _ActionMenuElement_abortController, "f").abort();
|
@@ -141,10 +151,9 @@ let ActionMenuElement = class ActionMenuElement extends HTMLElement {
|
|
141
151
|
handleEvent(event) {
|
142
152
|
const targetIsInvoker = this.invokerElement?.contains(event.target);
|
143
153
|
const eventIsActivation = __classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_isActivation).call(this, event);
|
144
|
-
if (event.type === 'toggle' && event
|
145
|
-
|
146
|
-
|
147
|
-
});
|
154
|
+
if (event.type === 'toggle' && event instanceof ToggleEvent) {
|
155
|
+
__classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_handleToggleEvent).call(this, event);
|
156
|
+
return;
|
148
157
|
}
|
149
158
|
if (targetIsInvoker && event.type === 'mousedown') {
|
150
159
|
__classPrivateFieldSet(this, _ActionMenuElement_invokerBeingClicked, true, "f");
|
@@ -172,7 +181,7 @@ let ActionMenuElement = class ActionMenuElement extends HTMLElement {
|
|
172
181
|
});
|
173
182
|
return;
|
174
183
|
}
|
175
|
-
const item = event.target.closest(menuItemSelectors.join(','));
|
184
|
+
const item = event.target.closest(ActionMenuElement_1.menuItemSelectors.join(','));
|
176
185
|
const targetIsItem = item !== null;
|
177
186
|
if (targetIsItem && eventIsActivation) {
|
178
187
|
if (__classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_potentiallyDisallowActivation).call(this, event))
|
@@ -191,15 +200,26 @@ let ActionMenuElement = class ActionMenuElement extends HTMLElement {
|
|
191
200
|
event.preventDefault();
|
192
201
|
item.click();
|
193
202
|
}
|
203
|
+
const subMenu = __classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_subMenuForItem).call(this, item);
|
204
|
+
if (subMenu) {
|
205
|
+
// Prevent submitting a form when clicking on sub-menu items
|
206
|
+
event.preventDefault();
|
207
|
+
subMenu.showPopover();
|
208
|
+
return;
|
209
|
+
}
|
194
210
|
__classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_handleItemActivated).call(this, item);
|
195
211
|
return;
|
196
212
|
}
|
197
213
|
if (event.type === 'include-fragment-replaced') {
|
198
214
|
__classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_handleIncludeFragmentReplaced).call(this);
|
215
|
+
return;
|
216
|
+
}
|
217
|
+
if (targetIsItem && event instanceof KeyboardEvent) {
|
218
|
+
__classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_handleItemKeyboardEvent).call(this, event, item);
|
199
219
|
}
|
200
220
|
}
|
201
221
|
get items() {
|
202
|
-
return Array.from(this.querySelectorAll(menuItemSelectors.join(',')));
|
222
|
+
return Array.from(this.querySelectorAll(ActionMenuElement_1.menuItemSelectors.join(',')));
|
203
223
|
}
|
204
224
|
getItemById(itemId) {
|
205
225
|
return this.querySelector(`li[data-item-id="${itemId}"`);
|
@@ -274,10 +294,18 @@ _ActionMenuElement_originalLabel = new WeakMap();
|
|
274
294
|
_ActionMenuElement_inputName = new WeakMap();
|
275
295
|
_ActionMenuElement_invokerBeingClicked = new WeakMap();
|
276
296
|
_ActionMenuElement_intersectionObserver = new WeakMap();
|
297
|
+
_ActionMenuElement_focusZoneStack = new WeakMap();
|
277
298
|
_ActionMenuElement_instances = new WeakSet();
|
299
|
+
_ActionMenuElement_updateLevels = function _ActionMenuElement_updateLevels() {
|
300
|
+
let idx = 1;
|
301
|
+
for (const menu of this.querySelectorAll('[role=menu]')) {
|
302
|
+
menu.setAttribute('data-level', idx.toString());
|
303
|
+
idx++;
|
304
|
+
}
|
305
|
+
};
|
278
306
|
_ActionMenuElement_softDisableItems = function _ActionMenuElement_softDisableItems() {
|
279
307
|
const { signal } = __classPrivateFieldGet(this, _ActionMenuElement_abortController, "f");
|
280
|
-
for (const item of this.querySelectorAll(validSelectors.join(','))) {
|
308
|
+
for (const item of this.querySelectorAll(ActionMenuElement_1.validSelectors.join(','))) {
|
281
309
|
item.addEventListener('click', __classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_potentiallyDisallowActivation).bind(this), { signal });
|
282
310
|
item.addEventListener('keydown', __classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_potentiallyDisallowActivation).bind(this), { signal });
|
283
311
|
}
|
@@ -285,7 +313,7 @@ _ActionMenuElement_softDisableItems = function _ActionMenuElement_softDisableIte
|
|
285
313
|
_ActionMenuElement_potentiallyDisallowActivation = function _ActionMenuElement_potentiallyDisallowActivation(event) {
|
286
314
|
if (!__classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_isActivation).call(this, event))
|
287
315
|
return false;
|
288
|
-
const item = event.target.closest(menuItemSelectors.join(','));
|
316
|
+
const item = event.target.closest(ActionMenuElement_1.menuItemSelectors.join(','));
|
289
317
|
if (!item)
|
290
318
|
return false;
|
291
319
|
if (item.getAttribute('aria-disabled')) {
|
@@ -305,11 +333,55 @@ _ActionMenuElement_isAnchorActivationViaSpace = function _ActionMenuElement_isAn
|
|
305
333
|
!(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) &&
|
306
334
|
event.key === ' ');
|
307
335
|
};
|
336
|
+
_ActionMenuElement_isClipboardActivationViaKeyboard = function _ActionMenuElement_isClipboardActivationViaKeyboard(event) {
|
337
|
+
return (event.target instanceof ClipboardCopyElement &&
|
338
|
+
event instanceof KeyboardEvent &&
|
339
|
+
event.type === 'keydown' &&
|
340
|
+
!(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) &&
|
341
|
+
(event.key === ' ' || event.key === 'Enter'));
|
342
|
+
};
|
308
343
|
_ActionMenuElement_isActivation = function _ActionMenuElement_isActivation(event) {
|
309
344
|
// Some browsers fire MouseEvents (Firefox) and others fire PointerEvents (Chrome). Activating an item via
|
310
345
|
// enter or space counterintuitively fires one of these rather than a KeyboardEvent. Since PointerEvent
|
311
346
|
// inherits from MouseEvent, it is enough to check for MouseEvent here.
|
312
|
-
return (event instanceof MouseEvent && event.type === 'click') ||
|
347
|
+
return ((event instanceof MouseEvent && event.type === 'click') ||
|
348
|
+
__classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_isAnchorActivationViaSpace).call(this, event) ||
|
349
|
+
__classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_isClipboardActivationViaKeyboard).call(this, event));
|
350
|
+
};
|
351
|
+
_ActionMenuElement_handleItemKeyboardEvent = function _ActionMenuElement_handleItemKeyboardEvent(event, item) {
|
352
|
+
switch (event.key) {
|
353
|
+
case 'ArrowRight': {
|
354
|
+
const subMenu = __classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_subMenuForItem).call(this, item);
|
355
|
+
subMenu?.showPopover();
|
356
|
+
break;
|
357
|
+
}
|
358
|
+
case 'ArrowLeft':
|
359
|
+
if (item.closest('role[menu]') !== this.list) {
|
360
|
+
const overlay = item.closest('anchored-position');
|
361
|
+
overlay?.hidePopover();
|
362
|
+
}
|
363
|
+
break;
|
364
|
+
}
|
365
|
+
};
|
366
|
+
_ActionMenuElement_handleToggleEvent = function _ActionMenuElement_handleToggleEvent(event) {
|
367
|
+
const subMenu = event.target;
|
368
|
+
if (event.newState === 'open') {
|
369
|
+
// allow tabbing away from primary menu, but trap focus in sub-menus
|
370
|
+
const isPrimaryMenu = subMenu === this.overlay;
|
371
|
+
__classPrivateFieldGet(this, _ActionMenuElement_focusZoneStack, "f").push(subMenu, { trapFocus: !isPrimaryMenu });
|
372
|
+
window.requestAnimationFrame(() => {
|
373
|
+
const firstItem = subMenu.querySelector(ActionMenuElement_1.menuItemSelectors.join(','));
|
374
|
+
firstItem?.focus();
|
375
|
+
});
|
376
|
+
}
|
377
|
+
else {
|
378
|
+
// Note that this will also cause focus to return to the invoker button, which is
|
379
|
+
// desirable
|
380
|
+
__classPrivateFieldGet(this, _ActionMenuElement_focusZoneStack, "f").pop(subMenu);
|
381
|
+
const item = __classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_itemForSubMenu).call(this, subMenu);
|
382
|
+
if (item)
|
383
|
+
item.focus();
|
384
|
+
}
|
313
385
|
};
|
314
386
|
_ActionMenuElement_handleInvokerActivated = function _ActionMenuElement_handleInvokerActivated(event) {
|
315
387
|
event.preventDefault();
|
@@ -412,6 +484,9 @@ _ActionMenuElement_show = function _ActionMenuElement_show() {
|
|
412
484
|
};
|
413
485
|
_ActionMenuElement_hide = function _ActionMenuElement_hide() {
|
414
486
|
this.popoverElement?.hidePopover();
|
487
|
+
for (const child of this.childPopoverElements) {
|
488
|
+
child.hidePopover();
|
489
|
+
}
|
415
490
|
};
|
416
491
|
_ActionMenuElement_isOpen = function _ActionMenuElement_isOpen() {
|
417
492
|
return this.popoverElement?.matches(':popover-open');
|
@@ -476,15 +551,35 @@ _ActionMenuElement_updateInput = function _ActionMenuElement_updateInput() {
|
|
476
551
|
}
|
477
552
|
};
|
478
553
|
_ActionMenuElement_firstItem_get = function _ActionMenuElement_firstItem_get() {
|
479
|
-
return this.querySelector(menuItemSelectors.join(','));
|
554
|
+
return this.querySelector(ActionMenuElement_1.menuItemSelectors.join(','));
|
555
|
+
};
|
556
|
+
_ActionMenuElement_subMenuForItem = function _ActionMenuElement_subMenuForItem(item) {
|
557
|
+
const popoverId = item.getAttribute('popovertarget');
|
558
|
+
if (popoverId) {
|
559
|
+
return this.querySelector(`[id="${popoverId}"]`);
|
560
|
+
}
|
561
|
+
return null;
|
480
562
|
};
|
563
|
+
_ActionMenuElement_itemForSubMenu = function _ActionMenuElement_itemForSubMenu(subMenu) {
|
564
|
+
const anchorId = subMenu.getAttribute('anchor');
|
565
|
+
if (anchorId) {
|
566
|
+
return this.querySelector(`[id="${anchorId}"]`);
|
567
|
+
}
|
568
|
+
return null;
|
569
|
+
};
|
570
|
+
ActionMenuElement.validItemRoles = ['menuitem', 'menuitemcheckbox', 'menuitemradio'];
|
571
|
+
ActionMenuElement.validSelectors = ActionMenuElement_1.validItemRoles.map(role => `[role="${role}"]`);
|
572
|
+
ActionMenuElement.menuItemSelectors = ActionMenuElement_1.validSelectors.map(selector => `:not([hidden]) > ${selector}`);
|
481
573
|
__decorate([
|
482
574
|
target
|
483
575
|
], ActionMenuElement.prototype, "includeFragment", void 0);
|
484
576
|
__decorate([
|
485
577
|
target
|
486
578
|
], ActionMenuElement.prototype, "overlay", void 0);
|
487
|
-
|
579
|
+
__decorate([
|
580
|
+
target
|
581
|
+
], ActionMenuElement.prototype, "list", void 0);
|
582
|
+
ActionMenuElement = ActionMenuElement_1 = __decorate([
|
488
583
|
controller
|
489
584
|
], ActionMenuElement);
|
490
585
|
export { ActionMenuElement };
|
@@ -3,6 +3,8 @@ import '@oddbird/popover-polyfill'
|
|
3
3
|
import type {IncludeFragmentElement} from '@github/include-fragment-element'
|
4
4
|
import AnchoredPositionElement from '../../anchored_position'
|
5
5
|
import {observeMutationsUntilConditionMet} from '../../utils'
|
6
|
+
import {ActionMenuFocusZoneStack} from './action_menu_focus_zone_stack'
|
7
|
+
import {ClipboardCopyElement} from '@github/clipboard-copy-element'
|
6
8
|
|
7
9
|
type SelectVariant = 'none' | 'single' | 'multiple' | null
|
8
10
|
type SelectedItem = {
|
@@ -11,22 +13,22 @@ type SelectedItem = {
|
|
11
13
|
element: Element
|
12
14
|
}
|
13
15
|
|
14
|
-
const validSelectors = ['[role="menuitem"]', '[role="menuitemcheckbox"]', '[role="menuitemradio"]']
|
15
|
-
const menuItemSelectors = validSelectors.map(selector => `:not([hidden]) > ${selector}`)
|
16
|
-
|
17
16
|
@controller
|
18
17
|
export class ActionMenuElement extends HTMLElement {
|
19
|
-
@target
|
20
|
-
|
21
|
-
|
22
|
-
@target
|
23
|
-
overlay: AnchoredPositionElement
|
18
|
+
@target includeFragment: IncludeFragmentElement
|
19
|
+
@target overlay: AnchoredPositionElement
|
20
|
+
@target list: HTMLElement
|
24
21
|
|
25
22
|
#abortController: AbortController
|
26
23
|
#originalLabel = ''
|
27
24
|
#inputName = ''
|
28
25
|
#invokerBeingClicked = false
|
29
26
|
#intersectionObserver: IntersectionObserver
|
27
|
+
#focusZoneStack: ActionMenuFocusZoneStack
|
28
|
+
|
29
|
+
static validItemRoles = ['menuitem', 'menuitemcheckbox', 'menuitemradio']
|
30
|
+
static validSelectors = ActionMenuElement.validItemRoles.map(role => `[role="${role}"]`)
|
31
|
+
static menuItemSelectors = ActionMenuElement.validSelectors.map(selector => `:not([hidden]) > ${selector}`)
|
30
32
|
|
31
33
|
get selectVariant(): SelectVariant {
|
32
34
|
return this.getAttribute('data-select-variant') as SelectVariant
|
@@ -62,6 +64,11 @@ export class ActionMenuElement extends HTMLElement {
|
|
62
64
|
return (this.invokerElement?.popoverTargetElement as HTMLElement) || null
|
63
65
|
}
|
64
66
|
|
67
|
+
// i.e. sub-menus
|
68
|
+
get childPopoverElements(): HTMLElement[] {
|
69
|
+
return Array.from(this.overlay.querySelectorAll('anchored-position')) as AnchoredPositionElement[]
|
70
|
+
}
|
71
|
+
|
65
72
|
get invokerElement(): HTMLButtonElement | null {
|
66
73
|
const id = this.querySelector('[role=menu]')?.id
|
67
74
|
if (!id) return null
|
@@ -102,7 +109,7 @@ export class ActionMenuElement extends HTMLElement {
|
|
102
109
|
this.addEventListener('mouseover', this, {signal})
|
103
110
|
this.addEventListener('focusout', this, {signal})
|
104
111
|
this.addEventListener('mousedown', this, {signal})
|
105
|
-
this.
|
112
|
+
this.addEventListener('toggle', this, {signal, capture: true})
|
106
113
|
this.#setDynamicLabel()
|
107
114
|
this.#updateInput()
|
108
115
|
this.#softDisableItems()
|
@@ -149,6 +156,22 @@ export class ActionMenuElement extends HTMLElement {
|
|
149
156
|
if (!this.includeFragment) {
|
150
157
|
this.setAttribute('data-ready', 'true')
|
151
158
|
}
|
159
|
+
|
160
|
+
const levelObserver = new MutationObserver(() => this.#updateLevels())
|
161
|
+
levelObserver.observe(this, {childList: true, subtree: true})
|
162
|
+
|
163
|
+
this.#updateLevels()
|
164
|
+
|
165
|
+
this.#focusZoneStack = new ActionMenuFocusZoneStack()
|
166
|
+
}
|
167
|
+
|
168
|
+
#updateLevels() {
|
169
|
+
let idx = 1
|
170
|
+
|
171
|
+
for (const menu of this.querySelectorAll('[role=menu]')) {
|
172
|
+
menu.setAttribute('data-level', idx.toString())
|
173
|
+
idx++
|
174
|
+
}
|
152
175
|
}
|
153
176
|
|
154
177
|
disconnectedCallback() {
|
@@ -158,7 +181,7 @@ export class ActionMenuElement extends HTMLElement {
|
|
158
181
|
#softDisableItems() {
|
159
182
|
const {signal} = this.#abortController
|
160
183
|
|
161
|
-
for (const item of this.querySelectorAll(validSelectors.join(','))) {
|
184
|
+
for (const item of this.querySelectorAll(ActionMenuElement.validSelectors.join(','))) {
|
162
185
|
item.addEventListener('click', this.#potentiallyDisallowActivation.bind(this), {signal})
|
163
186
|
item.addEventListener('keydown', this.#potentiallyDisallowActivation.bind(this), {signal})
|
164
187
|
}
|
@@ -168,7 +191,7 @@ export class ActionMenuElement extends HTMLElement {
|
|
168
191
|
#potentiallyDisallowActivation(event: Event): boolean {
|
169
192
|
if (!this.#isActivation(event)) return false
|
170
193
|
|
171
|
-
const item = (event.target as HTMLElement).closest(menuItemSelectors.join(','))
|
194
|
+
const item = (event.target as HTMLElement).closest(ActionMenuElement.menuItemSelectors.join(','))
|
172
195
|
if (!item) return false
|
173
196
|
|
174
197
|
if (item.getAttribute('aria-disabled')) {
|
@@ -193,21 +216,34 @@ export class ActionMenuElement extends HTMLElement {
|
|
193
216
|
)
|
194
217
|
}
|
195
218
|
|
219
|
+
#isClipboardActivationViaKeyboard(event: Event): boolean {
|
220
|
+
return (
|
221
|
+
event.target instanceof ClipboardCopyElement &&
|
222
|
+
event instanceof KeyboardEvent &&
|
223
|
+
event.type === 'keydown' &&
|
224
|
+
!(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) &&
|
225
|
+
(event.key === ' ' || event.key === 'Enter')
|
226
|
+
)
|
227
|
+
}
|
228
|
+
|
196
229
|
#isActivation(event: Event): boolean {
|
197
230
|
// Some browsers fire MouseEvents (Firefox) and others fire PointerEvents (Chrome). Activating an item via
|
198
231
|
// enter or space counterintuitively fires one of these rather than a KeyboardEvent. Since PointerEvent
|
199
232
|
// inherits from MouseEvent, it is enough to check for MouseEvent here.
|
200
|
-
return (
|
233
|
+
return (
|
234
|
+
(event instanceof MouseEvent && event.type === 'click') ||
|
235
|
+
this.#isAnchorActivationViaSpace(event) ||
|
236
|
+
this.#isClipboardActivationViaKeyboard(event)
|
237
|
+
)
|
201
238
|
}
|
202
239
|
|
203
240
|
handleEvent(event: Event) {
|
204
241
|
const targetIsInvoker = this.invokerElement?.contains(event.target as HTMLElement)
|
205
242
|
const eventIsActivation = this.#isActivation(event)
|
206
243
|
|
207
|
-
if (event.type === 'toggle' &&
|
208
|
-
|
209
|
-
|
210
|
-
})
|
244
|
+
if (event.type === 'toggle' && event instanceof ToggleEvent) {
|
245
|
+
this.#handleToggleEvent(event)
|
246
|
+
return
|
211
247
|
}
|
212
248
|
|
213
249
|
if (targetIsInvoker && event.type === 'mousedown') {
|
@@ -241,7 +277,7 @@ export class ActionMenuElement extends HTMLElement {
|
|
241
277
|
return
|
242
278
|
}
|
243
279
|
|
244
|
-
const item = (event.target as Element).closest(menuItemSelectors.join(','))
|
280
|
+
const item = (event.target as Element).closest(ActionMenuElement.menuItemSelectors.join(',')) as HTMLElement | null
|
245
281
|
const targetIsItem = item !== null
|
246
282
|
|
247
283
|
if (targetIsItem && eventIsActivation) {
|
@@ -262,7 +298,16 @@ export class ActionMenuElement extends HTMLElement {
|
|
262
298
|
// We then click it manually to navigate.
|
263
299
|
if (this.#isAnchorActivationViaSpace(event)) {
|
264
300
|
event.preventDefault()
|
265
|
-
|
301
|
+
item.click()
|
302
|
+
}
|
303
|
+
|
304
|
+
const subMenu = this.#subMenuForItem(item)
|
305
|
+
|
306
|
+
if (subMenu) {
|
307
|
+
// Prevent submitting a form when clicking on sub-menu items
|
308
|
+
event.preventDefault()
|
309
|
+
subMenu.showPopover()
|
310
|
+
return
|
266
311
|
}
|
267
312
|
|
268
313
|
this.#handleItemActivated(item)
|
@@ -272,6 +317,50 @@ export class ActionMenuElement extends HTMLElement {
|
|
272
317
|
|
273
318
|
if (event.type === 'include-fragment-replaced') {
|
274
319
|
this.#handleIncludeFragmentReplaced()
|
320
|
+
return
|
321
|
+
}
|
322
|
+
|
323
|
+
if (targetIsItem && event instanceof KeyboardEvent) {
|
324
|
+
this.#handleItemKeyboardEvent(event, item)
|
325
|
+
}
|
326
|
+
}
|
327
|
+
|
328
|
+
#handleItemKeyboardEvent(event: KeyboardEvent, item: HTMLElement) {
|
329
|
+
switch (event.key) {
|
330
|
+
case 'ArrowRight': {
|
331
|
+
const subMenu = this.#subMenuForItem(item)
|
332
|
+
subMenu?.showPopover()
|
333
|
+
break
|
334
|
+
}
|
335
|
+
|
336
|
+
case 'ArrowLeft':
|
337
|
+
if (item.closest('role[menu]') !== this.list) {
|
338
|
+
const overlay = item.closest('anchored-position') as AnchoredPositionElement | null
|
339
|
+
overlay?.hidePopover()
|
340
|
+
}
|
341
|
+
|
342
|
+
break
|
343
|
+
}
|
344
|
+
}
|
345
|
+
|
346
|
+
#handleToggleEvent(event: ToggleEvent) {
|
347
|
+
const subMenu = event.target as AnchoredPositionElement
|
348
|
+
|
349
|
+
if (event.newState === 'open') {
|
350
|
+
// allow tabbing away from primary menu, but trap focus in sub-menus
|
351
|
+
const isPrimaryMenu = subMenu === this.overlay
|
352
|
+
this.#focusZoneStack.push(subMenu, {trapFocus: !isPrimaryMenu})
|
353
|
+
|
354
|
+
window.requestAnimationFrame(() => {
|
355
|
+
const firstItem = subMenu.querySelector(ActionMenuElement.menuItemSelectors.join(',')) as HTMLElement | null
|
356
|
+
firstItem?.focus()
|
357
|
+
})
|
358
|
+
} else {
|
359
|
+
// Note that this will also cause focus to return to the invoker button, which is
|
360
|
+
// desirable
|
361
|
+
this.#focusZoneStack.pop(subMenu)
|
362
|
+
const item = this.#itemForSubMenu(subMenu)
|
363
|
+
if (item) item.focus()
|
275
364
|
}
|
276
365
|
}
|
277
366
|
|
@@ -322,7 +411,7 @@ export class ActionMenuElement extends HTMLElement {
|
|
322
411
|
dialog.addEventListener('cancel', handleDialogClose, {signal})
|
323
412
|
}
|
324
413
|
|
325
|
-
#handleItemActivated(item:
|
414
|
+
#handleItemActivated(item: HTMLElement) {
|
326
415
|
// Hide popover after current event loop to prevent changes in focus from
|
327
416
|
// altering the target of the event. Not doing this specifically affects
|
328
417
|
// <a> tags. It causes the event to be sent to the currently focused element
|
@@ -391,6 +480,10 @@ export class ActionMenuElement extends HTMLElement {
|
|
391
480
|
|
392
481
|
#hide() {
|
393
482
|
this.popoverElement?.hidePopover()
|
483
|
+
|
484
|
+
for (const child of this.childPopoverElements) {
|
485
|
+
child.hidePopover()
|
486
|
+
}
|
394
487
|
}
|
395
488
|
|
396
489
|
#isOpen() {
|
@@ -458,11 +551,11 @@ export class ActionMenuElement extends HTMLElement {
|
|
458
551
|
}
|
459
552
|
|
460
553
|
get #firstItem(): HTMLElement | null {
|
461
|
-
return this.querySelector(menuItemSelectors.join(','))
|
554
|
+
return this.querySelector(ActionMenuElement.menuItemSelectors.join(','))
|
462
555
|
}
|
463
556
|
|
464
557
|
get items(): HTMLElement[] {
|
465
|
-
return Array.from(this.querySelectorAll(menuItemSelectors.join(',')))
|
558
|
+
return Array.from(this.querySelectorAll(ActionMenuElement.menuItemSelectors.join(',')))
|
466
559
|
}
|
467
560
|
|
468
561
|
getItemById(itemId: string): HTMLElement | null {
|
@@ -521,7 +614,7 @@ export class ActionMenuElement extends HTMLElement {
|
|
521
614
|
|
522
615
|
checkItem(item: Element | null) {
|
523
616
|
if (item && (this.selectVariant === 'single' || this.selectVariant === 'multiple')) {
|
524
|
-
const itemContent = item.querySelector('.ActionListContent')!
|
617
|
+
const itemContent = item.querySelector('.ActionListContent')! as HTMLElement
|
525
618
|
const ariaChecked = itemContent.getAttribute('aria-checked') === 'true'
|
526
619
|
|
527
620
|
if (!ariaChecked) {
|
@@ -532,7 +625,7 @@ export class ActionMenuElement extends HTMLElement {
|
|
532
625
|
|
533
626
|
uncheckItem(item: Element | null) {
|
534
627
|
if (item && (this.selectVariant === 'single' || this.selectVariant === 'multiple')) {
|
535
|
-
const itemContent = item.querySelector('.ActionListContent')!
|
628
|
+
const itemContent = item.querySelector('.ActionListContent')! as HTMLElement
|
536
629
|
const ariaChecked = itemContent.getAttribute('aria-checked') === 'true'
|
537
630
|
|
538
631
|
if (ariaChecked) {
|
@@ -540,6 +633,26 @@ export class ActionMenuElement extends HTMLElement {
|
|
540
633
|
}
|
541
634
|
}
|
542
635
|
}
|
636
|
+
|
637
|
+
#subMenuForItem(item: HTMLElement): AnchoredPositionElement | null {
|
638
|
+
const popoverId = item.getAttribute('popovertarget')
|
639
|
+
|
640
|
+
if (popoverId) {
|
641
|
+
return this.querySelector(`[id="${popoverId}"]`) as AnchoredPositionElement
|
642
|
+
}
|
643
|
+
|
644
|
+
return null
|
645
|
+
}
|
646
|
+
|
647
|
+
#itemForSubMenu(subMenu: HTMLElement): HTMLElement | null {
|
648
|
+
const anchorId = subMenu.getAttribute('anchor')
|
649
|
+
|
650
|
+
if (anchorId) {
|
651
|
+
return this.querySelector(`[id="${anchorId}"]`) as HTMLElement | null
|
652
|
+
}
|
653
|
+
|
654
|
+
return null
|
655
|
+
}
|
543
656
|
}
|
544
657
|
|
545
658
|
if (!window.customElements.get('action-menu')) {
|
@@ -0,0 +1,17 @@
|
|
1
|
+
import AnchoredPositionElement from '../../anchored_position';
|
2
|
+
type StackEntry = {
|
3
|
+
element: AnchoredPositionElement;
|
4
|
+
abortController?: AbortController;
|
5
|
+
};
|
6
|
+
export declare class ActionMenuFocusZoneStack {
|
7
|
+
#private;
|
8
|
+
constructor();
|
9
|
+
get current(): StackEntry | undefined;
|
10
|
+
push(next: AnchoredPositionElement, options?: {
|
11
|
+
trapFocus: boolean;
|
12
|
+
}): void;
|
13
|
+
pop(target?: AnchoredPositionElement): void;
|
14
|
+
elementIsMenuItem(element: HTMLElement): boolean;
|
15
|
+
get isEmpty(): boolean;
|
16
|
+
}
|
17
|
+
export {};
|
@@ -0,0 +1,62 @@
|
|
1
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
2
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
3
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
4
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
5
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
6
|
+
};
|
7
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
8
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
9
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
10
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
11
|
+
};
|
12
|
+
var _ActionMenuFocusZoneStack_instances, _ActionMenuFocusZoneStack_stack, _ActionMenuFocusZoneStack_setupFocusZone, _ActionMenuFocusZoneStack_validItemRoles_get;
|
13
|
+
import { FocusKeys, focusTrap, focusZone } from '@primer/behaviors';
|
14
|
+
import { ActionMenuElement } from './action_menu_element';
|
15
|
+
export class ActionMenuFocusZoneStack {
|
16
|
+
constructor() {
|
17
|
+
_ActionMenuFocusZoneStack_instances.add(this);
|
18
|
+
_ActionMenuFocusZoneStack_stack.set(this, void 0);
|
19
|
+
__classPrivateFieldSet(this, _ActionMenuFocusZoneStack_stack, [], "f");
|
20
|
+
}
|
21
|
+
get current() {
|
22
|
+
return __classPrivateFieldGet(this, _ActionMenuFocusZoneStack_stack, "f")[__classPrivateFieldGet(this, _ActionMenuFocusZoneStack_stack, "f").length - 1];
|
23
|
+
}
|
24
|
+
push(next, options = { trapFocus: true }) {
|
25
|
+
const { trapFocus } = options;
|
26
|
+
__classPrivateFieldGet(this, _ActionMenuFocusZoneStack_stack, "f").push({ element: next, abortController: __classPrivateFieldGet(this, _ActionMenuFocusZoneStack_instances, "m", _ActionMenuFocusZoneStack_setupFocusZone).call(this, next, trapFocus) });
|
27
|
+
}
|
28
|
+
pop(target) {
|
29
|
+
if (target) {
|
30
|
+
while (__classPrivateFieldGet(this, _ActionMenuFocusZoneStack_stack, "f").length > 0 && this.current?.element !== target) {
|
31
|
+
const entry = __classPrivateFieldGet(this, _ActionMenuFocusZoneStack_stack, "f").pop();
|
32
|
+
entry?.abortController?.abort();
|
33
|
+
}
|
34
|
+
}
|
35
|
+
const entry = __classPrivateFieldGet(this, _ActionMenuFocusZoneStack_stack, "f").pop();
|
36
|
+
entry?.abortController?.abort();
|
37
|
+
}
|
38
|
+
elementIsMenuItem(element) {
|
39
|
+
return __classPrivateFieldGet(this, _ActionMenuFocusZoneStack_instances, "a", _ActionMenuFocusZoneStack_validItemRoles_get).includes(element.getAttribute('role') || '');
|
40
|
+
}
|
41
|
+
get isEmpty() {
|
42
|
+
return __classPrivateFieldGet(this, _ActionMenuFocusZoneStack_stack, "f").length === 0;
|
43
|
+
}
|
44
|
+
}
|
45
|
+
_ActionMenuFocusZoneStack_stack = new WeakMap(), _ActionMenuFocusZoneStack_instances = new WeakSet(), _ActionMenuFocusZoneStack_setupFocusZone = function _ActionMenuFocusZoneStack_setupFocusZone(containerEl, trapFocus) {
|
46
|
+
const focusZoneAbortController = focusZone(containerEl, {
|
47
|
+
bindKeys: FocusKeys.ArrowVertical | FocusKeys.ArrowHorizontal | FocusKeys.HomeAndEnd | FocusKeys.Backspace,
|
48
|
+
focusOutBehavior: 'wrap',
|
49
|
+
focusableElementFilter: (element) => {
|
50
|
+
return this.elementIsMenuItem(element) && element.closest('anchored-position') === containerEl;
|
51
|
+
},
|
52
|
+
});
|
53
|
+
if (trapFocus) {
|
54
|
+
const { signal: focusZoneSignal } = focusZoneAbortController;
|
55
|
+
return focusTrap(containerEl, undefined, focusZoneSignal);
|
56
|
+
}
|
57
|
+
else {
|
58
|
+
return focusZoneAbortController;
|
59
|
+
}
|
60
|
+
}, _ActionMenuFocusZoneStack_validItemRoles_get = function _ActionMenuFocusZoneStack_validItemRoles_get() {
|
61
|
+
return ActionMenuElement.validItemRoles;
|
62
|
+
};
|