Skip to content

Commit 057b3e1

Browse files
authored
Merge pull request #2672 from kev1n77/fmy/ui-sys
fix(web-ui): prevent submenus from closing during pointer navigation
2 parents c4b3392 + 071415f commit 057b3e1

9 files changed

Lines changed: 936 additions & 239 deletions

File tree

src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ describe('WorkspaceSessionFilterMenu', () => {
3232
let root: Root;
3333

3434
beforeEach(() => {
35+
vi.useFakeTimers();
3536
localStorage.clear();
3637
const view = useWorkspaceSessionViewStore.getState();
3738
view.setGrouping('grouped');
@@ -52,6 +53,7 @@ describe('WorkspaceSessionFilterMenu', () => {
5253
act(() => root.unmount());
5354
container.remove();
5455
vi.unstubAllGlobals();
56+
vi.useRealTimers();
5557
});
5658

5759
it('keeps filtering available while grouping lives in the separate quick toggle', () => {
@@ -76,4 +78,48 @@ describe('WorkspaceSessionFilterMenu', () => {
7678
expect(document.querySelector('[data-testid="nav-session-filter-menu"]')).not.toBeNull();
7779
expect(document.querySelector('[data-testid="nav-session-collapse-all"]')).toBeNull();
7880
});
81+
82+
it.each([
83+
{ side: 'right', parentLeft: 30, submenuLeft: 255, gapX: 252 },
84+
{ side: 'left', parentLeft: 300, submenuLeft: 75, gapX: 298 },
85+
])('preserves the $side Portal submenu across a pause in the gap in both directions', ({ parentLeft, submenuLeft, gapX }) => {
86+
act(() => container.querySelector<HTMLButtonElement>('[data-testid="nav-session-filter-btn"]')!.click());
87+
const menu = document.querySelector<HTMLElement>('[data-testid="nav-session-filter-menu"]')!;
88+
const ordering = menu.querySelector<HTMLButtonElement>('[aria-haspopup="menu"]')!;
89+
act(() => ordering.click());
90+
const submenu = document.querySelector<HTMLElement>('[data-testid="nav-session-filter-ordering-menu"]')!;
91+
expect(container.contains(submenu)).toBe(false);
92+
93+
for (const [element, left] of [[menu, parentLeft], [submenu, submenuLeft]] as const) {
94+
element.getBoundingClientRect = () => ({
95+
left, right: left + 220, top: 20, bottom: 220,
96+
width: 220, height: 200, x: left, y: 20, toJSON: () => ({}),
97+
});
98+
}
99+
100+
const pauseInGapFrom = (element: HTMLElement) => {
101+
act(() => {
102+
element.dispatchEvent(new MouseEvent('pointerout', {
103+
bubbles: true, relatedTarget: document.body, clientX: gapX, clientY: 80,
104+
}));
105+
document.body.dispatchEvent(new MouseEvent('pointermove', {
106+
bubbles: true, clientX: gapX, clientY: 80,
107+
}));
108+
});
109+
act(() => vi.advanceTimersByTime(1000));
110+
expect(document.querySelector('[data-testid="nav-session-filter-ordering-menu"]')).toBe(submenu);
111+
};
112+
113+
pauseInGapFrom(ordering);
114+
act(() => submenu.dispatchEvent(new MouseEvent('pointerover', {
115+
bubbles: true, relatedTarget: document.body, clientX: submenuLeft + 20, clientY: 80,
116+
})));
117+
pauseInGapFrom(submenu);
118+
119+
act(() => document.body.dispatchEvent(new MouseEvent('pointermove', {
120+
bubbles: true, clientX: gapX, clientY: 400,
121+
})));
122+
act(() => vi.advanceTimersByTime(300));
123+
expect(document.querySelector('[data-testid="nav-session-filter-ordering-menu"]')).toBeNull();
124+
});
79125
});

src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useI18n } from '@/infrastructure/i18n';
66
import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost';
77
import { flowChatStore } from '@/flow_chat/store/FlowChatStore';
88
import { Icon, Tooltip } from '@bitfun/ui';
9+
import { useSubmenuIntent } from '@/shared/utils/useSubmenuIntent';
910
import {
1011
DEFAULT_WORKSPACE_SESSION_VIEW,
1112
hasWorkspaceSessionFilters,
@@ -54,6 +55,18 @@ const WorkspaceSessionFilterMenu: React.FC = () => {
5455
const buttonRef = useRef<HTMLButtonElement>(null);
5556
const menuRef = useRef<HTMLDivElement>(null);
5657
const submenuRef = useRef<HTMLDivElement>(null);
58+
const {
59+
requestChange: requestSubmenuChange,
60+
requestClose: requestSubmenuClose,
61+
keepOpen: keepSubmenuOpen,
62+
openNow: openSubmenuNow,
63+
} = useSubmenuIntent<Submenu>({
64+
activeId: activeSubmenu,
65+
onActiveIdChange: setActiveSubmenu,
66+
parentRef: menuRef,
67+
submenuRef,
68+
enabled: open,
69+
});
5770

5871
const isCustomized = view.ordering !== DEFAULT_WORKSPACE_SESSION_VIEW.ordering
5972
|| view.show !== DEFAULT_WORKSPACE_SESSION_VIEW.show
@@ -130,9 +143,10 @@ const WorkspaceSessionFilterMenu: React.FC = () => {
130143
role="menuitem"
131144
aria-haspopup="menu"
132145
aria-expanded={activeSubmenu === submenu}
133-
onMouseEnter={() => setActiveSubmenu(submenu)}
134-
onFocus={() => setActiveSubmenu(submenu)}
135-
onClick={() => setActiveSubmenu(submenu)}
146+
onPointerEnter={event => requestSubmenuChange(submenu, event)}
147+
onPointerLeave={requestSubmenuClose}
148+
onFocus={() => openSubmenuNow(submenu)}
149+
onClick={() => openSubmenuNow(submenu)}
136150
>
137151
<span>{t(`nav.sessions.viewMenu.${submenu}.label`)}</span>
138152
<span className="bitfun-nav-panel__session-filter-menu-value">
@@ -184,7 +198,7 @@ const WorkspaceSessionFilterMenu: React.FC = () => {
184198
className={view.filters.hideArchived ? 'is-filtered' : ''}
185199
role="menuitemcheckbox"
186200
aria-checked={!view.filters.hideArchived}
187-
onMouseEnter={() => setActiveSubmenu(null)}
201+
onPointerEnter={event => requestSubmenuChange(null, event)}
188202
onClick={view.toggleArchived}
189203
>
190204
<span>{t('nav.sessions.viewMenu.archived')}</span>
@@ -196,7 +210,7 @@ const WorkspaceSessionFilterMenu: React.FC = () => {
196210
type="button"
197211
role="menuitem"
198212
data-testid="nav-session-collapse-all"
199-
onMouseEnter={() => setActiveSubmenu(null)}
213+
onPointerEnter={event => requestSubmenuChange(null, event)}
200214
onClick={() => { view.requestCollapseAll(); close(); }}
201215
>
202216
<span>{t('nav.sessions.viewMenu.collapseAll')}</span>
@@ -205,7 +219,7 @@ const WorkspaceSessionFilterMenu: React.FC = () => {
205219
<button
206220
type="button"
207221
role="menuitem"
208-
onMouseEnter={() => setActiveSubmenu(null)}
222+
onPointerEnter={event => requestSubmenuChange(null, event)}
209223
onClick={() => {
210224
for (const session of flowChatStore.getState().sessions.values()) {
211225
if (session.hasUnreadCompletion) flowChatStore.clearSessionUnreadCompletion(session.sessionId);
@@ -228,6 +242,8 @@ const WorkspaceSessionFilterMenu: React.FC = () => {
228242
role="menu"
229243
aria-label={t(`nav.sessions.viewMenu.${activeSubmenu}.label`)}
230244
data-testid={`nav-session-filter-${activeSubmenu}-menu`}
245+
onPointerEnter={keepSubmenuOpen}
246+
onPointerLeave={requestSubmenuClose}
231247
>
232248
{definition.options.map(option => {
233249
const selected = definition.kind === 'single'

src/web-ui/src/flow_chat/components/ChatInputBoostSubmenu.test.tsx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import { act } from 'react';
66
import { createRoot, type Root } from 'react-dom/client';
7-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
88

99
import { ChatInputBoostSubmenu } from './ChatInputBoostSubmenu';
1010

@@ -13,6 +13,7 @@ describe('ChatInputBoostSubmenu', () => {
1313
let root: Root;
1414

1515
beforeEach(() => {
16+
vi.useFakeTimers();
1617
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean })
1718
.IS_REACT_ACT_ENVIRONMENT = true;
1819
container = document.createElement('div');
@@ -23,6 +24,7 @@ describe('ChatInputBoostSubmenu', () => {
2324
afterEach(() => {
2425
act(() => root.unmount());
2526
container.remove();
27+
vi.useRealTimers();
2628
});
2729

2830
it('opens by click or keyboard and closes with the reverse arrow', async () => {
@@ -55,4 +57,46 @@ describe('ChatInputBoostSubmenu', () => {
5557
});
5658
expect(trigger?.getAttribute('aria-expanded')).toBe('true');
5759
});
60+
61+
it.each([
62+
{ label: 'Skills', parentLeft: 30, submenuLeft: 260, gapX: 255 },
63+
{ label: 'Additional modes', parentLeft: 650, submenuLeft: 420, gapX: 645 },
64+
])('retains gap protection in the extracted $label submenu', ({ label, parentLeft, submenuLeft, gapX }) => {
65+
act(() => root.render(
66+
<ChatInputBoostSubmenu label={label} icon={<span>+</span>}>
67+
<button type="button" role="menuitem">Plan</button>
68+
</ChatInputBoostSubmenu>,
69+
));
70+
71+
const host = container.querySelector<HTMLElement>('.bitfun-chat-input__boost-submenu-host')!;
72+
const shell = container.querySelector<HTMLElement>('.bitfun-chat-input__boost-submenu-shell')!;
73+
const trigger = container.querySelector<HTMLElement>('[aria-haspopup="menu"]')!;
74+
host.getBoundingClientRect = () => new DOMRect(parentLeft, 20, 220, 34);
75+
shell.getBoundingClientRect = () => new DOMRect(submenuLeft, 20, 220, 200);
76+
77+
act(() => trigger.dispatchEvent(new MouseEvent('pointerover', {
78+
bubbles: true, clientX: parentLeft + 20, clientY: 30,
79+
})));
80+
expect(trigger.getAttribute('aria-expanded')).toBe('true');
81+
82+
for (const element of [host, shell]) {
83+
act(() => element.dispatchEvent(new MouseEvent('pointerout', {
84+
bubbles: true, relatedTarget: document.body, clientX: gapX, clientY: 80,
85+
})));
86+
act(() => vi.advanceTimersByTime(1000));
87+
expect(trigger.getAttribute('aria-expanded')).toBe('true');
88+
act(() => shell.dispatchEvent(new MouseEvent('pointerover', {
89+
bubbles: true, relatedTarget: document.body, clientX: submenuLeft + 20, clientY: 80,
90+
})));
91+
}
92+
93+
act(() => document.dispatchEvent(new MouseEvent('pointermove', {
94+
bubbles: true, clientX: gapX, clientY: 80,
95+
})));
96+
act(() => document.dispatchEvent(new MouseEvent('pointermove', {
97+
bubbles: true, clientX: gapX, clientY: 400,
98+
})));
99+
act(() => vi.advanceTimersByTime(180));
100+
expect(trigger.getAttribute('aria-expanded')).toBe('false');
101+
});
58102
});

src/web-ui/src/flow_chat/components/ChatInputBoostSubmenu.tsx

Lines changed: 35 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
1+
import React, { useCallback, useId, useRef, useState } from 'react';
22
import { ChevronRight } from 'lucide-react';
3+
import { useSubmenuIntent } from '@/shared/utils/useSubmenuIntent';
34

45
interface ChatInputBoostSubmenuProps {
56
label: string;
@@ -23,48 +24,46 @@ export const ChatInputBoostSubmenu: React.FC<ChatInputBoostSubmenuProps> = ({
2324
const [openLeft, setOpenLeft] = useState(false);
2425
const [openUp, setOpenUp] = useState(false);
2526
const hostRef = useRef<HTMLDivElement>(null);
26-
const closeTimerRef = useRef<number | null>(null);
27+
const submenuRef = useRef<HTMLDivElement>(null);
2728
const panelId = useId();
2829

29-
const clearCloseTimer = useCallback(() => {
30-
if (closeTimerRef.current !== null) {
31-
window.clearTimeout(closeTimerRef.current);
32-
closeTimerRef.current = null;
30+
const setActiveSubmenu = useCallback((id: 'submenu' | null) => {
31+
if (id !== null) {
32+
const host = hostRef.current;
33+
if (host) {
34+
const bounds = host.getBoundingClientRect();
35+
setOpenLeft(bounds.right + estimatedPanelWidth > window.innerWidth - 8);
36+
setOpenUp(bounds.top + estimatedPanelHeight > window.innerHeight - 8);
37+
}
3338
}
34-
}, []);
39+
setOpen(id !== null);
40+
}, [estimatedPanelHeight, estimatedPanelWidth]);
3541

36-
const openFlyout = useCallback(() => {
37-
clearCloseTimer();
38-
const host = hostRef.current;
39-
if (host) {
40-
const bounds = host.getBoundingClientRect();
41-
setOpenLeft(bounds.right + estimatedPanelWidth > window.innerWidth - 8);
42-
setOpenUp(bounds.top + estimatedPanelHeight > window.innerHeight - 8);
43-
}
44-
setOpen(true);
45-
}, [clearCloseTimer, estimatedPanelHeight, estimatedPanelWidth]);
46-
47-
const closeFlyout = useCallback(() => {
48-
clearCloseTimer();
49-
closeTimerRef.current = window.setTimeout(() => {
50-
closeTimerRef.current = null;
51-
setOpen(false);
52-
}, 150);
53-
}, [clearCloseTimer]);
42+
const {
43+
requestChange,
44+
requestClose,
45+
keepOpen,
46+
openNow,
47+
closeNow: closeImmediately,
48+
} = useSubmenuIntent<'submenu'>({
49+
activeId: open ? 'submenu' : null,
50+
onActiveIdChange: setActiveSubmenu,
51+
parentRef: hostRef,
52+
submenuRef,
53+
openDelayMs: 0,
54+
closeDelayMs: 180,
55+
});
5456

55-
const closeImmediately = useCallback(() => {
56-
clearCloseTimer();
57-
setOpen(false);
58-
}, [clearCloseTimer]);
59-
60-
useEffect(() => clearCloseTimer, [clearCloseTimer]);
57+
const openFlyout = useCallback(() => {
58+
openNow('submenu');
59+
}, [openNow]);
6160

6261
return (
6362
<div
6463
ref={hostRef}
6564
className="bitfun-chat-input__boost-submenu-host"
66-
onMouseEnter={openFlyout}
67-
onMouseLeave={closeFlyout}
65+
onPointerEnter={event => requestChange('submenu', event)}
66+
onPointerLeave={requestClose}
6867
data-testid={testId}
6968
>
7069
<div
@@ -106,6 +105,9 @@ export const ChatInputBoostSubmenu: React.FC<ChatInputBoostSubmenuProps> = ({
106105
/>
107106
</div>
108107
<div
108+
ref={submenuRef}
109+
onPointerEnter={keepOpen}
110+
onPointerLeave={requestClose}
109111
className={[
110112
'bitfun-chat-input__boost-submenu-shell',
111113
open ? 'bitfun-chat-input__boost-submenu-shell--open' : '',

src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,48 @@ describe('ContextMenu presence', () => {
157157
expect(document.activeElement).toBe(trigger);
158158
});
159159

160+
it('keeps the nested submenu open while pausing in the gap in either direction', () => {
161+
act(() => root.render(
162+
<ContextMenu
163+
items={[{ id: 'share', label: 'Share', submenu: [{ id: 'email', label: 'Email' }] }]}
164+
position={{ x: 0, y: 0 }}
165+
visible
166+
onClose={vi.fn()}
167+
/>,
168+
));
169+
const parentItem = container.querySelector<HTMLElement>('[role="menuitem"]')!;
170+
act(() => parentItem.dispatchEvent(new window.MouseEvent('pointerover', {
171+
bubbles: true, clientX: 150, clientY: 40,
172+
})));
173+
act(() => vi.advanceTimersByTime(150));
174+
175+
const parent = container.querySelector<HTMLElement>('[role="menu"]')!;
176+
const submenu = container.querySelector<HTMLElement>('.context-menu-submenu.visible')!;
177+
parent.getBoundingClientRect = () => new window.DOMRect(0, 20, 215, 200);
178+
submenu.getBoundingClientRect = () => new window.DOMRect(220, 20, 220, 200);
179+
180+
for (const element of [parentItem, submenu]) {
181+
act(() => element.dispatchEvent(new window.MouseEvent('pointerout', {
182+
bubbles: true, relatedTarget: document.body, clientX: 218, clientY: 80,
183+
})));
184+
act(() => vi.advanceTimersByTime(1000));
185+
expect(parentItem.getAttribute('aria-expanded')).toBe('true');
186+
187+
act(() => submenu.dispatchEvent(new window.MouseEvent('pointerover', {
188+
bubbles: true, relatedTarget: document.body, clientX: 225, clientY: 80,
189+
})));
190+
}
191+
192+
act(() => document.dispatchEvent(new window.MouseEvent('pointermove', {
193+
bubbles: true, clientX: 218, clientY: 80,
194+
})));
195+
act(() => document.dispatchEvent(new window.MouseEvent('pointermove', {
196+
bubbles: true, clientX: 218, clientY: 400,
197+
})));
198+
act(() => vi.advanceTimersByTime(300));
199+
expect(parentItem.getAttribute('aria-expanded')).toBe('false');
200+
});
201+
160202
it('opens a submenu from the keyboard and keeps keyboard handling in the owning menu', () => {
161203
const items: ContextMenuItem[] = [{
162204
id: 'share',

0 commit comments

Comments
 (0)