diff --git a/web/e2e/bug-3231-drag-out-keeps-menu.spec.ts b/web/e2e/bug-3231-drag-out-keeps-menu.spec.ts new file mode 100644 index 000000000..59f786f96 --- /dev/null +++ b/web/e2e/bug-3231-drag-out-keeps-menu.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from './fixtures'; +import { browserLogin } from './lib/collab-helpers'; +import { authJson } from './lib/attachment-viewer'; +import type { Locator, Page } from '@playwright/test'; + +/** + * BUG-3231: menus that closed on a window CLICK outside them closed when a drag + * started inside and was released outside, because that click lands on a + * common ancestor outside the menu. They now dismiss through `clickOutside`, + * which decides on the press. + * + * Each leg drags with a real mouse from inside the menu to a point outside it + * and asserts the menu is still open, then presses that point and asserts it + * closes, so a menu that simply stopped closing fails the second half. + */ + +const DESKTOP = { width: 1280, height: 800 }; +const OUTSIDE = { x: 640, y: DESKTOP.height - 20 }; + +async function dragOut(page: Page, from: Locator) { + const box = await from.boundingBox(); + if (!box) throw new Error('drag source has no box'); + await page.mouse.move(box.x + 4, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 4 }); + await page.mouse.move(OUTSIDE.x, OUTSIDE.y, { steps: 10 }); + await page.mouse.up(); +} + +test.describe('BUG-3231: a drag started inside a menu and released outside does not close it', () => { + test.beforeEach(async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'a mouse-drag concern; one desktop browser is enough'); + await page.setViewportSize(DESKTOP); + await browserLogin(page); + }); + + test('field select dropdown (FieldEditor)', async ({ page, fixture, request }) => { + const stamp = Date.now(); + const resp = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/collections/tasks/items`, { + headers: authJson(fixture), + data: { title: `Menu drag ${stamp}`, content: '', fields: JSON.stringify({ status: 'open' }) }, + }); + expect(resp.ok(), await resp.text()).toBeTruthy(); + const { slug } = (await resp.json()) as { slug: string }; + await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/tasks/${slug}`); + + const trigger = page.locator('.select-trigger', { hasText: 'Open' }).first(); + await expect(trigger).toBeVisible({ timeout: 15_000 }); + await trigger.click(); + const list = page.locator('.select-dropdown[role="listbox"]'); + await expect(list).toBeVisible(); + + await dragOut(page, list.getByRole('option').first()); + await expect(list).toBeVisible(); + + await page.mouse.click(OUTSIDE.x, OUTSIDE.y); + await expect(list).toBeHidden(); + }); + + test('board lane actions menu, tag input (BoardView)', async ({ page, fixture, request }) => { + const stamp = Date.now(); + const resp = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/collections/tasks/items`, { + headers: authJson(fixture), + data: { title: `Lane drag ${stamp}`, content: '', fields: JSON.stringify({ status: 'open' }) }, + }); + expect(resp.ok(), await resp.text()).toBeTruthy(); + await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/tasks?view=board`); + + const kebab = page.locator('.lane-menu-btn').first(); + await expect(kebab).toBeVisible({ timeout: 15_000 }); + await kebab.click(); + const first = page.getByRole('menuitem').first(); + await expect(first).toBeVisible(); + + // Press a menu row and release outside: the click lands on a common + // ancestor outside the menu, which the old window closer took for an + // outside click. (A drag out of the tag INPUT does not reproduce in + // Chromium, whose text-selection drag keeps the release on the input.) + await dragOut(page, first); + await expect(first).toBeVisible(); + + await page.mouse.click(OUTSIDE.x, OUTSIDE.y); + await expect(first).toBeHidden(); + }); +}); diff --git a/web/src/lib/components/collections/BoardView.svelte b/web/src/lib/components/collections/BoardView.svelte index 1e22e2cfe..13d7c9903 100644 --- a/web/src/lib/components/collections/BoardView.svelte +++ b/web/src/lib/components/collections/BoardView.svelte @@ -28,6 +28,7 @@ import EmptyState from '../common/EmptyState.svelte'; import LaneActionsMenu from './LaneActionsMenu.svelte'; import { viewport } from '$lib/stores/breakpoint.svelte'; + import { clickOutside } from '$lib/utils/clickOutside'; import { draftKey, lostLaneLabel, type DraftSaveTarget } from '$lib/collections/laneDrafts'; @@ -237,12 +238,6 @@ // Dismiss the open lane menu on any click outside it (mirrors the // QuickActionsMenu pattern). The menu markup lives under // `.lane-menu-wrap`, so clicks there don't close it. - function handleWindowClick(e: MouseEvent) { - if (openMenuColumn === null) return; - const target = e.target as HTMLElement | null; - if (!target) return; - if (!target.closest('.lane-menu-wrap')) closeMenu(); - } const flipDurationMs = 200; const touchDragDelayMs = 500; @@ -785,7 +780,6 @@ } - {#if items.length === 0} @@ -917,7 +911,11 @@ {#if onCreateInColumn || colItems.length > 0} -
+ +