Skip to content

Commit 21fe8dd

Browse files
authored
refactor: simplify actions menu (#1904)
1 parent b6c1b7e commit 21fe8dd

3 files changed

Lines changed: 42 additions & 47 deletions

File tree

packages/web/src/views/Forms/ActionsMenu/ActionsMenu.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
33
import { act, type ReactElement } from "react";
44
import { ActionsMenu, useMenuContext } from "./ActionsMenu";
5+
import MenuItem from "./MenuItem";
56
import { describe, expect, it } from "bun:test";
67

78
const renderMenu = (ui: ReactElement) => render(ui);
@@ -81,4 +82,31 @@ describe("ActionsMenu", () => {
8182
expect(screen.queryByRole("menu")).toBeNull();
8283
expect(priorityButton).toHaveFocus();
8384
});
85+
86+
it("moves focus through every item as arrow keys are pressed", async () => {
87+
const user = userEvent.setup();
88+
89+
renderMenu(
90+
<ActionsMenu bgColor="#fff">
91+
{() => (
92+
<>
93+
<MenuItem bgColor="#fff">First</MenuItem>
94+
<MenuItem bgColor="#fff">Second</MenuItem>
95+
<MenuItem bgColor="#fff">Third</MenuItem>
96+
</>
97+
)}
98+
</ActionsMenu>,
99+
);
100+
101+
await user.click(screen.getByLabelText("Open actions menu"));
102+
103+
await user.keyboard("{ArrowDown}");
104+
expect(document.activeElement?.textContent).toBe("First");
105+
106+
await user.keyboard("{ArrowDown}");
107+
expect(document.activeElement?.textContent).toBe("Second");
108+
109+
await user.keyboard("{ArrowDown}");
110+
expect(document.activeElement?.textContent).toBe("Third");
111+
});
84112
});

packages/web/src/views/Forms/ActionsMenu/ActionsMenu.tsx

Lines changed: 3 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import {
1818
createContext,
1919
type MouseEvent,
2020
useContext,
21-
useEffect,
2221
useRef,
2322
useState,
2423
} from "react";
@@ -89,47 +88,11 @@ export const ActionsMenu: React.FC<ActionsMenuProps> = ({
8988
const click = useClick(context);
9089
const dismiss = useDismiss(context);
9190
const role = useRole(context, { role: "menu" });
92-
// Create a dense array for FloatingUI and mapping back to sparse array
93-
const denseListRef = useRef<Array<HTMLElement>>([]);
94-
const sparseToCompactMap = useRef<Map<number, number>>(new Map());
95-
const compactToSparseMap = useRef<Map<number, number>>(new Map());
96-
97-
// Update dense list and mappings when listRef changes
98-
useEffect(() => {
99-
const denseArray: HTMLElement[] = [];
100-
sparseToCompactMap.current.clear();
101-
compactToSparseMap.current.clear();
102-
103-
listRef.current.forEach((item, sparseIndex) => {
104-
if (item !== null) {
105-
const compactIndex = denseArray.length;
106-
denseArray.push(item);
107-
sparseToCompactMap.current.set(sparseIndex, compactIndex);
108-
compactToSparseMap.current.set(compactIndex, sparseIndex);
109-
}
110-
});
111-
112-
denseListRef.current = denseArray;
113-
});
114-
115-
// Convert sparse activeIndex to compact activeIndex for FloatingUI
116-
const compactActiveIndex =
117-
activeIndex !== null
118-
? (sparseToCompactMap.current.get(activeIndex) ?? null)
119-
: null;
12091

12192
const listNavigation = useListNavigation(context, {
122-
listRef: denseListRef,
123-
activeIndex: compactActiveIndex,
124-
onNavigate: (compactIndex) => {
125-
const sparseIndex =
126-
compactIndex !== null && compactIndex !== undefined
127-
? (compactToSparseMap.current.get(compactIndex) ?? null)
128-
: null;
129-
if (sparseIndex !== null) {
130-
setActiveIndex(sparseIndex);
131-
}
132-
},
93+
listRef,
94+
activeIndex,
95+
onNavigate: setActiveIndex,
13396
focusItemOnHover: false,
13497
loop: true,
13598
});

packages/web/src/views/Forms/ActionsMenu/MenuItem.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,24 @@ const MenuItem: React.FC<MenuItemProps> = ({
3232
const itemRef = useRef<HTMLButtonElement | null>(null);
3333
const indexRef = useRef<number | null>(null);
3434

35-
// Register with menu context
35+
// Register with menu context. Depend on the stable `listRef` object
36+
// (not `menuContext` itself, which is a new object every render because
37+
// it carries `activeIndex`) so this doesn't tear down and re-register on
38+
// every arrow-key press.
39+
const listRef = menuContext?.listRef;
3640
useEffect(() => {
37-
if (menuContext && itemRef.current) {
38-
const index = menuContext.listRef.current.length;
41+
if (listRef && itemRef.current) {
42+
const index = listRef.current.length;
3943
indexRef.current = index;
40-
menuContext.listRef.current[index] = itemRef.current;
44+
listRef.current[index] = itemRef.current;
4145

4246
return () => {
4347
if (indexRef.current !== null) {
44-
menuContext.listRef.current[indexRef.current] = null;
48+
listRef.current[indexRef.current] = null;
4549
}
4650
};
4751
}
48-
}, [menuContext]);
52+
}, [listRef]);
4953

5054
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
5155
// Handle Enter/Space for activation
@@ -83,7 +87,7 @@ const MenuItem: React.FC<MenuItemProps> = ({
8387
role="menuitem"
8488
tabIndex={tabIndex}
8589
type={type}
86-
className="flex w-full cursor-pointer items-center gap-2 border-0 bg-[var(--actions-menu-item-bg)] px-2 py-1 text-left text-m text-text-dark outline-none hover:[text-shadow:0_0_0.5px_var(--compass-color-text-dark),0_0_0.5px_var(--compass-color-text-dark)] focus-visible:[text-shadow:0_0_0.5px_var(--compass-color-text-dark),0_0_0.5px_var(--compass-color-text-dark)]"
90+
className="flex w-full cursor-pointer items-center gap-2 border-0 bg-(--actions-menu-item-bg) px-2 py-1 text-left text-m text-text-dark outline-none hover:[text-shadow:0_0_0.5px_var(--compass-color-text-dark),0_0_0.5px_var(--compass-color-text-dark)] focus-visible:[text-shadow:0_0_0.5px_var(--compass-color-text-dark),0_0_0.5px_var(--compass-color-text-dark)]"
8791
style={{ backgroundColor: bgColor }}
8892
>
8993
{children}

0 commit comments

Comments
 (0)