fix web list project menu placement near viewport edge (#6971)

This commit is contained in:
Ray Xi
2026-08-17 09:30:57 +00:00
committed by GitHub
parent 169dea1701
commit bcedd985d7
3 changed files with 113 additions and 1 deletions
@@ -7,7 +7,15 @@
// surfaces (e.g. an in-project quick-switcher pane).
import type { CSSProperties } from 'react';
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Dialog, DialogDescription, DialogFooter, DialogTitle } from '@open-design/components';
const MOVE_CONFIRM_SKIP_KEY = 'od.projects.moveConfirmSkip';
@@ -203,6 +211,8 @@ const deckCoverCache = new Map<string, string>();
const deckCoverInflight = new Map<string, Promise<string>>();
const DEFAULT_RECENT_PROJECT_LIMIT = 6;
const WIDE_RECENT_PROJECT_LIMIT = 7;
const PROJECT_MENU_GAP = 6;
const PROJECT_MENU_VIEWPORT_MARGIN = 24;
// Card covers are background decoration. Browsers commonly allow only six
// concurrent connections per origin, so an unbounded All Projects scan can
// occupy every slot and queue the project file list/preview the user just
@@ -473,6 +483,7 @@ export function RecentProjectsStrip({
Record<string, ProjectCoverOverride | null>
>({});
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const [menuPlacement, setMenuPlacement] = useState<'down' | 'up'>('down');
const [renameTarget, setRenameTarget] = useState<{ id: string; original: string } | null>(null);
const [renameInput, setRenameInput] = useState('');
const [confirmTarget, setConfirmTarget] = useState<Project | null>(null);
@@ -549,6 +560,7 @@ export function RecentProjectsStrip({
],
);
const menuContainerRef = useRef<HTMLDivElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const renameTitleId = useId();
const confirmTitleId = useId();
const moveTitleId = useId();
@@ -628,6 +640,48 @@ export function RecentProjectsStrip({
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [menuOpenId]);
useLayoutEffect(() => {
if (!menuOpenId) return;
const anchor = menuContainerRef.current;
const menu = menuRef.current;
const trigger = anchor?.querySelector<HTMLElement>('.recent-projects__card-more');
if (!anchor || !menu || !trigger) return;
const measureMenuPlacement = () => {
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 720;
const scrollBoundary = anchor.closest<HTMLElement>('.entry-main--scroll');
const scrollRect = scrollBoundary?.getBoundingClientRect();
const visibleTop = Math.max(0, scrollRect?.top ?? 0);
const visibleBottom = Math.min(viewportHeight, scrollRect?.bottom ?? viewportHeight);
const triggerRect = trigger.getBoundingClientRect();
const menuHeight = menu.getBoundingClientRect().height;
const spaceBelow =
visibleBottom - triggerRect.bottom - PROJECT_MENU_GAP - PROJECT_MENU_VIEWPORT_MARGIN;
const spaceAbove =
triggerRect.top - visibleTop - PROJECT_MENU_GAP - PROJECT_MENU_VIEWPORT_MARGIN;
const nextPlacement =
spaceBelow < menuHeight && spaceAbove > spaceBelow ? 'up' : 'down';
setMenuPlacement((current) => (current === nextPlacement ? current : nextPlacement));
};
measureMenuPlacement();
window.addEventListener('resize', measureMenuPlacement);
window.addEventListener('scroll', measureMenuPlacement, true);
const observer = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(measureMenuPlacement);
if (observer) {
observer.observe(anchor);
observer.observe(menu);
}
return () => {
window.removeEventListener('resize', measureMenuPlacement);
window.removeEventListener('scroll', measureMenuPlacement, true);
observer?.disconnect();
};
}, [menuOpenId]);
// Cover fetching must key off the *set of project ids and their readiness*, not the
// `visibleProjects` array reference. That reference changes on every render
// (upstream props/derived lists are recreated, and a 2s poll re-renders the
@@ -1873,6 +1927,8 @@ export function RecentProjectsStrip({
{menuOpenId === project.id ? (
<div
className="recent-projects__card-menu"
data-placement={menuPlacement}
ref={menuRef}
role="menu"
onClick={(event) => event.stopPropagation()}
>
@@ -504,6 +504,10 @@
background: var(--bg-panel);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
}
.recent-projects__card-menu[data-placement='up'] {
top: auto;
bottom: calc(100% + 6px);
}
.recent-projects__card-menu button {
appearance: none;
display: flex;
+52
View File
@@ -589,6 +589,58 @@ test.beforeEach(async ({ page }) => {
});
});
test('[P1] last project list row keeps its overflow menu inside the viewport', async ({ page }) => {
const recentProjects = Array.from({ length: 3 }, (_, index) => ({
id: `recent-list-menu-${index + 1}`,
name: `List Project ${index + 1}`,
skillId: null,
designSystemId: null,
createdAt: Date.now() - (index + 1) * 10_000,
updatedAt: Date.now() - (index + 1) * 5_000,
metadata: { kind: 'prototype', nameSource: 'user' },
}));
const lastProject = recentProjects.at(-1)!;
await page.setViewportSize({ width: 1280, height: 500 });
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects: recentProjects } });
return;
}
await route.continue();
});
await gotoEntryHome(page);
await page.getByRole('button', { name: 'List view' }).click();
const lastRow = page.locator(`[data-project-id="${lastProject.id}"]`);
await expect(lastRow).toBeVisible();
await page.evaluate((projectId) => {
const scroller = document.querySelector<HTMLElement>('.entry-main--scroll');
const row = document.querySelector<HTMLElement>(`[data-project-id="${projectId}"]`);
const trigger = row?.querySelector<HTMLElement>('.recent-projects__card-more');
if (!scroller || !row || !trigger) {
throw new Error('Recent project list-menu fixture is missing');
}
const desiredTop = scroller.getBoundingClientRect().bottom - 60;
scroller.scrollTop += trigger.getBoundingClientRect().top - desiredTop;
}, lastProject.id);
await lastRow.hover();
const trigger = lastRow.getByRole('button', { name: /more actions/i });
await trigger.click();
const menu = lastRow.getByRole('menu');
await expect(menu).toBeVisible();
await expect(menu.getByRole('menuitem', { name: 'Delete' })).toBeInViewport();
const triggerBox = await trigger.boundingBox();
const menuBox = await menu.boundingBox();
expect(triggerBox).not.toBeNull();
expect(menuBox).not.toBeNull();
expect((menuBox?.y ?? 0) + (menuBox?.height ?? 0)).toBeLessThan(triggerBox?.y ?? 0);
});
test('[P1] home left rail expands and collapses from the shell controls', async ({ page }) => {
await gotoEntryHome(page);