Skip to content

Commit 3f80faa

Browse files
authored
feat(ui5-combobox, ui5-multi-combo-box, ui5-input, ui5-multi-input): link navigation in value state (#11575)
Make links in a value messages focusable using CTRL+ALT+F8 key combination. The keyboard combination is announced after the value state message is read.
1 parent 8cd0506 commit 3f80faa

26 files changed

+1147
-284
lines changed

packages/base/src/Keys.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,8 @@ const isF6Previous = (event: KeyboardEvent): boolean => ((event.key ? event.key
228228

229229
const isF7 = (event: KeyboardEvent): boolean => (event.key ? event.key === "F7" : event.keyCode === KeyCodes.F7) && !hasModifierKeys(event);
230230

231+
const isCtrlAltF8 = (event: KeyboardEvent): boolean => (event.key ? event.key === "F8" : event.keyCode === KeyCodes.F8) && checkModifierKeys(event, true, true, false);
232+
231233
const isShowByArrows = (event: KeyboardEvent): boolean => {
232234
return ((event.key === "ArrowDown" || event.key === "Down") || (event.key === "ArrowUp" || event.key === "Up")) && checkModifierKeys(event, /* Ctrl */ false, /* Alt */ true, /* Shift */ false);
233235
};
@@ -316,4 +318,5 @@ export {
316318
isInsertCtrl,
317319
isNumber,
318320
isColon,
321+
isCtrlAltF8,
319322
};
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {
2+
isDown,
3+
isUp,
4+
isTabNext,
5+
isTabPrevious,
6+
isEscape,
7+
} from "../Keys.js";
8+
9+
interface ControlHandlers {
10+
closeValueState: () => void;
11+
focusInput: () => void;
12+
navigateToItem: () => void;
13+
isPopoverOpen: () => boolean;
14+
}
15+
16+
const attachListeners = (e: KeyboardEvent, links: Array<HTMLElement>, index: number, handlers: ControlHandlers) => {
17+
if (isTabNext(e)) {
18+
if (index !== links.length - 1) {
19+
e.stopImmediatePropagation();
20+
e.preventDefault();
21+
links[index + 1].focus();
22+
} else {
23+
handlers.closeValueState();
24+
handlers.focusInput();
25+
}
26+
}
27+
28+
if (isTabPrevious(e)) {
29+
e.preventDefault();
30+
e.stopImmediatePropagation();
31+
if (index > 0) {
32+
links[index - 1].focus();
33+
} else {
34+
handlers.focusInput();
35+
}
36+
}
37+
38+
if (isUp(e)) {
39+
e.preventDefault();
40+
e.stopImmediatePropagation();
41+
handlers.isPopoverOpen() && handlers.focusInput();
42+
}
43+
44+
if (isDown(e)) {
45+
e.preventDefault();
46+
e.stopImmediatePropagation();
47+
handlers.navigateToItem();
48+
}
49+
50+
if (isEscape(e)) {
51+
e.preventDefault();
52+
e.stopImmediatePropagation();
53+
}
54+
};
55+
56+
export { attachListeners, ControlHandlers };

packages/fiori/cypress/specs/SideNavigation.cy.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -819,15 +819,15 @@ describe("Side Navigation interaction", () => {
819819
.find(".ui5-side-navigation-overflow-menu [ui5-navigation-menu-item][text='link']")
820820
.realClick();
821821

822-
cy.url()
822+
cy.url()
823823
.should("not.include", "#test");
824824

825825
cy.get("#sideNav")
826826
.shadow()
827827
.find(".ui5-side-navigation-overflow-menu [ui5-navigation-menu-item][text='item']")
828828
.realClick();
829829

830-
cy.get("@selectionChangeHandler").should("not.have.been.called");
830+
cy.get("@selectionChangeHandler", {timeout: 1000 }).should("not.have.been.called");
831831
});
832832

833833
it("Tests preventDefault on child items in collapsed side navigation", () => {
@@ -963,7 +963,7 @@ describe("Side Navigation interaction", () => {
963963
element.realClick();
964964

965965
// assert
966-
cy.get("@selectionChangeHandler").should("have.callCount", expectedCallCount);
966+
cy.get("@selectionChangeHandler", { timeout: 1000 }).should("have.callCount", expectedCallCount);
967967
});
968968
});
969969

packages/main/cypress/specs/ComboBox.cy.tsx

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import ComboBox from "../../src/ComboBox.js";
22
import ComboBoxItem from "../../src/ComboBoxItem.js";
33
import ComboBoxItemGroup from "../../src/ComboBoxItemGroup.js";
4+
import Link from "../../src/Link.js";
5+
import Input from "../../src/Input.js";
46

57
describe("Security", () => {
68
it("tests setting malicious text to items", () => {
@@ -87,6 +89,198 @@ describe("Keyboard interaction", () => {
8789
});
8890
});
8991

92+
describe("Keyboard interaction when pressing Ctrl + Alt + F8 for navigation", () => {
93+
beforeEach(() => {
94+
cy.mount(<>
95+
<ComboBox valueState="Negative">
96+
<div slot="valueStateMessage">
97+
Custom error value state message with a <Link href="#">Link</Link> <Link href="#">Second Link</Link>.
98+
</div>
99+
<ComboBoxItem text="alert('XSS')"></ComboBoxItem>
100+
<ComboBoxItem text="<b onmouseover=alert('XSS')></b>"></ComboBoxItem>
101+
<ComboBoxItem text="Albania"></ComboBoxItem>
102+
</ComboBox>
103+
<Input id="nextInput" class="input2auto" placeholder="Next input"></Input>
104+
</>);
105+
});
106+
107+
it("Should move the focus from the ComboBox to the first link in the value state message", () => {
108+
cy.get("ui5-combobox")
109+
.shadow()
110+
.find("input")
111+
.as("innerInput");
112+
113+
cy.get("ui5-combobox")
114+
.as("combobox");
115+
116+
cy.get("@innerInput")
117+
.realClick()
118+
.should("be.focused");
119+
120+
cy.realPress(["Control", "Alt", "F8"]);
121+
122+
cy.get("@combobox")
123+
.shadow()
124+
.find("ui5-popover")
125+
.as("popover")
126+
.should("have.class", "ui5-valuestatemessage-popover");
127+
128+
cy.get("@popover")
129+
.should("have.attr", "open")
130+
131+
cy.get("ui5-link")
132+
.eq(0)
133+
.should("have.focus");
134+
});
135+
136+
it("Pressing [Tab] moves the focus to the next value state message link. Pressing [Tab] again closes the popup and moves the focus to the next input", () => {
137+
cy.get("ui5-combobox")
138+
.as("combobox");
139+
140+
cy.get("@combobox")
141+
.shadow()
142+
.find("input")
143+
.as("innerInput");
144+
145+
cy.get("@innerInput")
146+
.realClick()
147+
.should("be.focused");
148+
149+
cy.realPress(["Control", "Alt", "F8"]);
150+
151+
cy.get("@combobox")
152+
.shadow()
153+
.find("ui5-popover")
154+
.as("ui5-popover")
155+
.should("have.attr", "open");
156+
157+
cy.get("ui5-link")
158+
.eq(0)
159+
.as("firstLink")
160+
.should("have.focus");
161+
162+
cy.get("@firstLink")
163+
.realPress("Tab");
164+
165+
cy.get("@firstLink")
166+
.should("not.have.focus");
167+
168+
cy.get("ui5-link")
169+
.eq(1)
170+
.as("secondLink")
171+
.should("have.focus");
172+
173+
cy.get("@secondLink")
174+
.realPress("Tab");
175+
176+
177+
cy.get("ui5-input")
178+
.as("input");
179+
180+
cy.get("@input")
181+
.should("have.focus");
182+
});
183+
184+
it("Pressing [Shift+Tab] moves the focus from the second value state message link to the first. Pressing it again shifts the focus to the ComboBox", () => {
185+
cy.get("ui5-combobox")
186+
.shadow()
187+
.find("input")
188+
.as("innerInput");
189+
190+
cy.get("ui5-combobox")
191+
.as("combobox");
192+
193+
cy.get("@innerInput")
194+
.realClick()
195+
.should("be.focused");
196+
197+
cy.realPress(["Control", "Alt", "F8"]);
198+
199+
cy.get("@combobox")
200+
.shadow()
201+
.find("ui5-popover")
202+
.as("ui5-popover")
203+
.should("have.attr", "open");
204+
205+
cy.get("ui5-link")
206+
.eq(0)
207+
.as("firstLink")
208+
.should("have.focus");
209+
210+
cy.get("@firstLink")
211+
.realPress("Tab");
212+
213+
cy.get("@firstLink")
214+
.should("not.have.focus");
215+
216+
cy.get("ui5-link")
217+
.eq(1)
218+
.as("secondLink")
219+
.should("have.focus");
220+
221+
cy.get("@secondLink")
222+
.realPress(["Shift", "Tab"]);
223+
224+
cy.get("@firstLink")
225+
.should("have.focus");
226+
227+
cy.get("@firstLink")
228+
.realPress(["Shift", "Tab"]);
229+
230+
cy.get("@innerInput")
231+
.should("have.focus");
232+
});
233+
234+
it("When pressing [Down Arrow] while focused on the first value state message link and suggestions are open, the focus moves to the next suggestion item", () => {
235+
cy.get("ui5-combobox")
236+
.shadow()
237+
.find("input")
238+
.as("innerInput");
239+
240+
cy.get("ui5-combobox")
241+
.as("combobox");
242+
243+
cy.get("@innerInput")
244+
.realClick()
245+
.should("be.focused");
246+
247+
cy.realPress(["Control", "Alt", "F8"]);
248+
249+
cy.get("@combobox")
250+
.shadow()
251+
.find("ui5-responsive-popover")
252+
.as("popover");
253+
254+
cy.get("@combobox")
255+
.realClick()
256+
.should("be.focused");
257+
258+
cy.realType("A");
259+
260+
cy.get("@popover")
261+
.should("have.attr", "open");
262+
263+
cy.get("@innerInput")
264+
.realClick()
265+
.should("be.focused");
266+
267+
cy.realPress(["Control", "Alt", "F8"]);
268+
269+
cy.get("ui5-link")
270+
.as("firstLink")
271+
.should("have.focus");
272+
273+
cy.get("@firstLink")
274+
.realPress("ArrowDown");
275+
276+
cy.get("@combobox")
277+
.should("have.attr", "value", "Albania");
278+
279+
cy.get("@combobox")
280+
.find("[ui5-cb-item]").eq(2).should("have.prop", "focused", true);
281+
});
282+
});
283+
90284
describe("Event firing", () => {
91285
it("tests if open and close events are fired correctly", () => {
92286
cy.mount(
@@ -168,6 +362,7 @@ describe("Event firing", () => {
168362
cy.get("@changeStub").should("not.have.been.called");
169363
});
170364
});
365+
171366
describe("Accessibility", () => {
172367
it("should announce the associated label when ComboBox is focused", () => {
173368
cy.mount(

0 commit comments

Comments
 (0)