Skip to content

Commit 42b3070

Browse files
feat(vertical-menu): implements updates required for Global Nav v2
1 parent 83a1889 commit 42b3070

23 files changed

+3864
-0
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { Page } from "@playwright/test";
2+
import {
3+
RESPONSIVE_VERTICAL_MENU_LAUNCHER,
4+
RESPONSIVE_VERTICAL_MENU_WRAPPER,
5+
RESPONSIVE_VERTICAL_MENU_PRIMARY,
6+
RESPONSIVE_VERTICAL_MENU_SECONDARY,
7+
RESPONSIVE_VERTICAL_MENU_ITEM_CUSTOM_ICON,
8+
RESPONSIVE_VERTICAL_MENU_ITEM_ICON,
9+
RESPONSIVE_VERTICAL_MENU_ITEM_EXPANDER,
10+
} from "./locators";
11+
12+
export const responsiveVerticalMenuLauncher = (page: Page) =>
13+
page.locator(RESPONSIVE_VERTICAL_MENU_LAUNCHER);
14+
export const responsiveVerticalMenuWrapper = (page: Page) =>
15+
page.locator(RESPONSIVE_VERTICAL_MENU_WRAPPER);
16+
export const responsiveVerticalMenuPrimary = (page: Page) =>
17+
page.locator(RESPONSIVE_VERTICAL_MENU_PRIMARY);
18+
export const responsiveVerticalMenuSecondary = (page: Page) =>
19+
page.locator(RESPONSIVE_VERTICAL_MENU_SECONDARY);
20+
export const responsiveVerticalMenuCustomIcon = (page: Page) =>
21+
page.locator(RESPONSIVE_VERTICAL_MENU_ITEM_CUSTOM_ICON);
22+
export const responsiveVerticalMenuIcon = (page: Page) =>
23+
page.locator(RESPONSIVE_VERTICAL_MENU_ITEM_ICON);
24+
export const responsiveVerticalMenuExpander = (page: Page) =>
25+
page.locator(RESPONSIVE_VERTICAL_MENU_ITEM_EXPANDER);
26+
export const responsiveVerticalMenuNthPrimaryItem = (
27+
page: Page,
28+
index: number,
29+
) => page.locator(RESPONSIVE_VERTICAL_MENU_PRIMARY).nth(index);
30+
export const responsiveVerticalMenuNthSecondaryItem = (
31+
page: Page,
32+
index: number,
33+
) => page.locator(RESPONSIVE_VERTICAL_MENU_SECONDARY).nth(index);
34+
35+
export const responsiveVerticalMenuNestedMenu = (page: Page, id: string) =>
36+
page.locator(`[data-component="${id}-nested-menu"]`);
37+
export const responsiveVerticalMenuNestedMenuNthChild = (
38+
page: Page,
39+
id: string,
40+
index: number,
41+
) => page.locator(`[data-component="${id}-nested-menu"]`).nth(index);
42+
export const responsiveVerticalMenuMenuItem = (page: Page, id: string) =>
43+
page.locator(`[data-component="responsive-vertical-menu-item-${id}"]`);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export const RESPONSIVE_VERTICAL_MENU_LAUNCHER =
2+
'[data-component="responsive-vertical-menu-launcher"]';
3+
export const RESPONSIVE_VERTICAL_MENU_WRAPPER =
4+
'[data-component="responsive-vertical-menu"]';
5+
export const RESPONSIVE_VERTICAL_MENU_PRIMARY =
6+
'[data-component="responsive-vertical-menu-primary"]';
7+
export const RESPONSIVE_VERTICAL_MENU_SECONDARY =
8+
'[data-component="responsive-vertical-menu-secondary"]';
9+
export const RESPONSIVE_VERTICAL_MENU_ITEM_CUSTOM_ICON =
10+
'[data-component="responsive-vertical-menu-custom-icon"]';
11+
export const RESPONSIVE_VERTICAL_MENU_ITEM_ICON =
12+
'[data-component="responsive-vertical-menu-icon"]';
13+
export const RESPONSIVE_VERTICAL_MENU_ITEM_EXPANDER =
14+
'[data-component="responsive-vertical-menu-expander-icon"]';

src/components/vertical-menu/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,9 @@ export type { VerticalMenuFullScreenProps } from "./vertical-menu-full-screen/ve
99

1010
export { default as VerticalMenuTrigger } from "./vertical-menu-trigger/vertical-menu-trigger.component";
1111
export type { VerticalMenuTriggerProps } from "./vertical-menu-trigger/vertical-menu-trigger.component";
12+
13+
export {
14+
ResponsiveVerticalMenu,
15+
ResponsiveVerticalMenuItem,
16+
ResponsiveVerticalMenuProvider,
17+
} from "./responsive-vertical-menu";
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/* eslint-disable no-console */
2+
/**
3+
* Provides context which can be used to dynamcally track the depth of a component
4+
* in the component tree. This is useful for components that need to know their depth
5+
* in the tree for styling or layout purposes.
6+
*/
7+
import React, { createContext, ReactNode, useContext } from "react";
8+
9+
// Context to hold the current depth
10+
const DepthContext = createContext<number>(0);
11+
12+
// Hook to use the depth context
13+
export const useDepth = (): number => {
14+
// Get the current context value
15+
const context = useContext(DepthContext);
16+
17+
// If context is undefined, it means the hook is being used outside of a DepthProvider
18+
/* istanbul ignore next */
19+
if (context === undefined) {
20+
console.error(
21+
"useDepth must be used within a DepthProvider. Please ensure you are using the correct context.",
22+
);
23+
// Return a default value to avoid breakages
24+
return -1;
25+
}
26+
27+
// Return the current depth value
28+
return context;
29+
};
30+
31+
export const DepthProvider = ({
32+
children,
33+
value = 0,
34+
}: {
35+
children: ReactNode;
36+
value?: number;
37+
}) => {
38+
// Provide the current depth value to the context
39+
return (
40+
<DepthContext.Provider value={value}>{children}</DepthContext.Provider>
41+
);
42+
};
43+
44+
export const IncreaseDepth = ({ children }: { children: ReactNode }) => {
45+
const currentDepth = useDepth();
46+
47+
// Increase the depth by 1 for the children
48+
return (
49+
<DepthContext.Provider value={currentDepth + 1}>
50+
{children}
51+
</DepthContext.Provider>
52+
);
53+
};
54+
55+
export default DepthContext;
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
/* eslint-disable no-console */
2+
/**
3+
* Provides context for managing focus within a menu component.
4+
* This context allows for tracking expanded items, focusing on specific items,
5+
* and moving focus in various directions (next, previous, parent, first child, last child).
6+
*/
7+
import React, {
8+
createContext,
9+
ReactNode,
10+
RefObject,
11+
useCallback,
12+
useContext,
13+
useRef,
14+
useState,
15+
} from "react";
16+
17+
interface MenuFocusContextType {
18+
expandedItems: string[];
19+
expandItem: (id: string, expand: boolean) => void;
20+
focusedItemId: string | null;
21+
getRegisteredItems: () => Array<{
22+
id: string;
23+
ref: RefObject<HTMLElement>;
24+
parentId?: string;
25+
}>;
26+
focusItem: (id: string) => void;
27+
moveFocus: (
28+
direction: "next" | "prev" | "parent" | "firstChild" | "lastChild",
29+
) => void;
30+
registerMenuItem: (
31+
id: string,
32+
ref: RefObject<HTMLElement>,
33+
parentId?: string,
34+
) => void;
35+
}
36+
37+
// Create a context for menu focus
38+
const MenuFocusContext = createContext<MenuFocusContextType | undefined>(
39+
undefined,
40+
);
41+
42+
// Custom hook to use the MenuFocusContext
43+
export const useMenuFocus = () => {
44+
// Get the current context value
45+
const context = useContext(MenuFocusContext);
46+
47+
// If context is undefined, it means the hook is being used outside of a MenuFocusProvider
48+
/* istanbul ignore next */
49+
if (!context) {
50+
console.error("useMenuFocus must be used within a MenuFocusProvider");
51+
// Return a default value to avoid breakages
52+
return {
53+
expandedItems: [],
54+
expandItem: () => {},
55+
focusedItemId: null,
56+
getRegisteredItems: () => [],
57+
focusItem: () => {},
58+
moveFocus: () => {},
59+
registerMenuItem: () => {},
60+
} as MenuFocusContextType;
61+
}
62+
63+
// Return the current context value
64+
return context;
65+
};
66+
67+
export const MenuFocusProvider = ({ children }: { children: ReactNode }) => {
68+
// State to track expanded items
69+
const [expandedItems, setExpandedItems] = useState<string[]>([]);
70+
// State to track the currently focused item
71+
const [focusedItemId, setFocusedItemId] = useState<string | null>(null);
72+
73+
// Ref to hold registered menu items
74+
const menuItemsRef = useRef<
75+
Map<
76+
string,
77+
{
78+
ref: RefObject<HTMLElement>;
79+
parentId?: string;
80+
childIds: string[];
81+
}
82+
>
83+
>(new Map());
84+
85+
// Function to register a menu item
86+
// This function takes an id, a ref to the item, and an optional parentId
87+
// and stores the item in the menuItemsRef map.
88+
// If a parentId is provided, it also updates the parent's childIds array
89+
// to include the new item.
90+
const registerMenuItem = useCallback(
91+
(id: string, ref: RefObject<HTMLElement>, parentId?: string) => {
92+
menuItemsRef.current.set(id, {
93+
ref,
94+
parentId,
95+
childIds: [],
96+
});
97+
98+
if (parentId) {
99+
const parentItem = menuItemsRef.current.get(parentId);
100+
parentItem?.childIds.push(id);
101+
}
102+
},
103+
[],
104+
);
105+
106+
// Function to get all registered menu items
107+
// This function returns an array of objects, each containing the id,
108+
// ref, parentId, and childIds of the registered items.
109+
const getRegisteredItems = useCallback(() => {
110+
const items = Array.from(menuItemsRef.current.entries()).map(
111+
([id, { ref, parentId, childIds }]) => ({
112+
id,
113+
ref,
114+
parentId,
115+
childIds,
116+
}),
117+
);
118+
return items;
119+
}, []);
120+
121+
// Function to focus on a specific menu item
122+
// This function takes an id and focuses the corresponding item
123+
// by calling the focus method on its ref.
124+
// It also updates the focusedItemId state to the new id.
125+
const focusItem = useCallback((id: string) => {
126+
const item = menuItemsRef.current.get(id);
127+
/* istanbul ignore else */
128+
if (item?.ref?.current) {
129+
item.ref.current.focus();
130+
setFocusedItemId(id);
131+
}
132+
}, []);
133+
134+
// Function to expand or collapse a menu item
135+
// This function takes an id and a boolean value (expand)
136+
// and updates the expandedItems state accordingly.
137+
// If expand is true, it adds the id to the expandedItems array;
138+
// otherwise, it removes the id from the array.
139+
const expandItem = useCallback((id: string, expand: boolean) => {
140+
if (expand) {
141+
setExpandedItems((prev) => [...prev, id]);
142+
} else {
143+
setExpandedItems((prev) => prev.filter((itemId) => itemId !== id));
144+
}
145+
}, []);
146+
147+
// Function to move focus in a specific direction
148+
// This function takes a direction (next, prev, parent, firstChild, lastChild)
149+
// and moves the focus accordingly.
150+
// It uses the current focusedItemId to determine the current item
151+
// and then finds the next item based on the direction.
152+
// It also handles expanding/collapsing items as needed.
153+
const moveFocus = useCallback(
154+
(direction: "next" | "prev" | "parent" | "firstChild" | "lastChild") => {
155+
/* istanbul ignore if */
156+
if (!focusedItemId) return;
157+
158+
// Get the current item based on the focusedItemId
159+
const currentItem = menuItemsRef.current.get(focusedItemId);
160+
161+
// If the current item is not found, return early
162+
/* istanbul ignore if */
163+
if (!currentItem) return;
164+
165+
let allItems = [];
166+
let visibleItems = [];
167+
let currentIndex = -1;
168+
169+
switch (direction) {
170+
// Move focus to the parent item
171+
case "parent":
172+
/* istanbul ignore else */
173+
if (currentItem.parentId) {
174+
focusItem(currentItem.parentId);
175+
}
176+
break;
177+
178+
// Move focus to the first child
179+
// If the current item has children and is not already expanded,
180+
// expand it and focus on the first child
181+
case "firstChild":
182+
/* istanbul ignore else */
183+
if (currentItem.childIds.length > 0) {
184+
if (!expandedItems.includes(focusedItemId)) {
185+
expandItem(focusedItemId, true);
186+
}
187+
focusItem(currentItem.childIds[0]);
188+
}
189+
break;
190+
191+
// Move focus to the last child
192+
// Moving backwards through the menu works slightly differently:
193+
// If the current item has children, get the last child. Before focusing on it,
194+
// check if the last child is expanded. If it is, focus on its last child.
195+
// If the last child is not expanded (i.e. it's just a link), focus on it directly.
196+
case "lastChild":
197+
/* istanbul ignore else */
198+
if (currentItem.childIds.length > 0) {
199+
const lastChild =
200+
currentItem.childIds[currentItem.childIds.length - 1];
201+
/* istanbul ignore else */
202+
if (lastChild) {
203+
/* istanbul ignore if */
204+
if (expandedItems.includes(lastChild)) {
205+
const lastChildItem = getRegisteredItems().find(
206+
(item) => item.id === lastChild,
207+
);
208+
if (lastChildItem) {
209+
focusItem(
210+
lastChildItem.childIds[lastChildItem.childIds.length - 1],
211+
);
212+
}
213+
} else {
214+
focusItem(lastChild);
215+
}
216+
}
217+
}
218+
break;
219+
220+
// Move focus to the next or previous item
221+
// This case handles both next and previous focus movement.
222+
// Whilst this functionality is not currently used in the menu,
223+
// it is included for completeness/future-proofing.
224+
/* istanbul ignore next */
225+
default:
226+
allItems = Array.from(menuItemsRef.current.keys());
227+
visibleItems = allItems.filter((id) => {
228+
const item = menuItemsRef.current.get(id);
229+
if (!item || !item.parentId) return true;
230+
231+
const parentVisible = expandedItems.includes(item.parentId);
232+
return parentVisible;
233+
});
234+
235+
currentIndex = visibleItems.indexOf(focusedItemId);
236+
if (currentIndex !== -1) {
237+
const nextIndex =
238+
direction === "next"
239+
? (currentIndex + 1) % visibleItems.length
240+
: (currentIndex - 1 + visibleItems.length) %
241+
visibleItems.length;
242+
243+
focusItem(visibleItems[nextIndex]);
244+
}
245+
break;
246+
}
247+
},
248+
[focusedItemId, focusItem, expandedItems, expandItem, getRegisteredItems],
249+
);
250+
251+
const value = {
252+
expandedItems,
253+
expandItem,
254+
focusedItemId,
255+
focusItem,
256+
getRegisteredItems,
257+
registerMenuItem,
258+
moveFocus,
259+
};
260+
261+
// Provide the current context value to the MenuFocusContext
262+
return (
263+
<MenuFocusContext.Provider value={value}>
264+
{children}
265+
</MenuFocusContext.Provider>
266+
);
267+
};

0 commit comments

Comments
 (0)