@sapudex/web-components 2.51.0 → 2.52.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 (97) hide show
  1. package/cypress/specs/CountrySelector.cy.tsx +21 -0
  2. package/cypress/specs/List.cy.tsx +119 -10
  3. package/cypress/specs/SelectBox.cy.tsx +414 -70
  4. package/dist/CountrySelector.js +1 -0
  5. package/dist/CountrySelector.js.map +1 -1
  6. package/dist/List.d.ts +10 -0
  7. package/dist/List.js +35 -0
  8. package/dist/List.js.map +1 -1
  9. package/dist/ListGroupHeaderBase.js +3 -0
  10. package/dist/ListGroupHeaderBase.js.map +1 -1
  11. package/dist/ListItem.d.ts +1 -0
  12. package/dist/ListItem.js +11 -3
  13. package/dist/ListItem.js.map +1 -1
  14. package/dist/ListItemBase.d.ts +9 -0
  15. package/dist/ListItemBase.js +12 -0
  16. package/dist/ListItemBase.js.map +1 -1
  17. package/dist/ListSelectAll.js +1 -1
  18. package/dist/ListSelectAll.js.map +1 -1
  19. package/dist/SearchableListBase.d.ts +10 -0
  20. package/dist/SearchableListBase.js +29 -6
  21. package/dist/SearchableListBase.js.map +1 -1
  22. package/dist/SearchableListBaseTemplate.js +32 -5
  23. package/dist/SearchableListBaseTemplate.js.map +1 -1
  24. package/dist/SelectBase.d.ts +65 -1
  25. package/dist/SelectBase.js +446 -59
  26. package/dist/SelectBase.js.map +1 -1
  27. package/dist/SelectBox.js +24 -2
  28. package/dist/SelectBox.js.map +1 -1
  29. package/dist/SelectBoxTemplate.js +2 -2
  30. package/dist/SelectBoxTemplate.js.map +1 -1
  31. package/dist/assets/{InputSuggestions-7eP5S95I.js → InputSuggestions-0wafO-g3.js} +1 -1
  32. package/dist/assets/{ListItemStandardExpandableTextTemplate-CLcWBFcg.js → ListItemStandardExpandableTextTemplate-DoOYtqI8.js} +1 -1
  33. package/dist/assets/{bundle.esm-D4--IDh7.js → bundle.esm-DwHZ3nCW.js} +241 -241
  34. package/dist/assets/messagebundle_nl-BKUpVV-D.js +1 -0
  35. package/dist/css/themes/AccordionItem.css +1 -1
  36. package/dist/css/themes/Divider.css +1 -1
  37. package/dist/css/themes/Link.css +1 -1
  38. package/dist/css/themes/ListGroupHeader.css +1 -1
  39. package/dist/css/themes/ListItem.css +1 -1
  40. package/dist/css/themes/SelectBase.css +1 -1
  41. package/dist/css/themes/SelectBox.css +1 -1
  42. package/dist/generated/assets/i18n/messagebundle_nl.json +1 -1
  43. package/dist/generated/templates/ListTemplate.lit.js +1 -1
  44. package/dist/generated/templates/ListTemplate.lit.js.map +1 -1
  45. package/dist/generated/themes/AccordionItem.css.d.ts +1 -1
  46. package/dist/generated/themes/AccordionItem.css.js +1 -1
  47. package/dist/generated/themes/AccordionItem.css.js.map +1 -1
  48. package/dist/generated/themes/Divider.css.d.ts +1 -1
  49. package/dist/generated/themes/Divider.css.js +1 -1
  50. package/dist/generated/themes/Divider.css.js.map +1 -1
  51. package/dist/generated/themes/Link.css.d.ts +1 -1
  52. package/dist/generated/themes/Link.css.js +1 -1
  53. package/dist/generated/themes/Link.css.js.map +1 -1
  54. package/dist/generated/themes/ListGroupHeader.css.d.ts +1 -1
  55. package/dist/generated/themes/ListGroupHeader.css.js +1 -1
  56. package/dist/generated/themes/ListGroupHeader.css.js.map +1 -1
  57. package/dist/generated/themes/ListItem.css.d.ts +1 -1
  58. package/dist/generated/themes/ListItem.css.js +1 -1
  59. package/dist/generated/themes/ListItem.css.js.map +1 -1
  60. package/dist/generated/themes/SelectBase.css.d.ts +1 -1
  61. package/dist/generated/themes/SelectBase.css.js +1 -1
  62. package/dist/generated/themes/SelectBase.css.js.map +1 -1
  63. package/dist/generated/themes/SelectBox.css.d.ts +1 -1
  64. package/dist/generated/themes/SelectBox.css.js +1 -1
  65. package/dist/generated/themes/SelectBox.css.js.map +1 -1
  66. package/dist/messagebundle_nl.properties +126 -0
  67. package/dist/web-types.json +1 -1
  68. package/package.json +1 -1
  69. package/src/CountrySelector.ts +1 -0
  70. package/src/List.hbs +2 -1
  71. package/src/List.ts +36 -0
  72. package/src/ListGroupHeaderBase.ts +3 -0
  73. package/src/ListItem.ts +12 -3
  74. package/src/ListItemBase.ts +11 -0
  75. package/src/ListSelectAll.ts +1 -1
  76. package/src/SearchableListBase.ts +31 -8
  77. package/src/SearchableListBaseTemplate.tsx +38 -6
  78. package/src/SelectBase.ts +466 -69
  79. package/src/SelectBox.ts +27 -2
  80. package/src/SelectBoxTemplate.tsx +4 -3
  81. package/src/generated/templates/ListTemplate.lit.ts +1 -1
  82. package/src/generated/themes/AccordionItem.css.ts +1 -1
  83. package/src/generated/themes/Divider.css.ts +1 -1
  84. package/src/generated/themes/Link.css.ts +1 -1
  85. package/src/generated/themes/ListGroupHeader.css.ts +1 -1
  86. package/src/generated/themes/ListItem.css.ts +1 -1
  87. package/src/generated/themes/SelectBase.css.ts +1 -1
  88. package/src/generated/themes/SelectBox.css.ts +1 -1
  89. package/src/i18n/messagebundle_nl.properties +126 -0
  90. package/src/themes/AccordionItem.css +8 -3
  91. package/src/themes/Divider.css +2 -0
  92. package/src/themes/Link.css +20 -2
  93. package/src/themes/ListGroupHeader.css +18 -1
  94. package/src/themes/ListItem.css +2 -1
  95. package/src/themes/SelectBase.css +2 -1
  96. package/src/themes/SelectBox.css +10 -0
  97. package/dist/assets/messagebundle_nl-etaldyfo.js +0 -1
package/src/SelectBase.ts CHANGED
@@ -3,16 +3,19 @@ import slot from "@ui5/webcomponents-base/dist/decorators/slot-strict.js";
3
3
  import executeTemplate from "@ui5/webcomponents-base/dist/renderer/executeTemplate.js";
4
4
  import type { ChangeInfo, Slot } from "@ui5/webcomponents-base/dist/UI5Element.js";
5
5
  import {
6
- isEscape, isDown, isUp, isHome, isEnd, isTabNext, isTabPrevious, isLeft, isRight,
6
+ isEscape, isDown, isUp, isHome, isEnd, isTabNext, isTabPrevious, isLeft, isRight, isEnter, isSpace,
7
7
  } from "@ui5/webcomponents-base/dist/Keys.js";
8
8
  import { getAllAccessibleNameRefTexts } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js";
9
+ import { cleanLabel } from "./utils/listItemLabelUtils.js";
9
10
  import type { UDExListItemBase } from "./List.js";
10
11
  import type Popover from "@ui5/webcomponents/dist/Popover.js";
11
12
  import type UDExList from "./List.js";
12
13
  import type UDExSearch from "./Search.js";
13
14
  import "@ui5/webcomponents-icons/dist/decline.js";
14
15
  import "@ui5/webcomponents-icons/dist/navigation-down-arrow.js";
15
- import { COUNTRY_SELECT, STARS } from "./generated/i18n/i18n-defaults.js";
16
+ import {
17
+ COUNTRY_SELECT, STARS, SELECTED, UNSELECTED, INDETERMINATE, DISABLED,
18
+ } from "./generated/i18n/i18n-defaults.js";
16
19
  import SearchableListBaseTemplate from "./SearchableListBaseTemplate.js";
17
20
  import CountrySelectorTemplate from "./CountrySelectorTemplate.js";
18
21
  import type { ResizeObserverCallback } from "@ui5/webcomponents-base/delegate/ResizeHandler.js";
@@ -20,10 +23,11 @@ import ResizeHandler from "@ui5/webcomponents-base/delegate/ResizeHandler.js";
20
23
  import UDExSearchableListBase from "./SearchableListBase.js";
21
24
  import UDExValueState from "./types/common.js";
22
25
  import type { UDExSearchableListBaseEvents } from "./SearchableListBase.js";
23
- import { getTokensCountText } from "@ui5/webcomponents/dist/Tokenizer.js";
26
+ import Tokenizer, { getTokensCountText } from "@ui5/webcomponents/dist/Tokenizer.js";
24
27
  import isLetterKey from "./utils/isLetterKeyPressed.js";
25
28
  import translate from "./utils/translate.js";
26
29
  import type { AriaAutoComplete, AriaHasPopup } from "@ui5/webcomponents-base/dist/types.js";
30
+ import { renderFinished } from "@ui5/webcomponents-base/dist/Render.js";
27
31
 
28
32
  type UDExSelectBaseEvents = {
29
33
  "open": void,
@@ -164,6 +168,26 @@ class UDExSelectBase extends UDExSearchableListBase {
164
168
  @property({ type: Boolean, noAttribute: true })
165
169
  _requestRender = false;
166
170
 
171
+ /**
172
+ * True while keyboard list navigation is active (aria-activedescendant set).
173
+ * Used in CSS to suppress the input focus ring and show the active item ring.
174
+ *
175
+ * @private
176
+ */
177
+ @property({ type: Boolean })
178
+ _navigating: boolean = false;
179
+
180
+ /**
181
+ * Text read by screen readers when the active item changes.
182
+ * Announced via an aria-live="assertive" region in the template.
183
+ * This works regardless of shadow DOM / popover teleportation because
184
+ * the live region lives inside SelectBox's own shadow root.
185
+ *
186
+ * @private
187
+ */
188
+ @property({ type: String, noAttribute: true })
189
+ _liveRegionText: string = "";
190
+
167
191
  /**
168
192
  * Defines the footer content for the component dropdown.
169
193
  * Place any content (e.g. an action button) here. Clicking footer content does not close the dropdown —
@@ -190,6 +214,7 @@ class UDExSelectBase extends UDExSearchableListBase {
190
214
  _handleResizeBound: ResizeObserverCallback;
191
215
  _handleListKeyDownBound: (e: KeyboardEvent) => void;
192
216
  _listKeyDownListenerAdded: boolean = false;
217
+ _activeDescendantItem: UDExListItemBase | null = null;
193
218
 
194
219
  constructor() {
195
220
  super();
@@ -219,6 +244,7 @@ class UDExSelectBase extends UDExSearchableListBase {
219
244
  }
220
245
 
221
246
  onAfterRendering() {
247
+ super.onAfterRendering();
222
248
  this._popover = this.getPopover();
223
249
  this._search = this.getSearch();
224
250
  this._list = this.getList();
@@ -227,6 +253,10 @@ class UDExSelectBase extends UDExSearchableListBase {
227
253
 
228
254
  if (this._list) {
229
255
  this._list._scrollIntoView = "Nearest";
256
+ // Set a stable HTML id on the udex-list host so aria-controls can reference it.
257
+ if (!this._list.id) {
258
+ this._list.id = `${this._id}-list`;
259
+ }
230
260
  }
231
261
 
232
262
  if (this._requestRender) {
@@ -243,6 +273,104 @@ class UDExSelectBase extends UDExSearchableListBase {
243
273
  this.updatePopoverWidth();
244
274
  }
245
275
 
276
+ /**
277
+ * Given a candidate index, skips over a non-interactive non-collapsible header if needed.
278
+ * Returns the effective target index.
279
+ */
280
+ _skipNonInteractive(items: UDExListItemBase[], raw: number, lastIndex: number, direction: "down" | "up"): number {
281
+ if (!items[raw]?.nonInteractive || items[raw].collapsible) {
282
+ return raw;
283
+ }
284
+ if (direction === "down") {
285
+ return raw + 1 > lastIndex ? 0 : raw + 1;
286
+ }
287
+ return raw === 0 ? lastIndex : raw - 1;
288
+ }
289
+
290
+ /**
291
+ * Sets or clears the aria-activedescendant on the combobox input and updates
292
+ * the aria-live region so JAWS/VoiceOver announce the item name and position.
293
+ * The live region is the primary announcement mechanism because aria-activedescendant
294
+ * across a UI5 Popover (which teleports content to document.body) is unreliable —
295
+ * screen readers cannot follow the ID reference across shadow root boundaries.
296
+ */
297
+ _setActiveDescendant(item: UDExListItemBase | null, scroll = true): void {
298
+ this._activeDescendantItem = item;
299
+
300
+ // Query the SelectBox's OWN shadow DOM for all elements bearing the class —
301
+ // this reaches the rendered udex-list-group-header / udex-list-item elements
302
+ // regardless of whether they are the current or a previously stale instance.
303
+ this.shadowRoot?.querySelectorAll<HTMLElement>("._udex-active-descendant")
304
+ .forEach(el => {
305
+ el.classList.remove("_udex-active-descendant");
306
+ el.removeAttribute("_active-descendant");
307
+ });
308
+
309
+ if (!item) {
310
+ (this._search as HTMLElement).removeAttribute("aria-activedescendant");
311
+ this._liveRegionText = "";
312
+ return;
313
+ }
314
+
315
+ item.classList.add("_udex-active-descendant");
316
+ item.setAttribute("_active-descendant", "");
317
+ (this._search as HTMLElement).setAttribute("aria-activedescendant", item._id);
318
+
319
+ // Update the live region with tree-aware position and state so JAWS/VoiceOver
320
+ // announce the active item regardless of whether the popover is teleported to document.body.
321
+ if (this._list) {
322
+ const label = cleanLabel(item.label ?? "");
323
+ const supporting = item.supportingText ? cleanLabel(item.supportingText) : "";
324
+ const stateParts = this._itemStateLabels(item);
325
+ const posText = this._treePositionText(item);
326
+ this._liveRegionText = [label, supporting, posText, ...stateParts].filter(Boolean).join(", ");
327
+ }
328
+
329
+ if (scroll && this._list?._scrollIntoView) {
330
+ // Only scroll if the item is not already fully visible in the dropdown.
331
+ const popoverContent = this._popover?.shadowRoot?.querySelector<HTMLElement>("[part=content]");
332
+ if (popoverContent) {
333
+ const itemRect = item.getBoundingClientRect();
334
+ const containerRect = popoverContent.getBoundingClientRect();
335
+ const isVisible = itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom;
336
+ if (!isVisible) {
337
+ this._list.scrollToItem(item);
338
+ }
339
+ } else {
340
+ this._list.scrollToItem(item);
341
+ }
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Activates the first navigable item as the aria-activedescendant without moving DOM focus.
347
+ */
348
+ _activateFirstItem(): void {
349
+ const items = this._list?.getKeyboardNavigationItems();
350
+ if (!items?.length) { return; }
351
+ const firstIndex = (items[0]?.nonInteractive && !items[0]?.collapsible) ? 1 : 0;
352
+ this._list.setTabIndexByIndex(items, firstIndex);
353
+ this._setActiveDescendant(items[firstIndex], false);
354
+ }
355
+
356
+ /**
357
+ * Navigates to the item at the given index, updating aria-activedescendant and _selectedOption.
358
+ */
359
+ _navigateToIndex(items: UDExListItemBase[], nextIndex: number): void {
360
+ const currentIndex = this._activeDescendantItem
361
+ ? this._list.getCurrentIndex(items, this._activeDescendantItem)
362
+ : -1;
363
+ if (currentIndex >= 0) {
364
+ this._list.unsetTabIndexByIndex(items, currentIndex);
365
+ }
366
+ this._list.setTabIndexByIndex(items, nextIndex);
367
+ this._selectedOption = items[nextIndex].label;
368
+ if (this.showImageFlags) {
369
+ this.handleImagePosition(items, nextIndex);
370
+ }
371
+ this._setActiveDescendant(items[nextIndex]);
372
+ }
373
+
246
374
  handleKeyDownDropdown(e: KeyboardEvent) {
247
375
  const items = this._list?.getKeyboardNavigationItems();
248
376
 
@@ -301,78 +429,65 @@ class UDExSelectBase extends UDExSearchableListBase {
301
429
  return;
302
430
  }
303
431
 
304
- if (isRight(e)) {
432
+ if (isRight(e) || isLeft(e)) {
305
433
  e.preventDefault();
306
- if (this._handleGroupHeaderCollapse(e.target as HTMLElement, false)) {
434
+ // Resolve the collapsible group header from the active descendant (not e.target,
435
+ // since DOM focus stays on the input and e.target is the input element).
436
+ const activeHeader = this._activeDescendantItem
437
+ ? this._resolveCollapsibleGroupHeader(this._activeDescendantItem as unknown as HTMLElement)
438
+ : null;
439
+ if (activeHeader && this._handleGroupHeaderCollapse(activeHeader as unknown as HTMLElement, isLeft(e))) {
307
440
  return;
308
441
  }
309
- this._handleArrowRightInRow(e.target as HTMLElement);
310
- return;
311
- }
312
-
313
- if (isLeft(e)) {
314
- e.preventDefault();
315
- if (this._handleGroupHeaderCollapse(e.target as HTMLElement, true)) {
316
- return;
442
+ // Fall through to icon-row navigation when focus is on an icon button
443
+ if (isRight(e)) {
444
+ this._handleArrowRightInRow(e.target as HTMLElement);
445
+ } else {
446
+ this._handleArrowLeftInRow(e.target as HTMLElement);
317
447
  }
318
- this._handleArrowLeftInRow(e.target as HTMLElement);
319
448
  return;
320
449
  }
321
450
 
322
- const iconSlot = this._getIconButtonSlot(e.target as HTMLElement);
323
- const effectiveTarget = iconSlot
324
- ? (e.target as HTMLElement).parentElement as UDExListItemBase | undefined
325
- : undefined;
326
-
327
- this.handleListNavigationKeys(e, items, effectiveTarget);
328
- }
329
-
330
- handleListNavigationKeys(e: KeyboardEvent, items: UDExListItemBase[], effectiveTarget?: UDExListItemBase) {
331
- const currentSelectedIndex = this._list?.getCurrentIndex(items, effectiveTarget ?? e.target as UDExListItemBase);
332
- const lastItemIndex = items.length - 1;
333
-
334
- if (isDown(e)) {
451
+ if (isDown(e) || isUp(e)) {
335
452
  e.preventDefault();
336
- this.handleDownKeyPress(items, currentSelectedIndex, lastItemIndex);
337
-
338
- return;
339
- }
340
-
341
- if (isUp(e)) {
342
- e.preventDefault();
343
- this.handleUpKeyPress(items, currentSelectedIndex, lastItemIndex);
344
-
453
+ const lastIndex = items.length - 1;
454
+ const currentItem = this._activeDescendantItem;
455
+ const currentIndex = currentItem ? this._list.getCurrentIndex(items, currentItem) : -1;
456
+
457
+ let nextIndex: number;
458
+ if (isDown(e)) {
459
+ const raw = currentIndex + 1 > lastIndex ? 0 : currentIndex + 1;
460
+ nextIndex = this._skipNonInteractive(items, raw, lastIndex, "down");
461
+ } else {
462
+ const raw = currentIndex - 1 < 0 ? lastIndex : currentIndex - 1;
463
+ nextIndex = this._skipNonInteractive(items, raw, lastIndex, "up");
464
+ }
465
+ this._navigateToIndex(items, nextIndex);
345
466
  return;
346
467
  }
347
468
 
348
469
  if (isHome(e)) {
349
470
  e.preventDefault();
350
471
  this.handleHomeKeyPress(items);
351
-
352
472
  return;
353
473
  }
354
474
 
355
475
  if (isEnd(e)) {
356
476
  e.preventDefault();
357
- this.handleEndKeyPress(items, lastItemIndex);
477
+ this.handleEndKeyPress(items, items.length - 1);
358
478
  }
479
+
480
+ // Icon-button slot: Up/Down while an icon button is focused (handled by capture listener).
481
+ // _handleListKeyDownCapture fires first via capture; nothing else needed here.
359
482
  }
360
483
 
361
484
  handleEndKeyPress(items: UDExListItemBase[], lastItemIndex: number) {
362
- if (this.showImageFlags) {
363
- this.handleImagePosition(items, lastItemIndex);
364
- }
365
-
366
- this._selectedOption = items[lastItemIndex].label;
485
+ this._navigateToIndex(items, lastItemIndex);
367
486
  }
368
487
 
369
488
  handleHomeKeyPress(items: UDExListItemBase[]) {
370
489
  const firstIndex = (items[0]?.nonInteractive && !items[0]?.collapsible) ? 1 : 0;
371
- this._selectedOption = items[firstIndex].label;
372
-
373
- if (this.showImageFlags) {
374
- this.handleImagePosition(items, firstIndex);
375
- }
490
+ this._navigateToIndex(items, firstIndex);
376
491
  }
377
492
 
378
493
  handleDownKeyPress(items: UDExListItemBase[], currentSelectedIndex: number, lastItemIndex: number) {
@@ -430,6 +545,7 @@ class UDExSelectBase extends UDExSearchableListBase {
430
545
  handleEscKeyPress(e: KeyboardEvent) {
431
546
  e.preventDefault();
432
547
  e.stopPropagation();
548
+ this._setActiveDescendant(null);
433
549
  this.closeDropdown();
434
550
  this._search.focus();
435
551
  this._selectedOption = "";
@@ -442,9 +558,35 @@ class UDExSelectBase extends UDExSearchableListBase {
442
558
  this.resetSearchState();
443
559
 
444
560
  if (this.modeSingleSelect) {
561
+ // SingleSelect closes the dropdown — clear active descendant.
562
+ this._setActiveDescendant(null);
445
563
  this._search.focus();
446
564
  this.closeDropdown();
565
+ return;
566
+ }
567
+
568
+ // MultiSelect: ensure active descendant tracks the interacted item so there
569
+ // is only one ring. Find the rendered item in the navigation list by matching
570
+ // data-token-id (the source item's _id set by the template) since e.detail.item
571
+ // is the source element, not the rendered udex-list-item.
572
+ const tokenId = this.getDataTokenId(item);
573
+ const navItems = this._list?.getKeyboardNavigationItems();
574
+ const renderedItem = navItems?.find(
575
+ i => (i.getAttribute("data-token-id") ?? i._id) === tokenId,
576
+ );
577
+ if (renderedItem && renderedItem !== this._activeDescendantItem) {
578
+ this._setActiveDescendant(renderedItem);
447
579
  }
580
+
581
+ // Return DOM focus to the input so the clicked item loses its :focus ring.
582
+ // Without this, the browser keeps focus on the item (from the click) and a
583
+ // second ring appears when arrow keys move _activeDescendantItem elsewhere.
584
+ (this._search as HTMLElement).focus();
585
+
586
+ // Re-announce with the updated checked/unchecked state. The rendered item's
587
+ // `selected` prop is updated on the next render cycle (template re-render),
588
+ // so we wait for it before re-reading state labels.
589
+ renderFinished().then(() => this._announceCurrentOrFirst()).catch(() => {});
448
590
  }
449
591
 
450
592
  onKeyDownSearch(e: KeyboardEvent) {
@@ -457,13 +599,37 @@ class UDExSelectBase extends UDExSearchableListBase {
457
599
  const isEscAndPopoverOpened = isEscape(e) && popoverOpened;
458
600
  const isDownAndPopoverClosed = isDown(e) && !popoverOpened;
459
601
  const isDownAndPopoverOpened = isDown(e) && popoverOpened;
602
+ const isUpAndPopoverOpened = isUp(e) && popoverOpened;
603
+
604
+ // Tab while popup is open: move focus to the first footer element if one exists,
605
+ // otherwise close the dropdown and move past the component.
606
+ if (popoverOpened && isTabNext(e)) {
607
+ e.preventDefault();
608
+ if (this.hasFooter) {
609
+ this._setActiveDescendant(null);
610
+ (this._firstFocusableIn(this.footer[0]) ?? this.footer[0])?.focus();
611
+ } else {
612
+ this.closeDropdown();
613
+ this._moveFocusAfterComponent();
614
+ }
615
+ return;
616
+ }
617
+
618
+ // Shift+Tab while popup is open: close the dropdown.
619
+ // DOM focus stays on the search input throughout (our aria-activedescendant model
620
+ // never moves focus away from the input), so no explicit re-focus is needed.
621
+ if (popoverOpened && isTabPrevious(e)) {
622
+ e.preventDefault();
623
+ this._setActiveDescendant(null);
624
+ this.closeDropdown();
625
+ return;
626
+ }
460
627
 
461
628
  if (!hasItems) {
462
629
  return;
463
630
  }
464
631
 
465
632
  const firstIndex = (items[0]?.nonInteractive && !items[0]?.collapsible) ? 1 : 0;
466
- const firstItem = items[firstIndex];
467
633
 
468
634
  if (isEscAndPopoverOpened) {
469
635
  return this.closeDropdown();
@@ -472,21 +638,104 @@ class UDExSelectBase extends UDExSearchableListBase {
472
638
  if (isDownAndPopoverClosed) {
473
639
  e.preventDefault();
474
640
  this.openDropdown();
475
- this.focusFirstItem();
476
- this._selectedOption = firstItem.label;
641
+ this._activateFirstItem();
477
642
  if (this.showImageFlags) {
478
- this.handleImagePosition(items, 0);
643
+ this.handleImagePosition(items, firstIndex);
479
644
  }
480
645
  return;
481
646
  }
482
647
 
483
648
  if (isDownAndPopoverOpened) {
484
649
  e.preventDefault();
485
- if (this.showImageFlags) {
486
- this.handleImagePosition(items, 0);
650
+ if (this._activeDescendantItem) {
651
+ const lastIndex = items.length - 1;
652
+ const currentIndex = this._list.getCurrentIndex(items, this._activeDescendantItem);
653
+ const raw = currentIndex + 1 > lastIndex ? 0 : currentIndex + 1;
654
+ const nextIndex = this._skipNonInteractive(items, raw, lastIndex, "down");
655
+ this._navigateToIndex(items, nextIndex);
656
+ } else {
657
+ this._activateFirstItem();
658
+ // Also update _selectedOption so the input reflects the active item.
659
+ const activeIdx = (items[0]?.nonInteractive && !items[0]?.collapsible) ? 1 : 0;
660
+ this._selectedOption = items[activeIdx].label;
661
+ if (this.showImageFlags) {
662
+ this.handleImagePosition(items, activeIdx);
663
+ }
487
664
  }
488
- this._selectedOption = firstItem.label;
489
- return this.focusFirstItem();
665
+ return;
666
+ }
667
+
668
+ if (isUpAndPopoverOpened) {
669
+ e.preventDefault();
670
+ if (this._activeDescendantItem) {
671
+ const lastIndex = items.length - 1;
672
+ const currentIndex = this._list.getCurrentIndex(items, this._activeDescendantItem);
673
+ const raw = currentIndex - 1 < 0 ? lastIndex : currentIndex - 1;
674
+ const nextIndex = this._skipNonInteractive(items, raw, lastIndex, "up");
675
+ this._navigateToIndex(items, nextIndex);
676
+ } else {
677
+ this._navigateToIndex(items, items.length - 1);
678
+ }
679
+ return;
680
+ }
681
+
682
+ if (popoverOpened && isHome(e)) {
683
+ e.preventDefault();
684
+ const homeIndex = (items[0]?.nonInteractive && !items[0]?.collapsible) ? 1 : 0;
685
+ this._navigateToIndex(items, homeIndex);
686
+ return;
687
+ }
688
+
689
+ if (popoverOpened && isEnd(e)) {
690
+ e.preventDefault();
691
+ this._navigateToIndex(items, items.length - 1);
692
+ return;
693
+ }
694
+
695
+ // Enter/Space: select the active descendant item (or toggle for MultiSelect).
696
+ if (popoverOpened && this._activeDescendantItem && (isEnter(e) || isSpace(e))) {
697
+ e.preventDefault();
698
+ const activeItem = this._activeDescendantItem;
699
+ if (activeItem.isGroup) {
700
+ // Group header: fire header-press (same event the click handler fires) so
701
+ // handleHeaderPress → onHeaderPress → handleGroupHeaderSelection runs.
702
+ activeItem.fireDecoratorEvent("header-press", { item: activeItem });
703
+ } else {
704
+ // Leaf item: fire item-press so the list handles selection consistently.
705
+ // Do NOT toggle .selected directly — handleItemClick (called via onItemPress)
706
+ // handles the toggle, and double-toggling would be a no-op.
707
+ activeItem.fireDecoratorEvent("item-press", {
708
+ selected: activeItem.selected,
709
+ item: activeItem,
710
+ });
711
+ }
712
+ return;
713
+ }
714
+
715
+ // Left/Right: collapse/expand the active group header when popup is open.
716
+ if (popoverOpened && (isLeft(e) || isRight(e))) {
717
+ e.preventDefault();
718
+ const activeHeader = this._activeDescendantItem
719
+ ? this._resolveCollapsibleGroupHeader(this._activeDescendantItem as unknown as HTMLElement)
720
+ : null;
721
+ if (activeHeader) {
722
+ this._handleGroupHeaderCollapse(activeHeader as unknown as HTMLElement, isLeft(e));
723
+ return;
724
+ }
725
+ // Fall through to icon-row navigation when the active item has icon slots.
726
+ if (this._activeDescendantItem) {
727
+ if (isRight(e)) {
728
+ this._handleArrowRightInRow(this._activeDescendantItem as unknown as HTMLElement);
729
+ } else {
730
+ this._handleArrowLeftInRow(this._activeDescendantItem as unknown as HTMLElement);
731
+ }
732
+ }
733
+ return;
734
+ }
735
+
736
+ // Sync _selectedOption display for the first item (used when popover just opened via click).
737
+ if (!this._activeDescendantItem && hasItems) {
738
+ this._selectedOption = items[firstIndex].label;
490
739
  }
491
740
  }
492
741
 
@@ -505,6 +754,15 @@ class UDExSelectBase extends UDExSearchableListBase {
505
754
  }
506
755
  }
507
756
 
757
+ override onSearch(value: string): void {
758
+ super.onSearch(value);
759
+ // Route search result count into the shared live region so SelectBox
760
+ // only needs one aria-live span (no separate polite span in the template).
761
+ if (this._accSearchResultInfo) {
762
+ this._liveRegionText = this._accSearchResultInfo;
763
+ }
764
+ }
765
+
508
766
  handleDirection() {
509
767
  const directionStyles = window.getComputedStyle(this).getPropertyValue("direction");
510
768
  this._direction = directionStyles || "ltr";
@@ -523,12 +781,83 @@ class UDExSelectBase extends UDExSearchableListBase {
523
781
  }
524
782
 
525
783
  handleDropdownOpen() {
526
- if (!this.activeItem) {
784
+ // Scroll to the previously selected/active item if there is one,
785
+ // otherwise scroll to top. Do NOT pre-activate any item — that is done
786
+ // by the first ArrowDown so that ArrowDown[1]=item[0], ArrowDown[2]=item[1].
787
+ if (this.activeItem) {
788
+ const items = this._list?.getKeyboardNavigationItems();
789
+ const active = items?.find(i => !i.isGroup && (i as any).selected);
790
+ if (active && this._list?._scrollIntoView) {
791
+ this._list.scrollToItem(active);
792
+ } else {
793
+ this._dropdownScrollToTop();
794
+ }
795
+ } else {
527
796
  this._dropdownScrollToTop();
528
797
  }
798
+
799
+ // Announce the visually focused item (fake focus outline on first item).
800
+ // Using polite politeness — it waits for the AT to finish announcing
801
+ // "expanded" before reading the item, so no timeout is needed.
802
+ // Clear first so the mutation fires even if the text is unchanged from
803
+ // the previous open, then populate after render completes.
804
+ this._liveRegionText = "";
805
+ renderFinished().then(() => this._announceCurrentOrFirst()).catch(() => {});
806
+
529
807
  this.fireEventDropdownOpen();
530
808
  }
531
809
 
810
+ /**
811
+ * Announces the currently active descendant item (if set), or the first
812
+ * navigable item as a fallback. Used on open and after state changes.
813
+ */
814
+ _announceCurrentOrFirst(): void {
815
+ const items = this._list?.getKeyboardNavigationItems();
816
+ if (!items?.length) { return; }
817
+ const item = this._activeDescendantItem ?? items.find(i => !i.nonInteractive || i.collapsible) ?? items[0];
818
+ if (!item) { return; }
819
+ const label = cleanLabel(item.label ?? "");
820
+ const stateParts = this._itemStateLabels(item);
821
+ const posText = this._treePositionText(item);
822
+ this._liveRegionText = [label, posText, ...stateParts].filter(Boolean).join(", ");
823
+ }
824
+
825
+ /**
826
+ * Returns the positional announcement text for an item, tree-aware when the
827
+ * list has groups. Matches the JAWS reading pattern of FilterMenu:
828
+ * group header → "level 0, X of Y" (Y = total navigable groups)
829
+ * group child → "level 1, X of Y" (Y = siblings in that group)
830
+ * flat item → "X of Y" (Y = total items, no level prefix)
831
+ */
832
+ _treePositionText(item: UDExListItemBase): string {
833
+ if (!this.isTreeStructure) {
834
+ const items = this._list?.getKeyboardNavigationItems() ?? [];
835
+ const pos = items.indexOf(item);
836
+ return pos >= 0 ? `${pos + 1} of ${items.length}` : "";
837
+ }
838
+
839
+ if (item.isGroup) {
840
+ // Position among navigable group headers.
841
+ const groups = (this._list?.getKeyboardNavigationItems() ?? []).filter(i => i.isGroup);
842
+ const pos = groups.indexOf(item);
843
+ return pos >= 0 ? `level 0, ${pos + 1} of ${groups.length}` : "level 0";
844
+ }
845
+
846
+ // Child item — find parent group and count siblings visible in navigation.
847
+ const parentGroup = item.parentElement as UDExListItemBase | null;
848
+ if (parentGroup?.isGroup) {
849
+ const navItems = this._list?.getKeyboardNavigationItems() ?? [];
850
+ const siblings = navItems.filter(i => !i.isGroup && i.parentElement === parentGroup);
851
+ const pos = siblings.indexOf(item);
852
+ return pos >= 0 ? `level 1, ${pos + 1} of ${siblings.length}` : "level 1";
853
+ }
854
+
855
+ // Fallback: flat position.
856
+ const all = this._list?.getKeyboardNavigationItems() ?? [];
857
+ const pos = all.indexOf(item);
858
+ return pos >= 0 ? `${pos + 1} of ${all.length}` : "";
859
+ }
860
+
532
861
  fireEventDropdownBeforeOClose() {
533
862
  this.fireDecoratorEvent("before-close");
534
863
  }
@@ -568,12 +897,15 @@ class UDExSelectBase extends UDExSearchableListBase {
568
897
  this._popover.opener = this._trigger;
569
898
  this._popover.open = true;
570
899
  this._open = true;
900
+ this._navigating = true;
901
+ this._focused = false;
571
902
  }
572
903
 
573
904
  closeDropdown() {
574
905
  if (this._popover.open) {
575
906
  this._open = false;
576
907
  this._popover.open = false;
908
+ this._navigating = false;
577
909
  }
578
910
  }
579
911
 
@@ -643,11 +975,13 @@ class UDExSelectBase extends UDExSearchableListBase {
643
975
  if (collapse && !header.collapsed) {
644
976
  header.collapsed = true;
645
977
  this._list.setCollapsedItems(true, header);
978
+ renderFinished().then(() => this._announceCurrentOrFirst()).catch(() => {});
646
979
  return true;
647
980
  }
648
981
  if (!collapse && header.collapsed) {
649
982
  header.collapsed = false;
650
983
  this._list.setCollapsedItems(false, header);
984
+ renderFinished().then(() => this._announceCurrentOrFirst()).catch(() => {});
651
985
  return true;
652
986
  }
653
987
  // Target is a collapsible header but already in the requested state — still consume
@@ -676,7 +1010,12 @@ class UDExSelectBase extends UDExSearchableListBase {
676
1010
  const iconSlot = this._getIconButtonSlot(target);
677
1011
  if (!iconSlot) {
678
1012
  const firstIcon = target.querySelector<HTMLElement>("[slot=\"firstIcon\"]");
679
- firstIcon?.focus();
1013
+ if (firstIcon) {
1014
+ // Entering icon row: clear the active descendant ring so the list item
1015
+ // doesn't show two simultaneous outlines alongside the icon's :focus ring.
1016
+ this._setActiveDescendant(null);
1017
+ firstIcon.focus();
1018
+ }
680
1019
  return;
681
1020
  }
682
1021
  if (iconSlot === "firstIcon") {
@@ -697,7 +1036,17 @@ class UDExSelectBase extends UDExSearchableListBase {
697
1036
  const firstIcon = parentItem.querySelector<HTMLElement>("[slot=\"firstIcon\"]");
698
1037
  firstIcon?.focus();
699
1038
  } else if (iconSlot === "firstIcon") {
700
- this._list.focusItem(parentItem as UDExListItemBase);
1039
+ // Return to the list item: restore the active descendant ring and return
1040
+ // DOM focus to the input so the icon's :focus ring is cleared.
1041
+ const navItems = this._list?.getKeyboardNavigationItems();
1042
+ const tokenId = this.getDataTokenId(parentItem as UDExListItemBase);
1043
+ const renderedItem = navItems?.find(i => (i.getAttribute("data-token-id") ?? i._id) === tokenId);
1044
+ if (renderedItem) {
1045
+ this._setActiveDescendant(renderedItem);
1046
+ } else {
1047
+ this._list.focusItem(parentItem as UDExListItemBase);
1048
+ }
1049
+ (this._search as HTMLElement).focus();
701
1050
  }
702
1051
  }
703
1052
 
@@ -735,25 +1084,22 @@ class UDExSelectBase extends UDExSearchableListBase {
735
1084
 
736
1085
  this._list.unsetTabIndexByIndex(items, currentIndex);
737
1086
  this._list.setTabIndexByIndex(items, targetIndex);
738
- this._list.focusItem(items[targetIndex]);
1087
+ this._setActiveDescendant(items[targetIndex]);
739
1088
  this._selectedOption = items[targetIndex].label;
740
1089
  }
741
1090
 
742
1091
  _focusLastListItem() {
743
1092
  const items = this._list?.getKeyboardNavigationItems();
744
1093
  if (!items || items.length === 0) {
745
- this._search.focus();
1094
+ (this._search as HTMLElement).focus();
746
1095
  return;
747
1096
  }
748
1097
  const lastIndex = items.length - 1;
749
- const lastItem = items[lastIndex];
750
1098
  this._list.setTabIndexByIndex(items, lastIndex);
751
- this._selectedOption = lastItem.label;
752
- if (lastItem.isGroup) {
753
- this._list.focusHeader(lastItem);
754
- } else {
755
- this._list.focusItem(lastItem);
756
- }
1099
+ this._selectedOption = items[lastIndex].label;
1100
+ this._setActiveDescendant(items[lastIndex]);
1101
+ // Return DOM focus to the search input (aria-activedescendant model).
1102
+ (this._search as HTMLElement).focus();
757
1103
  }
758
1104
 
759
1105
  _firstFocusableIn(el: Element | undefined): HTMLElement | null {
@@ -786,11 +1132,50 @@ class UDExSelectBase extends UDExSearchableListBase {
786
1132
  findAndFocus(this);
787
1133
  }
788
1134
 
1135
+ /**
1136
+ * Builds the state portion of the live region announcement for a navigation item.
1137
+ * Returns an array of state strings (e.g. ["collapsed", "Checked"]) to be joined
1138
+ * with the label and position. Handles:
1139
+ * - collapsible group headers: expanded / collapsed
1140
+ * - MultiSelect: Checked / Unchecked / Indeterminate
1141
+ * - SingleSelect (tree): Selected / Unselected
1142
+ * - disabled: Disabled
1143
+ */
1144
+ _itemStateLabels(item: UDExListItemBase): string[] {
1145
+ const parts: string[] = [];
1146
+
1147
+ if (item.collapsible) {
1148
+ // Use plain English — matches what AT announce for aria-expanded.
1149
+ parts.push(item.expanded ? "expanded" : "collapsed");
1150
+ }
1151
+
1152
+ if (item.disabled) {
1153
+ parts.push(translate(UDExSearchableListBase, DISABLED));
1154
+ } else if (item.modeMultiSelect || (item.isGroup && this.modeMultiSelect)) {
1155
+ if (item.indeterminate) {
1156
+ parts.push(translate(UDExSearchableListBase, INDETERMINATE));
1157
+ } else {
1158
+ parts.push(translate(UDExSearchableListBase, item.selected ? SELECTED : UNSELECTED));
1159
+ }
1160
+ } else if (this.isTreeStructure && item.selected) {
1161
+ // SingleSelect tree: only announce "Selected" when the item is actually selected.
1162
+ parts.push(translate(UDExSearchableListBase, SELECTED));
1163
+ }
1164
+
1165
+ return parts;
1166
+ }
1167
+
789
1168
  get ariaDescribedbyText(): string {
790
1169
  if (this.modeSingleSelect) {
791
- return this._selectedOption;
1170
+ return this._navigating ? "" : this._selectedOption;
792
1171
  }
793
1172
  const tags = this.selectedItems.filter(item => !item.isGroup);
1173
+ // Tokenizer.i18nBundle may not be loaded yet on direct page load.
1174
+ // Return empty string until the bundle is available; the component re-renders
1175
+ // once i18n is ready and will pick up the correct text.
1176
+ if (!Tokenizer.i18nBundle) {
1177
+ return "";
1178
+ }
794
1179
  return getTokensCountText(tags.length);
795
1180
  }
796
1181
 
@@ -840,6 +1225,18 @@ class UDExSelectBase extends UDExSearchableListBase {
840
1225
  return this._listBoxRole;
841
1226
  }
842
1227
 
1228
+ get listAriaControlsId(): string {
1229
+ return this._list?.id || this.popoverId;
1230
+ }
1231
+
1232
+ override get _activeDescendantMode(): boolean {
1233
+ return true;
1234
+ }
1235
+
1236
+ override get isTreeStructure(): boolean {
1237
+ return this._filteredItems.some(e => e.isGroup);
1238
+ }
1239
+
843
1240
  get hideDropdownSearch(): boolean {
844
1241
  return true;
845
1242
  }