@sapudex/web-components 2.40.0 → 2.41.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 (115) hide show
  1. package/cypress/specs/Dropdown.cy.tsx +90 -0
  2. package/cypress/specs/PasswordField.cy.tsx +37 -0
  3. package/cypress/specs/Search.cy.tsx +244 -138
  4. package/cypress/specs/SelectBox.cy.tsx +184 -5
  5. package/cypress/support/component-imports.instrumented.ts +2 -0
  6. package/cypress/support/component-imports.src.ts +2 -0
  7. package/dist/Dropdown.d.ts +5 -0
  8. package/dist/Dropdown.js +56 -0
  9. package/dist/Dropdown.js.map +1 -1
  10. package/dist/List.d.ts +1 -6
  11. package/dist/List.js +2 -10
  12. package/dist/List.js.map +1 -1
  13. package/dist/MastheadCountrySelector.d.ts +7 -0
  14. package/dist/MastheadCountrySelector.js +3 -0
  15. package/dist/MastheadCountrySelector.js.map +1 -1
  16. package/dist/PasswordField.d.ts +10 -0
  17. package/dist/PasswordField.js +17 -2
  18. package/dist/PasswordField.js.map +1 -1
  19. package/dist/PasswordFieldTemplate.js +1 -1
  20. package/dist/PasswordFieldTemplate.js.map +1 -1
  21. package/dist/Search.d.ts +1 -1
  22. package/dist/Search.js +13 -9
  23. package/dist/Search.js.map +1 -1
  24. package/dist/SearchTemplate.js +3 -3
  25. package/dist/SearchTemplate.js.map +1 -1
  26. package/dist/SearchableListBase.d.ts +1 -6
  27. package/dist/SearchableListBase.js.map +1 -1
  28. package/dist/SearchableListBaseTemplate.js +1 -1
  29. package/dist/SearchableListBaseTemplate.js.map +1 -1
  30. package/dist/SelectBase.d.ts +7 -1
  31. package/dist/SelectBase.js +114 -17
  32. package/dist/SelectBase.js.map +1 -1
  33. package/dist/TextField.d.ts +1 -1
  34. package/dist/TextField.js +2 -2
  35. package/dist/TextField.js.map +1 -1
  36. package/dist/TextFieldTemplate.js +1 -1
  37. package/dist/TextFieldTemplate.js.map +1 -1
  38. package/dist/assets/{InputSuggestions-DAElWNvY.js → InputSuggestions-BSg7bjcx.js} +1 -1
  39. package/dist/assets/{ListItemStandardExpandableTextTemplate-B2fGcGW7.js → ListItemStandardExpandableTextTemplate-CCEQur_J.js} +1 -1
  40. package/dist/assets/{bundle.esm-DPVxY8xv.js → bundle.esm-I7iOjLgn.js} +232 -232
  41. package/dist/assets/messagebundle_en-BZqDcZHN.js +1 -0
  42. package/dist/assets/messagebundle_hu-CBV3ISbF.js +1 -0
  43. package/dist/css/themes/MastheadCountrySelector.css +1 -1
  44. package/dist/css/themes/MastheadProfileMenu.css +1 -1
  45. package/dist/css/themes/StatusMessage.css +1 -1
  46. package/dist/css/themes/TextField.css +1 -1
  47. package/dist/css/themes/Toast.css +1 -1
  48. package/dist/custom-elements-internal.json +38 -146
  49. package/dist/custom-elements.json +38 -114
  50. package/dist/features/SearchSuggestionsPopover.d.ts +6 -9
  51. package/dist/features/SearchSuggestionsPopover.js +47 -163
  52. package/dist/features/SearchSuggestionsPopover.js.map +1 -1
  53. package/dist/generated/assets/i18n/messagebundle_en.json +1 -1
  54. package/dist/generated/assets/i18n/messagebundle_hu.json +1 -1
  55. package/dist/generated/i18n/i18n-defaults.d.ts +3 -2
  56. package/dist/generated/i18n/i18n-defaults.js +3 -2
  57. package/dist/generated/i18n/i18n-defaults.js.map +1 -1
  58. package/dist/generated/templates/MastheadCountrySelectorTemplate.lit.js +6 -5
  59. package/dist/generated/templates/MastheadCountrySelectorTemplate.lit.js.map +1 -1
  60. package/dist/generated/templates/MastheadMobileMenuTemplate.lit.js +1 -1
  61. package/dist/generated/templates/MastheadMobileMenuTemplate.lit.js.map +1 -1
  62. package/dist/generated/themes/MastheadCountrySelector.css.d.ts +1 -1
  63. package/dist/generated/themes/MastheadCountrySelector.css.js +1 -1
  64. package/dist/generated/themes/MastheadCountrySelector.css.js.map +1 -1
  65. package/dist/generated/themes/MastheadProfileMenu.css.d.ts +1 -1
  66. package/dist/generated/themes/MastheadProfileMenu.css.js +1 -1
  67. package/dist/generated/themes/MastheadProfileMenu.css.js.map +1 -1
  68. package/dist/generated/themes/StatusMessage.css.d.ts +1 -1
  69. package/dist/generated/themes/StatusMessage.css.js +1 -1
  70. package/dist/generated/themes/StatusMessage.css.js.map +1 -1
  71. package/dist/generated/themes/TextField.css.d.ts +1 -1
  72. package/dist/generated/themes/TextField.css.js +1 -1
  73. package/dist/generated/themes/TextField.css.js.map +1 -1
  74. package/dist/generated/themes/Toast.css.d.ts +1 -1
  75. package/dist/generated/themes/Toast.css.js +1 -1
  76. package/dist/generated/themes/Toast.css.js.map +1 -1
  77. package/dist/messagebundle.properties +2 -1
  78. package/dist/messagebundle_en.properties +2 -1
  79. package/dist/messagebundle_hu.properties +125 -0
  80. package/dist/vscode.html-custom-data.json +10 -25
  81. package/dist/web-types.json +21 -41
  82. package/package.json +1 -1
  83. package/src/Dropdown.ts +62 -0
  84. package/src/List.ts +3 -18
  85. package/src/MastheadCountrySelector.hbs +2 -0
  86. package/src/MastheadCountrySelector.ts +9 -0
  87. package/src/MastheadMobileMenu.hbs +1 -0
  88. package/src/PasswordField.ts +24 -0
  89. package/src/PasswordFieldTemplate.tsx +2 -2
  90. package/src/Search.ts +13 -9
  91. package/src/SearchTemplate.tsx +0 -3
  92. package/src/SearchableListBase.ts +1 -6
  93. package/src/SearchableListBaseTemplate.tsx +0 -1
  94. package/src/SelectBase.ts +114 -19
  95. package/src/TextField.ts +1 -1
  96. package/src/TextFieldTemplate.tsx +1 -1
  97. package/src/features/SearchSuggestionsPopover.ts +55 -177
  98. package/src/generated/i18n/i18n-defaults.ts +2 -2
  99. package/src/generated/templates/MastheadCountrySelectorTemplate.lit.ts +6 -5
  100. package/src/generated/templates/MastheadMobileMenuTemplate.lit.ts +1 -1
  101. package/src/generated/themes/MastheadCountrySelector.css.ts +1 -1
  102. package/src/generated/themes/MastheadProfileMenu.css.ts +1 -1
  103. package/src/generated/themes/StatusMessage.css.ts +1 -1
  104. package/src/generated/themes/TextField.css.ts +1 -1
  105. package/src/generated/themes/Toast.css.ts +1 -1
  106. package/src/i18n/messagebundle.properties +2 -1
  107. package/src/i18n/messagebundle_en.properties +2 -1
  108. package/src/i18n/messagebundle_hu.properties +125 -0
  109. package/src/themes/MastheadCountrySelector.css +5 -2
  110. package/src/themes/MastheadProfileMenu.css +0 -5
  111. package/src/themes/StatusMessage.css +4 -4
  112. package/src/themes/TextField.css +3 -0
  113. package/src/themes/Toast.css +14 -2
  114. package/dist/assets/messagebundle_en-BxjFAY2S.js +0 -1
  115. package/dist/assets/messagebundle_hu-BEMbu-EE.js +0 -1
@@ -0,0 +1,90 @@
1
+ import {
2
+ UDExDropdown,
3
+ UDExButton,
4
+ UDExList,
5
+ UDExListItem,
6
+ UDExSearch,
7
+ } from "../support/component-imports.js";
8
+
9
+ const dropdownFixture = (
10
+ <div style="position:relative; padding: 24px;">
11
+ <a id="prev-link" href="#">Previous focusable element</a>
12
+ {" "}
13
+ <UDExButton id="opener">Open dropdown</UDExButton>
14
+ {" "}
15
+ <a id="next-link" href="#">Next focusable element</a>
16
+ <UDExDropdown id="dropdown" opener="opener">
17
+ <div slot="header">
18
+ <UDExSearch id="dropdown-search"></UDExSearch>
19
+ </div>
20
+ <UDExList id="dropdown-list">
21
+ <UDExListItem id="item-1">List Item 1</UDExListItem>
22
+ <UDExListItem id="item-2">List Item 2</UDExListItem>
23
+ <UDExListItem id="item-3">List Item 3</UDExListItem>
24
+ </UDExList>
25
+ <div slot="footer">
26
+ <UDExButton id="footer-button" design="Tertiary">Apply</UDExButton>
27
+ </div>
28
+ </UDExDropdown>
29
+ </div>
30
+ );
31
+
32
+ const openDropdown = () => {
33
+ cy.get("#dropdown").then($el => {
34
+ ($el[0] as any).open = true;
35
+ });
36
+ cy.get("[udex-dropdown]").should("have.attr", "open");
37
+ };
38
+
39
+ describe("UDExDropdown", () => {
40
+ describe("Rendering", () => {
41
+ it("registers the udex-dropdown custom element", () => {
42
+ cy.mount(dropdownFixture);
43
+ cy.get("[udex-dropdown]").should("exist");
44
+ });
45
+ });
46
+
47
+ describe("Dropdown keyboard navigation", () => {
48
+ it("Shift+Tab from first focusable element closes dropdown and returns focus to opener", () => {
49
+ cy.mount(dropdownFixture);
50
+ openDropdown();
51
+
52
+ // Opening the dropdown auto-focuses the search input in the header.
53
+ // forwardToLast() (Shift+Tab from the first focusable element) calls
54
+ // closePopup() which resets focus back to the opener.
55
+ cy.realPress(["Shift", "Tab"]);
56
+
57
+ cy.get("[udex-dropdown]").should("not.have.attr", "open");
58
+ cy.get("#opener").should("be.focused");
59
+ });
60
+
61
+ it("Tab from last focusable element closes dropdown and moves focus past opener", () => {
62
+ cy.mount(dropdownFixture);
63
+ openDropdown();
64
+
65
+ // Directly focus the last-fe sentinel — this is exactly what the browser
66
+ // does when Tab is pressed from the last focusable element inside the
67
+ // popover. The sentinel's onFocusIn calls forwardToFirst(), which closes
68
+ // the dropdown and moves focus to the next element after the opener.
69
+ cy.get("[udex-dropdown]").shadow().find(".last-fe").then($el => {
70
+ $el[0].focus();
71
+ });
72
+
73
+ cy.get("[udex-dropdown]").should("not.have.attr", "open");
74
+ cy.get("#next-link").should("be.focused");
75
+ });
76
+
77
+ it("Tab navigates forward through focusable elements without closing the dropdown", () => {
78
+ cy.mount(dropdownFixture);
79
+ openDropdown();
80
+
81
+ // The search input is auto-focused on open. A single forward Tab should
82
+ // move focus to the next focusable element inside the popover (list /
83
+ // footer) — the dropdown should remain open because we have not yet
84
+ // crossed the last-fe sentinel.
85
+ cy.realPress("Tab");
86
+
87
+ cy.get("[udex-dropdown]").should("have.attr", "open");
88
+ });
89
+ });
90
+ });
@@ -237,6 +237,43 @@ describe("passwordVisible property", () => {
237
237
  });
238
238
  });
239
239
 
240
+ describe("activeButtonTooltip", () => {
241
+ const controlBtn = () =>
242
+ cy.get("[udex-password-field]")
243
+ .shadow().find("[udex-text-field]")
244
+ .shadow().find("[udex-control-button]");
245
+
246
+ it("uses showButtonTooltip when password is hidden", () => {
247
+ cy.mount(<UDExPasswordField show-button-enabled show-button-tooltip="Show it" hide-button-tooltip="Hide it"></UDExPasswordField>);
248
+ controlBtn().should("have.attr", "accessible-name", "Show it");
249
+ controlBtn().shadow().find("[data-sap-focus-ref]").should("have.attr", "title", "Show it");
250
+ });
251
+
252
+ it("uses hideButtonTooltip when password is visible", () => {
253
+ cy.mount(<UDExPasswordField show-button-enabled show-button-tooltip="Show it" hide-button-tooltip="Hide it"></UDExPasswordField>);
254
+ controlBtn().click();
255
+ controlBtn().should("have.attr", "accessible-name", "Hide it");
256
+ controlBtn().shadow().find("[data-sap-focus-ref]").should("have.attr", "title", "Hide it");
257
+ });
258
+
259
+ it("falls back to showButtonTooltip when hideButtonTooltip is not set", () => {
260
+ cy.mount(<UDExPasswordField show-button-enabled show-button-tooltip="Show it"></UDExPasswordField>);
261
+ controlBtn().click();
262
+ controlBtn().should("have.attr", "accessible-name", "Hide password");
263
+ });
264
+
265
+ it("falls back to i18n default 'Show password' when no tooltips are set and password is hidden", () => {
266
+ cy.mount(<UDExPasswordField show-button-enabled></UDExPasswordField>);
267
+ controlBtn().should("have.attr", "accessible-name", "Show password");
268
+ });
269
+
270
+ it("falls back to i18n default 'Hide password' when no tooltips are set and password is visible", () => {
271
+ cy.mount(<UDExPasswordField show-button-enabled></UDExPasswordField>);
272
+ controlBtn().click();
273
+ controlBtn().should("have.attr", "accessible-name", "Hide password");
274
+ });
275
+ });
276
+
240
277
  describe("Events", () => {
241
278
  it("fires input event on typing", () => {
242
279
  const inputHandler = cy.stub().as("inputHandler");
@@ -15,26 +15,40 @@ const SUGGESTION_LIST_RECOMMENDED_ONLY = JSON.stringify({
15
15
  const SEARCH_SHADOW = "[udex-search]";
16
16
  const SEARCH_INPUT = "#search-input";
17
17
  const SUGGESTIONS_LIST = ".udex-search-suggestions__list";
18
+ const ITEM_BUTTON = "[ui5-button]";
19
+ const SUBMIT_BUTTON = "#submit-button";
18
20
 
19
21
  const focusInputThroughShadow = () => {
20
22
  cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).focus();
21
23
  };
22
24
 
23
- // Cypress's should("be.focused") traverses activeElement through shadow roots
24
- // but fails for elements that are light-DOM children of a nested custom element
25
- // (ui5-popover inside udex-search's shadow root). Use the native :focus
26
- // pseudo-class match which the browser applies directly to the focused element.
27
- const assertListFocused = (index: number) => {
28
- cy.get(SEARCH_SHADOW).shadow().find(SUGGESTIONS_LIST).eq(index).should($el => {
29
- expect($el[0].matches(":focus")).to.be.true;
30
- });
25
+ // Items are focused via item.focus() inside the popover (light-DOM children of
26
+ // ui5-popover that lives inside the search shadow root). Cypress's
27
+ // should("be.focused") fails on such cross-shadow chains, so use the native
28
+ // :focus pseudo-class which the browser applies to the actually-focused node.
29
+ const assertNthFlatItemFocused = (flatIndex: number) => {
30
+ cy.get(SEARCH_SHADOW)
31
+ .shadow()
32
+ .find(`${SUGGESTIONS_LIST} ${ITEM_BUTTON}`)
33
+ .eq(flatIndex)
34
+ .should($el => {
35
+ expect($el[0].matches(":focus")).to.be.true;
36
+ });
37
+ };
38
+
39
+ const assertNthFlatItemAriaSelected = (flatIndex: number) => {
40
+ cy.get(SEARCH_SHADOW)
41
+ .shadow()
42
+ .find(`${SUGGESTIONS_LIST} ${ITEM_BUTTON}`)
43
+ .eq(flatIndex)
44
+ .should("have.attr", "aria-selected", "true");
31
45
  };
32
46
 
33
47
  // suggestion-list must be set dynamically after mount.
34
48
  // UI5Element._suppressInvalidation is true until after the first render, so
35
49
  // attributes set by the JSX renderer never reach onInvalidation and the lists
36
50
  // are never populated. Setting the attribute after mount (when
37
- // _suppressInvalidation is false) allows onInvalidation → setSuggestedRecommendedLists
51
+ // _suppressInvalidation is false) allows onInvalidation -> setSuggestedRecommendedLists
38
52
  // to run and populate suggestedList / recommendedList.
39
53
  const setSuggestionList = (list: string) => {
40
54
  cy.get(SEARCH_SHADOW).invoke("attr", "suggestion-list", list);
@@ -51,18 +65,31 @@ const renderAlwaysOpen = () => (
51
65
  </div>
52
66
  );
53
67
 
54
- const renderAlwaysOpenSingleList = () => (
68
+ const renderAlwaysOpenWithSubmit = () => (
55
69
  <div>
56
70
  <UDExSearch
57
71
  id="search"
58
72
  show-suggested-popover
59
73
  open-suggestion-popup
74
+ show-submit-button
60
75
  />
61
76
  <a id="next-focus" href="#">Next focus</a>
62
77
  </div>
63
78
  );
64
79
 
65
- describe("UDExSearch — keyboard focus management with openSuggestionPopup", () => {
80
+ const renderAlwaysOpenWithPrev = () => (
81
+ <div>
82
+ <a id="prev-focus" href="#">Previous focus</a>
83
+ <UDExSearch
84
+ id="search"
85
+ show-suggested-popover
86
+ open-suggestion-popup
87
+ />
88
+ <a id="next-focus" href="#">Next focus</a>
89
+ </div>
90
+ );
91
+
92
+ describe("UDExSearch — keyboard focus management (rewritten)", () => {
66
93
  describe("Always-open popover with two lists (suggested + recommended)", () => {
67
94
  beforeEach(() => {
68
95
  cy.mount(renderAlwaysOpen());
@@ -70,211 +97,290 @@ describe("UDExSearch — keyboard focus management with openSuggestionPopup", ()
70
97
  // Wait for the popover to be opened by onAfterRendering -> handleOpenSuggestionPopover.
71
98
  cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "true");
72
99
  cy.get(SEARCH_SHADOW).shadow().find(SUGGESTIONS_LIST).should("have.length", 2);
100
+ // 2 + 2 = 4 items across both lists.
101
+ cy.get(SEARCH_SHADOW).shadow().find(`${SUGGESTIONS_LIST} ${ITEM_BUTTON}`).should("have.length", 4);
73
102
  });
74
103
 
75
- it("Tab traverses input -> lists[0] -> lists[1] -> input -> exits the component", () => {
104
+ it("ArrowDown from the input opens the dropdown and focuses the first item", () => {
76
105
  focusInputThroughShadow();
77
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("be.focused");
106
+ cy.realPress("ArrowDown");
78
107
 
79
- // Tab 1: input -> lists[0]
80
- cy.realPress("Tab");
81
- assertListFocused(0);
108
+ assertNthFlatItemFocused(0);
109
+ assertNthFlatItemAriaSelected(0);
110
+ });
82
111
 
83
- // Tab 2: lists[0] -> lists[1]
84
- cy.realPress("Tab");
85
- assertListFocused(1);
112
+ it("ArrowDown navigates a flat list across both suggested and recommended items", () => {
113
+ focusInputThroughShadow();
114
+ // Arrow down four times moves through index 0, 1, 2, 3.
115
+ cy.realPress("ArrowDown"); // -> first item of suggested
116
+ assertNthFlatItemFocused(0);
86
117
 
87
- // Tab 3: lists[1] -> input (escape flag is set; popover stays open)
88
- cy.realPress("Tab");
118
+ cy.realPress("ArrowDown"); // -> second item of suggested
119
+ assertNthFlatItemFocused(1);
120
+
121
+ cy.realPress("ArrowDown"); // -> first item of recommended
122
+ assertNthFlatItemFocused(2);
123
+
124
+ cy.realPress("ArrowDown"); // -> second item of recommended (last)
125
+ assertNthFlatItemFocused(3);
126
+ });
127
+
128
+ it("ArrowDown on the last item wraps to the first item (circular)", () => {
129
+ focusInputThroughShadow();
130
+ cy.realPress("ArrowDown");
131
+ cy.realPress("ArrowDown");
132
+ cy.realPress("ArrowDown");
133
+ cy.realPress("ArrowDown");
134
+ assertNthFlatItemFocused(3);
135
+
136
+ // Wrap forward: last -> first.
137
+ cy.realPress("ArrowDown");
138
+ assertNthFlatItemFocused(0);
139
+ assertNthFlatItemAriaSelected(0);
140
+ });
141
+
142
+ it("ArrowUp on the first item wraps to the last item (circular)", () => {
143
+ focusInputThroughShadow();
144
+ // Initial state: selectedItemIndex = -1. ArrowUp wraps to last.
145
+ cy.realPress("ArrowDown");
146
+ assertNthFlatItemFocused(0);
147
+
148
+ // From first item, ArrowUp wraps to last item.
149
+ cy.realPress("ArrowUp");
150
+ assertNthFlatItemFocused(3);
151
+ assertNthFlatItemAriaSelected(3);
152
+ });
153
+
154
+ it("ArrowUp navigates backward across both lists as one flat sequence", () => {
155
+ focusInputThroughShadow();
156
+ // Move to last item.
157
+ cy.realPress("ArrowDown");
158
+ cy.realPress("ArrowDown");
159
+ cy.realPress("ArrowDown");
160
+ cy.realPress("ArrowDown");
161
+ assertNthFlatItemFocused(3);
162
+
163
+ // Step backward: last -> 2 -> 1 -> 0.
164
+ cy.realPress("ArrowUp");
165
+ assertNthFlatItemFocused(2);
166
+ cy.realPress("ArrowUp");
167
+ assertNthFlatItemFocused(1);
168
+ cy.realPress("ArrowUp");
169
+ assertNthFlatItemFocused(0);
170
+ });
171
+
172
+ it("Escape from a focused dropdown item returns focus to the input", () => {
173
+ focusInputThroughShadow();
174
+ cy.realPress("ArrowDown");
175
+ assertNthFlatItemFocused(0);
176
+
177
+ cy.realPress("Escape");
178
+ // With openSuggestionPopup=true the popover re-opens immediately after close,
179
+ // so we only verify that focus returned to the input.
89
180
  cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("be.focused");
90
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "true");
181
+ });
91
182
 
92
- // Tab 4: input + flag -> focus leaves the search component entirely
93
- cy.realPress("Tab");
94
- cy.get("#next-focus").should("be.focused");
95
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("not.be.focused");
183
+ it("aria-selected is removed from items after the dropdown closes", () => {
184
+ focusInputThroughShadow();
185
+ cy.realPress("ArrowDown");
186
+ assertNthFlatItemAriaSelected(0);
187
+
188
+ cy.realPress("Escape");
189
+
190
+ // After close, no item should retain aria-selected.
191
+ cy.get(SEARCH_SHADOW)
192
+ .shadow()
193
+ .find(`${SUGGESTIONS_LIST} ${ITEM_BUTTON}[aria-selected="true"]`)
194
+ .should("have.length", 0);
96
195
  });
196
+ });
97
197
 
98
- it("Shift+Tab traverses input -> lists[1] -> lists[0] -> input -> exits the component backward", () => {
99
- // Place a focusable element BEFORE the search so Shift+Tab past the input has somewhere to go.
198
+ describe("Tab / Shift+Tab no-trap behaviour from the input", () => {
199
+ it("Tab from the input while dropdown is open closes the dropdown and proceeds to the next focusable", () => {
200
+ // Use a search WITHOUT openSuggestionPopup so handleTabKey actually closes.
100
201
  cy.mount(
101
202
  <div>
102
- <a id="prev-focus" href="#">Previous focus</a>
103
203
  <UDExSearch
104
204
  id="search"
105
205
  show-suggested-popover
106
- open-suggestion-popup
206
+ value="SAP hana"
107
207
  />
208
+ <a id="next-focus" href="#">Next focus</a>
108
209
  </div>,
109
210
  );
110
211
  setSuggestionList(SUGGESTION_LIST_BOTH);
212
+ // Open the popover by focusing the input and triggering ArrowDown,
213
+ // which calls handleDownKey -> onOpenPopover.
214
+ focusInputThroughShadow();
215
+ cy.realPress("ArrowDown");
111
216
  cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "true");
112
- cy.get(SEARCH_SHADOW).shadow().find(SUGGESTIONS_LIST).should("have.length", 2);
113
217
 
218
+ // Move focus back to the input (the popover stays open).
114
219
  focusInputThroughShadow();
115
220
  cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("be.focused");
116
221
 
117
- // Shift+Tab 1: input -> lists[1] (last list)
118
- cy.realPress(["Shift", "Tab"]);
119
- assertListFocused(1);
120
-
121
- // Shift+Tab 2: lists[1] -> lists[0]
122
- cy.realPress(["Shift", "Tab"]);
123
- assertListFocused(0);
124
-
125
- // Shift+Tab 3: lists[0] -> input (escape flag is set; popover stays open)
126
- cy.realPress(["Shift", "Tab"]);
127
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("be.focused");
128
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "true");
129
-
130
- // Shift+Tab 4: input + flag -> focus leaves the search component backward
131
- cy.realPress(["Shift", "Tab"]);
132
- cy.get("#prev-focus").should("be.focused");
222
+ // Tab from input: handleTabKey calls onClosePopover (no preventDefault),
223
+ // so default Tab moves focus to the next focusable in the document.
224
+ cy.realPress("Tab");
225
+ cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "false");
226
+ // Default Tab proceeded — the input is no longer the focused element.
133
227
  cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("not.be.focused");
134
228
  });
135
229
 
136
- it("Shift+Tab from reset button while popup is open moves focus to input (not trapped)", () => {
230
+ it("Shift+Tab from the input while dropdown is open closes the dropdown and proceeds backward", () => {
137
231
  cy.mount(
138
232
  <div>
233
+ <a id="prev-focus" href="#">Previous focus</a>
139
234
  <UDExSearch
140
235
  id="search"
141
236
  show-suggested-popover
142
- open-suggestion-popup
143
- value="SAP"
237
+ value="SAP hana"
144
238
  />
145
- <a id="next-focus" href="#">Next focus</a>
146
239
  </div>,
147
240
  );
148
241
  setSuggestionList(SUGGESTION_LIST_BOTH);
242
+ focusInputThroughShadow();
243
+ cy.realPress("ArrowDown");
149
244
  cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "true");
150
- cy.get(SEARCH_SHADOW).shadow().find(SUGGESTIONS_LIST).should("have.length", 2);
151
245
 
246
+ // Return focus to the input.
152
247
  focusInputThroughShadow();
153
248
  cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("be.focused");
154
249
 
155
- // Navigate through popover lists with Tab and return to input via escape flag.
156
- cy.realPress("Tab");
157
- assertListFocused(0);
158
- cy.realPress("Tab");
159
- assertListFocused(1);
160
- cy.realPress("Tab");
161
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("be.focused");
250
+ // Shift+Tab from input: handleTabKey closes (no preventDefault), default
251
+ // Shift+Tab proceeds backward to the previous focusable.
252
+ cy.realPress(["Shift", "Tab"]);
253
+ cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "false");
254
+ cy.get("#prev-focus").should("be.focused");
255
+ });
256
+
257
+ it("with openSuggestionPopup=true, Tab from the input does not close the popover (no preventDefault)", () => {
258
+ cy.mount(renderAlwaysOpen());
259
+ setSuggestionList(SUGGESTION_LIST_BOTH);
260
+ cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "true");
162
261
 
163
- // Tab again: escape flag is consumed, natural Tab moves to reset button.
262
+ focusInputThroughShadow();
164
263
  cy.realPress("Tab");
165
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("not.be.focused");
166
264
 
167
- // Shift+Tab from reset button must return to input — not get trapped.
168
- cy.realPress(["Shift", "Tab"]);
169
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("be.focused");
265
+ // handleTabKey returned early because openSuggestionPopup is true.
266
+ // The popover remains open (or is re-opened by handleOpenSuggestionPopover on next render).
267
+ cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "true");
170
268
  });
269
+ });
270
+
271
+ describe("Tab / Shift+Tab from a focused dropdown item", () => {
272
+ it("Tab from a focused item closes the dropdown and moves focus to #submit-button when present", () => {
273
+ cy.mount(renderAlwaysOpenWithSubmit());
274
+ setSuggestionList(SUGGESTION_LIST_BOTH);
275
+ cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "true");
276
+ cy.get(SEARCH_SHADOW).shadow().find(`${SUGGESTIONS_LIST} ${ITEM_BUTTON}`).should("have.length", 4);
277
+ cy.get(SEARCH_SHADOW).shadow().find(SUBMIT_BUTTON).should("exist");
171
278
 
172
- it("Down arrow from a focused list moves focus into individual item buttons; Up arrow reverses it", () => {
173
279
  focusInputThroughShadow();
174
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("be.focused");
280
+ cy.realPress("ArrowDown");
281
+ assertNthFlatItemFocused(0);
175
282
 
176
- // Tab into lists[0].
177
283
  cy.realPress("Tab");
178
- assertListFocused(0);
179
284
 
180
- // ArrowDown: first item in lists[0] receives focus & aria-selected.
181
- cy.realPress("ArrowDown");
182
- cy.get(SEARCH_SHADOW)
183
- .shadow()
184
- .find(SUGGESTIONS_LIST)
185
- .eq(0)
186
- .find("[ui5-button]")
187
- .eq(0)
188
- .should("have.attr", "aria-selected", "true");
285
+ // Focus moved to submit button (programmatic focus by onKeyTabList).
286
+ cy.get(SEARCH_SHADOW).shadow().find(SUBMIT_BUTTON).should($el => {
287
+ expect($el[0].matches(":focus")).to.be.true;
288
+ });
289
+ });
189
290
 
190
- // ArrowDown: focus moves to the second item in lists[0].
191
- cy.realPress("ArrowDown");
192
- cy.get(SEARCH_SHADOW)
193
- .shadow()
194
- .find(SUGGESTIONS_LIST)
195
- .eq(0)
196
- .find("[ui5-button]")
197
- .eq(1)
198
- .should("have.attr", "aria-selected", "true");
291
+ it("Tab from a focused item closes the dropdown and falls back to the input when there is no submit button", () => {
292
+ cy.mount(renderAlwaysOpen());
293
+ setSuggestionList(SUGGESTION_LIST_BOTH);
294
+ cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "true");
295
+ cy.get(SEARCH_SHADOW).shadow().find(`${SUGGESTIONS_LIST} ${ITEM_BUTTON}`).should("have.length", 4);
296
+ cy.get(SEARCH_SHADOW).shadow().find(SUBMIT_BUTTON).should("not.exist");
199
297
 
200
- // ArrowDown from the LAST item of lists[0] hops focus to lists[1] (selectedItemIndex resets to -1).
298
+ focusInputThroughShadow();
201
299
  cy.realPress("ArrowDown");
202
- assertListFocused(1);
300
+ assertNthFlatItemFocused(0);
301
+
302
+ cy.realPress("Tab");
303
+
304
+ cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("be.focused");
305
+ });
306
+
307
+ it("Shift+Tab from a focused item closes the dropdown and moves focus to the input", () => {
308
+ cy.mount(renderAlwaysOpenWithSubmit());
309
+ setSuggestionList(SUGGESTION_LIST_BOTH);
310
+ cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "true");
311
+ cy.get(SEARCH_SHADOW).shadow().find(`${SUGGESTIONS_LIST} ${ITEM_BUTTON}`).should("have.length", 4);
203
312
 
204
- // ArrowDown inside lists[1]: focuses first item there.
313
+ focusInputThroughShadow();
205
314
  cy.realPress("ArrowDown");
206
- cy.get(SEARCH_SHADOW)
207
- .shadow()
208
- .find(SUGGESTIONS_LIST)
209
- .eq(1)
210
- .find("[ui5-button]")
211
- .eq(0)
212
- .should("have.attr", "aria-selected", "true");
315
+ assertNthFlatItemFocused(0);
213
316
 
214
- // ArrowUp from the FIRST item of lists[1] hops focus back to lists[0].
215
- cy.realPress("ArrowUp");
216
- assertListFocused(0);
317
+ cy.realPress(["Shift", "Tab"]);
318
+
319
+ cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("be.focused");
217
320
  });
218
321
  });
219
322
 
220
323
  describe("Always-open popover with a single list (recommended only)", () => {
221
324
  beforeEach(() => {
222
- cy.mount(renderAlwaysOpenSingleList());
325
+ cy.mount(renderAlwaysOpen());
223
326
  setSuggestionList(SUGGESTION_LIST_RECOMMENDED_ONLY);
224
327
  cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "true");
225
328
  cy.get(SEARCH_SHADOW).shadow().find(SUGGESTIONS_LIST).should("have.length", 1);
329
+ cy.get(SEARCH_SHADOW).shadow().find(`${SUGGESTIONS_LIST} ${ITEM_BUTTON}`).should("have.length", 2);
226
330
  });
227
331
 
228
- it("Tab traverses input -> lists[0] -> input -> exits the component", () => {
332
+ it("ArrowDown moves through the single list, ArrowDown on last item wraps to first", () => {
229
333
  focusInputThroughShadow();
230
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("be.focused");
231
334
 
232
- // Tab 1: input -> lists[0]
233
- cy.realPress("Tab");
234
- assertListFocused(0);
335
+ cy.realPress("ArrowDown");
336
+ assertNthFlatItemFocused(0);
235
337
 
236
- // Tab 2: lists[0] -> input (escape flag set; popover stays open)
237
- cy.realPress("Tab");
238
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("be.focused");
239
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "true");
338
+ cy.realPress("ArrowDown");
339
+ assertNthFlatItemFocused(1);
240
340
 
241
- // Tab 3: input + flag -> exits component
242
- cy.realPress("Tab");
243
- cy.get("#next-focus").should("be.focused");
244
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("not.be.focused");
341
+ // Last item -> wrap to first.
342
+ cy.realPress("ArrowDown");
343
+ assertNthFlatItemFocused(0);
245
344
  });
246
345
 
247
- it("Shift+Tab traverses input -> lists[0] -> input -> exits the component backward", () => {
248
- cy.mount(
249
- <div>
250
- <a id="prev-focus" href="#">Previous focus</a>
251
- <UDExSearch
252
- id="search"
253
- show-suggested-popover
254
- open-suggestion-popup
255
- />
256
- </div>,
257
- );
258
- setSuggestionList(SUGGESTION_LIST_RECOMMENDED_ONLY);
259
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "true");
260
- cy.get(SEARCH_SHADOW).shadow().find(SUGGESTIONS_LIST).should("have.length", 1);
261
-
346
+ it("ArrowUp from the first item wraps to the last item", () => {
262
347
  focusInputThroughShadow();
263
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("be.focused");
348
+ cy.realPress("ArrowDown");
349
+ assertNthFlatItemFocused(0);
264
350
 
265
- // Shift+Tab 1: input -> lists[0] (the only / last list)
266
- cy.realPress(["Shift", "Tab"]);
267
- assertListFocused(0);
351
+ cy.realPress("ArrowUp");
352
+ assertNthFlatItemFocused(1);
353
+ });
268
354
 
269
- // Shift+Tab 2: lists[0] -> input (escape flag set)
270
- cy.realPress(["Shift", "Tab"]);
355
+ it("Tab from a focused item closes the dropdown and returns focus to the input (no submit button)", () => {
356
+ focusInputThroughShadow();
357
+ cy.realPress("ArrowDown");
358
+ assertNthFlatItemFocused(0);
359
+
360
+ cy.realPress("Tab");
361
+ // With openSuggestionPopup=true the popover re-opens immediately after close,
362
+ // so we only verify that focus returned to the input.
271
363
  cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("be.focused");
364
+ });
365
+ });
366
+
367
+ describe("Suggestion list divs are no longer Tab-reachable", () => {
368
+ it("the list divs do not carry tabindex=0 and are skipped in the Tab order", () => {
369
+ cy.mount(renderAlwaysOpenWithPrev());
370
+ setSuggestionList(SUGGESTION_LIST_BOTH);
272
371
  cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("have.attr", "aria-expanded", "true");
273
372
 
274
- // Shift+Tab 3: input + flag -> exits component backward
275
- cy.realPress(["Shift", "Tab"]);
276
- cy.get("#prev-focus").should("be.focused");
277
- cy.get(SEARCH_SHADOW).shadow().find(SEARCH_INPUT).should("not.be.focused");
373
+ // Confirm the lists themselves are not exposed in the Tab order.
374
+ cy.get(SEARCH_SHADOW)
375
+ .shadow()
376
+ .find(SUGGESTIONS_LIST)
377
+ .each($el => {
378
+ // Either no tabindex attribute at all, or not a positive/zero value.
379
+ const tabindex = $el.attr("tabindex");
380
+ if (tabindex !== undefined) {
381
+ expect(Number(tabindex)).to.be.lessThan(0);
382
+ }
383
+ });
278
384
  });
279
385
  });
280
386
  });