Skip to content

Commit f688d09

Browse files
authored
feat(core): add shared floating tree (#420)
1 parent a74bbe5 commit f688d09

140 files changed

Lines changed: 11255 additions & 4395 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/visual-regression/playwright.config.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { defineConfig, devices } from '@playwright/test';
99
* baselines can coexist — regenerate the Linux set in the official Playwright Docker image when
1010
* wiring CI.
1111
*/
12-
const PORT = 4400;
13-
const BASE_URL = `http://localhost:${PORT}`;
12+
const PORT = Number(process.env['PLAYWRIGHT_PORT'] ?? 4400);
13+
const BASE_URL = process.env['PLAYWRIGHT_BASE_URL'] ?? `http://localhost:${PORT}`;
1414

1515
export default defineConfig({
1616
testDir: './tests',
@@ -38,10 +38,12 @@ export default defineConfig({
3838
use: { ...devices['Desktop Chrome'] }
3939
}
4040
],
41-
webServer: {
42-
command: `pnpm exec http-server ../../dist/radix-storybook -p ${PORT} -s -c-1`,
43-
url: `${BASE_URL}/index.json`,
44-
reuseExistingServer: !process.env.CI,
45-
timeout: 120_000
46-
}
41+
webServer: process.env['PLAYWRIGHT_NO_WEBSERVER']
42+
? undefined
43+
: {
44+
command: `pnpm exec http-server ../../dist/radix-storybook -p ${PORT} -s -c-1`,
45+
url: `${BASE_URL}/index.json`,
46+
reuseExistingServer: !process.env.CI,
47+
timeout: 120_000
48+
}
4749
});

apps/visual-regression/tests/autocomplete.behavior.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,42 @@ const visibleItems = '[rdxAutocompleteItem]:not([hidden])';
1414
const highlighted = '[rdxAutocompleteItem][data-highlighted]';
1515
const popup = '[rdxAutocompletePopup]';
1616

17+
/**
18+
* ADR 0015/0017 Phase-4 migration of Autocomplete onto the new floating dismissal engine.
19+
*/
20+
test.describe('Autocomplete — new floating engine migration', () => {
21+
test('Escape closes the autocomplete', async ({ page }) => {
22+
await gotoStory(page, 'primitives-autocomplete--default');
23+
await page.locator(input).click();
24+
await page.locator(input).pressSequentially('f');
25+
await expect(page.locator(popup)).toBeVisible();
26+
27+
await page.keyboard.press('Escape');
28+
await expect(page.locator(popup)).toHaveCount(0);
29+
});
30+
31+
test('an outside press closes the autocomplete', async ({ page }) => {
32+
await gotoStory(page, 'primitives-autocomplete--default');
33+
await page.locator(input).click();
34+
await page.locator(input).pressSequentially('f');
35+
await expect(page.locator(popup)).toBeVisible();
36+
37+
await page.mouse.click(5, 5);
38+
await expect(page.locator(popup)).toHaveCount(0);
39+
});
40+
41+
test('a press on the input keeps the popup open (the input is registered inside)', async ({ page }) => {
42+
await gotoStory(page, 'primitives-autocomplete--default');
43+
await page.locator(input).click();
44+
await page.locator(input).pressSequentially('f');
45+
await expect(page.locator(popup)).toBeVisible();
46+
47+
// Clicking the input again must not self-dismiss — it is "inside" the floating layer.
48+
await page.locator(input).click();
49+
await expect(page.locator(popup)).toBeVisible();
50+
});
51+
});
52+
1753
test.describe('Autocomplete auto highlight', () => {
1854
test('typing a full match highlights the item so Enter selects it', async ({ page }) => {
1955
await gotoStory(page, 'primitives-autocomplete--auto-highlight');

apps/visual-regression/tests/combobox.behavior.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,32 @@ test('autocomplete teleports the positioner directly into <body> with no wrapper
3030
expect(parentTag).toBe('BODY');
3131
});
3232

33+
/**
34+
* ADR 0015/0017 Phase-4 migration of Combobox onto the new floating dismissal engine.
35+
*/
36+
test.describe('Combobox — new floating engine migration', () => {
37+
const input = '[rdxComboboxInput]';
38+
const popup = '[rdxComboboxPopup]';
39+
40+
test('Escape closes the combobox', async ({ page }) => {
41+
await gotoStory(page, 'primitives-combobox--default');
42+
await page.locator(input).click();
43+
await expect(page.locator(popup)).toBeVisible();
44+
45+
await page.keyboard.press('Escape');
46+
await expect(page.locator(popup)).toHaveCount(0);
47+
});
48+
49+
test('an outside press closes the combobox', async ({ page }) => {
50+
await gotoStory(page, 'primitives-combobox--default');
51+
await page.locator(input).click();
52+
await expect(page.locator(popup)).toBeVisible();
53+
54+
await page.mouse.click(5, 5);
55+
await expect(page.locator(popup)).toHaveCount(0);
56+
});
57+
});
58+
3359
/**
3460
* Regression: in multiple mode, once focus has stepped into the chips (ArrowLeft from the input),
3561
* ArrowDown / ArrowUp must hand focus back to the input and engage the list — otherwise the popup
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { expect, Page, test } from '@playwright/test';
2+
3+
/**
4+
* ADR 0015/0017 Phase-4 migration of Context Menu (composes `RdxMenuRoot`, so it inherits the new
5+
* floating dismissal engine) onto a real browser. Context menus open at the cursor via a virtual
6+
* anchor; these guard that opening + every dismissal path still works and throws no runtime errors.
7+
*/
8+
async function gotoStory(page: Page, storyId: string): Promise<void> {
9+
await page.goto(`/iframe.html?id=${storyId}&viewMode=story`);
10+
await page.waitForSelector('#storybook-root', { state: 'attached' });
11+
}
12+
13+
const trigger = '[rdxContextMenuTrigger]';
14+
const popup = '[rdxMenuPopup]';
15+
16+
async function openAtTrigger(page: Page): Promise<void> {
17+
await page.locator(trigger).first().click({ button: 'right' });
18+
await expect(page.locator(popup)).toBeVisible();
19+
}
20+
21+
test('right-click opens the context menu without runtime errors', async ({ page }) => {
22+
const errors: string[] = [];
23+
page.on('pageerror', (e) => errors.push(String(e)));
24+
await gotoStory(page, 'primitives-context-menu--default');
25+
26+
await openAtTrigger(page);
27+
expect(errors).toEqual([]);
28+
});
29+
30+
test('Escape closes the context menu', async ({ page }) => {
31+
await gotoStory(page, 'primitives-context-menu--default');
32+
await openAtTrigger(page);
33+
34+
await page.keyboard.press('Escape');
35+
await expect(page.locator(popup)).toHaveCount(0);
36+
});
37+
38+
test('a modal context menu renders an internal backdrop (finding #1)', async ({ page }) => {
39+
await gotoStory(page, 'primitives-context-menu--default');
40+
await openAtTrigger(page);
41+
42+
await expect(page.locator('[data-rdx-menu-internal-backdrop]')).toHaveCount(1);
43+
});
44+
45+
test('a modal context menu traps focus — a focus-out does not close it (finding #3)', async ({ page }) => {
46+
await gotoStory(page, 'primitives-context-menu--default');
47+
await openAtTrigger(page);
48+
49+
// Programmatically move focus to an element outside the menu. A context menu is the one menu kind
50+
// that TRAPS focus (Base UI `FloatingFocusManager modal`), so focus is pulled back and it stays open.
51+
await page.evaluate(() => {
52+
const b = document.createElement('button');
53+
b.id = 'cm-outside';
54+
document.body.appendChild(b);
55+
b.focus();
56+
});
57+
await page.waitForTimeout(120); // let the async focus-out check settle
58+
await expect(page.locator(popup)).toBeVisible();
59+
});
60+
61+
test('an outside press closes the context menu', async ({ page }) => {
62+
await gotoStory(page, 'primitives-context-menu--default');
63+
await openAtTrigger(page);
64+
65+
await page.mouse.click(5, 5);
66+
await expect(page.locator(popup)).toHaveCount(0);
67+
});

0 commit comments

Comments
 (0)