Skip to content

Commit 63602a0

Browse files
committed
feat(ui-shell): HeaderNavMenu implements keyboard navigation (#2248)
Closes #1068
1 parent 92b8581 commit 63602a0

File tree

4 files changed

+298
-7
lines changed

4 files changed

+298
-7
lines changed

src/UIShell/HeaderNavItem.svelte

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,28 @@
2323
const ctx = getContext("HeaderNavMenu");
2424
2525
let selectedItemIds = [];
26+
let menuItems = [];
2627
2728
const unsubSelectedItems = ctx?.selectedItems.subscribe((_selectedItems) => {
2829
selectedItemIds = Object.keys(_selectedItems);
2930
});
3031
32+
const unsubMenuItems = ctx?.menuItems.subscribe((_menuItems) => {
33+
menuItems = _menuItems;
34+
});
35+
3136
$: ctx?.updateSelectedItems({ id, isSelected });
3237
3338
onMount(() => {
39+
if (ctx && ref) {
40+
ctx.registerMenuItem(ref);
41+
}
3442
return () => {
3543
if (unsubSelectedItems) unsubSelectedItems();
44+
if (unsubMenuItems) unsubMenuItems();
45+
if (ctx && ref) {
46+
ctx.unregisterMenuItem(ref);
47+
}
3648
};
3749
});
3850
</script>
@@ -53,10 +65,45 @@
5365
on:mouseleave
5466
on:keyup
5567
on:keydown
68+
on:keydown={(e) => {
69+
if (!ctx) return;
70+
71+
const currentIndex = menuItems.indexOf(ref);
72+
if (currentIndex === -1) return;
73+
74+
if (e.key === "ArrowDown") {
75+
e.preventDefault();
76+
// Move to next item, wrap to first
77+
const nextIndex = (currentIndex + 1) % menuItems.length;
78+
menuItems[nextIndex]?.focus();
79+
} else if (e.key === "ArrowUp") {
80+
e.preventDefault();
81+
// Move to previous item, wrap to last
82+
const prevIndex =
83+
(currentIndex - 1 + menuItems.length) % menuItems.length;
84+
menuItems[prevIndex]?.focus();
85+
} else if (e.key === "Home") {
86+
e.preventDefault();
87+
// Focus first item
88+
menuItems[0]?.focus();
89+
} else if (e.key === "End") {
90+
e.preventDefault();
91+
// Focus last item
92+
menuItems[menuItems.length - 1]?.focus();
93+
} else if (e.key === "Escape") {
94+
e.preventDefault();
95+
ctx.closeMenu();
96+
}
97+
}}
5698
on:focus
5799
on:blur
58-
on:blur={() => {
59-
if (selectedItemIds.indexOf(id) === selectedItemIds.length - 1) {
100+
on:blur={(e) => {
101+
// Only close menu if blur is moving focus outside the menu
102+
// (not when navigating between menu items with arrow keys)
103+
if (
104+
selectedItemIds.indexOf(id) === selectedItemIds.length - 1 &&
105+
(!e.relatedTarget || !menuItems.includes(e.relatedTarget))
106+
) {
60107
ctx?.closeMenu();
61108
}
62109
}}

src/UIShell/HeaderNavMenu.svelte

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,34 @@
1414
/** Obtain a reference to the HTML anchor element */
1515
export let ref = null;
1616
17-
import { setContext } from "svelte";
17+
import { setContext, tick } from "svelte";
1818
import { writable } from "svelte/store";
1919
import ChevronDown from "../icons/ChevronDown.svelte";
2020
2121
const selectedItems = writable({});
22+
const menuItems = writable([]);
2223
2324
let menuRef = null;
2425
2526
setContext("HeaderNavMenu", {
2627
selectedItems,
28+
menuItems,
2729
updateSelectedItems(item) {
2830
selectedItems.update((_items) => ({
2931
..._items,
3032
[item.id]: item.isSelected,
3133
}));
3234
},
33-
closeMenu() {
35+
registerMenuItem(element) {
36+
menuItems.update((items) => [...items, element]);
37+
},
38+
unregisterMenuItem(element) {
39+
menuItems.update((items) => items.filter((item) => item !== element));
40+
},
41+
async closeMenu() {
3442
expanded = false;
43+
await tick();
44+
ref?.focus();
3545
},
3646
});
3747
@@ -77,10 +87,46 @@
7787
style:z-index={1}
7888
{...$$restProps}
7989
on:keydown
80-
on:keydown={(e) => {
81-
if (e.key === " ") e.preventDefault();
82-
if (e.key === "Enter" || e.key === " ") {
90+
on:keydown={async (e) => {
91+
if (e.key === " ") {
92+
e.preventDefault();
93+
e.stopPropagation();
94+
const wasExpanded = expanded;
8395
expanded = !expanded;
96+
if (!wasExpanded && expanded && $menuItems.length > 0) {
97+
// Only focus first item when opening (not closing)
98+
await tick();
99+
$menuItems[0]?.focus();
100+
}
101+
} else if (e.key === "Enter") {
102+
e.preventDefault();
103+
// Let the li handler toggle the expanded state
104+
// Just focus the first item if opening
105+
if (!expanded && $menuItems.length > 0) {
106+
await tick();
107+
$menuItems[0]?.focus();
108+
}
109+
} else if (e.key === "ArrowDown") {
110+
e.preventDefault();
111+
if (!expanded) {
112+
expanded = true;
113+
}
114+
// Focus first item
115+
await tick();
116+
$menuItems[0]?.focus();
117+
} else if (e.key === "ArrowUp") {
118+
e.preventDefault();
119+
if (!expanded) {
120+
expanded = true;
121+
}
122+
// Focus last item
123+
await tick();
124+
$menuItems[$menuItems.length - 1]?.focus();
125+
} else if (e.key === "Escape") {
126+
e.preventDefault();
127+
expanded = false;
128+
await tick();
129+
ref?.focus();
84130
}
85131
}}
86132
on:click|preventDefault

tests/UIShell/HeaderNav.test.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { render, screen } from "@testing-library/svelte";
2+
import { user } from "../setup-tests";
3+
import HeaderNavTest from "./HeaderNavKeyboard.test.svelte";
4+
5+
describe("HeaderNav keyboard navigation", () => {
6+
beforeEach(() => {
7+
vi.clearAllMocks();
8+
});
9+
10+
describe("HeaderNavMenu", () => {
11+
it("should open menu and focus first item when Down Arrow is pressed", async () => {
12+
render(HeaderNavTest);
13+
14+
const menuTrigger = screen.getByRole("menuitem", { name: "Menu" });
15+
menuTrigger.focus();
16+
17+
await user.keyboard("{ArrowDown}");
18+
expect(menuTrigger).toHaveAttribute("aria-expanded", "true");
19+
const firstItem = screen.getByRole("menuitem", { name: "Menu Item 1" });
20+
expect(firstItem).toHaveFocus();
21+
});
22+
23+
it("should open menu and focus last item when Up Arrow is pressed", async () => {
24+
render(HeaderNavTest);
25+
26+
const menuTrigger = screen.getByRole("menuitem", { name: "Menu" });
27+
menuTrigger.focus();
28+
29+
await user.keyboard("{ArrowUp}");
30+
expect(menuTrigger).toHaveAttribute("aria-expanded", "true");
31+
32+
const lastItem = screen.getByRole("menuitem", { name: "Menu Item 3" });
33+
expect(lastItem).toHaveFocus();
34+
});
35+
36+
it("should close menu when Escape is pressed and return focus to trigger", async () => {
37+
render(HeaderNavTest);
38+
39+
const menuTrigger = screen.getByRole("menuitem", { name: "Menu" });
40+
menuTrigger.focus();
41+
42+
await user.keyboard("{Enter}");
43+
expect(menuTrigger).toHaveAttribute("aria-expanded", "true");
44+
45+
await user.keyboard("{Escape}");
46+
expect(menuTrigger).toHaveAttribute("aria-expanded", "false");
47+
expect(menuTrigger).toHaveFocus();
48+
});
49+
50+
it("should toggle menu when Space is pressed on trigger", async () => {
51+
render(HeaderNavTest);
52+
53+
const menuTrigger = screen.getByRole("menuitem", { name: "Menu" });
54+
menuTrigger.focus();
55+
56+
await user.keyboard(" ");
57+
expect(menuTrigger).toHaveAttribute("aria-expanded", "true");
58+
59+
menuTrigger.focus();
60+
await user.keyboard(" ");
61+
expect(menuTrigger).toHaveAttribute("aria-expanded", "false");
62+
});
63+
});
64+
65+
describe("HeaderNavItem within menu", () => {
66+
it("should move to next item when Down Arrow is pressed", async () => {
67+
render(HeaderNavTest);
68+
69+
const menuTrigger = screen.getByRole("menuitem", { name: "Menu" });
70+
menuTrigger.focus();
71+
72+
await user.keyboard("{ArrowDown}");
73+
const firstItem = screen.getByRole("menuitem", { name: "Menu Item 1" });
74+
expect(firstItem).toHaveFocus();
75+
76+
await user.keyboard("{ArrowDown}");
77+
const secondItem = screen.getByRole("menuitem", { name: "Menu Item 2" });
78+
expect(secondItem).toHaveFocus();
79+
});
80+
81+
it("should move to previous item when Up Arrow is pressed", async () => {
82+
render(HeaderNavTest);
83+
84+
const menuTrigger = screen.getByRole("menuitem", { name: "Menu" });
85+
menuTrigger.focus();
86+
87+
await user.keyboard("{ArrowDown}");
88+
const firstItem = screen.getByRole("menuitem", { name: "Menu Item 1" });
89+
90+
await user.keyboard("{ArrowDown}");
91+
const secondItem = screen.getByRole("menuitem", { name: "Menu Item 2" });
92+
expect(secondItem).toHaveFocus();
93+
94+
await user.keyboard("{ArrowUp}");
95+
expect(firstItem).toHaveFocus();
96+
});
97+
98+
it("should wrap to last item when Up Arrow is pressed on first item", async () => {
99+
render(HeaderNavTest);
100+
101+
const menuTrigger = screen.getByRole("menuitem", { name: "Menu" });
102+
menuTrigger.focus();
103+
104+
await user.keyboard("{ArrowDown}");
105+
106+
const firstItem = screen.getByRole("menuitem", { name: "Menu Item 1" });
107+
expect(firstItem).toHaveFocus();
108+
109+
await user.keyboard("{ArrowUp}");
110+
const lastItem = screen.getByRole("menuitem", { name: "Menu Item 3" });
111+
expect(lastItem).toHaveFocus();
112+
});
113+
114+
it("should wrap to first item when Down Arrow is pressed on last item", async () => {
115+
render(HeaderNavTest);
116+
117+
const menuTrigger = screen.getByRole("menuitem", { name: "Menu" });
118+
menuTrigger.focus();
119+
120+
await user.keyboard("{ArrowUp}");
121+
const lastItem = screen.getByRole("menuitem", { name: "Menu Item 3" });
122+
expect(lastItem).toHaveFocus();
123+
124+
await user.keyboard("{ArrowDown}");
125+
const firstItem = screen.getByRole("menuitem", { name: "Menu Item 1" });
126+
expect(firstItem).toHaveFocus();
127+
});
128+
129+
it("should focus first item when Home is pressed", async () => {
130+
render(HeaderNavTest);
131+
132+
const menuTrigger = screen.getByRole("menuitem", { name: "Menu" });
133+
menuTrigger.focus();
134+
135+
await user.keyboard("{ArrowDown}");
136+
await user.keyboard("{ArrowDown}");
137+
138+
const secondItem = screen.getByRole("menuitem", { name: "Menu Item 2" });
139+
expect(secondItem).toHaveFocus();
140+
141+
await user.keyboard("{Home}");
142+
const firstItem = screen.getByRole("menuitem", { name: "Menu Item 1" });
143+
expect(firstItem).toHaveFocus();
144+
});
145+
146+
it("should focus last item when End is pressed", async () => {
147+
render(HeaderNavTest);
148+
149+
const menuTrigger = screen.getByRole("menuitem", { name: "Menu" });
150+
menuTrigger.focus();
151+
152+
await user.keyboard("{ArrowDown}");
153+
154+
const firstItem = screen.getByRole("menuitem", { name: "Menu Item 1" });
155+
expect(firstItem).toHaveFocus();
156+
157+
await user.keyboard("{End}");
158+
const lastItem = screen.getByRole("menuitem", { name: "Menu Item 3" });
159+
expect(lastItem).toHaveFocus();
160+
});
161+
162+
it("should close menu when Escape is pressed from menu item", async () => {
163+
render(HeaderNavTest);
164+
165+
const menuTrigger = screen.getByRole("menuitem", { name: "Menu" });
166+
menuTrigger.focus();
167+
168+
await user.keyboard("{ArrowDown}");
169+
170+
const firstItem = screen.getByRole("menuitem", { name: "Menu Item 1" });
171+
expect(firstItem).toHaveFocus();
172+
173+
await user.keyboard("{Escape}");
174+
expect(menuTrigger).toHaveAttribute("aria-expanded", "false");
175+
expect(menuTrigger).toHaveFocus();
176+
});
177+
});
178+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script lang="ts">
2+
import {
3+
Header,
4+
HeaderNav,
5+
HeaderNavItem,
6+
HeaderNavMenu,
7+
} from "carbon-components-svelte";
8+
</script>
9+
10+
<Header company="Test" platformName="Test">
11+
<HeaderNav>
12+
<HeaderNavItem href="/" text="Link 1" />
13+
<HeaderNavItem href="/" text="Link 2" />
14+
<HeaderNavMenu text="Menu">
15+
<HeaderNavItem href="/item1" text="Menu Item 1" />
16+
<HeaderNavItem href="/item2" text="Menu Item 2" />
17+
<HeaderNavItem href="/item3" text="Menu Item 3" />
18+
</HeaderNavMenu>
19+
</HeaderNav>
20+
</Header>

0 commit comments

Comments
 (0)