Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
08bffa9
feat(core): add shared floating tree foundation
pimenovoleg Jun 14, 2026
0246f97
fix: tri-state, DI and etc
pimenovoleg Jun 14, 2026
f9db173
fix: tri-state, DI and etc
pimenovoleg Jun 14, 2026
31d861b
feat(dismissable-layer): parallel Base UI-aligned dismissal engine
pimenovoleg Jun 15, 2026
1aa9f94
feat(focus-scope): owner-Document rework + portal-focus bridge toolkit
pimenovoleg Jun 15, 2026
f065025
feat(floating-focus-manager): rdxFloatingFocusManager skeleton
pimenovoleg Jun 15, 2026
6a7c653
feat(floating-focus-manager): markOthers aria-hidden + marker passes
pimenovoleg Jun 15, 2026
506b35b
feat(floating-focus-manager): close-on-focus-out reading the shared tree
pimenovoleg Jun 15, 2026
d7e2fed
feat(floating-focus-manager): initial-focus orchestration + interacti…
pimenovoleg Jun 15, 2026
aebcf73
feat(focus-scope): tab-order navigation helpers for the portal-focus …
pimenovoleg Jun 15, 2026
38e151f
feat(dialog): provide floating tree + root context — migration ground…
pimenovoleg Jun 15, 2026
18bf891
chore: upd wip commit
pimenovoleg Jun 15, 2026
70e085d
fix(floating): markOthers keeps all owned floating roots, not just th…
pimenovoleg Jun 15, 2026
3d924d5
feat(dialog): migrate to the new floating dismissal + focus engine
pimenovoleg Jun 15, 2026
10eea66
feat(floating): migrate tooltip & preview-card, align dialog with Bas…
pimenovoleg Jun 15, 2026
b7ffad5
chore: upd skills
pimenovoleg Jun 15, 2026
1d00d5b
fix(menu): after refactoring
pimenovoleg Jun 15, 2026
37346b4
chore: added touch sloppy-mode to RdxDismissableCapability
pimenovoleg Jun 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions apps/visual-regression/tests/context-menu.behavior.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { expect, Page, test } from '@playwright/test';

/**
* ADR 0015/0017 Phase-4 migration of Context Menu (composes `RdxMenuRoot`, so it inherits the new
* floating dismissal engine) onto a real browser. Context menus open at the cursor via a virtual
* anchor; these guard that opening + every dismissal path still works and throws no runtime errors.
*/
async function gotoStory(page: Page, storyId: string): Promise<void> {
await page.goto(`/iframe.html?id=${storyId}&viewMode=story`);
await page.waitForSelector('#storybook-root', { state: 'attached' });
}

const trigger = '[rdxContextMenuTrigger]';
const popup = '[rdxMenuPopup]';

async function openAtTrigger(page: Page): Promise<void> {
await page.locator(trigger).first().click({ button: 'right' });
await expect(page.locator(popup)).toBeVisible();
}

test('right-click opens the context menu without runtime errors', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(String(e)));
await gotoStory(page, 'primitives-context-menu--default');

await openAtTrigger(page);
expect(errors).toEqual([]);
});

test('Escape closes the context menu', async ({ page }) => {
await gotoStory(page, 'primitives-context-menu--default');
await openAtTrigger(page);

await page.keyboard.press('Escape');
await expect(page.locator(popup)).toHaveCount(0);
});

test('a modal context menu renders an internal backdrop (finding #1)', async ({ page }) => {
await gotoStory(page, 'primitives-context-menu--default');
await openAtTrigger(page);

await expect(page.locator('[data-rdx-menu-internal-backdrop]')).toHaveCount(1);
});

test('a modal context menu traps focus — a focus-out does not close it (finding #3)', async ({ page }) => {
await gotoStory(page, 'primitives-context-menu--default');
await openAtTrigger(page);

// Programmatically move focus to an element outside the menu. A context menu is the one menu kind
// that TRAPS focus (Base UI `FloatingFocusManager modal`), so focus is pulled back and it stays open.
await page.evaluate(() => {
const b = document.createElement('button');
b.id = 'cm-outside';
document.body.appendChild(b);
b.focus();
});
await page.waitForTimeout(120); // let the async focus-out check settle
await expect(page.locator(popup)).toBeVisible();
});

test('an outside press closes the context menu', async ({ page }) => {
await gotoStory(page, 'primitives-context-menu--default');
await openAtTrigger(page);

await page.mouse.click(5, 5);
await expect(page.locator(popup)).toHaveCount(0);
});
195 changes: 188 additions & 7 deletions apps/visual-regression/tests/dialog.behavior.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,14 @@ test.describe('Dialog structural portal', () => {
await expect(page.locator(popup)).toHaveCount(0);
});

test('holds the scroll lock through the exit animation, releasing it only on unmount', async ({ page }) => {
test('releases the scroll lock at close-start, before the exit animation finishes (Base UI parity)', async ({
page
}) => {
await gotoStory(page, 'primitives-dialog--default');

// Slow the 150ms exit keyframes to a comfortable window so the transitional state below is
// observable without a race (the bug — releasing the lock the instant `isOpen` flips — would
// restore `overflow` synchronously, well before the animation finishes).
// observable without a race: the lock must already be released while the popup is still
// mounted-and-animating-out (`open && modal` gating, not mounted-lifetime gating).
await page.addStyleTag({
content: `[rdxDialogBackdrop][data-closed], [rdxDialogPopup][data-closed] { animation-duration: 2000ms !important; }`
});
Expand All @@ -67,12 +69,13 @@ test.describe('Dialog structural portal', () => {

await page.locator('[rdxDialogClose][aria-label="Close"]').click();

// Mid-exit: the popup is closing but still mounted — the scroll lock must stay held so the
// page scrollbar doesn't reappear and reflow the page (the judder) while the dialog animates.
// Mid-exit: the popup is closing but still mounted, yet the scroll lock is already released
// (Base UI gates it on `open && modal === true`). `useScrollLock` compensates the scrollbar
// width with padding, so no content reflow accompanies the release.
await expect(page.locator(popup)).toHaveAttribute('data-closed', '');
expect(await htmlOverflow()).toBe('hidden');
expect(await htmlOverflow()).toBe('');

// Only once the view unmounts does the lock release and the original overflow return.
// Still released after unmount.
await expect(page.locator(popup)).toHaveCount(0);
expect(await htmlOverflow()).toBe('');
});
Expand Down Expand Up @@ -132,6 +135,184 @@ test.describe('Dialog outside-scroll (custom scroll area)', () => {
});
});

/**
* ⚠️ **NOT YET VERIFIED — ADR 0015/0017 Phase-4 Dialog migration onto the new floating engine.** These
* are the browser checks the migrated `RdxDialogPopup` must pass before merge (jsdom cannot exercise
* focus trap / live focus / focus-out / aria-hidden). Run with `pnpm test-visual` (or the local loop
* against `:4400`). Some tests below intentionally encode **known gaps** in the first-cut wiring — see
* the `KNOWN GAP` notes; they are expected to FAIL until the wiring is fixed in the verification session.
*/
test.describe('Dialog — new floating engine migration', () => {
const closeButton = '[rdxDialogClose][aria-label="Close"]';

const focusInsidePopup = (page: Page) =>
page.locator(popup).evaluate((el) => el.contains(el.ownerDocument.activeElement));

test('modal dialog moves focus into the popup on open', async ({ page }) => {
await gotoStory(page, 'primitives-dialog--default');
await page.locator(trigger).first().click();
await expect(page.locator(popup)).toBeVisible();

expect(await focusInsidePopup(page)).toBe(true);
});

test('modal dialog traps focus — Tab keeps focus inside the popup', async ({ page }) => {
await gotoStory(page, 'primitives-dialog--default');
await page.locator(trigger).first().click();
await expect(page.locator(popup)).toBeVisible();

// Tab repeatedly: focus must cycle within the popup, never escaping to the trigger / page.
for (let i = 0; i < 8; i++) {
await page.keyboard.press('Tab');
expect(await focusInsidePopup(page)).toBe(true);
}
});

test('returns focus to the trigger after closing', async ({ page }) => {
await gotoStory(page, 'primitives-dialog--default');
const triggerEl = page.locator(trigger).first();
await triggerEl.click();
await expect(page.locator(popup)).toBeVisible();

await page.keyboard.press('Escape');
await expect(page.locator(popup)).toHaveCount(0);

await expect(triggerEl).toBeFocused();
});

test('Escape and outside-press still close (dismissal regression)', async ({ page }) => {
await gotoStory(page, 'primitives-dialog--default');

await page.locator(trigger).first().click();
await expect(page.locator(popup)).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator(popup)).toHaveCount(0);

await page.locator(trigger).first().click();
await expect(page.locator(popup)).toBeVisible();
await page.locator(closeButton).click();
await expect(page.locator(popup)).toHaveCount(0);
});

test('non-modal dialog closes when focus leaves to an unrelated element', async ({ page }) => {
await gotoStory(page, 'primitives-dialog--non-modal');
await page.locator(trigger).first().click();
await expect(page.locator(popup)).toBeVisible();

// Move focus to a real element OUTSIDE the dialog (relatedTarget set, unrelated node) → the
// focus manager's focus-out close fires (§3). A null relatedTarget (bare blur) does NOT close.
await page.evaluate(() => {
const button = document.createElement('button');
button.id = 'rdx-focus-out-target';
document.body.appendChild(button);
button.focus();
});
await expect(page.locator(popup)).toHaveCount(0);
});

test('an outside press onto an interactive element keeps focus there, not back on the trigger (finding #3)', async ({
page
}) => {
await gotoStory(page, 'primitives-dialog--non-modal');
await page.locator(trigger).first().click();
await expect(page.locator(popup)).toBeVisible();

// A real focusable control outside the non-modal dialog. Pressing it dismisses the dialog AND
// moves focus onto it — the return-focus must not yank focus back to the trigger.
await page.evaluate(() => {
const button = document.createElement('button');
button.id = 'rdx-outside-target';
button.textContent = 'Outside';
document.body.appendChild(button);
});
await page.locator('#rdx-outside-target').click();

await expect(page.locator(popup)).toHaveCount(0);
await expect(page.locator('#rdx-outside-target')).toBeFocused();
});

test('nested dialog: Escape closes only the inner dialog (deepest-first ownership)', async ({ page }) => {
await gotoStory(page, 'primitives-dialog--nested');
await page.locator(trigger).first().click();
await expect(page.locator(popup).first()).toBeVisible();

// Open the nested dialog from inside the first.
await page.locator(popup).first().locator(trigger).first().click();
await expect(page.locator(popup)).toHaveCount(2);

// Escape closes the deepest (inner) layer only — the outer stays open.
await page.keyboard.press('Escape');
await expect(page.locator(popup)).toHaveCount(1);
});

test('nested dialog: an outside press closes only the topmost dialog (finding #1)', async ({ page }) => {
await gotoStory(page, 'primitives-dialog--nested');
await page.locator(trigger).first().click();
await expect(page.locator(popup).first()).toBeVisible();

await page.locator(popup).first().locator(trigger).first().click();
await expect(page.locator(popup)).toHaveCount(2);

// A press in the far corner lands on the (parent) backdrop. Only the topmost (inner) dialog
// dismisses — the parent, which has an open nested dialog, must NOT self-close.
await page.mouse.click(5, 5);
await expect(page.locator(popup)).toHaveCount(1);
await expect(page.locator(popup)).toBeVisible();
});

test('does not aria-hide / mark the dialog backdrop (it is an owned root)', async ({ page }) => {
await gotoStory(page, 'primitives-dialog--default');
await page.locator(trigger).first().click();
await expect(page.locator(popup)).toBeVisible();

// The backdrop is a second portal root (sibling of the popup); it registers as an owned floating
// element, so the manager's markOthers keep-set covers it (Fix #1).
await expect(page.locator(backdrop)).not.toHaveAttribute('aria-hidden', 'true');
await expect(page.locator(backdrop)).not.toHaveAttribute('data-rdx-floating-inert', '');
});

test('a modal dialog inerts outside content, not the global body pointer-events (finding #4)', async ({ page }) => {
await gotoStory(page, 'primitives-dialog--default');
await page.locator(trigger).first().click();
await expect(page.locator(popup)).toBeVisible();

// No global `body { pointer-events: none }` lock anymore — independent overlays keep working.
expect(await page.evaluate(() => document.body.style.pointerEvents)).toBe('');

// Outside content (the app root that holds the trigger) is a body sibling of the portal → it gets
// the real `inert` attribute: non-interactive AND removed from the a11y tree, scoped to it.
expect(await page.locator('#storybook-root').evaluate((el) => el.hasAttribute('inert'))).toBe(true);

// On close the isolation lifts.
await page.keyboard.press('Escape');
await expect(page.locator(popup)).toHaveCount(0);
expect(await page.locator('#storybook-root').evaluate((el) => el.hasAttribute('inert'))).toBe(false);
});

test('the backdrop is decorative — role="presentation" (Base UI parity)', async ({ page }) => {
await gotoStory(page, 'primitives-dialog--default');
await page.locator(trigger).first().click();
await expect(page.locator(popup)).toBeVisible();

await expect(page.locator(backdrop)).toHaveAttribute('role', 'presentation');
});

test('a nested dialog renders no second backdrop (only the parent dims the page)', async ({ page }) => {
await gotoStory(page, 'primitives-dialog--nested');
await page.locator(trigger).first().click();
await expect(page.locator(popup).first()).toBeVisible();
await expect(page.locator(`${backdrop}:visible`)).toHaveCount(1);

// Open the nested dialog from inside the parent.
await page.locator(popup).first().locator(trigger).first().click();
await expect(page.locator(popup)).toHaveCount(2);

// Both backdrops are in the DOM, but the nested one is `[hidden]` — exactly one stays visible.
await expect(page.locator(backdrop)).toHaveCount(2);
await expect(page.locator(`${backdrop}:visible`)).toHaveCount(1);
});
});

/**
* The "uncontained" story renders the close button outside the visible content card while keeping it
* inside the popup (and thus inside the focus trap). Guards the layout (close above the card) and that
Expand Down
34 changes: 34 additions & 0 deletions apps/visual-regression/tests/menu-submenu.behavior.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,40 @@ test('RTL: diagonal traversal toward a left-placed submenu keeps it open', async
await expect(page.locator(spellingRtlSubmenu)).toHaveCount(0);
});

test('Escape closes only the deepest submenu, keeping the parent menu open (tree deepest-first)', async ({ page }) => {
await openEditMenu(page);
await openFindSubmenu(page);

// The Find submenu is the deepest open layer. Escape (a document-level dismissal) closes only it —
// the parent Edit menu stays open because the open submenu node blocks the parent's Escape.
await page.keyboard.press('Escape');
await expect(page.locator(findSubmenu)).toHaveCount(0);
await expect(page.locator('[rdxMenuPopup]').first()).toBeVisible();

// A second Escape now closes the parent.
await page.keyboard.press('Escape');
await expect(page.locator('[rdxMenuPopup]')).toHaveCount(0);
});

test('a submenu renders no internal backdrop (only the root modal menu does, finding #1)', async ({ page }) => {
await openEditMenu(page); // root menu opened by click → modal → one internal backdrop
await expect(page.locator('[data-rdx-menu-internal-backdrop]')).toHaveCount(1);

await openFindSubmenu(page); // submenu (parent.type === 'menu') → adds no backdrop of its own
await expect(page.locator('[data-rdx-menu-internal-backdrop]')).toHaveCount(1);
});

test('an outside press closes the whole open menu chain (tree containment)', async ({ page }) => {
await openEditMenu(page);
await openFindSubmenu(page);
await expect(page.locator('[rdxMenuPopup]')).toHaveCount(2);

// A press far outside both popups closes the entire stack — the submenu is logically "inside" the
// parent via the shared floating tree, so neither survives.
await page.mouse.click(5, 5);
await expect(page.locator('[rdxMenuPopup]')).toHaveCount(0);
});

test('moving straight down to the sibling switches submenus', async ({ page }) => {
await openEditMenu(page);
await openFindSubmenu(page);
Expand Down
44 changes: 44 additions & 0 deletions apps/visual-regression/tests/menu.behavior.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,34 @@ test('menu locks page scrolling by default and releases it when closed', async (
expect(await htmlOverflow()).toBe('');
});

test('a modal menu renders an internal backdrop that blocks the background and is the outside-press target (finding #1)', async ({
page
}) => {
await gotoStory(page, 'primitives-menu--default');

// A fixed background button in the far corner that must NOT receive clicks while the modal menu is
// open — the internal backdrop has to intercept them.
await page.evaluate(() => {
const b = document.createElement('button');
b.id = 'rdx-bg-btn';
b.style.cssText = 'position:fixed; left:2px; top:2px; width:40px; height:40px';
b.addEventListener('click', () => {
(window as { __bgHits?: number }).__bgHits = ((window as { __bgHits?: number }).__bgHits || 0) + 1;
});
document.body.appendChild(b);
});

await page.locator('[rdxMenuTrigger]').first().click();
await expect(page.locator('[rdxMenuPopup]')).toBeVisible();
await expect(page.locator('[data-rdx-menu-internal-backdrop]')).toHaveCount(1);

// A press in the far corner lands on the backdrop (over the background button): the button must not
// fire (background blocked) and the menu closes (the backdrop is the outside-press target).
await page.mouse.click(10, 10);
await expect(page.locator('[rdxMenuPopup]')).toHaveCount(0);
expect(await page.evaluate(() => (window as { __bgHits?: number }).__bgHits || 0)).toBe(0);
});

test('modal menu trigger stays interactive and closes the open menu on click', async ({ page }) => {
await gotoStory(page, 'primitives-menu--default');

Expand All @@ -45,6 +73,22 @@ test('modal menu trigger stays interactive and closes the open menu on click', a
await expect(page.locator('[rdxMenuPopup]')).toHaveCount(0);
});

test('a standalone menu closes when focus leaves to an unrelated element (finding #3)', async ({ page }) => {
await gotoStory(page, 'primitives-menu--default');
await page.locator('[rdxMenuTrigger]').first().click();
await expect(page.locator('[rdxMenuPopup]')).toBeVisible();

// A standalone menu does NOT trap focus (only a context menu does) — moving focus to an unrelated
// element closes it.
await page.evaluate(() => {
const b = document.createElement('button');
b.id = 'm-outside';
document.body.appendChild(b);
b.focus();
});
await expect(page.locator('[rdxMenuPopup]')).toHaveCount(0);
});

test('animated menu keeps the popup mounted through the exit animation, then unmounts it', async ({ page }) => {
await gotoStory(page, 'primitives-menu--animated');

Expand Down
Loading
Loading