mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 03:16:43 +00:00
01a94d93a8
* feat(web): Menu/MenuItem primitive — escape-stack ESC, portal mode, pointerdown outside-click (TASK-2292) PLAN-2290 Phase 2, PR 4 (final primitive). New shared machinery: - lib/components/common/Menu.svelte — anchored + portal modes (portal = fixed coords + flip/clamp, escapes card content-visibility containment), instance-scoped POINTERDOWN outside-click (structurally removes the BUG-2281 stopPropagation-on-rows detach workaround), ESC via the shared escapeStack at new priority menu=40 (one ESC closes menu before pane/drawer), roving keyboard nav, focus-in/focus-return, BottomSheet swap at 768px, --bg-raised panel skin. - lib/components/common/MenuItem.svelte — icon/hint/danger/menuitemradio rows. - lib/utils/clickOutside.ts + lib/utils/portalAction.ts — extracted from the hand-copied per-menu versions. - app.css: --bg-raised token (dark = tertiary; light = white). Migrated: ItemActionsMenu (portal mode, entire hand-rolled machinery deleted), QuickActionsMenu (anchored + sheetOnMobile, EmojiPicker exemption via exempt(), BUG-2281 workarounds removed), TopBar user menu (desktop + mobile branches deduped into one snippet; gains aria-haspopup/expanded + keyboard nav it never had). E2E locators updated to accessible-name form. Documented leave-alones: TopBar workspace-overflow menu (it IS a dndzone — conditional mount / focus-steal / pointerdown-close each break drag-reorder; in-file comments), LaneActionsMenu drill-down + WorkspaceSwitcher (Phase 3 / later). Gates: svelte-check 0 errors, 488 tests, make check green; runtime-verified via Playwright: user menu roving nav (ArrowDown x2 -> Admin), ESC closes via stack, kebab portal placement + edge-aware rows. * fix(web): Menu review fixes — form focus hand-off, drag suppression, scroll-close without refocus, resize close Codex findings on #1022: (1) QuickActions create-form now receives focus when it swaps in (the focused MenuItem unmounts on the flip); (2) clickOutside gains suppress() and TopBar's user menu passes isDragging||dragArmed so pill drags can't slam it shut (parity with the old drag guard); (3) portal scroll/resize dismissal calls onclose() directly — no trigger refocus fighting the user's scroll (parity with the old returnFocus=false); (4) resize now also closes portal menus (stale fixed coords).
133 lines
5.5 KiB
TypeScript
133 lines
5.5 KiB
TypeScript
import { test, expect } from './fixtures';
|
|
import { browserLogin } from './lib/collab-helpers';
|
|
import type { APIRequestContext } from '@playwright/test';
|
|
import type { SuiteFixture } from './fixtures';
|
|
|
|
/**
|
|
* BUG-2281 regression e2e — the inline "New quick action" form must open
|
|
* and stay open.
|
|
*
|
|
* Found during the BUG-2129 switch-safety reconciliation. Clicking the
|
|
* footer "+ New quick action" flips `showCreateForm=true`, which unmounts
|
|
* the footer's {:else} branch (the very button clicked). Svelte 5
|
|
* flushSyncs after a delegated event handler, so by the time that click
|
|
* bubbles on to the <svelte:window> click-outside handler the button is
|
|
* DETACHED — `target.closest('.quick-actions-menu')` returns null and
|
|
* handleWindowClick treats it as an outside click, closing the whole menu
|
|
* and wiping the create form the instant it opens. `handleTriggerClick`
|
|
* already guards this with `e.stopPropagation()`; `handleOpenCreateForm`
|
|
* and the create-form Cancel button did not.
|
|
*
|
|
* jsdom (the unit test) doesn't reproduce the mid-bubble detach, and no
|
|
* e2e ever CLICKED "New quick action" (the capstone only asserts it's
|
|
* visible) — so this closes that gap in a real Chromium event pipeline.
|
|
* Pre-fix: the create form never appears. Post-fix (stopPropagation): it
|
|
* opens and stays, and Cancel returns to the action list instead of
|
|
* closing the whole menu.
|
|
*
|
|
* PLAN-2290 Phase 2 update: QuickActionsMenu now rides the shared Menu
|
|
* primitive, whose pointerdown-based instance-scoped outside-click fires
|
|
* BEFORE row clicks mutate state — the stopPropagation workaround is gone
|
|
* because the detach hazard is structurally impossible. This test stays
|
|
* as regression coverage for the same user-visible behavior.
|
|
*/
|
|
|
|
function authHeaders(fixture: SuiteFixture) {
|
|
return { Authorization: `Bearer ${fixture.apiToken}`, 'Content-Type': 'application/json' };
|
|
}
|
|
|
|
// A plain collection with a minimal valid schema. Letters-only prefix and a
|
|
// timestamped name so the server-derived slug stays in sync with the name
|
|
// (see pane-collection-migration-race.spec.ts::seedMigratableCollection for
|
|
// why numeric-derived prefixes 404 by-ref lookups and out-of-sync name/slug
|
|
// reassign the slug on the next save).
|
|
async function seedCollection(
|
|
fixture: SuiteFixture,
|
|
request: APIRequestContext,
|
|
namePrefix: string,
|
|
itemPrefix: string,
|
|
) {
|
|
const name = `${namePrefix} ${Date.now()}`;
|
|
const schema = JSON.stringify({
|
|
fields: [
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
type: 'select',
|
|
options: ['draft', 'published'],
|
|
default: 'draft',
|
|
required: true,
|
|
},
|
|
],
|
|
});
|
|
const resp = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/collections`, {
|
|
headers: authHeaders(fixture),
|
|
data: { name, prefix: itemPrefix, schema },
|
|
});
|
|
if (!resp.ok()) throw new Error(`collection create failed (${resp.status()}): ${await resp.text()}`);
|
|
return (await resp.json()) as { id: string; slug: string; name: string };
|
|
}
|
|
|
|
async function seedItem(
|
|
fixture: SuiteFixture,
|
|
request: APIRequestContext,
|
|
collSlug: string,
|
|
title: string,
|
|
) {
|
|
const resp = await request.post(
|
|
`/api/v1/workspaces/${fixture.workspaceSlug}/collections/${collSlug}/items`,
|
|
{
|
|
headers: authHeaders(fixture),
|
|
data: { title, fields: JSON.stringify({ status: 'draft' }), content: '' },
|
|
},
|
|
);
|
|
if (!resp.ok()) throw new Error(`item create failed (${resp.status()}): ${await resp.text()}`);
|
|
return (await resp.json()) as { id: string; slug: string };
|
|
}
|
|
|
|
test("the inline 'New quick action' form opens and stays open (BUG-2281)", async ({
|
|
page,
|
|
fixture,
|
|
request,
|
|
}, testInfo) => {
|
|
test.skip(
|
|
testInfo.project.name !== 'desktop-chromium',
|
|
'event-propagation behavior is viewport-agnostic; one project is enough',
|
|
);
|
|
test.setTimeout(30_000);
|
|
|
|
// A brand-new collection has NO quick actions yet, so the dropdown opens
|
|
// straight to the footer (New quick action / Manage actions) — the exact
|
|
// state a user hits when adding their first action.
|
|
const coll = await seedCollection(fixture, request, 'bug2281-form', 'BFRM');
|
|
const it = await seedItem(fixture, request, coll.slug, 'BUG-2281 form item');
|
|
|
|
await browserLogin(page);
|
|
await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/${coll.slug}/${it.slug}`);
|
|
await expect(page.locator('.title', { hasText: 'BUG-2281 form item' })).toBeVisible();
|
|
|
|
await page.locator('button.trigger-btn[title="Quick actions"]').click();
|
|
const openFormBtn = page.getByRole('menuitem', { name: 'New quick action' });
|
|
await expect(openFormBtn).toBeVisible();
|
|
await openFormBtn.click();
|
|
|
|
// Pre-fix, the click reached the <svelte:window> click-outside handler on a
|
|
// footer button that Svelte's flushSync had already detached, so the menu
|
|
// closed instantly and the create form never appeared. Post-fix
|
|
// (stopPropagation on handleOpenCreateForm), the form opens and stays.
|
|
await expect(page.locator('input.qa-label-input')).toBeVisible();
|
|
await expect(page.locator('textarea.qa-prompt-input')).toBeVisible();
|
|
// A short settle to prove it didn't merely flash open then close.
|
|
await page.waitForTimeout(200);
|
|
await expect(page.locator('input.qa-label-input')).toBeVisible();
|
|
|
|
// Cancel returns to the action list (not: closes the whole menu) — same
|
|
// stopPropagation fix on the create-form Cancel button.
|
|
await page.locator('button.qa-btn.qa-btn-cancel', { hasText: 'Cancel' }).click();
|
|
await expect(page.locator('input.qa-label-input')).toHaveCount(0);
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'New quick action' }),
|
|
'Cancel returns to the action list rather than closing the whole menu',
|
|
).toBeVisible();
|
|
});
|