mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
fix: open the Files ⋮ menus beside their button, above it when there is no room below, never over it
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { FileBrowser } from '../file-browser';
|
||||
import { useFileStore, type FileResource } from '@/stores/file-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { DEFAULT_FILES_SETTINGS } from '@/lib/files-settings';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// Usability pass 2, Files (pim-5): the ⋮ of a row near the bottom opened its
|
||||
// menu over itself, so a second click on the button hit "Delete".
|
||||
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null }));
|
||||
vi.mock('@/lib/standalone-display', () => ({ canOpenUrlInNewWindow: () => false }));
|
||||
|
||||
const res = (id: string, name: string, extra: Partial<FileResource> = {}): FileResource => ({
|
||||
id, name, serverName: name, isDirectory: false, contentType: 'text/plain', contentLength: 10,
|
||||
lastModified: '2026-10-01T00:00:00Z', blobId: `b-${id}`, parentId: null, ...extra,
|
||||
} as FileResource);
|
||||
const notes = res('n', 'notes.txt');
|
||||
const budget = res('x', 'budget.xlsx', { contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
const docs = res('d', 'Documents', { isDirectory: true, contentType: '', blobId: null });
|
||||
|
||||
export function renderBrowser(props: Partial<React.ComponentProps<typeof FileBrowser>> = {}, resources: FileResource[] = [docs, notes, budget]) {
|
||||
const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient;
|
||||
act(() => {
|
||||
useFileStore.setState({ client, sharedRoots: [], allFolders: [docs], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true });
|
||||
});
|
||||
const h = {
|
||||
onPreviewFile: vi.fn(),
|
||||
onPreviewImage: vi.fn(),
|
||||
onDownload: vi.fn(async () => {}),
|
||||
onShowFileInfo: vi.fn(),
|
||||
onSetSelection: vi.fn(),
|
||||
onUploadFiles: vi.fn(async () => {}),
|
||||
};
|
||||
const noop = vi.fn(async () => {});
|
||||
render(
|
||||
<FileBrowser
|
||||
currentPath="/"
|
||||
resources={resources}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
selectedResources={new Set()}
|
||||
uploadProgress={null}
|
||||
clipboard={null}
|
||||
onNavigate={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
onCreateFolder={noop}
|
||||
onUploadFolder={noop}
|
||||
onCancelUpload={vi.fn()}
|
||||
onDelete={noop}
|
||||
onBatchDelete={noop}
|
||||
onRename={noop}
|
||||
onBatchDownload={noop}
|
||||
onRefresh={noop}
|
||||
onClearSelection={vi.fn()}
|
||||
onCut={vi.fn()}
|
||||
onCopy={vi.fn()}
|
||||
onPaste={noop}
|
||||
onMoveTo={noop}
|
||||
onCreateFile={noop}
|
||||
onDuplicate={noop}
|
||||
getImageUrl={async () => ''}
|
||||
favorites={[]}
|
||||
recentFiles={[]}
|
||||
onToggleFavorite={vi.fn()}
|
||||
onOpenRecent={vi.fn()}
|
||||
onRenameNode={noop}
|
||||
onDeleteNode={noop}
|
||||
onDownloadNode={noop}
|
||||
onRevealNode={noop}
|
||||
onRemoveRecent={vi.fn()}
|
||||
client={client}
|
||||
ownAccountId="a"
|
||||
{...h}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return h;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
act(() => { useSettingsStore.setState({ filesSettings: DEFAULT_FILES_SETTINGS }); });
|
||||
});
|
||||
|
||||
describe('the ⋮ of a row opens its menu beside the button, never over it (pim-5)', () => {
|
||||
it('goes above a button near the bottom of the window', () => {
|
||||
// 1280×720, as in the audit: the last row's ⋮ at y 536–568, a menu 357 px tall.
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280);
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(720);
|
||||
const button = { left: 1200, right: 1232, top: 536, bottom: 568, width: 32, height: 32 };
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.getAttribute('role') === 'menu') return { x: 0, y: 0, left: 0, top: 0, right: 220, bottom: 357, width: 220, height: 357, toJSON: () => ({}) } as DOMRect;
|
||||
if (this.hasAttribute('data-item-action')) return { x: button.left, y: button.top, ...button, toJSON: () => ({}) } as DOMRect;
|
||||
return { x: 0, y: 0, left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON: () => ({}) } as DOMRect;
|
||||
});
|
||||
renderBrowser();
|
||||
const kebab = document.querySelector<HTMLElement>('[data-resource="budget.xlsx"] [data-item-action][title="context_menu"]')!;
|
||||
expect(kebab).toBeTruthy();
|
||||
fireEvent.click(kebab, { detail: 1, clientX: 1216, clientY: 552 });
|
||||
const menu = screen.getByRole('menu');
|
||||
const top = parseFloat(menu.style.top);
|
||||
// Above the button: the menu ends before the button starts.
|
||||
expect(top + 357).toBeLessThanOrEqual(button.top);
|
||||
// The row stays marked while its menu is open.
|
||||
expect(document.querySelector('[data-resource="budget.xlsx"]')?.closest('[data-menu-opener]') ?? document.querySelector('[data-menu-opener]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ import { FilesSidebar, type CreateKind } from "@/components/files/files-sidebar"
|
||||
import { FilesHeader, type Crumb, type SortDir, type SortKey, type ViewMode } from "@/components/files/files-header";
|
||||
import { FileGridView, FileListView, type FileItemHandlers } from "@/components/files/file-views";
|
||||
import { FileDetailsPanel } from "@/components/files/file-details-panel";
|
||||
import { INTERNAL_DRAG_TYPE, anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
import { INTERNAL_DRAG_TYPE, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { AppMain } from "@/components/layout/app-landmarks";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
@@ -880,6 +880,7 @@ export function FileBrowser({
|
||||
|
||||
// ---- Context menu ----------------------------------------------------------
|
||||
const openMenu = menu.openContextMenu;
|
||||
const openMenuFrom = menu.openContextMenuFrom;
|
||||
const openItemMenu = useCallback((e: React.MouseEvent, resource: FileResource) => {
|
||||
// Acting on an item outside the selection makes it the selection, like a
|
||||
// desktop file manager.
|
||||
@@ -891,9 +892,9 @@ export function FileBrowser({
|
||||
setKeyboardNav(false);
|
||||
const target = e.currentTarget as HTMLElement | null;
|
||||
// The "..." button opens the menu under itself; a right-click at the pointer.
|
||||
if (e.type === "click" && target) openMenu(anchorEvent(target, "end"), { kind: "item", resource });
|
||||
if (e.type === "click" && target) openMenuFrom(target, { kind: "item", resource }, "end");
|
||||
else openMenu(e, { kind: "item", resource });
|
||||
}, [selectedResources, onSetSelection, openMenu]);
|
||||
}, [selectedResources, onSetSelection, openMenu, openMenuFrom]);
|
||||
|
||||
const closeMenu = menu.closeContextMenu;
|
||||
const menuData = menu.contextMenu.data;
|
||||
@@ -1417,7 +1418,7 @@ export function FileBrowser({
|
||||
size="icon-lg"
|
||||
className="absolute bottom-4 end-4 z-20 rounded-full shadow-lg"
|
||||
aria-label={t("new")}
|
||||
onClick={(e) => menu.openContextMenu(anchorEvent(e.currentTarget, "end"), { kind: "new" })}
|
||||
onClick={(e) => menu.openContextMenuFrom(e.currentTarget, { kind: "new" }, "end")}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
@@ -230,16 +230,3 @@ export function MiddleCutName({ name, title, className }: { name: string; title?
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Open a shared context menu as a dropdown under a button. */
|
||||
export function anchorEvent(el: HTMLElement, align: "start" | "end" = "start"): React.MouseEvent {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const rtl = typeof document !== "undefined" && document.dir === "rtl";
|
||||
const alignEnd = (align === "end") !== rtl;
|
||||
return {
|
||||
clientX: alignEnd ? rect.right - 200 : rect.left,
|
||||
clientY: rect.bottom + 4,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as React.MouseEvent;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
import { isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
|
||||
@@ -303,7 +303,7 @@ export function FilesHeader({
|
||||
className="px-1.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
aria-label={t("show_path")}
|
||||
aria-haspopup="menu"
|
||||
onClick={(e) => overflowMenu.openContextMenu(anchorEvent(e.currentTarget), hidden)}
|
||||
onClick={(e) => overflowMenu.openContextMenuFrom(e.currentTarget, hidden)}
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -347,7 +347,7 @@ export function FilesHeader({
|
||||
onMouseDown={(e) => { if (sortMenu.contextMenu.isOpen) e.stopPropagation(); }}
|
||||
onClick={(e) => {
|
||||
if (sortMenu.contextMenu.isOpen) sortMenu.closeContextMenu();
|
||||
else sortMenu.openContextMenu(anchorEvent(e.currentTarget, "end"), null);
|
||||
else sortMenu.openContextMenuFrom(e.currentTarget, null, "end");
|
||||
}}
|
||||
>
|
||||
<ArrowUpDown />
|
||||
@@ -384,7 +384,7 @@ export function FilesHeader({
|
||||
below="md"
|
||||
menuItems={(close) => (
|
||||
<>
|
||||
{narrow && <ContextMenuItem icon={ArrowUpDown} label={t("sort_by")} onClick={() => { close(); if (toolsRef.current) sortMenu.openContextMenu(anchorEvent(toolsRef.current, "end"), null); }} />}
|
||||
{narrow && <ContextMenuItem icon={ArrowUpDown} label={t("sort_by")} onClick={() => { close(); if (toolsRef.current) sortMenu.openContextMenuFrom(toolsRef.current, null, "end"); }} />}
|
||||
{narrow && (viewMode === "list"
|
||||
? <ContextMenuItem icon={LayoutGrid} label={t("grid_view")} onClick={() => { close(); onViewModeChange("grid"); }} />
|
||||
: <ContextMenuItem icon={LayoutList} label={t("list_view")} onClick={() => { close(); onViewModeChange("list"); }} />)}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sharedFilePages, type FileResource } from "@/stores/file-store";
|
||||
import { getFileIconByName } from "@/components/files/file-icons";
|
||||
import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
import { isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
|
||||
const COLLAPSED_KEY = "files-sidebar-collapsed";
|
||||
const RECENT_LIMIT = 8;
|
||||
@@ -297,7 +297,7 @@ export function FilesSidebar({
|
||||
onMouseDown={(e) => { if (newMenu.contextMenu.isOpen) e.stopPropagation(); }}
|
||||
onClick={(e) => {
|
||||
if (newMenu.contextMenu.isOpen) newMenu.closeContextMenu();
|
||||
else newMenu.openContextMenu(anchorEvent(e.currentTarget, "end"), null);
|
||||
else newMenu.openContextMenuFrom(e.currentTarget, null, "end");
|
||||
}}
|
||||
>
|
||||
<Plus />
|
||||
|
||||
@@ -89,7 +89,11 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
}, []);
|
||||
|
||||
const openContextMenuFrom = useCallback((element: HTMLElement, data: T, align: "start" | "end" = "start") => {
|
||||
setContextMenu({ isOpen: true, position: anchorPosition(element, align), data });
|
||||
const position = anchorPosition(element, align);
|
||||
// Focus goes back to it, and the row it's in stays marked while the menu is open.
|
||||
position.trigger = focusReturnTarget(element);
|
||||
position.opener = element.closest<HTMLElement>("[data-menu-row]") ?? element;
|
||||
setContextMenu({ isOpen: true, position, data });
|
||||
}, []);
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user