Skip to content
Open
447 changes: 447 additions & 0 deletions docs/plans/use-combobox-architecture.md

Large diffs are not rendered by default.

123 changes: 120 additions & 3 deletions playwright/combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { test, expect, devices, type Page } from "@playwright/test";
const URL = "http://127.0.0.1:8080/component/?name=combobox&";
const variantUrl = (variant: string) =>
`http://127.0.0.1:8080/component/?name=combobox&variant=${variant}&`;
const blockVariantUrl = (variant: string) =>
`http://127.0.0.1:8080/component/block/?name=combobox&variant=${variant}&`;

const input = (page: Page) =>
page.getByRole("combobox", { name: "Select framework" });
Expand Down Expand Up @@ -230,6 +232,7 @@ test("dynamic option removal updates filtering and keyboard selection", async ({
await page.waitForLoadState('networkidle');

const trigger = page.getByRole("combobox", { name: "Dynamic framework" });
const toggleSvelte = page.getByRole("button", { name: "Toggle SvelteKit" });
await trigger.click();
await page.keyboard.type("s");

Expand All @@ -246,12 +249,13 @@ test("dynamic option removal updates filtering and keyboard selection", async ({
"true",
);

await page.getByRole("button", { name: "Toggle SvelteKit" }).click();
await expect(trigger).toBeFocused();
await toggleSvelte.click();
await expect(list(page).getByRole("option", { name: "SvelteKit" })).toHaveCount(0);
await expect(list(page).getByRole("option", { name: "SolidStart" })).toBeVisible();

await trigger.click();
await expect(content(page)).toBeVisible();
await expect(trigger).toBeFocused();

await page.keyboard.press("ArrowDown");
const next = list(page).getByRole("option", { name: "Next.js" });
await expect(next).toHaveAttribute("data-highlighted", "true");
Expand All @@ -261,6 +265,119 @@ test("dynamic option removal updates filtering and keyboard selection", async ({
await expect(trigger).toHaveValue("Next.js");
});

test("virtualized variant shows visible options when opened", async ({ page }) => {
await page.goto(blockVariantUrl("virtualized"), { timeout: 20 * 60 * 1000 });
await page.waitForLoadState('networkidle');

const trigger = page.getByRole("combobox", { name: "Virtualized option picker" });
await trigger.click();

const menu = list(page);
await expect(menu).toBeVisible();
await expect(menu.getByRole("option", { name: "Option 0", exact: true })).toBeVisible();
await expect(menu.getByRole("option", { name: "Option 1", exact: true })).toBeVisible();
});

test("virtualized variant keeps scrollHeight stable while scrolling", async ({ page }) => {
await page.goto(blockVariantUrl("virtualized"), { timeout: 20 * 60 * 1000 });
await page.waitForLoadState('networkidle');

const trigger = page.getByRole("combobox", { name: "Virtualized option picker" });
await trigger.click();

const menu = list(page);
await expect(menu).toBeVisible();
await page.waitForTimeout(500);

const initialState = await menu.evaluate((el) => ({
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
ratio: el.scrollHeight / el.clientHeight,
}));

const maxScroll = initialState.scrollHeight - initialState.clientHeight;
const steps = 20;
const stepSize = maxScroll / steps;
const measurements: Array<{
scrollTop: number;
scrollHeight: number;
clientHeight: number;
ratio: number;
}> = [];

for (let i = 1; i <= steps; i++) {
const targetScroll = Math.round(stepSize * i);

await menu.evaluate((el, scroll) => {
el.scrollTop = scroll;
}, targetScroll);
await page.waitForTimeout(100);

measurements.push(await menu.evaluate((el) => ({
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
ratio: el.scrollHeight / el.clientHeight,
})));
}

const duringScrollMeasurements = measurements.slice(0, -1);
const scrollHeights = duringScrollMeasurements.map((m) => m.scrollHeight);
const clientHeights = duringScrollMeasurements.map((m) => m.clientHeight);
const ratios = duringScrollMeasurements.map((m) => m.ratio);
const minHeight = Math.min(...scrollHeights);
const maxHeight = Math.max(...scrollHeights);
const heightVariance = maxHeight - minHeight;
const minClientHeight = Math.min(...clientHeights);
const maxClientHeight = Math.max(...clientHeights);
const clientHeightVariance = maxClientHeight - minClientHeight;
const minRatio = Math.min(...ratios);
const maxRatio = Math.max(...ratios);
const ratioVariance = maxRatio - minRatio;

expect(
heightVariance,
`combobox scrollHeight changed by ${heightVariance}px during scroll`
).toBeLessThan(100);
expect(
clientHeightVariance,
`combobox clientHeight changed by ${clientHeightVariance}px during scroll`
).toBeLessThanOrEqual(1);
expect(
ratioVariance,
`combobox scrollHeight/clientHeight ratio changed by ${ratioVariance} during scroll`
).toBeLessThan(0.5);

const lastMeasurement = measurements.at(-1);
expect(lastMeasurement).toBeDefined();

await page.waitForTimeout(650);

const settledState = await menu.evaluate((el) => ({
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
ratio: el.scrollHeight / el.clientHeight,
}));

expect(
Math.abs(settledState.scrollHeight - lastMeasurement!.scrollHeight),
"combobox scrollHeight shifted after the 600ms scroll debounce settled"
).toBeLessThan(100);
expect(
Math.abs(settledState.clientHeight - lastMeasurement!.clientHeight),
"combobox clientHeight changed after the 600ms scroll debounce settled"
).toBeLessThanOrEqual(1);
expect(
Math.abs(settledState.ratio - lastMeasurement!.ratio),
"combobox scrollHeight/clientHeight ratio shifted after the 600ms scroll debounce settled"
).toBeLessThan(0.5);
expect(
Math.abs(settledState.scrollTop - lastMeasurement!.scrollTop),
"combobox scrollTop drifted after the 600ms scroll debounce settled"
).toBeLessThanOrEqual(1);
});

test("touch selection commits and closes", async ({ browser, browserName }) => {
test.skip(browserName === "firefox", "Firefox does not support mobile contexts");

Expand Down
Loading
Loading