@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.
Files changed (181) hide show
  1. package/dist/components/alert/alert-content.svelte +51 -0
  2. package/dist/components/alert/alert-content.svelte.d.ts +39 -0
  3. package/dist/components/alert/alert.d.ts +3 -0
  4. package/dist/components/alert/alert.js +21 -0
  5. package/dist/components/alert/alert.svelte +6 -30
  6. package/dist/components/alert/infobar.svelte +7 -21
  7. package/dist/components/alert/infobar.svelte.d.ts +2 -2
  8. package/dist/components/button/button.svelte +11 -11
  9. package/dist/components/button/button.svelte.d.ts +2 -2
  10. package/dist/components/button/select-button.svelte +1 -1
  11. package/dist/components/button/select-button.svelte.d.ts +2 -2
  12. package/dist/components/button/split-button.svelte +1 -1
  13. package/dist/components/button/split-button.svelte.d.ts +3 -3
  14. package/dist/components/calendar/calendar.d.ts +2 -0
  15. package/dist/components/calendar/calendar.js +34 -0
  16. package/dist/components/calendar/calendar.svelte +49 -5
  17. package/dist/components/checkbox/checkbox.svelte +11 -4
  18. package/dist/components/checkbox/checkbox.svelte.d.ts +2 -2
  19. package/dist/components/dialog/alert-dialog.svelte +1 -1
  20. package/dist/components/dialog/alert-dialog.svelte.d.ts +2 -2
  21. package/dist/components/dialog/confirmation-dialog.svelte +1 -1
  22. package/dist/components/dialog/confirmation-dialog.svelte.d.ts +2 -2
  23. package/dist/components/dialog/dialog.svelte +8 -18
  24. package/dist/components/dialog/dialog.svelte.d.ts +2 -2
  25. package/dist/components/dialog/modal-close-button.svelte +50 -0
  26. package/dist/components/dialog/modal-close-button.svelte.d.ts +43 -0
  27. package/dist/components/dialog/prompt-dialog.svelte +1 -1
  28. package/dist/components/dialog/prompt-dialog.svelte.d.ts +4 -4
  29. package/dist/components/disclosure/disclosure.svelte +1 -1
  30. package/dist/components/drawer/drawer.svelte +12 -34
  31. package/dist/components/drawer/drawer.svelte.d.ts +1 -1
  32. package/dist/components/emoji/caret.d.ts +1 -1
  33. package/dist/components/emoji/caret.js +1 -1
  34. package/dist/components/emoji/emoji-suggestions.svelte +1 -1
  35. package/dist/components/emoji/emoji-suggestions.svelte.d.ts +3 -3
  36. package/dist/components/emoji/emoji.d.ts +2 -2
  37. package/dist/components/emoji/emoji.js +1 -1
  38. package/dist/components/emoji/position.d.ts +2 -2
  39. package/dist/components/emoji/position.js +1 -1
  40. package/dist/components/grid/grid-body.svelte +6 -2
  41. package/dist/components/grid/grid-cell.svelte +3 -9
  42. package/dist/components/grid/grid-col-header.svelte +3 -9
  43. package/dist/components/grid/grid-foot.svelte +4 -11
  44. package/dist/components/grid/grid-head.svelte +4 -11
  45. package/dist/components/grid/grid-row-header.svelte +3 -10
  46. package/dist/components/grid/grid-row.svelte +3 -16
  47. package/dist/components/listbox/listbox.svelte +1 -1
  48. package/dist/components/listbox/listbox.svelte.d.ts +1 -1
  49. package/dist/components/listbox/option.svelte +1 -1
  50. package/dist/components/listbox/option.svelte.d.ts +2 -2
  51. package/dist/components/menu/menu-button.svelte +2 -2
  52. package/dist/components/menu/menu-button.svelte.d.ts +4 -4
  53. package/dist/components/menu/menu-item-checkable.svelte +71 -0
  54. package/dist/components/menu/menu-item-checkable.svelte.d.ts +25 -0
  55. package/dist/components/menu/menu-item-checkbox.svelte +3 -36
  56. package/dist/components/menu/menu-item-checkbox.svelte.d.ts +3 -3
  57. package/dist/components/menu/menu-item-group.svelte +8 -5
  58. package/dist/components/menu/menu-item-group.svelte.d.ts +9 -1
  59. package/dist/components/menu/menu-item-radio.svelte +3 -36
  60. package/dist/components/menu/menu-item-radio.svelte.d.ts +3 -3
  61. package/dist/components/menu/menu-item.svelte +22 -6
  62. package/dist/components/menu/menu-item.svelte.d.ts +4 -4
  63. package/dist/components/radio/radio.svelte +12 -1
  64. package/dist/components/resizable-pane/resizable-handle.svelte +14 -18
  65. package/dist/components/resizable-pane/resizable-pane-group.svelte +9 -6
  66. package/dist/components/resizable-pane/resizable-pane.svelte +4 -4
  67. package/dist/components/resizable-pane/resizable-pane.svelte.d.ts +7 -6
  68. package/dist/components/resizable-pane/sizing.d.ts +5 -4
  69. package/dist/components/resizable-pane/sizing.js +84 -25
  70. package/dist/components/scroll/infinite-scroll.svelte +16 -21
  71. package/dist/components/select/combobox.svelte +43 -52
  72. package/dist/components/select/combobox.svelte.d.ts +2 -2
  73. package/dist/components/select/select-tags.svelte +7 -1
  74. package/dist/components/select/select.svelte +2 -22
  75. package/dist/components/select/select.svelte.d.ts +1 -1
  76. package/dist/components/slider/slider.d.ts +6 -0
  77. package/dist/components/slider/slider.js +55 -2
  78. package/dist/components/slider/slider.svelte +63 -32
  79. package/dist/components/table/table-cell.svelte +3 -9
  80. package/dist/components/table/table-col-header.svelte +3 -9
  81. package/dist/components/table/table-foot.svelte +4 -11
  82. package/dist/components/table/table-head.svelte +4 -11
  83. package/dist/components/table/table-part.svelte +69 -0
  84. package/dist/components/table/table-part.svelte.d.ts +48 -0
  85. package/dist/components/table/table-row-header.svelte +3 -10
  86. package/dist/components/table/table-row.svelte +3 -10
  87. package/dist/components/tabs/tab-list.svelte +12 -0
  88. package/dist/components/tabs/tab.svelte +1 -1
  89. package/dist/components/tabs/tab.svelte.d.ts +2 -2
  90. package/dist/components/text-editor/constants.d.ts +5 -5
  91. package/dist/components/text-editor/constants.js +1 -1
  92. package/dist/components/text-editor/core.d.ts +4 -5
  93. package/dist/components/text-editor/core.js +376 -273
  94. package/dist/components/text-editor/emoji-autocomplete.svelte +1 -1
  95. package/dist/components/text-editor/lexical-root.svelte +1 -1
  96. package/dist/components/text-editor/raw-markdown.d.ts +6 -33
  97. package/dist/components/text-editor/raw-markdown.js +63 -8
  98. package/dist/components/text-editor/shiki/facade.d.ts +1 -1
  99. package/dist/components/text-editor/shiki/facade.js +63 -48
  100. package/dist/components/text-editor/shiki/generated.d.ts +1 -1
  101. package/dist/components/text-editor/shiki/generated.js +1 -1
  102. package/dist/components/text-editor/shiki/highlighter.d.ts +6 -1
  103. package/dist/components/text-editor/shiki/highlighter.js +3 -4
  104. package/dist/components/text-editor/shiki/loader.d.ts +1 -1
  105. package/dist/components/text-editor/shiki/loader.js +1 -1
  106. package/dist/components/text-editor/shiki/version.d.ts +1 -1
  107. package/dist/components/text-editor/shiki/version.js +1 -1
  108. package/dist/components/text-editor/store.svelte.d.ts +1 -1
  109. package/dist/components/text-editor/store.svelte.js +5 -2
  110. package/dist/components/text-editor/text-editor.svelte +2 -2
  111. package/dist/components/text-editor/text-editor.svelte.d.ts +3 -3
  112. package/dist/components/text-editor/toolbar/code-language-switcher.svelte +24 -16
  113. package/dist/components/text-editor/toolbar/format-text-button.svelte +7 -31
  114. package/dist/components/text-editor/toolbar/format-text-button.svelte.d.ts +1 -1
  115. package/dist/components/text-editor/toolbar/insert-component.d.ts +3 -0
  116. package/dist/components/text-editor/toolbar/insert-component.js +34 -0
  117. package/dist/components/text-editor/toolbar/insert-item-button.svelte +4 -24
  118. package/dist/components/text-editor/toolbar/insert-item-button.svelte.d.ts +1 -1
  119. package/dist/components/text-editor/toolbar/insert-link-button.svelte +9 -25
  120. package/dist/components/text-editor/toolbar/insert-menu-button.svelte +4 -24
  121. package/dist/components/text-editor/toolbar/insert-menu-button.svelte.d.ts +1 -1
  122. package/dist/components/text-editor/toolbar/text-editor-toolbar.svelte +1 -1
  123. package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte +31 -35
  124. package/dist/components/text-editor/toolbar/toggle-block-menu-item.svelte.d.ts +1 -1
  125. package/dist/components/text-editor/toolbar/toolbar-wrapper.svelte +0 -10
  126. package/dist/components/text-editor/toolbar/toolbar-wrapper.svelte.d.ts +1 -11
  127. package/dist/components/text-editor/transformers/table.d.ts +0 -1
  128. package/dist/components/text-editor/transformers/table.js +1 -2
  129. package/dist/components/text-field/emoji-autocomplete.svelte +1 -1
  130. package/dist/components/text-field/masked-input.svelte +179 -0
  131. package/dist/components/text-field/masked-input.svelte.d.ts +42 -0
  132. package/dist/components/text-field/number-input.js +7 -1
  133. package/dist/components/text-field/number-input.svelte +13 -5
  134. package/dist/components/text-field/number-input.svelte.d.ts +3 -3
  135. package/dist/components/text-field/password-input.svelte +3 -116
  136. package/dist/components/text-field/password-input.svelte.d.ts +3 -3
  137. package/dist/components/text-field/search-bar.svelte +1 -1
  138. package/dist/components/text-field/search-bar.svelte.d.ts +3 -3
  139. package/dist/components/text-field/secret-input.svelte +3 -119
  140. package/dist/components/text-field/secret-input.svelte.d.ts +3 -3
  141. package/dist/components/text-field/text-area.svelte +1 -1
  142. package/dist/components/text-field/text-area.svelte.d.ts +3 -3
  143. package/dist/components/text-field/text-input.svelte +1 -1
  144. package/dist/components/text-field/text-input.svelte.d.ts +3 -3
  145. package/dist/components/toast/toast.svelte +11 -20
  146. package/dist/components/toast/toast.svelte.d.ts +1 -1
  147. package/dist/components/tree/tree-item.svelte +1 -1
  148. package/dist/components/tree/tree-item.svelte.d.ts +1 -1
  149. package/dist/components/tree/tree.svelte +1 -1
  150. package/dist/components/tree/tree.svelte.d.ts +1 -1
  151. package/dist/components/util/font-links.svelte +1 -0
  152. package/dist/components/util/modal.svelte +37 -1
  153. package/dist/components/util/modal.svelte.d.ts +1 -1
  154. package/dist/components/util/popup.svelte +2 -5
  155. package/dist/components/util/popup.svelte.d.ts +2 -2
  156. package/dist/components/util/visibility-observer.svelte +1 -0
  157. package/dist/locales/ar.yaml +1 -1
  158. package/dist/locales/sk.yaml +136 -0
  159. package/dist/services/group.svelte.d.ts +10 -1
  160. package/dist/services/group.svelte.js +361 -251
  161. package/dist/services/navigation.d.ts +19 -0
  162. package/dist/services/navigation.js +84 -0
  163. package/dist/services/popup-position.d.ts +90 -0
  164. package/dist/services/popup-position.js +275 -0
  165. package/dist/services/popup.svelte.d.ts +16 -17
  166. package/dist/services/popup.svelte.js +25 -169
  167. package/dist/services/scroll.d.ts +1 -0
  168. package/dist/services/scroll.js +17 -0
  169. package/dist/services/select.svelte.d.ts +2 -2
  170. package/dist/services/select.svelte.js +1 -1
  171. package/dist/services/text.d.ts +2 -0
  172. package/dist/services/text.js +28 -0
  173. package/dist/services/tree.svelte.d.ts +3 -11
  174. package/dist/services/tree.svelte.js +35 -77
  175. package/dist/services/type-ahead.d.ts +0 -4
  176. package/dist/services/type-ahead.js +3 -1
  177. package/dist/services/widget.d.ts +8 -0
  178. package/dist/services/widget.js +39 -0
  179. package/dist/shiki-engine.js +15 -15
  180. package/dist/styles/mixins.scss +126 -0
  181. 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
- * Diacritic characters regex for normalization. We use a regex instead of `Intl` APIs for better
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 DIACRITIC_RE = /\p{Diacritic}/gu;
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
- (async () => {
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 controlTargetId = this.controlsPanel ? element.getAttribute('aria-controls') : null;
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
- element.setAttribute(this.childSelectedAttr, String(isSelected));
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.setAttribute('aria-hidden', String(!isSelected));
436
+ this.#setPanelShown(controlTarget, isSelected);
403
437
 
404
438
  if (isSelected) {
405
439
  globalThis.setTimeout(() => {
406
- try {
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('[aria-disabled="true"], [aria-hidden="true"]'),
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
- const selectByClick = event.type === 'click';
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
- const selectByKeydown =
712
- event.type === 'keydown' && /** @type {KeyboardEvent} */ (event).key === ' ';
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
- (isMenuItemCheckbox || isMenuItemRadio) &&
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 (multiSelect && isTarget && (selectByClick || selectByKeydown)) {
756
- element.setAttribute(this.childSelectedAttr, String(!isSelected));
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
- if (
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
- // Done right away rather than on the next frame: a key that repeats, or a second press
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
- this.parent.dispatchEvent(
827
- new CustomEvent('Change', { detail: getSelectedItemDetail(newTarget) }),
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((element) => {
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 { allMembers, activeMembers } = this;
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 labels = activeMembers.map((member) =>
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 (index !== -1 && activeMembers[index] !== currentTarget) {
983
- this.selectTarget(event, activeMembers[index]);
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 = isRTL() ? 'ArrowLeft' : 'ArrowRight';
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
- let index;
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
- if (key === 'ArrowUp' && index > 0) {
1064
- newTarget = allMembers[Math.max(index - colCount, 0)];
1065
- }
1152
+ if (newTarget && newTarget !== currentTarget) {
1153
+ this.selectTarget(event, newTarget);
1154
+ }
1155
+ }
1066
1156
 
1067
- if (key === 'ArrowDown' && index !== -1 && index < lastIndex) {
1068
- // A partial last row still gets reached
1069
- newTarget = allMembers[Math.min(index + colCount, lastIndex)];
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
- // In RTL, ArrowLeft moves to the next member, ArrowRight to the previous one
1073
- const prevKey = _isRTL ? 'ArrowRight' : 'ArrowLeft';
1074
- const nextKey = _isRTL ? 'ArrowLeft' : 'ArrowRight';
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
- if (key === prevKey && index > 0) {
1077
- newTarget = allMembers[index - 1];
1078
- }
1172
+ return member && this.parent.contains(member) ? member : undefined;
1173
+ }
1079
1174
 
1080
- if (key === nextKey && index !== -1 && index < lastIndex) {
1081
- newTarget = allMembers[index + 1];
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
- if (newTarget?.matches('[aria-disabled="true"], [aria-hidden="true"]')) {
1085
- newTarget = undefined;
1086
- }
1087
- } else {
1088
- index = currentTarget ? activeMembers.indexOf(currentTarget) : -1;
1194
+ const index = findTypeAheadMatch(
1195
+ labels,
1196
+ this.#typeAhead.push(key),
1197
+ currentTarget ? activeMembers.indexOf(currentTarget) : -1,
1198
+ );
1089
1199
 
1090
- const _isRTL = isRTL();
1200
+ return index === -1 ? undefined : activeMembers[index];
1201
+ }
1091
1202
 
1092
- // For horizontal orientation in RTL: ArrowLeft moves forward, ArrowRight moves backward
1093
- const prevKey =
1094
- this.orientation === 'horizontal' ? (_isRTL ? 'ArrowRight' : 'ArrowLeft') : 'ArrowUp';
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
- const nextKey =
1097
- this.orientation === 'horizontal' ? (_isRTL ? 'ArrowLeft' : 'ArrowRight') : 'ArrowDown';
1213
+ if (!this.grid) {
1214
+ const index = currentTarget ? activeMembers.indexOf(currentTarget) : -1;
1215
+ const keys = getArrowKeys(this.orientation, rtl);
1098
1216
 
1099
- if (key === prevKey) {
1100
- if (index > 0) {
1101
- // Previous member
1102
- newTarget = activeMembers[index - 1];
1103
- }
1217
+ return activeMembers[
1218
+ getLinearTargetIndex({ key, index, count: activeMembers.length, ...keys })
1219
+ ];
1220
+ }
1104
1221
 
1105
- if (index <= 0) {
1106
- // Last member (also handles the case when nothing is focused, index === -1)
1107
- newTarget = activeMembers[activeMembers.length - 1];
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
- if (key === nextKey) {
1112
- if (index < activeMembers.length - 1) {
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
- if (index === activeMembers.length - 1) {
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
  }