Skip to content

Commit 0689c2c

Browse files
feat(vertical-menu): implements updates required for Global Nav v2
1 parent d1c494c commit 0689c2c

19 files changed

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

0 commit comments

Comments
 (0)