mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix web list project menu placement near viewport edge (#6971)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user