@sveltia/ui 0.78.3 → 0.79.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/alert/alert-content.svelte +51 -0
- package/dist/components/alert/alert-content.svelte.d.ts +39 -0
- package/dist/components/alert/alert.d.ts +3 -0
- package/dist/components/alert/alert.js +21 -0
- package/dist/components/alert/alert.svelte +6 -30
- package/dist/components/alert/infobar.svelte +7 -21
- package/dist/components/alert/infobar.svelte.d.ts +2 -2
- package/dist/components/button/button.svelte +11 -11
- package/dist/components/button/button.svelte.d.ts +2 -2
- package/dist/components/button/select-button.svelte +1 -1
- package/dist/components/button/select-button.svelte.d.ts +2 -2
- package/dist/components/button/split-button.svelte +1 -1
- package/dist/components/button/split-button.svelte.d.ts +3 -3
- package/dist/components/calendar/calendar.d.ts +2 -0
- package/dist/components/calendar/calendar.js +34 -0
- package/dist/components/calendar/calendar.svelte +49 -5
- package/dist/components/checkbox/checkbox.svelte +11 -4
- package/dist/components/checkbox/checkbox.svelte.d.ts +2 -2
- package/dist/components/dialog/alert-dialog.svelte +1 -1
- package/dist/components/dialog/alert-dialog.svelte.d.ts +2 -2
- package/dist/components/dialog/confirmation-dialog.svelte +1 -1
- package/dist/components/dialog/confirmation-dialog.svelte.d.ts +2 -2
- package/dist/components/dialog/dialog.svelte +8 -18
- package/dist/components/dialog/dialog.svelte.d.ts +2 -2
- package/dist/components/dialog/modal-close-button.svelte +50 -0
- package/dist/components/dialog/modal-close-button.svelte.d.ts +43 -0
- package/dist/components/dialog/prompt-dialog.svelte +1 -1
- package/dist/components/dialog/prompt-dialog.svelte.d.ts +4 -4
- package/dist/components/disclosure/disclosure.svelte +1 -1
- package/dist/components/drawer/drawer.svelte +12 -34
- package/dist/components/drawer/drawer.svelte.d.ts +1 -1
- package/dist/components/emoji/caret.d.ts +1 -1
- package/dist/components/emoji/caret.js +1 -1
- package/dist/components/emoji/emoji-suggestions.svelte +1 -1
- package/dist/components/emoji/emoji-suggestions.svelte.d.ts +3 -3
- package/dist/components/emoji/emoji.d.ts +2 -2
- package/dist/components/emoji/emoji.js +1 -1
- package/dist/components/emoji/position.d.ts +2 -2
- package/dist/components/emoji/position.js +1 -1
- package/dist/components/grid/grid-body.svelte +6 -2
- package/dist/components/grid/grid-cell.svelte +3 -9
- package/dist/components/grid/grid-col-header.svelte +3 -9
- package/dist/components/grid/grid-foot.svelte +4 -11
- package/dist/components/grid/grid-head.svelte +4 -11
- package/dist/components/grid/grid-row-header.svelte +3 -10
- package/dist/components/grid/grid-row.svelte +3 -16
- package/dist/components/listbox/listbox.svelte +1 -1
- package/dist/components/listbox/listbox.svelte.d.ts +1 -1
- package/dist/components/listbox/option.svelte +1 -1
- package/dist/components/listbox/option.svelte.d.ts +2 -2
- package/dist/components/menu/menu-button.svelte +2 -2
- package/dist/components/menu/menu-button.svelte.d.ts +4 -4
- package/dist/components/menu/menu-item-checkable.svelte +71 -0
- package/dist/components/menu/menu-item-checkable.svelte.d.ts +25 -0
- package/dist/components/menu/menu-item-checkbox.svelte +3 -36
- package/dist/components/menu/menu-item-checkbox.svelte.d.ts +3 -3
- package/dist/components/menu/menu-item-group.svelte +8 -5
- package/dist/components/menu/menu-item-group.svelte.d.ts +9 -1
- package/dist/components/menu/menu-item-radio.svelte +3 -36
- package/dist/components/menu/menu-item-radio.svelte.d.ts +3 -3
- package/dist/components/menu/menu-item.svelte +22 -6
- package/dist/components/menu/menu-item.svelte.d.ts +4 -4
- package/dist/components/radio/radio.svelte +12 -1
- package/dist/components/resizable-pane/resizable-handle.svelte +14 -18
- package/dist/components/resizable-pane/resizable-pane-group.svelte +9 -6
- package/dist/components/resizable-pane/resizable-pane.svelte +4 -4
- package/dist/components/resizable-pane/resizable-pane.svelte.d.ts +7 -6
- package/dist/components/resizable-pane/sizing.d.ts +5 -4
- package/dist/components/resizable-pane/sizing.js +84 -25
- package/dist/components/scroll/infinite-scroll.svelte +16 -21
- package/dist/components/select/combobox.svelte +43 -52
- package/dist/components/select/combobox.svelte.d.ts +2 -2
- package/dist/components/select/select-tags.svelte +7 -1
- package/dist/components/select/select.svelte +2 -22
- package/dist/components/select/select.svelte.d.ts +1 -1
- package/dist/components/slider/slider.d.ts +6 -0
- package/dist/components/slider/slider.js +55 -2
- package/dist/components/slider/slider.svelte +63 -32
- package/dist/components/table/table-cell.svelte +3 -9
- package/dist/components/table/table-col-header.svelte +3 -9
- package/dist/components/table/table-foot.svelte +4 -11
- package/dist/components/table/table-head.svelte +4 -11
- package/dist/components/table/table-part.svelte +69 -0
- package/dist/components/table/table-part.svelte.d.ts +48 -0
- package/dist/components/table/table-row-header.svelte +3 -10
- package/dist/components/table/table-row.svelte +3 -10
- package/dist/components/tabs/tab-list.svelte +12 -0
- package/dist/components/tabs/tab.svelte +1 -1
- package/dist/components/tabs/tab.svelte.d.ts +2 -2
- package/dist/components/text-editor/constants.d.ts +5 -5
- package/dist/components/text-editor/constants.js +1 -1
- package/dist/components/text-editor/core.d.ts +4 -5
- package/dist/components/text-editor/core.js +376 -273
- package/dist/components/text-editor/emoji-autocomplete.svelte +1 -1
- package/dist/components/text-editor/lexical-root.svelte +1 -1
- package/dist/components/text-editor/raw-markdown.d.ts +6 -33
- package/dist/components/text-editor/raw-markdown.js +63 -8
- package/dist/components/text-editor/shiki/facade.d.ts +1 -1
- package/dist/components/text-editor/shiki/facade.js +63 -48
- package/dist/components/text-editor/shiki/generated.d.ts +1 -1
- package/dist/components/text-editor/shiki/generated.js +1 -1
- package/dist/components/text-editor/shiki/highlighter.d.ts +6 -1
- package/dist/components/text-editor/shiki/highlighter.js +3 -4
- package/dist/components/text-editor/shiki/loader.d.ts +1 -1
- package/dist/components/text-editor/shiki/loader.js +1 -1
- package/dist/components/text-editor/shiki/version.d.ts +1 -1
- package/dist/components/text-editor/shiki/version.js +1 -1
- package/dist/components/text-editor/store.svelte.d.ts +1 -1
- package/dist/components/text-editor/store.svelte.js +5 -2
- package/dist/components/text-editor/text-editor.svelte +2 -2
- package/dist/components/text-editor/text-editor.svelte.d.ts +3 -3
- package/dist/components/text-editor/toolbar/code-language-switcher.svelte +24 -16
- package/dist/components/text-editor/toolbar/format-text-button.svelte +7 -31
- package/dist/components/text-editor/toolbar/format-text-button.svelte.d.ts +1 -1
- package/dist/components/text-editor/toolbar/insert-component.d.ts +3 -0
- package/dist/components/text-editor/toolbar/insert-component.js +34 -0
- package/dist/components/text-editor/toolbar/insert-item-button.svelte +4 -24
- package/dist/components/text-editor/toolbar/insert-item-button.svelte.d.ts +1 -1
- package/dist/components/text-editor/toolbar/insert-link-button.svelte +9 -25
- package/dist/components/text-editor/toolbar/insert-menu-button.svelte +4 -24
- package/dist/components/text-editor/toolbar/insert-menu-button.svelte.d.ts +1 -1
- package/dist/components/text-editor/toolbar/text-editor-toolbar.svelte +1 -1
- package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +31 -35
- package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte.d.ts +1 -1
- package/dist/components/text-editor/toolbar/toolbar-wrapper.svelte +0 -10
- package/dist/components/text-editor/toolbar/toolbar-wrapper.svelte.d.ts +1 -11
- package/dist/components/text-editor/transformers/table.d.ts +0 -1
- package/dist/components/text-editor/transformers/table.js +1 -2
- package/dist/components/text-field/emoji-autocomplete.svelte +1 -1
- package/dist/components/text-field/masked-input.svelte +179 -0
- package/dist/components/text-field/masked-input.svelte.d.ts +42 -0
- package/dist/components/text-field/number-input.js +7 -1
- package/dist/components/text-field/number-input.svelte +13 -5
- package/dist/components/text-field/number-input.svelte.d.ts +3 -3
- package/dist/components/text-field/password-input.svelte +3 -116
- package/dist/components/text-field/password-input.svelte.d.ts +3 -3
- package/dist/components/text-field/search-bar.svelte +1 -1
- package/dist/components/text-field/search-bar.svelte.d.ts +3 -3
- package/dist/components/text-field/secret-input.svelte +3 -119
- package/dist/components/text-field/secret-input.svelte.d.ts +3 -3
- package/dist/components/text-field/text-area.svelte +1 -1
- package/dist/components/text-field/text-area.svelte.d.ts +3 -3
- package/dist/components/text-field/text-input.svelte +1 -1
- package/dist/components/text-field/text-input.svelte.d.ts +3 -3
- package/dist/components/toast/toast.svelte +11 -20
- package/dist/components/toast/toast.svelte.d.ts +1 -1
- package/dist/components/tree/tree-item.svelte +1 -1
- package/dist/components/tree/tree-item.svelte.d.ts +1 -1
- package/dist/components/tree/tree.svelte +1 -1
- package/dist/components/tree/tree.svelte.d.ts +1 -1
- package/dist/components/util/font-links.svelte +1 -0
- package/dist/components/util/modal.svelte +37 -1
- package/dist/components/util/modal.svelte.d.ts +1 -1
- package/dist/components/util/popup.svelte +2 -5
- package/dist/components/util/popup.svelte.d.ts +2 -2
- package/dist/components/util/visibility-observer.svelte +1 -0
- package/dist/locales/ar.yaml +1 -1
- package/dist/locales/sk.yaml +136 -0
- package/dist/services/group.svelte.d.ts +10 -1
- package/dist/services/group.svelte.js +361 -251
- package/dist/services/navigation.d.ts +19 -0
- package/dist/services/navigation.js +84 -0
- package/dist/services/popup-position.d.ts +90 -0
- package/dist/services/popup-position.js +275 -0
- package/dist/services/popup.svelte.d.ts +16 -17
- package/dist/services/popup.svelte.js +25 -169
- package/dist/services/scroll.d.ts +1 -0
- package/dist/services/scroll.js +17 -0
- package/dist/services/select.svelte.d.ts +2 -2
- package/dist/services/select.svelte.js +1 -1
- package/dist/services/text.d.ts +2 -0
- package/dist/services/text.js +28 -0
- package/dist/services/tree.svelte.d.ts +3 -11
- package/dist/services/tree.svelte.js +35 -77
- package/dist/services/type-ahead.d.ts +0 -4
- package/dist/services/type-ahead.js +3 -1
- package/dist/services/widget.d.ts +8 -0
- package/dist/services/widget.js +39 -0
- package/dist/shiki-engine.js +15 -15
- package/dist/styles/mixins.scss +126 -0
- package/package.json +38 -34
|
@@ -2,36 +2,21 @@ import { isRTL } from '@sveltia/i18n';
|
|
|
2
2
|
import { generateElementId } from '@sveltia/utils/element';
|
|
3
3
|
import { sleep } from '@sveltia/utils/misc';
|
|
4
4
|
import { untrack } from 'svelte';
|
|
5
|
+
import { scrollIntoViewIfNeeded } from './scroll.js';
|
|
5
6
|
import { getSelectedItemDetail } from './select.svelte.js';
|
|
7
|
+
import { normalize } from './text.js';
|
|
8
|
+
import { getArrowKeys, getGridTargetIndex, getLinearTargetIndex } from './navigation.js';
|
|
6
9
|
import { findTypeAheadMatch, TypeAhead } from './type-ahead.js';
|
|
10
|
+
import { activateBefore, activateLater } from './widget.js';
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* @import { Attachment } from 'svelte/attachments';
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
|
-
*
|
|
14
|
-
* performance, since `transliterate` is slow and we only need basic normalization.
|
|
17
|
+
* CSS selector matching the members that are disabled or hidden, which are left out of navigation.
|
|
15
18
|
*/
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Normalize the given string for search value comparison. Since `transliterate` is slow, we only
|
|
20
|
-
* apply basic normalization.
|
|
21
|
-
* @internal
|
|
22
|
-
* @param {string} value Original value.
|
|
23
|
-
* @returns {string} Normalized value.
|
|
24
|
-
* @todo Move this to `@sveltia/utils`.
|
|
25
|
-
*/
|
|
26
|
-
export const normalize = (value) => {
|
|
27
|
-
value = value.trim();
|
|
28
|
-
|
|
29
|
-
if (!value) {
|
|
30
|
-
return '';
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return value.normalize('NFD').replace(DIACRITIC_RE, '').toLocaleLowerCase();
|
|
34
|
-
};
|
|
19
|
+
const INACTIVE_MEMBER_SELECTOR = '[aria-disabled="true"], [aria-hidden="true"]';
|
|
35
20
|
|
|
36
21
|
/**
|
|
37
22
|
* Set an element’s `tabindex` attribute, leaving the DOM alone if it already holds that value.
|
|
@@ -188,6 +173,18 @@ const getMenuOpener = (element) => {
|
|
|
188
173
|
return null;
|
|
189
174
|
};
|
|
190
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Check whether the given member can hold a selected state. A plain menu item is an action, not a
|
|
178
|
+
* choice, so it has no checked state, and a member marked with `data-selectable="false"`, such as
|
|
179
|
+
* the caption row of a collapsible row group in a grid, can be focused but not selected.
|
|
180
|
+
* @internal
|
|
181
|
+
* @param {HTMLElement} element Member element.
|
|
182
|
+
* @param {string | null} [role] The member’s role, if already read.
|
|
183
|
+
* @returns {boolean} Result.
|
|
184
|
+
*/
|
|
185
|
+
const isSelectable = (element, role = element.getAttribute('role')) =>
|
|
186
|
+
role !== 'menuitem' && element.dataset.selectable !== 'false';
|
|
187
|
+
|
|
191
188
|
/**
|
|
192
189
|
* Implement keyboard and mouse interactions for a grouping composite widget.
|
|
193
190
|
*/
|
|
@@ -198,6 +195,12 @@ export class Group {
|
|
|
198
195
|
*/
|
|
199
196
|
activated = false;
|
|
200
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Observer of the members’ selected state, for the widgets whose panels or tab stop follow it.
|
|
200
|
+
* @type {MutationObserver | undefined}
|
|
201
|
+
*/
|
|
202
|
+
selectionObserver = undefined;
|
|
203
|
+
|
|
201
204
|
/**
|
|
202
205
|
* Whether {@link destroy} has run, so a pending {@link activate} must not run.
|
|
203
206
|
* @type {boolean}
|
|
@@ -239,6 +242,13 @@ export class Group {
|
|
|
239
242
|
*/
|
|
240
243
|
#typeAhead = new TypeAhead();
|
|
241
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Whether {@link selectTarget} is clicking a radio button to have its component check it, so the
|
|
247
|
+
* click that comes back to {@link onClick} is not handled as a second selection.
|
|
248
|
+
* @type {boolean}
|
|
249
|
+
*/
|
|
250
|
+
#clickingRadio = false;
|
|
251
|
+
|
|
242
252
|
/**
|
|
243
253
|
* Get the normalized value a member is searched by, computing it only when the underlying raw
|
|
244
254
|
* value has changed since the last call.
|
|
@@ -265,6 +275,30 @@ export class Group {
|
|
|
265
275
|
return normalized;
|
|
266
276
|
}
|
|
267
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Get the panel a member controls, if the group owns such panels. See {@link controlsPanel}.
|
|
280
|
+
* @param {HTMLElement} member Member element.
|
|
281
|
+
* @returns {HTMLElement | null} The member’s `aria-controls` target, or `null` if there’s none
|
|
282
|
+
* or the group doesn’t own its members’ targets.
|
|
283
|
+
*/
|
|
284
|
+
#getControlTarget(member) {
|
|
285
|
+
const controlTargetId = this.controlsPanel ? member.getAttribute('aria-controls') : null;
|
|
286
|
+
|
|
287
|
+
// Looked up by ID rather than with a selector, which would throw on an ID that isn’t a valid
|
|
288
|
+
// CSS identifier, such as one starting with a digit
|
|
289
|
+
return controlTargetId ? document.getElementById(controlTargetId) : null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Show or hide a panel controlled by a member, both to the user and to assistive technology.
|
|
294
|
+
* @param {HTMLElement} panel Panel element.
|
|
295
|
+
* @param {boolean} shown Whether the panel is to be shown.
|
|
296
|
+
*/
|
|
297
|
+
#setPanelShown(panel, shown) {
|
|
298
|
+
panel.inert = !shown;
|
|
299
|
+
panel.setAttribute('aria-hidden', String(!shown));
|
|
300
|
+
}
|
|
301
|
+
|
|
268
302
|
/**
|
|
269
303
|
* Initialize a new `Group` instance.
|
|
270
304
|
* @param {HTMLElement} parent Parent element.
|
|
@@ -281,19 +315,10 @@ export class Group {
|
|
|
281
315
|
this.parentGroupSelector = `[role="group"], [role="${this.role}"]`;
|
|
282
316
|
this.clickToSelect = clickToSelect;
|
|
283
317
|
|
|
284
|
-
// eslint-disable-next-line jsdoc/require-description
|
|
285
318
|
/** @type {(event: MouseEvent) => void} */
|
|
286
|
-
this._onClick = (event) =>
|
|
287
|
-
this.activate();
|
|
288
|
-
this.onClick(event);
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
// eslint-disable-next-line jsdoc/require-description
|
|
319
|
+
this._onClick = activateBefore(this, (event) => this.onClick(event));
|
|
292
320
|
/** @type {(event: KeyboardEvent) => void} */
|
|
293
|
-
this._onKeyDown = (event) =>
|
|
294
|
-
this.activate();
|
|
295
|
-
this.onKeyDown(event);
|
|
296
|
-
};
|
|
321
|
+
this._onKeyDown = activateBefore(this, (event) => this.onKeyDown(event));
|
|
297
322
|
|
|
298
323
|
const {
|
|
299
324
|
orientation,
|
|
@@ -354,20 +379,30 @@ export class Group {
|
|
|
354
379
|
attributeFilter: ['aria-disabled', 'aria-hidden'],
|
|
355
380
|
});
|
|
356
381
|
|
|
382
|
+
// A member can also be selected from code, as when a `<Tab>`’s `selected` prop changes. The
|
|
383
|
+
// group’s own writes are already in sync, so this only matters for the ones made elsewhere: the
|
|
384
|
+
// panels and the roving tab stop have to follow the new selection.
|
|
385
|
+
if (controlsPanel || (focusChild && rovingTabStop === 'selected')) {
|
|
386
|
+
this.selectionObserver = new globalThis.MutationObserver(() => {
|
|
387
|
+
if (this.activated) {
|
|
388
|
+
this.syncSelection();
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
this.selectionObserver.observe(parent, {
|
|
393
|
+
subtree: true,
|
|
394
|
+
attributes: true,
|
|
395
|
+
attributeFilter: [childSelectedAttr],
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
357
399
|
// Listen right away: a click or a key press that comes before the activation below activates
|
|
358
400
|
// the members first, rather than being lost
|
|
359
401
|
parent.addEventListener('click', this._onClick);
|
|
360
402
|
parent.addEventListener('keydown', this._onKeyDown);
|
|
361
403
|
|
|
362
404
|
// Wait a bit before the relevant components, including the `aria-controls` target are mounted
|
|
363
|
-
(
|
|
364
|
-
await sleep(100);
|
|
365
|
-
|
|
366
|
-
// The widget may have been unmounted in the meantime
|
|
367
|
-
if (!this.#destroyed) {
|
|
368
|
-
this.activate();
|
|
369
|
-
}
|
|
370
|
-
})();
|
|
405
|
+
activateLater(this, () => this.#destroyed);
|
|
371
406
|
}
|
|
372
407
|
|
|
373
408
|
/**
|
|
@@ -388,30 +423,21 @@ export class Group {
|
|
|
388
423
|
element.getAttribute(this.childSelectedAttr) === 'true' ||
|
|
389
424
|
(defaultSelected ? element === defaultSelected : this.selectFirst && index === 0);
|
|
390
425
|
|
|
391
|
-
const
|
|
392
|
-
// Looked up by ID rather than with a selector, which would throw on an ID that isn’t a valid
|
|
393
|
-
// CSS identifier, such as one starting with a digit
|
|
394
|
-
const controlTarget = controlTargetId ? document.getElementById(controlTargetId) : null;
|
|
426
|
+
const controlTarget = this.#getControlTarget(element);
|
|
395
427
|
|
|
396
428
|
element.id ||= `${this.id}-item-${index + 1}`;
|
|
397
|
-
|
|
429
|
+
|
|
430
|
+
if (isSelectable(element)) {
|
|
431
|
+
element.setAttribute(this.childSelectedAttr, String(isSelected));
|
|
432
|
+
}
|
|
398
433
|
|
|
399
434
|
if (controlTarget) {
|
|
400
|
-
controlTarget.inert = !isSelected;
|
|
401
435
|
controlTarget.setAttribute('aria-labelledby', element.id);
|
|
402
|
-
controlTarget
|
|
436
|
+
this.#setPanelShown(controlTarget, isSelected);
|
|
403
437
|
|
|
404
438
|
if (isSelected) {
|
|
405
439
|
globalThis.setTimeout(() => {
|
|
406
|
-
|
|
407
|
-
controlTarget.scrollIntoView({
|
|
408
|
-
block: 'nearest',
|
|
409
|
-
inline: 'nearest',
|
|
410
|
-
behavior: 'auto',
|
|
411
|
-
});
|
|
412
|
-
} catch {
|
|
413
|
-
controlTarget.scrollIntoView(true);
|
|
414
|
-
}
|
|
440
|
+
scrollIntoViewIfNeeded(controlTarget);
|
|
415
441
|
}, 300);
|
|
416
442
|
}
|
|
417
443
|
}
|
|
@@ -454,11 +480,7 @@ export class Group {
|
|
|
454
480
|
focused ??
|
|
455
481
|
(holders.length === 1
|
|
456
482
|
? holders[0]
|
|
457
|
-
: ((this.rovingTabStop === 'selected'
|
|
458
|
-
? activeMembers.find(
|
|
459
|
-
(element) => element.getAttribute(this.childSelectedAttr) === 'true',
|
|
460
|
-
)
|
|
461
|
-
: undefined) ?? activeMembers[0]));
|
|
483
|
+
: ((this.rovingTabStop === 'selected' ? this.selected : undefined) ?? activeMembers[0]));
|
|
462
484
|
|
|
463
485
|
allMembers.forEach((element) => {
|
|
464
486
|
// Only touch the DOM when it changes; this runs on every mutation of a large widget. The
|
|
@@ -468,6 +490,39 @@ export class Group {
|
|
|
468
490
|
});
|
|
469
491
|
}
|
|
470
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Bring the panels and the roving tab stop in line with the members’ selected state, after it has
|
|
495
|
+
* been changed from outside the group.
|
|
496
|
+
*/
|
|
497
|
+
syncSelection() {
|
|
498
|
+
const { allMembers, parent } = this;
|
|
499
|
+
|
|
500
|
+
if (this.controlsPanel) {
|
|
501
|
+
allMembers.forEach((element) => {
|
|
502
|
+
const controlTarget = this.#getControlTarget(element);
|
|
503
|
+
|
|
504
|
+
if (controlTarget) {
|
|
505
|
+
this.#setPanelShown(
|
|
506
|
+
controlTarget,
|
|
507
|
+
element.getAttribute(this.childSelectedAttr) === 'true',
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// The tab stop moves to the newly selected member, unless the user is in the widget, in which
|
|
514
|
+
// case it stays where they are
|
|
515
|
+
if (this.focusChild && !parent.contains(document.activeElement)) {
|
|
516
|
+
const { selected } = this;
|
|
517
|
+
|
|
518
|
+
if (selected) {
|
|
519
|
+
allMembers.forEach((element) => {
|
|
520
|
+
setTabIndex(element, element === selected ? 0 : -1);
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
471
526
|
/**
|
|
472
527
|
* Count the columns of a grid layout from where the members sit, rather than from their widths:
|
|
473
528
|
* a row of a data grid spans the full width, a tile in a grid listbox doesn’t, and a header row
|
|
@@ -516,9 +571,7 @@ export class Group {
|
|
|
516
571
|
|
|
517
572
|
this.#memberCache = {
|
|
518
573
|
all,
|
|
519
|
-
active: all.filter(
|
|
520
|
-
(element) => !element.matches('[aria-disabled="true"], [aria-hidden="true"]'),
|
|
521
|
-
),
|
|
574
|
+
active: all.filter((element) => !element.matches(INACTIVE_MEMBER_SELECTOR)),
|
|
522
575
|
};
|
|
523
576
|
}
|
|
524
577
|
|
|
@@ -631,7 +684,7 @@ export class Group {
|
|
|
631
684
|
for (let i = 0; i < 20; i += 1) {
|
|
632
685
|
const first = /** @type {HTMLElement | undefined} */ (
|
|
633
686
|
[...(submenu?.querySelectorAll(this.selector) ?? [])].find(
|
|
634
|
-
(element) => !element.matches(
|
|
687
|
+
(element) => !element.matches(INACTIVE_MEMBER_SELECTOR),
|
|
635
688
|
)
|
|
636
689
|
);
|
|
637
690
|
|
|
@@ -692,6 +745,141 @@ export class Group {
|
|
|
692
745
|
return this.role === 'grid' || (this.role === 'listbox' && this.parent.matches('.grid'));
|
|
693
746
|
}
|
|
694
747
|
|
|
748
|
+
/**
|
|
749
|
+
* Set a member’s selected state and tell its component about the change.
|
|
750
|
+
* @param {HTMLElement} element Member element.
|
|
751
|
+
* @param {boolean} selected Whether the member is selected.
|
|
752
|
+
*/
|
|
753
|
+
#setMemberSelected(element, selected) {
|
|
754
|
+
element.setAttribute(this.childSelectedAttr, String(selected));
|
|
755
|
+
element.dispatchEvent(
|
|
756
|
+
new CustomEvent('Change', { detail: { [this.childSelectedProp]: selected } }),
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Click a radio button, so its component checks itself and updates its bound group. The click
|
|
762
|
+
* bubbles back up to the group, which must not take it as another selection.
|
|
763
|
+
* @param {HTMLElement} element Radio button.
|
|
764
|
+
*/
|
|
765
|
+
#clickRadio(element) {
|
|
766
|
+
this.#clickingRadio = true;
|
|
767
|
+
|
|
768
|
+
try {
|
|
769
|
+
element.click();
|
|
770
|
+
} finally {
|
|
771
|
+
this.#clickingRadio = false;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Apply a selection made on the target to one of the members taking part in it: activate a plain
|
|
777
|
+
* menu item, toggle a member of a multi-select group, or check or uncheck a member of a
|
|
778
|
+
* single-select group.
|
|
779
|
+
* @param {MouseEvent | KeyboardEvent} event Triggered event.
|
|
780
|
+
* @param {HTMLElement} element Member element.
|
|
781
|
+
* @param {object} context Selection context.
|
|
782
|
+
* @param {string | null} context.role The member’s role.
|
|
783
|
+
* @param {boolean} context.isTarget Whether the member is the target.
|
|
784
|
+
* @param {boolean} context.targetSelectable Whether the target can hold a selected state.
|
|
785
|
+
* @param {boolean} context.selecting Whether the event is a click or Space, which selects, rather
|
|
786
|
+
* than a key that merely moves.
|
|
787
|
+
* @returns {boolean} Whether the member’s selection changed, or an action was taken on it.
|
|
788
|
+
*/
|
|
789
|
+
#applySelection(event, element, { role, isTarget, targetSelectable, selecting }) {
|
|
790
|
+
const isMenuItemRadio = role === 'menuitemradio';
|
|
791
|
+
const selectable = isSelectable(element, role);
|
|
792
|
+
const multiSelect = selectable && (role === 'menuitemcheckbox' || this.multi);
|
|
793
|
+
const singleSelect = selectable && targetSelectable && (isMenuItemRadio || !multiSelect);
|
|
794
|
+
const isSelected = element.getAttribute(this.childSelectedAttr) === 'true';
|
|
795
|
+
let changed = false;
|
|
796
|
+
|
|
797
|
+
// A plain menu item is activated, never checked, and only by a click or Space: the arrow
|
|
798
|
+
// keys merely move to it
|
|
799
|
+
if (role === 'menuitem' && isTarget && selecting) {
|
|
800
|
+
changed = true;
|
|
801
|
+
element.dispatchEvent(new CustomEvent('Select'));
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (multiSelect && isTarget && selecting) {
|
|
805
|
+
changed = true;
|
|
806
|
+
this.#setMemberSelected(element, !isSelected);
|
|
807
|
+
|
|
808
|
+
if (!isSelected) {
|
|
809
|
+
element.dispatchEvent(new CustomEvent('Select'));
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (singleSelect && isSelected !== isTarget && (isMenuItemRadio ? selecting : true)) {
|
|
814
|
+
changed = true;
|
|
815
|
+
this.#setMemberSelected(element, isTarget);
|
|
816
|
+
|
|
817
|
+
if (isTarget) {
|
|
818
|
+
if (event.type === 'keydown' && role === 'radio') {
|
|
819
|
+
this.#clickRadio(element);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
element.dispatchEvent(new CustomEvent('Select'));
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return changed;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Move the cursor onto the target, or off any other member taking part in a selection: the
|
|
831
|
+
* focused state where the group itself holds focus, the panel the member controls, and the
|
|
832
|
+
* group’s active descendant.
|
|
833
|
+
* @param {HTMLElement} element Member element.
|
|
834
|
+
* @param {boolean} isTarget Whether the member is the target.
|
|
835
|
+
* @param {HTMLElement[]} scrollTargets Elements to be scrolled into view, which the target and
|
|
836
|
+
* its panel are added to.
|
|
837
|
+
*/
|
|
838
|
+
#moveCursor(element, isTarget, scrollTargets) {
|
|
839
|
+
const controlTarget = this.#getControlTarget(element);
|
|
840
|
+
|
|
841
|
+
if (!this.focusChild) {
|
|
842
|
+
element.classList.toggle('focused', isTarget);
|
|
843
|
+
|
|
844
|
+
if (isTarget) {
|
|
845
|
+
element.dispatchEvent(new CustomEvent('Focus'));
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (controlTarget) {
|
|
850
|
+
this.#setPanelShown(controlTarget, isTarget);
|
|
851
|
+
|
|
852
|
+
if (isTarget) {
|
|
853
|
+
scrollTargets.push(controlTarget);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (isTarget) {
|
|
858
|
+
this.parent.setAttribute('aria-activedescendant', element.id);
|
|
859
|
+
scrollTargets.push(element);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Move the roving tab stop and the focus onto the target.
|
|
865
|
+
* @param {HTMLElement[]} affected Members that took part in the selection.
|
|
866
|
+
* @param {HTMLElement} newTarget Target element.
|
|
867
|
+
* @param {boolean} targetAffected Whether the target itself took part, meaning it’s the one to
|
|
868
|
+
* receive focus.
|
|
869
|
+
*/
|
|
870
|
+
#moveFocus(affected, newTarget, targetAffected) {
|
|
871
|
+
// Done right away rather than on the next frame: a key that repeats, or a second press before
|
|
872
|
+
// the frame, has to start from the member that was just reached, which it reads from the focus
|
|
873
|
+
affected.forEach((element) => {
|
|
874
|
+
setTabIndex(element, element === newTarget ? 0 : -1);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
if (targetAffected) {
|
|
878
|
+
newTarget.focus();
|
|
879
|
+
newTarget.dispatchEvent(new CustomEvent('Focus'));
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
695
883
|
/**
|
|
696
884
|
* Select (and move focus to) the given target.
|
|
697
885
|
* @param {(MouseEvent | KeyboardEvent)} event Triggered event.
|
|
@@ -706,10 +894,14 @@ export class Group {
|
|
|
706
894
|
|
|
707
895
|
const targetRole = newTarget.getAttribute('role');
|
|
708
896
|
const targetParent = newTarget.closest(this.parentGroupSelector);
|
|
709
|
-
|
|
897
|
+
// Moving to a member that can’t be selected, such as a plain menu item, leaves the selection
|
|
898
|
+
// as it is, rather than clearing it as moving to another member of a single-select group does
|
|
899
|
+
const targetSelectable = isSelectable(newTarget, targetRole);
|
|
710
900
|
|
|
711
|
-
|
|
712
|
-
|
|
901
|
+
// A click or Space selects; the arrow keys and the like merely move
|
|
902
|
+
const selecting =
|
|
903
|
+
event.type === 'click' ||
|
|
904
|
+
(event.type === 'keydown' && /** @type {KeyboardEvent} */ (event).key === ' ');
|
|
713
905
|
|
|
714
906
|
/**
|
|
715
907
|
* Members that took part in this selection, and whose roving `tabindex` therefore has to be
|
|
@@ -727,82 +919,36 @@ export class Group {
|
|
|
727
919
|
* @type {HTMLElement[]}
|
|
728
920
|
*/
|
|
729
921
|
const scrollTargets = [];
|
|
922
|
+
/**
|
|
923
|
+
* Whether the selection changed, or an action was taken on a menu item, which is what the
|
|
924
|
+
* group’s own `Change` event reports. Moving the cursor alone, or selecting the member that is
|
|
925
|
+
* already selected, changes nothing.
|
|
926
|
+
* @type {boolean}
|
|
927
|
+
*/
|
|
928
|
+
let changed = false;
|
|
730
929
|
|
|
731
930
|
this.activeMembers.forEach((element) => {
|
|
732
931
|
// Reading the role once and comparing it is markedly cheaper than putting every member
|
|
733
932
|
// through the selector engine three times over
|
|
734
933
|
const role = element.getAttribute('role');
|
|
735
|
-
const isMenuItemCheckbox = role === 'menuitemcheckbox';
|
|
736
|
-
const isMenuItemRadio = role === 'menuitemradio';
|
|
737
934
|
|
|
738
935
|
if (
|
|
739
|
-
(
|
|
936
|
+
(role === 'menuitemcheckbox' || role === 'menuitemradio') &&
|
|
740
937
|
(role !== targetRole || element.closest(this.parentGroupSelector) !== targetParent)
|
|
741
938
|
) {
|
|
742
939
|
return;
|
|
743
940
|
}
|
|
744
941
|
|
|
745
|
-
const multiSelect = isMenuItemCheckbox || this.multi;
|
|
746
|
-
const singleSelect = isMenuItemRadio || !multiSelect;
|
|
747
942
|
const isTarget = element === newTarget;
|
|
748
|
-
const isSelected = element.getAttribute(this.childSelectedAttr) === 'true';
|
|
749
|
-
const controlTargetId = this.controlsPanel ? element.getAttribute('aria-controls') : null;
|
|
750
|
-
const controlTarget = controlTargetId ? document.getElementById(controlTargetId) : null;
|
|
751
943
|
|
|
752
944
|
affected.push(element);
|
|
753
945
|
targetAffected ||= isTarget;
|
|
754
946
|
|
|
755
|
-
if (
|
|
756
|
-
|
|
757
|
-
element.dispatchEvent(
|
|
758
|
-
new CustomEvent('Change', { detail: { [this.childSelectedProp]: !isSelected } }),
|
|
759
|
-
);
|
|
760
|
-
|
|
761
|
-
if (!isSelected) {
|
|
762
|
-
element.dispatchEvent(new CustomEvent('Select'));
|
|
763
|
-
}
|
|
947
|
+
if (this.#applySelection(event, element, { role, isTarget, targetSelectable, selecting })) {
|
|
948
|
+
changed = true;
|
|
764
949
|
}
|
|
765
950
|
|
|
766
|
-
|
|
767
|
-
singleSelect &&
|
|
768
|
-
isSelected !== isTarget &&
|
|
769
|
-
(isMenuItemRadio ? selectByKeydown || selectByClick : true)
|
|
770
|
-
) {
|
|
771
|
-
element.setAttribute(this.childSelectedAttr, String(isTarget));
|
|
772
|
-
element.dispatchEvent(
|
|
773
|
-
new CustomEvent('Change', { detail: { [this.childSelectedProp]: isTarget } }),
|
|
774
|
-
);
|
|
775
|
-
|
|
776
|
-
if (isTarget) {
|
|
777
|
-
if (event.type === 'keydown' && role === 'radio') {
|
|
778
|
-
element.click();
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
element.dispatchEvent(new CustomEvent('Select'));
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
if (!this.focusChild) {
|
|
786
|
-
element.classList.toggle('focused', isTarget);
|
|
787
|
-
|
|
788
|
-
if (isTarget) {
|
|
789
|
-
element.dispatchEvent(new CustomEvent('Focus'));
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
if (controlTarget) {
|
|
794
|
-
controlTarget.inert = !isTarget;
|
|
795
|
-
controlTarget.setAttribute('aria-hidden', String(!isTarget));
|
|
796
|
-
|
|
797
|
-
if (isTarget) {
|
|
798
|
-
scrollTargets.push(controlTarget);
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
if (isTarget) {
|
|
803
|
-
this.parent.setAttribute('aria-activedescendant', element.id);
|
|
804
|
-
scrollTargets.push(element);
|
|
805
|
-
}
|
|
951
|
+
this.#moveCursor(element, isTarget, scrollTargets);
|
|
806
952
|
});
|
|
807
953
|
|
|
808
954
|
if (scrollTargets.length) {
|
|
@@ -810,22 +956,14 @@ export class Group {
|
|
|
810
956
|
}
|
|
811
957
|
|
|
812
958
|
if (this.focusChild) {
|
|
813
|
-
|
|
814
|
-
// before the frame, has to start from the member that was just reached, which it reads
|
|
815
|
-
// from the focus
|
|
816
|
-
affected.forEach((element) => {
|
|
817
|
-
setTabIndex(element, element === newTarget ? 0 : -1);
|
|
818
|
-
});
|
|
819
|
-
|
|
820
|
-
if (targetAffected) {
|
|
821
|
-
newTarget.focus();
|
|
822
|
-
newTarget.dispatchEvent(new CustomEvent('Focus'));
|
|
823
|
-
}
|
|
959
|
+
this.#moveFocus(affected, newTarget, targetAffected);
|
|
824
960
|
}
|
|
825
961
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
962
|
+
if (changed) {
|
|
963
|
+
this.parent.dispatchEvent(
|
|
964
|
+
new CustomEvent('Change', { detail: getSelectedItemDetail(newTarget) }),
|
|
965
|
+
);
|
|
966
|
+
}
|
|
829
967
|
}
|
|
830
968
|
|
|
831
969
|
/**
|
|
@@ -839,13 +977,7 @@ export class Group {
|
|
|
839
977
|
this.#scrollTimer = globalThis.setTimeout(() => {
|
|
840
978
|
this.#scrollTimer = undefined;
|
|
841
979
|
|
|
842
|
-
elements.forEach(
|
|
843
|
-
try {
|
|
844
|
-
element.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' });
|
|
845
|
-
} catch {
|
|
846
|
-
element.scrollIntoView(true);
|
|
847
|
-
}
|
|
848
|
-
});
|
|
980
|
+
elements.forEach(scrollIntoViewIfNeeded);
|
|
849
981
|
}, 300);
|
|
850
982
|
}
|
|
851
983
|
|
|
@@ -861,7 +993,7 @@ export class Group {
|
|
|
861
993
|
? target
|
|
862
994
|
: /** @type {HTMLElement | null} */ (target.closest(this.selector));
|
|
863
995
|
|
|
864
|
-
if (!newTarget || event.button !== 0 || !this.clickToSelect) {
|
|
996
|
+
if (!newTarget || event.button !== 0 || !this.clickToSelect || this.#clickingRadio) {
|
|
865
997
|
return;
|
|
866
998
|
}
|
|
867
999
|
|
|
@@ -904,9 +1036,8 @@ export class Group {
|
|
|
904
1036
|
|
|
905
1037
|
// eslint-disable-next-line prefer-destructuring
|
|
906
1038
|
const target = /** @type {HTMLElement} */ (event.target);
|
|
907
|
-
const {
|
|
1039
|
+
const { activeMembers } = this;
|
|
908
1040
|
|
|
909
|
-
/** @type {HTMLElement | undefined} */
|
|
910
1041
|
// A field the user is typing in keeps its keys: the caret moves, the text changes. Escape and
|
|
911
1042
|
// Tab still reach the group, so a menu holding a field can be left the usual ways.
|
|
912
1043
|
if (
|
|
@@ -918,17 +1049,7 @@ export class Group {
|
|
|
918
1049
|
return;
|
|
919
1050
|
}
|
|
920
1051
|
|
|
921
|
-
const currentTarget = (
|
|
922
|
-
if (!this.focusChild) {
|
|
923
|
-
return activeMembers.find((member) => member.matches('.focused'));
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
// A key pressed on a control inside a member, such as a checkbox in a grid row, moves from
|
|
927
|
-
// that member
|
|
928
|
-
const member = /** @type {HTMLElement | null} */ (target.closest(this.selector));
|
|
929
|
-
|
|
930
|
-
return member && this.parent.contains(member) ? member : undefined;
|
|
931
|
-
})();
|
|
1052
|
+
const currentTarget = this.#getCurrentTarget(target);
|
|
932
1053
|
|
|
933
1054
|
if (['Enter', ' ', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) {
|
|
934
1055
|
event.preventDefault();
|
|
@@ -964,23 +1085,10 @@ export class Group {
|
|
|
964
1085
|
// browser, a printable key on a non-editable element starts Firefox’s find-as-you-type.
|
|
965
1086
|
event.preventDefault();
|
|
966
1087
|
|
|
967
|
-
const
|
|
968
|
-
normalize(
|
|
969
|
-
member.dataset.label ??
|
|
970
|
-
member.querySelector('.label')?.textContent ??
|
|
971
|
-
member.textContent ??
|
|
972
|
-
'',
|
|
973
|
-
),
|
|
974
|
-
);
|
|
975
|
-
|
|
976
|
-
const index = findTypeAheadMatch(
|
|
977
|
-
labels,
|
|
978
|
-
this.#typeAhead.push(key),
|
|
979
|
-
currentTarget ? activeMembers.indexOf(currentTarget) : -1,
|
|
980
|
-
);
|
|
1088
|
+
const newTarget = this.#getTypeAheadTarget(key, currentTarget);
|
|
981
1089
|
|
|
982
|
-
if (
|
|
983
|
-
this.selectTarget(event,
|
|
1090
|
+
if (newTarget && newTarget !== currentTarget) {
|
|
1091
|
+
this.selectTarget(event, newTarget);
|
|
984
1092
|
}
|
|
985
1093
|
|
|
986
1094
|
return;
|
|
@@ -1015,11 +1123,12 @@ export class Group {
|
|
|
1015
1123
|
}
|
|
1016
1124
|
}
|
|
1017
1125
|
|
|
1126
|
+
const rtl = isRTL();
|
|
1127
|
+
|
|
1018
1128
|
// Submenu traversal. In a vertical menu the inline arrows are free, so they step into a submenu
|
|
1019
1129
|
// and back out again, as the Menu pattern expects. Mirrored for RTL.
|
|
1020
1130
|
if (this.orientation === 'vertical' && isMenu) {
|
|
1021
|
-
const intoSubmenuKey =
|
|
1022
|
-
const outOfSubmenuKey = isRTL() ? 'ArrowRight' : 'ArrowLeft';
|
|
1131
|
+
const { prevKey: outOfSubmenuKey, nextKey: intoSubmenuKey } = getArrowKeys('horizontal', rtl);
|
|
1023
1132
|
|
|
1024
1133
|
if (key === intoSubmenuKey && currentTarget?.getAttribute('aria-haspopup') === 'menu') {
|
|
1025
1134
|
this.enterSubmenu(currentTarget);
|
|
@@ -1038,92 +1147,92 @@ export class Group {
|
|
|
1038
1147
|
}
|
|
1039
1148
|
}
|
|
1040
1149
|
|
|
1041
|
-
|
|
1042
|
-
let newTarget;
|
|
1043
|
-
|
|
1044
|
-
if (this.grid) {
|
|
1045
|
-
const colCount = this.columnCount;
|
|
1046
|
-
const lastIndex = allMembers.length - 1;
|
|
1047
|
-
const _isRTL = isRTL();
|
|
1048
|
-
|
|
1049
|
-
index = currentTarget ? allMembers.indexOf(currentTarget) : -1;
|
|
1050
|
-
|
|
1051
|
-
// With nothing focused yet, the arrows start from either end, as in a list
|
|
1052
|
-
if (index === -1) {
|
|
1053
|
-
const forward = key === 'ArrowDown' || key === (_isRTL ? 'ArrowLeft' : 'ArrowRight');
|
|
1054
|
-
const backward = key === 'ArrowUp' || key === (_isRTL ? 'ArrowRight' : 'ArrowLeft');
|
|
1055
|
-
|
|
1056
|
-
if (forward) {
|
|
1057
|
-
[newTarget] = activeMembers;
|
|
1058
|
-
} else if (backward) {
|
|
1059
|
-
newTarget = activeMembers[activeMembers.length - 1];
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1150
|
+
const newTarget = this.#getArrowTarget(key, currentTarget, rtl);
|
|
1062
1151
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1152
|
+
if (newTarget && newTarget !== currentTarget) {
|
|
1153
|
+
this.selectTarget(event, newTarget);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1066
1156
|
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1157
|
+
/**
|
|
1158
|
+
* Get the member a key press moves from: the member with the cursor where the group itself holds
|
|
1159
|
+
* focus, otherwise the member the key was pressed on.
|
|
1160
|
+
* @param {HTMLElement} target Element the key was pressed on.
|
|
1161
|
+
* @returns {HTMLElement | undefined} Current member, if any.
|
|
1162
|
+
*/
|
|
1163
|
+
#getCurrentTarget(target) {
|
|
1164
|
+
if (!this.focusChild) {
|
|
1165
|
+
return this.activeMembers.find((member) => member.matches('.focused'));
|
|
1166
|
+
}
|
|
1071
1167
|
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1168
|
+
// A key pressed on a control inside a member, such as a checkbox in a grid row, moves from
|
|
1169
|
+
// that member
|
|
1170
|
+
const member = /** @type {HTMLElement | null} */ (target.closest(this.selector));
|
|
1075
1171
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
}
|
|
1172
|
+
return member && this.parent.contains(member) ? member : undefined;
|
|
1173
|
+
}
|
|
1079
1174
|
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1175
|
+
/**
|
|
1176
|
+
* Get the next member whose label starts with what has been typed so far, including the given
|
|
1177
|
+
* key.
|
|
1178
|
+
* @param {string} key Printable key.
|
|
1179
|
+
* @param {HTMLElement | undefined} currentTarget Current member, if any.
|
|
1180
|
+
* @returns {HTMLElement | undefined} Matching member, if any.
|
|
1181
|
+
*/
|
|
1182
|
+
#getTypeAheadTarget(key, currentTarget) {
|
|
1183
|
+
const { activeMembers } = this;
|
|
1184
|
+
|
|
1185
|
+
const labels = activeMembers.map((member) =>
|
|
1186
|
+
normalize(
|
|
1187
|
+
member.dataset.label ??
|
|
1188
|
+
member.querySelector('.label')?.textContent ??
|
|
1189
|
+
member.textContent ??
|
|
1190
|
+
'',
|
|
1191
|
+
),
|
|
1192
|
+
);
|
|
1083
1193
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1194
|
+
const index = findTypeAheadMatch(
|
|
1195
|
+
labels,
|
|
1196
|
+
this.#typeAhead.push(key),
|
|
1197
|
+
currentTarget ? activeMembers.indexOf(currentTarget) : -1,
|
|
1198
|
+
);
|
|
1089
1199
|
|
|
1090
|
-
|
|
1200
|
+
return index === -1 ? undefined : activeMembers[index];
|
|
1201
|
+
}
|
|
1091
1202
|
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1203
|
+
/**
|
|
1204
|
+
* Get the member an arrow key moves to.
|
|
1205
|
+
* @param {string} key Pressed key.
|
|
1206
|
+
* @param {HTMLElement | undefined} currentTarget Current member, if any.
|
|
1207
|
+
* @param {boolean} rtl Whether the layout runs right to left.
|
|
1208
|
+
* @returns {HTMLElement | undefined} Member to move to, if any.
|
|
1209
|
+
*/
|
|
1210
|
+
#getArrowTarget(key, currentTarget, rtl) {
|
|
1211
|
+
const { allMembers, activeMembers } = this;
|
|
1095
1212
|
|
|
1096
|
-
|
|
1097
|
-
|
|
1213
|
+
if (!this.grid) {
|
|
1214
|
+
const index = currentTarget ? activeMembers.indexOf(currentTarget) : -1;
|
|
1215
|
+
const keys = getArrowKeys(this.orientation, rtl);
|
|
1098
1216
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
}
|
|
1217
|
+
return activeMembers[
|
|
1218
|
+
getLinearTargetIndex({ key, index, count: activeMembers.length, ...keys })
|
|
1219
|
+
];
|
|
1220
|
+
}
|
|
1104
1221
|
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1222
|
+
const { columnCount } = this;
|
|
1223
|
+
// Members filtered out by a search take no cell in the layout, so they’re left out of the index
|
|
1224
|
+
// the same way they’re left out of the column count. A disabled member still takes its cell,
|
|
1225
|
+
// and stays in.
|
|
1226
|
+
const members = allMembers.filter((member) => member.getAttribute('aria-hidden') !== 'true');
|
|
1227
|
+
const index = currentTarget ? members.indexOf(currentTarget) : -1;
|
|
1228
|
+
// With nothing focused yet, the arrows start from either end of the active members
|
|
1229
|
+
const list = index === -1 ? activeMembers : members;
|
|
1230
|
+
const keys = getArrowKeys('horizontal', rtl);
|
|
1110
1231
|
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
// Next member
|
|
1114
|
-
newTarget = activeMembers[index + 1];
|
|
1115
|
-
}
|
|
1232
|
+
const newTarget =
|
|
1233
|
+
list[getGridTargetIndex({ key, index, count: list.length, columnCount, ...keys })];
|
|
1116
1234
|
|
|
1117
|
-
|
|
1118
|
-
// First member
|
|
1119
|
-
[newTarget] = activeMembers;
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
if (newTarget && newTarget !== currentTarget) {
|
|
1125
|
-
this.selectTarget(event, newTarget);
|
|
1126
|
-
}
|
|
1235
|
+
return newTarget?.matches('[aria-disabled="true"]') ? undefined : newTarget;
|
|
1127
1236
|
}
|
|
1128
1237
|
|
|
1129
1238
|
/**
|
|
@@ -1134,6 +1243,7 @@ export class Group {
|
|
|
1134
1243
|
globalThis.clearTimeout(this.#scrollTimer);
|
|
1135
1244
|
this.#typeAhead.reset();
|
|
1136
1245
|
this.observer.disconnect();
|
|
1246
|
+
this.selectionObserver?.disconnect();
|
|
1137
1247
|
this.parent.removeEventListener('click', this._onClick);
|
|
1138
1248
|
this.parent.removeEventListener('keydown', this._onKeyDown);
|
|
1139
1249
|
}
|