Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions component.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"preview/src/components/aspect_ratio",
"preview/src/components/scroll_area",
"preview/src/components/date_picker",
"preview/src/components/time_picker",
"preview/src/components/textarea",
"preview/src/components/skeleton",
"preview/src/components/card",
Expand Down
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