@sveltia/ui 0.55.1 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/listbox/option.svelte +107 -33
- package/dist/components/select/combobox.svelte +126 -50
- package/dist/components/text-editor/constants.d.ts +0 -4
- package/dist/components/text-editor/constants.js +0 -5
- package/dist/components/text-editor/toolbar/{insert-image-button.svelte → insert-item-button.svelte} +12 -5
- package/dist/components/text-editor/toolbar/{insert-image-button.svelte.d.ts → insert-item-button.svelte.d.ts} +5 -5
- package/dist/components/text-editor/toolbar/insert-menu-button.svelte +6 -2
- package/dist/components/text-editor/toolbar/text-editor-toolbar.svelte +12 -16
- package/dist/components/util/modal.svelte +210 -66
- package/dist/components/util/popup.svelte +28 -20
- package/dist/locales/nl.yaml +113 -0
- package/dist/locales/pl.yaml +113 -0
- package/dist/services/group.svelte.d.ts +57 -0
- package/dist/services/group.svelte.js +294 -7
- package/dist/services/popup.svelte.d.ts +37 -5
- package/dist/services/popup.svelte.js +132 -19
- package/dist/services/select.svelte.d.ts +49 -0
- package/dist/services/select.svelte.js +99 -1
- package/dist/typedefs.d.ts +38 -2
- package/dist/typedefs.js +17 -2
- package/package.json +7 -7
|
@@ -29,10 +29,33 @@ export class Group {
|
|
|
29
29
|
childSelectedProp: string;
|
|
30
30
|
focusChild: boolean;
|
|
31
31
|
selectFirst: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Whether a member’s `aria-controls` target is a panel this group owns, as with a tab and its
|
|
34
|
+
* tabpanel. Only then may the group hide the target when the member isn’t selected. Elsewhere
|
|
35
|
+
* `aria-controls` means something quite different — on a menu item it points at the submenu the
|
|
36
|
+
* item opens, and on a toolbar button at the region it acts on — and hiding those would break
|
|
37
|
+
* the very widget the member controls.
|
|
38
|
+
* @type {boolean}
|
|
39
|
+
*/
|
|
40
|
+
controlsPanel: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Which member holds the group’s single tab stop. `selected` suits widgets where one member is
|
|
43
|
+
* the natural entry point, such as the checked radio or the current tab. `first` suits menus,
|
|
44
|
+
* where any number of items can be checked at once, so the checked state says nothing about
|
|
45
|
+
* where the keyboard should land.
|
|
46
|
+
* @type {'selected' | 'first'}
|
|
47
|
+
*/
|
|
48
|
+
rovingTabStop: "selected" | "first";
|
|
32
49
|
/**
|
|
33
50
|
* Activate the members.
|
|
34
51
|
*/
|
|
35
52
|
activate(): void;
|
|
53
|
+
/**
|
|
54
|
+
* Put exactly one member in the tab sequence, as a composite widget should. Giving every checked
|
|
55
|
+
* member `tabindex="0"` would scatter tab stops through the widget — a menu with two checked
|
|
56
|
+
* items would take three tab presses to step over.
|
|
57
|
+
*/
|
|
58
|
+
updateTabStop(): void;
|
|
36
59
|
/**
|
|
37
60
|
* CSS selector to retrieve the members.
|
|
38
61
|
* @type {string}
|
|
@@ -48,6 +71,40 @@ export class Group {
|
|
|
48
71
|
* @type {HTMLElement[]}
|
|
49
72
|
*/
|
|
50
73
|
get activeMembers(): HTMLElement[];
|
|
74
|
+
/**
|
|
75
|
+
* The element that opens this menu, either a menu button or a menu item in the menu above.
|
|
76
|
+
* @type {HTMLElement | null}
|
|
77
|
+
*/
|
|
78
|
+
get opener(): HTMLElement | null;
|
|
79
|
+
/**
|
|
80
|
+
* The menu item that opens this menu, when this menu is a submenu. A menu button is deliberately
|
|
81
|
+
* excluded, because closing a top-level menu is not what “back to the parent item” means.
|
|
82
|
+
* @type {HTMLElement | null}
|
|
83
|
+
*/
|
|
84
|
+
get parentMenuItem(): HTMLElement | null;
|
|
85
|
+
/**
|
|
86
|
+
* Close this menu along with every menu above it.
|
|
87
|
+
* @returns {HTMLElement | null} The element that opens the outermost menu, so the caller can
|
|
88
|
+
* hand focus back to it.
|
|
89
|
+
*/
|
|
90
|
+
closeMenuChain(): HTMLElement | null;
|
|
91
|
+
/**
|
|
92
|
+
* Leave the menu the way Tab should: close it along with every menu above it, then carry focus on
|
|
93
|
+
* to whatever follows the outermost opener. The browser can’t be left to do this itself, because
|
|
94
|
+
* a modal `<dialog>` confines Tab to its own contents.
|
|
95
|
+
* @param {boolean} backwards Whether to move to the previous tab stop instead, as Shift+Tab.
|
|
96
|
+
*/
|
|
97
|
+
leaveMenu(backwards: boolean): Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* Open the submenu of the given menu item if needed, and move focus onto its first item.
|
|
100
|
+
* @param {HTMLElement} item Menu item with `aria-haspopup="menu"`.
|
|
101
|
+
*/
|
|
102
|
+
enterSubmenu(item: HTMLElement): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* Close this submenu and move focus back onto the menu item that opens it.
|
|
105
|
+
* @param {HTMLElement} item Menu item with `aria-haspopup="menu"`.
|
|
106
|
+
*/
|
|
107
|
+
leaveSubmenu(item: HTMLElement): void;
|
|
51
108
|
/**
|
|
52
109
|
* Get the currently selected member.
|
|
53
110
|
* @type {HTMLElement | undefined}
|
|
@@ -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 {
|
|
122
|
-
|
|
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 =
|
|
155
|
-
|
|
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
|
|
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
|
/**
|