fix: keep project activity submenu height independent
Opening long conversation lists stretched and repositioned the project menu. Give the conversation submenu its own surface and positioning, aligned to the selected project row with independent viewport clamping and scrolling. Use compact navigation when neither side has room. Validation: 13 component tests, typecheck and lint passed; 108 Electron fixture checks passed across 14 light/dark scenarios. Full test run timed out after 60 seconds. Release note: Project activity menus now keep the project list stable when opening long conversation submenus.
This commit is contained in:
@@ -185,7 +185,8 @@ describe('ProjectActivity', () => {
|
||||
const menu = screen.getByRole('menu', { name: 'All project activity' })
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Remote/ }))
|
||||
expect(menu).toHaveAttribute('data-left', 'true')
|
||||
expect(menu.style.left).toBe('336px')
|
||||
expect(menu.style.left).toBe('600px')
|
||||
expect(screen.getByRole('menu', { name: 'Remote' }).style.left).toBe('336px')
|
||||
expect(menu.style.top).toBe('136px')
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(400)
|
||||
fireEvent(window, new Event('resize'))
|
||||
@@ -196,6 +197,44 @@ describe('ProjectActivity', () => {
|
||||
expect(menu).toHaveAttribute('data-submenu', 'false')
|
||||
})
|
||||
|
||||
it('clamps a tall submenu independently without moving or widening the project menu', () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(600)
|
||||
render(view())
|
||||
const trigger = screen.getByRole('button', { name: /All project activity/ })
|
||||
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(new DOMRect(24, 300, 240, 32))
|
||||
open()
|
||||
const menu = screen.getByRole('menu', { name: 'All project activity' })
|
||||
vi.spyOn(menu, 'offsetHeight', 'get').mockReturnValue(200)
|
||||
const remote = screen.getByRole('menuitem', { name: /Remote/ })
|
||||
vi.spyOn(remote, 'getBoundingClientRect').mockReturnValue(new DOMRect(32, 410, 248, 52))
|
||||
fireEvent.click(remote)
|
||||
const sessions = screen.getByRole('menu', { name: 'Remote' })
|
||||
vi.spyOn(sessions, 'offsetHeight', 'get').mockReturnValue(100)
|
||||
fireEvent(window, new Event('resize'))
|
||||
expect(sessions.style.top).toBe('410px')
|
||||
vi.spyOn(sessions, 'offsetHeight', 'get').mockReturnValue(480)
|
||||
fireEvent(window, new Event('resize'))
|
||||
expect(sessions.style.top).toBe('104px')
|
||||
expect(sessions.style.left).toBe('288px')
|
||||
expect(menu.style.top).toBe('336px')
|
||||
expect(menu.style.left).toBe('24px')
|
||||
expect(menu.style.width).toBe('264px')
|
||||
})
|
||||
|
||||
it('drills down when neither side of the project menu can fit a submenu', () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(640)
|
||||
render(view())
|
||||
const trigger = screen.getByRole('button', { name: /All project activity/ })
|
||||
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(new DOMRect(200, 100, 200, 32))
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Remote/ }))
|
||||
expect(screen.getByRole('menu', { name: 'All project activity' })).toHaveAttribute('data-compact', 'true')
|
||||
const sessions = screen.getByRole('menu', { name: 'Remote' })
|
||||
expect(sessions.style.left).toBe('')
|
||||
expect(sessions.style.top).toBe('')
|
||||
})
|
||||
|
||||
it('uses shared native-browser occlusion only where menus intersect the viewport', () => {
|
||||
render(view())
|
||||
open()
|
||||
@@ -204,5 +243,9 @@ describe('ProjectActivity', () => {
|
||||
const host = screen.getByRole('button', { name: 'Destination' })
|
||||
expect(isBrowserViewportOccluded(host, new DOMRect(200, 10, 500, 500))).toBe(true)
|
||||
expect(isBrowserViewportOccluded(host, new DOMRect(400, 10, 500, 500))).toBe(false)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Remote/ }))
|
||||
vi.spyOn(screen.getByRole('menu', { name: 'Remote' }), 'getBoundingClientRect')
|
||||
.mockReturnValue(new DOMRect(274, 60, 264, 480))
|
||||
expect(isBrowserViewportOccluded(host, new DOMRect(400, 300, 500, 500))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -87,16 +87,29 @@ function ActivityMenu({
|
||||
const menu = menuRef.current
|
||||
if (!menu || !anchorRef.current) return
|
||||
const rect = anchorRef.current.getBoundingClientRect()
|
||||
const compact = window.innerWidth < 620
|
||||
const panelWidth = Math.min(264, window.innerWidth - 32)
|
||||
const baseLeft = Math.max(16, Math.min(rect.left, window.innerWidth - panelWidth - 16))
|
||||
const compact = window.innerWidth < 620 || (
|
||||
baseLeft + panelWidth * 2 > window.innerWidth - 16 && baseLeft < panelWidth + 16
|
||||
)
|
||||
const leftward = !compact && baseLeft + panelWidth * 2 > window.innerWidth - 16 && baseLeft >= panelWidth + 16
|
||||
menu.dataset.compact = String(compact)
|
||||
menu.dataset.left = String(leftward)
|
||||
const width = panelWidth * (active && !compact ? 2 : 1)
|
||||
menu.style.width = `${width}px`
|
||||
menu.style.left = `${Math.max(16, Math.min(baseLeft - (leftward && active ? panelWidth : 0), window.innerWidth - width - 16))}px`
|
||||
menu.style.width = `${panelWidth}px`
|
||||
menu.style.left = `${baseLeft}px`
|
||||
menu.style.top = `${Math.max(16, Math.min(rect.bottom + 4, window.innerHeight - menu.offsetHeight - 16))}px`
|
||||
const sessions = sessionsRef.current
|
||||
if (sessions) {
|
||||
sessions.style.width = `${panelWidth}px`
|
||||
if (compact) {
|
||||
sessions.style.left = ''
|
||||
sessions.style.top = ''
|
||||
} else {
|
||||
const row = projectRef.current?.querySelector<HTMLButtonElement>('[aria-expanded="true"]')
|
||||
sessions.style.left = `${baseLeft + (leftward ? -panelWidth : panelWidth)}px`
|
||||
sessions.style.top = `${Math.max(16, Math.min(row?.getBoundingClientRect().top ?? rect.bottom + 4, window.innerHeight - sessions.offsetHeight - 16))}px`
|
||||
}
|
||||
}
|
||||
if (compact && active && projectRef.current?.contains(document.activeElement)) {
|
||||
sessionsRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus()
|
||||
}
|
||||
|
||||
@@ -87,23 +87,22 @@
|
||||
position: fixed;
|
||||
-webkit-app-region: no-drag;
|
||||
z-index: var(--z-dialog);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
max-width: calc(100vw - 32px);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.project-activity__menu > div {
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-height: min(480px, calc(100dvh - 32px));
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-dialog);
|
||||
}
|
||||
|
||||
.project-activity__menu h3,
|
||||
@@ -114,13 +113,11 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-activity__sessions { border-left: 1px solid var(--border-default); }
|
||||
.project-activity__menu[data-left="true"] { flex-direction: row-reverse; }
|
||||
.project-activity__menu[data-left="true"] .project-activity__sessions { border-left: 0; border-right: 1px solid var(--border-default); }
|
||||
.project-activity__sessions { position: fixed; }
|
||||
.project-activity__back { display: none; }
|
||||
.project-activity__menu[data-compact="true"][data-submenu="true"] .project-activity__projects { display: none; }
|
||||
.project-activity__menu[data-compact="true"] .project-activity__back { display: flex; }
|
||||
.project-activity__menu[data-compact="true"] .project-activity__sessions { border: 0; }
|
||||
.project-activity__menu[data-compact="true"] .project-activity__sessions { position: static; }
|
||||
.project-activity__row[aria-expanded="true"] { background: var(--accent-subtle); border-color: var(--accent-selected); }
|
||||
|
||||
.project-activity__identity {
|
||||
|
||||
Reference in New Issue
Block a user