From ccd5febfffc9f5c41cb6b0959b56d41352ba1de9 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sat, 26 Sep 2026 14:08:07 -0400 Subject: [PATCH] refactor(web): the six click-outside menus dismiss through the shared clickOutside primitive, so no menu carries a hand-rolled window click closer (BUG-3231) (#1580) Six menus closed on a window or document click whose target lay outside them: the FieldEditor select dropdown, the BoardView lane menu, EmojiPickerButton, ReactionPicker, the TopBar workspace overflow menu and the console mobile nav. Each now uses lib/utils/clickOutside.ts, which decides on a capture-phase press outside the node and its extras, and the hand-rolled handlers are gone. No bug claim: the drag-out symptom the BUG-3229 census predicted for these menus did not reproduce in Chromium on main (real-mouse e2e, two menus). The TopBar overflow menu exempts the visible workspace pills, whose press may start a drag into it, and handleWsClick closes it for every real pill click, modifier-clicks included. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz --- web/e2e/bug-3231-drag-out-keeps-menu.spec.ts | 85 +++++++++++++++++++ .../components/collections/BoardView.svelte | 14 ++- .../collections/LaneActionsMenu.svelte | 12 +-- .../common/EmojiPickerButton.svelte | 20 ++--- .../lib/components/fields/FieldEditor.svelte | 24 ++---- web/src/lib/components/layout/TopBar.svelte | 72 +++++++--------- .../components/timeline/ReactionPicker.svelte | 25 ++---- web/src/lib/utils/clickOutside.svelte.test.ts | 71 ++++++++++++++++ .../lib/utils/clickOutsidePopulation.test.ts | 38 +++++++++ web/src/routes/console/+layout.svelte | 27 +++--- 10 files changed, 276 insertions(+), 112 deletions(-) create mode 100644 web/e2e/bug-3231-drag-out-keeps-menu.spec.ts create mode 100644 web/src/lib/utils/clickOutside.svelte.test.ts create mode 100644 web/src/lib/utils/clickOutsidePopulation.test.ts 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} -
+ +