openproject-primer_view_components 0.67.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -0
  3. data/app/assets/javascripts/components/primer/alpha/action_menu/action_menu_element.d.ts +5 -0
  4. data/app/assets/javascripts/components/primer/alpha/action_menu/action_menu_focus_zone_stack.d.ts +17 -0
  5. data/app/assets/javascripts/components/primer/open_project/tree_view/tree_view.d.ts +1 -0
  6. data/app/assets/javascripts/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.d.ts +1 -0
  7. data/app/assets/javascripts/primer_view_components.js +1 -1
  8. data/app/assets/javascripts/primer_view_components.js.map +1 -1
  9. data/app/components/primer/alpha/action_menu/action_menu_element.d.ts +5 -0
  10. data/app/components/primer/alpha/action_menu/action_menu_element.js +111 -16
  11. data/app/components/primer/alpha/action_menu/action_menu_element.ts +136 -23
  12. data/app/components/primer/alpha/action_menu/action_menu_focus_zone_stack.d.ts +17 -0
  13. data/app/components/primer/alpha/action_menu/action_menu_focus_zone_stack.js +62 -0
  14. data/app/components/primer/alpha/action_menu/action_menu_focus_zone_stack.ts +67 -0
  15. data/app/components/primer/alpha/action_menu/list.rb +3 -1
  16. data/app/components/primer/alpha/action_menu/list_wrapper.rb +31 -0
  17. data/app/components/primer/alpha/action_menu/menu.html.erb +24 -0
  18. data/app/components/primer/alpha/action_menu/menu.rb +136 -0
  19. data/app/components/primer/alpha/action_menu/primary_menu.rb +86 -0
  20. data/app/components/primer/alpha/action_menu/sub_menu.rb +74 -0
  21. data/app/components/primer/alpha/action_menu/sub_menu_item.html.erb +5 -0
  22. data/app/components/primer/alpha/action_menu/sub_menu_item.rb +54 -0
  23. data/app/components/primer/alpha/action_menu.html.erb +1 -26
  24. data/app/components/primer/alpha/action_menu.rb +44 -118
  25. data/app/components/primer/alpha/select_panel.rb +3 -3
  26. data/app/components/primer/open_project/border_box/collapsible_header.rb +0 -3
  27. data/app/components/primer/open_project/collapsible_section.rb +1 -7
  28. data/app/components/primer/open_project/page_header/title.rb +1 -1
  29. data/app/components/primer/open_project/tree_view/tree_view.d.ts +1 -0
  30. data/app/components/primer/open_project/tree_view/tree_view.js +34 -1
  31. data/app/components/primer/open_project/tree_view/tree_view.ts +37 -0
  32. data/app/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.d.ts +1 -0
  33. data/app/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.js +14 -0
  34. data/app/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.ts +18 -0
  35. data/lib/primer/view_components/version.rb +1 -1
  36. data/previews/primer/alpha/action_menu_preview/multiple_select_form.html.erb +13 -4
  37. data/previews/primer/alpha/action_menu_preview/opens_dialog.html.erb +20 -11
  38. data/previews/primer/alpha/action_menu_preview/single_select_form_items.html.erb +13 -2
  39. data/previews/primer/alpha/action_menu_preview/sub_menus.html.erb +19 -0
  40. data/previews/primer/alpha/action_menu_preview/with_actions.html.erb +20 -11
  41. data/previews/primer/alpha/action_menu_preview/with_deferred_content.html.erb +24 -0
  42. data/previews/primer/alpha/action_menu_preview.rb +93 -29
  43. data/previews/primer/open_project/border_box/collapsible_header_preview/playground.html.erb +1 -1
  44. data/previews/primer/open_project/tree_view_preview/async_alpha.html.erb +12 -0
  45. data/previews/primer/open_project/tree_view_preview.rb +24 -0
  46. data/static/arguments.json +169 -68
  47. data/static/audited_at.json +4 -0
  48. data/static/constants.json +28 -8
  49. data/static/info_arch.json +794 -170
  50. data/static/previews.json +39 -0
  51. data/static/statuses.json +4 -0
  52. metadata +15 -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
- const validSelectors = ['[role="menuitem"]', '[role="menuitemcheckbox"]', '[role="menuitemradio"]'];
23
- const menuItemSelectors = validSelectors.map(selector => `:not([hidden]) > ${selector}`);
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.popoverElement?.addEventListener('toggle', this, { signal });
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.newState === 'open') {
145
- window.requestAnimationFrame(() => {
146
- __classPrivateFieldGet(this, _ActionMenuElement_instances, "a", _ActionMenuElement_firstItem_get)?.focus();
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') || __classPrivateFieldGet(this, _ActionMenuElement_instances, "m", _ActionMenuElement_isAnchorActivationViaSpace).call(this, event);
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
- ActionMenuElement = __decorate([
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
- includeFragment: IncludeFragmentElement
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.popoverElement?.addEventListener('toggle', this, {signal})
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 (event instanceof MouseEvent && event.type === 'click') || this.#isAnchorActivationViaSpace(event)
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' && (event as ToggleEvent).newState === 'open') {
208
- window.requestAnimationFrame(() => {
209
- this.#firstItem?.focus()
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
- ;(item as HTMLElement).click()
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: Element) {
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
+ };