@sveltia/ui 0.55.1 → 0.56.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.
@@ -38,6 +38,8 @@ export const normalize = (value) => {
38
38
  * childSelectedAttr: 'aria-selected' | 'aria-checked',
39
39
  * focusChild: boolean
40
40
  * selectFirst: boolean
41
+ * controlsPanel: boolean
42
+ * rovingTabStop: 'selected' | 'first'
41
43
  * } }}
42
44
  */
43
45
  const config = {
@@ -47,6 +49,8 @@ const config = {
47
49
  childSelectedAttr: 'aria-selected',
48
50
  focusChild: true,
49
51
  selectFirst: true,
52
+ controlsPanel: false,
53
+ rovingTabStop: 'selected',
50
54
  },
51
55
  listbox: {
52
56
  orientation: 'vertical',
@@ -54,6 +58,8 @@ const config = {
54
58
  childSelectedAttr: 'aria-selected',
55
59
  focusChild: false,
56
60
  selectFirst: false,
61
+ controlsPanel: false,
62
+ rovingTabStop: 'selected',
57
63
  },
58
64
  menu: {
59
65
  orientation: 'vertical',
@@ -61,6 +67,8 @@ const config = {
61
67
  childSelectedAttr: 'aria-checked',
62
68
  focusChild: true,
63
69
  selectFirst: false,
70
+ controlsPanel: false,
71
+ rovingTabStop: 'first',
64
72
  },
65
73
  menubar: {
66
74
  orientation: 'horizontal',
@@ -68,6 +76,8 @@ const config = {
68
76
  childSelectedAttr: 'aria-checked',
69
77
  focusChild: true,
70
78
  selectFirst: false,
79
+ controlsPanel: false,
80
+ rovingTabStop: 'first',
71
81
  },
72
82
  radiogroup: {
73
83
  orientation: 'horizontal',
@@ -75,6 +85,8 @@ const config = {
75
85
  childSelectedAttr: 'aria-checked',
76
86
  focusChild: true,
77
87
  selectFirst: false,
88
+ controlsPanel: false,
89
+ rovingTabStop: 'selected',
78
90
  },
79
91
  tablist: {
80
92
  orientation: 'horizontal',
@@ -82,9 +94,59 @@ const config = {
82
94
  childSelectedAttr: 'aria-selected',
83
95
  focusChild: true,
84
96
  selectFirst: true,
97
+ controlsPanel: true,
98
+ rovingTabStop: 'selected',
85
99
  },
86
100
  };
87
101
 
102
+ /**
103
+ * Selector for the elements that can hold focus.
104
+ */
105
+ const FOCUSABLE_SELECTOR = 'a[href], button, input, select, textarea, summary, [tabindex]';
106
+
107
+ /**
108
+ * List the document’s tab stops in document order. Positive `tabindex` values, which reorder the
109
+ * sequence, are not accounted for; they’re discouraged and absent from this library.
110
+ * @internal
111
+ * @returns {HTMLElement[]} Elements that can be reached with Tab.
112
+ */
113
+ const getTabStops = () =>
114
+ /** @type {HTMLElement[]} */ ([...document.querySelectorAll(FOCUSABLE_SELECTOR)]).filter(
115
+ (element) =>
116
+ element.tabIndex >= 0 &&
117
+ !element.matches(':disabled, [aria-disabled="true"], [hidden], [inert], [inert] *') &&
118
+ !!element.getClientRects().length,
119
+ );
120
+
121
+ /**
122
+ * Find the element that opens the menu the given element belongs to. A menu lives outside its
123
+ * opener in the DOM tree, so the link runs the other way: the opener points at the menu’s container
124
+ * with `aria-controls`.
125
+ * @internal
126
+ * @param {HTMLElement} element Element within the menu.
127
+ * @returns {HTMLElement | null} Menu button or parent menu item, if any.
128
+ */
129
+ const getMenuOpener = (element) => {
130
+ /** @type {HTMLElement | null} */
131
+ let current = element;
132
+
133
+ while (current) {
134
+ if (current.id) {
135
+ const opener = /** @type {HTMLElement | null} */ (
136
+ document.querySelector(`[aria-haspopup="menu"][aria-controls="${CSS.escape(current.id)}"]`)
137
+ );
138
+
139
+ if (opener) {
140
+ return opener;
141
+ }
142
+ }
143
+
144
+ current = current.parentElement;
145
+ }
146
+
147
+ return null;
148
+ };
149
+
88
150
  /**
89
151
  * Implement keyboard and mouse interactions for a grouping composite widget.
90
152
  */
@@ -118,8 +180,15 @@ export class Group {
118
180
  this.onKeyDown(event);
119
181
  };
120
182
 
121
- const { orientation, childRoles, childSelectedAttr, focusChild, selectFirst } =
122
- config[this.role];
183
+ const {
184
+ orientation,
185
+ childRoles,
186
+ childSelectedAttr,
187
+ focusChild,
188
+ selectFirst,
189
+ controlsPanel,
190
+ rovingTabStop,
191
+ } = config[this.role];
123
192
 
124
193
  this.orientation = this.grid
125
194
  ? 'horizontal'
@@ -129,6 +198,23 @@ export class Group {
129
198
  this.childSelectedProp = childSelectedAttr.replace('aria-', '');
130
199
  this.focusChild = focusChild;
131
200
  this.selectFirst = selectFirst;
201
+ /**
202
+ * Whether a member’s `aria-controls` target is a panel this group owns, as with a tab and its
203
+ * tabpanel. Only then may the group hide the target when the member isn’t selected. Elsewhere
204
+ * `aria-controls` means something quite different — on a menu item it points at the submenu the
205
+ * item opens, and on a toolbar button at the region it acts on — and hiding those would break
206
+ * the very widget the member controls.
207
+ * @type {boolean}
208
+ */
209
+ this.controlsPanel = controlsPanel;
210
+ /**
211
+ * Which member holds the group’s single tab stop. `selected` suits widgets where one member is
212
+ * the natural entry point, such as the checked radio or the current tab. `first` suits menus,
213
+ * where any number of items can be checked at once, so the checked state says nothing about
214
+ * where the keyboard should land.
215
+ * @type {'selected' | 'first'}
216
+ */
217
+ this.rovingTabStop = rovingTabStop;
132
218
 
133
219
  this.parent.tabIndex = focusChild ? -1 : 0;
134
220
 
@@ -151,12 +237,13 @@ export class Group {
151
237
  element.getAttribute(this.childSelectedAttr) === 'true' ||
152
238
  (defaultSelected ? element === defaultSelected : this.selectFirst && index === 0);
153
239
 
154
- const controlTarget = /** @type {HTMLElement | null} */ (
155
- document.querySelector(`#${element.getAttribute('aria-controls')}`)
156
- );
240
+ const controlTarget = this.controlsPanel
241
+ ? /** @type {HTMLElement | null} */ (
242
+ document.querySelector(`#${element.getAttribute('aria-controls')}`)
243
+ )
244
+ : null;
157
245
 
158
246
  element.id ||= `${this.id}-item-${index + 1}`;
159
- element.tabIndex = isSelected ? 0 : -1;
160
247
  element.setAttribute(this.childSelectedAttr, String(isSelected));
161
248
 
162
249
  if (controlTarget) {
@@ -180,11 +267,40 @@ export class Group {
180
267
  }
181
268
  });
182
269
 
270
+ this.updateTabStop();
183
271
  parent.addEventListener('click', this._onClick);
184
272
  parent.addEventListener('keydown', this._onKeyDown);
185
273
  parent.dispatchEvent(new CustomEvent('Initialized'));
186
274
  }
187
275
 
276
+ /**
277
+ * Put exactly one member in the tab sequence, as a composite widget should. Giving every checked
278
+ * member `tabindex="0"` would scatter tab stops through the widget — a menu with two checked
279
+ * items would take three tab presses to step over.
280
+ */
281
+ updateTabStop() {
282
+ const { allMembers, activeMembers } = this;
283
+
284
+ // When the group element itself takes focus, as a listbox does, no member is a tab stop
285
+ if (!this.focusChild) {
286
+ allMembers.forEach((element) => {
287
+ element.tabIndex = -1;
288
+ });
289
+
290
+ return;
291
+ }
292
+
293
+ const tabStop =
294
+ this.rovingTabStop === 'selected'
295
+ ? (activeMembers.find((element) => element.matches(`[${this.childSelectedAttr}="true"]`)) ??
296
+ activeMembers[0])
297
+ : activeMembers[0];
298
+
299
+ allMembers.forEach((element) => {
300
+ element.tabIndex = element === tabStop ? 0 : -1;
301
+ });
302
+ }
303
+
188
304
  /**
189
305
  * CSS selector to retrieve the members.
190
306
  * @type {string}
@@ -211,6 +327,129 @@ export class Group {
211
327
  );
212
328
  }
213
329
 
330
+ /**
331
+ * The element that opens this menu, either a menu button or a menu item in the menu above.
332
+ * @type {HTMLElement | null}
333
+ */
334
+ get opener() {
335
+ return getMenuOpener(this.parent);
336
+ }
337
+
338
+ /**
339
+ * The menu item that opens this menu, when this menu is a submenu. A menu button is deliberately
340
+ * excluded, because closing a top-level menu is not what “back to the parent item” means.
341
+ * @type {HTMLElement | null}
342
+ */
343
+ get parentMenuItem() {
344
+ const { opener } = this;
345
+
346
+ return opener?.matches('[role^="menuitem"]') ? opener : null;
347
+ }
348
+
349
+ /**
350
+ * Close this menu along with every menu above it.
351
+ * @returns {HTMLElement | null} The element that opens the outermost menu, so the caller can
352
+ * hand focus back to it.
353
+ */
354
+ closeMenuChain() {
355
+ const { opener: innermost } = this;
356
+ /** @type {HTMLElement | null} */
357
+ let opener = innermost;
358
+ /** @type {HTMLElement | null} */
359
+ let outermost = null;
360
+
361
+ // Bounded, so a malformed `aria-controls` cycle can’t hang the page
362
+ for (let i = 0; i < 10 && opener; i += 1) {
363
+ outermost = opener;
364
+
365
+ if (opener.getAttribute('aria-expanded') === 'true') {
366
+ opener.click();
367
+ }
368
+
369
+ const menu = /** @type {HTMLElement | null} */ (
370
+ opener.closest('[role="menu"], [role="menubar"]')
371
+ );
372
+
373
+ opener = menu ? getMenuOpener(menu) : null;
374
+ }
375
+
376
+ return outermost;
377
+ }
378
+
379
+ /**
380
+ * Leave the menu the way Tab should: close it along with every menu above it, then carry focus on
381
+ * to whatever follows the outermost opener. The browser can’t be left to do this itself, because
382
+ * a modal `<dialog>` confines Tab to its own contents.
383
+ * @param {boolean} backwards Whether to move to the previous tab stop instead, as Shift+Tab.
384
+ */
385
+ async leaveMenu(backwards) {
386
+ const opener = this.closeMenuChain();
387
+
388
+ if (!opener) {
389
+ return;
390
+ }
391
+
392
+ // Somewhere to stand while the menu is torn down, and the fallback if there’s nothing beyond
393
+ opener.focus();
394
+ await sleep(50);
395
+
396
+ const tabStops = getTabStops();
397
+ const index = tabStops.indexOf(opener);
398
+
399
+ if (index > -1) {
400
+ tabStops[index + (backwards ? -1 : 1)]?.focus();
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Open the submenu of the given menu item if needed, and move focus onto its first item.
406
+ * @param {HTMLElement} item Menu item with `aria-haspopup="menu"`.
407
+ */
408
+ async enterSubmenu(item) {
409
+ const submenuId = item.getAttribute('aria-controls');
410
+
411
+ if (!submenuId) {
412
+ return;
413
+ }
414
+
415
+ if (item.getAttribute('aria-expanded') !== 'true') {
416
+ item.click();
417
+ }
418
+
419
+ const submenu = document.getElementById(submenuId);
420
+
421
+ // The submenu is revealed and positioned asynchronously, and an element that is still hidden
422
+ // or invisible cannot take focus, so keep trying for a few frames
423
+ for (let i = 0; i < 20; i += 1) {
424
+ const first = /** @type {HTMLElement | undefined} */ (
425
+ [...(submenu?.querySelectorAll(this.selector) ?? [])].find(
426
+ (element) => !element.matches('[aria-disabled="true"], [aria-hidden="true"]'),
427
+ )
428
+ );
429
+
430
+ first?.focus();
431
+
432
+ if (first && document.activeElement === first) {
433
+ return;
434
+ }
435
+
436
+ // eslint-disable-next-line no-await-in-loop
437
+ await sleep(20);
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Close this submenu and move focus back onto the menu item that opens it.
443
+ * @param {HTMLElement} item Menu item with `aria-haspopup="menu"`.
444
+ */
445
+ leaveSubmenu(item) {
446
+ if (item.getAttribute('aria-expanded') === 'true') {
447
+ item.click();
448
+ }
449
+
450
+ item.focus();
451
+ }
452
+
214
453
  /**
215
454
  * Get the currently selected member.
216
455
  * @type {HTMLElement | undefined}
@@ -280,7 +519,7 @@ export class Group {
280
519
  const singleSelect = isMenuItemRadio || !multiSelect;
281
520
  const isTarget = element === newTarget;
282
521
  const isSelected = element.matches(`[${this.childSelectedAttr}="true"]`);
283
- const controlTargetId = element.getAttribute('aria-controls');
522
+ const controlTargetId = this.controlsPanel ? element.getAttribute('aria-controls') : null;
284
523
  const controlTarget = controlTargetId ? document.getElementById(controlTargetId) : null;
285
524
 
286
525
  if (multiSelect && isTarget && (selectByClick || selectByKeydown)) {
@@ -394,6 +633,16 @@ export class Group {
394
633
  onKeyDown(event) {
395
634
  const { key, ctrlKey, metaKey, shiftKey, altKey } = event;
396
635
  const hasModifier = shiftKey || altKey || ctrlKey || metaKey;
636
+ const isMenu = this.childRoles.includes('menuitem');
637
+
638
+ // Tab leaves the menu entirely rather than stepping through it. Shift is allowed through here,
639
+ // unlike the keys below, because Shift+Tab leaves the menu just the same.
640
+ if (key === 'Tab' && isMenu && !ctrlKey && !metaKey && !altKey) {
641
+ event.preventDefault();
642
+ this.leaveMenu(shiftKey);
643
+
644
+ return;
645
+ }
397
646
 
398
647
  if (hasModifier) {
399
648
  return;
@@ -434,6 +683,44 @@ export class Group {
434
683
  return;
435
684
  }
436
685
 
686
+ // Escape in a submenu dismisses just that submenu. Propagation is stopped so the popup, whose
687
+ // own Escape handler sits on the `<dialog>` this submenu shares with its parent, doesn’t go on
688
+ // to close the whole stack.
689
+ if (key === 'Escape' && isMenu) {
690
+ const { parentMenuItem } = this;
691
+
692
+ if (parentMenuItem) {
693
+ event.preventDefault();
694
+ event.stopPropagation();
695
+ this.leaveSubmenu(parentMenuItem);
696
+
697
+ return;
698
+ }
699
+ }
700
+
701
+ // Submenu traversal. In a vertical menu the inline arrows are free, so they step into a submenu
702
+ // and back out again, as the Menu pattern expects. Mirrored for RTL.
703
+ if (this.orientation === 'vertical' && isMenu) {
704
+ const intoSubmenuKey = isRTL() ? 'ArrowLeft' : 'ArrowRight';
705
+ const outOfSubmenuKey = isRTL() ? 'ArrowRight' : 'ArrowLeft';
706
+
707
+ if (key === intoSubmenuKey && currentTarget?.getAttribute('aria-haspopup') === 'menu') {
708
+ this.enterSubmenu(currentTarget);
709
+
710
+ return;
711
+ }
712
+
713
+ if (key === outOfSubmenuKey) {
714
+ const { parentMenuItem } = this;
715
+
716
+ if (parentMenuItem) {
717
+ this.leaveSubmenu(parentMenuItem);
718
+
719
+ return;
720
+ }
721
+ }
722
+ }
723
+
437
724
  let index;
438
725
  let newTarget;
439
726
 
@@ -7,14 +7,17 @@ export function activatePopup(...args: any[]): Popup;
7
7
  */
8
8
  declare class Popup {
9
9
  /**
10
- * Initialize a new `Popup` instance.
10
+ * Initialize a new `Popup` instance. Note that the `popupElement` is optional, because the
11
+ * element is typically mounted only while the popup is open. Use {@link attachPopupElement} to
12
+ * provide it later.
11
13
  * @param {HTMLButtonElement} anchorElement `<button>` element that triggers the popup.
12
- * @param {HTMLDialogElement} popupElement `<dialog>` element to be used for the popup.
14
+ * @param {HTMLDialogElement | undefined} popupElement `<dialog>` element to be used for the
15
+ * popup, if it’s already in the DOM tree.
13
16
  * @param {PopupPosition} position Where to show the popup content.
14
17
  * @param {HTMLElement} [positionBaseElement] The base element of the `position`. If omitted, this
15
18
  * will be the `anchorElement`.
16
19
  */
17
- constructor(anchorElement: HTMLButtonElement, popupElement: HTMLDialogElement, position: PopupPosition, positionBaseElement?: HTMLElement | undefined);
20
+ constructor(anchorElement: HTMLButtonElement, popupElement: HTMLDialogElement | undefined, position: PopupPosition, positionBaseElement?: HTMLElement | undefined);
18
21
  /**
19
22
  * Open or close the popup, running side effects synchronously.
20
23
  * @param {boolean} value `true` to open, `false` to close.
@@ -33,14 +36,42 @@ declare class Popup {
33
36
  height: string | undefined;
34
37
  };
35
38
  observer: IntersectionObserver;
39
+ /**
40
+ * A reference to the `<dialog>` element used for the popup, which also serves as the backdrop.
41
+ * This is `undefined` while the element is not in the DOM tree, which is the case for a closed
42
+ * popup that doesn’t keep its content.
43
+ * @type {HTMLDialogElement | undefined}
44
+ */
45
+ popupElement: HTMLDialogElement | undefined;
46
+ /**
47
+ * A reference to the element holding the popup content. Unlike {@link popupElement}, which a
48
+ * nested popup shares with its parent, this element belongs to this popup alone, so it’s the one
49
+ * that carries the {@link id} and that the anchor’s `aria-controls` points at.
50
+ * @type {HTMLElement | undefined}
51
+ */
52
+ contentElement: HTMLElement | undefined;
36
53
  anchorElement: HTMLButtonElement;
37
- popupElement: HTMLDialogElement;
38
54
  position: PopupPosition;
39
55
  positionBaseElement: HTMLElement;
40
56
  id: string;
41
57
  intersectionObserver: IntersectionObserver;
42
58
  resizeObserver: ResizeObserver;
43
59
  _rafId: number;
60
+ /**
61
+ * Attach the `<dialog>` element used for the popup. This is called every time the element is
62
+ * mounted, which happens on each open.
63
+ * @param {HTMLDialogElement} popupElement `<dialog>` element to be used for the popup. A nested
64
+ * popup shares this with its parent, so it must not be labelled as belonging to this popup.
65
+ * @param {HTMLElement} [contentElement] Element holding this popup’s content. When omitted, the
66
+ * `popupElement` is assumed to hold the content on its own.
67
+ */
68
+ attachPopupElement(popupElement: HTMLDialogElement, contentElement?: HTMLElement | undefined): void;
69
+ /**
70
+ * Detach the `<dialog>` element, typically because it’s being unmounted. The `aria-controls`
71
+ * attribute on the anchor is left to the {@link open} setter, which removes it once the closing
72
+ * animation is complete.
73
+ */
74
+ detachPopupElement(): void;
44
75
  /**
45
76
  * Whether the anchor element is disabled.
46
77
  * @type {boolean}
@@ -52,7 +83,8 @@ declare class Popup {
52
83
  */
53
84
  get isReadOnly(): boolean;
54
85
  /**
55
- * Check the position of the anchor element.
86
+ * Check the position of the anchor element. This is a no-op while the popup element is not in the
87
+ * DOM tree; the caller is expected to call this again once the element is attached.
56
88
  */
57
89
  checkPosition(): void;
58
90
  /**