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
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { expect, test } from '../fixtures/e2e-test';

const syntheticProjects = Array.from({ length: 32 }, (_, index) => ({
id: index.toString(16).padStart(24, '0'),
name: `Synthetic Project ${String(index + 1).padStart(2, '0')}`,
organization_id: 'synthetic-organization'
}));

test('project dropdown layout preserves its search and footer while listing many projects', async ({ e2eScenario, page }, testInfo) => {
await page.route(
(url) => url.pathname.includes(`/organizations/${e2eScenario.organizationId}/projects`),
async (route) => await route.fulfill({ json: syntheticProjects })
);

await page.setViewportSize({ height: 720, width: 1280 });
await page.goto('/next/stack?time=all');
await expect(page.getByRole('heading', { name: 'Stacks' })).toBeVisible();
await expect(page.getByText('No data was found with the current filter.', { exact: true })).toBeVisible();

const projectFilter = page.getByRole('button', { name: /^Project/ }).first();
await expect(projectFilter).toBeVisible();
await projectFilter.click();

const popover = page.locator('[data-slot="popover-content"]:visible').last();
const search = popover.getByRole('combobox');
const commandList = popover.locator('[data-slot="command-list"]');
const footer = popover.locator('#Project-help + div');

await expect(search).toBeVisible();
await expect(commandList).toBeVisible();
await expect(footer).toBeVisible();
const screenshotPath = testInfo.outputPath('project-dropdown.png');
await page.screenshot({ fullPage: false, path: screenshotPath });
await testInfo.attach('project-dropdown.png', { contentType: 'image/png', path: screenshotPath });

const metrics = await popover.evaluate((element) => {
const searchInput = element.querySelector('[data-slot="command-input"]');
const list = element.querySelector('[data-slot="command-list"]');
const footer = element.querySelector('[id$="-help"] + div');
const bounds = element.getBoundingClientRect();
const footerBounds = footer?.getBoundingClientRect();

return {
bottom: bounds.bottom,
footerBottom: footerBounds?.bottom,
footerVisible: footerBounds ? footerBounds.bottom <= window.innerHeight : false,
height: bounds.height,
listClientHeight: list?.clientHeight,
listScrollHeight: list?.scrollHeight,
searchBottom: searchInput?.getBoundingClientRect().bottom,
top: bounds.top,
viewportHeight: window.innerHeight
};
});

expect(metrics.top).toBeGreaterThanOrEqual(8);
expect(metrics.bottom).toBeLessThanOrEqual(metrics.viewportHeight - 8);
expect(metrics.height).toBeLessThanOrEqual(metrics.viewportHeight);
expect(metrics.footerVisible).toBe(true);
expect(metrics.listClientHeight).toBeGreaterThan(288);
expect(metrics.listClientHeight).toBeLessThanOrEqual(384);
expect(metrics.listScrollHeight).toBeGreaterThan(metrics.listClientHeight);

await search.fill('synthetic project 32');
await expect(popover.getByRole('option', { name: 'Synthetic Project 32' })).toBeVisible();
const filteredListMetrics = await commandList.evaluate((element) => ({ clientHeight: element.clientHeight, scrollHeight: element.scrollHeight }));
expect(filteredListMetrics.clientHeight).toBeLessThan(metrics.listClientHeight!);
expect(filteredListMetrics.scrollHeight).toBeLessThanOrEqual(filteredListMetrics.clientHeight);

await search.fill('');
await search.press('ArrowDown');
await search.press('Enter');
await expect(popover).toBeVisible();
await page.keyboard.press('Escape');
await expect(popover).toBeHidden();
await testInfo.attach('project-dropdown-metrics.json', {
body: JSON.stringify({ filteredListMetrics, metrics }, null, 2),
contentType: 'application/json'
});
});

test('project dropdown keeps its controls visible in mobile viewports and a 200% browser-zoom-equivalent CSS viewport', async ({ e2eScenario, page }) => {
await page.route(
(url) => url.pathname.includes(`/organizations/${e2eScenario.organizationId}/projects`),
async (route) => await route.fulfill({ json: syntheticProjects })
);

const listHeights: number[] = [];
for (const viewport of [
{ compareListHeight: true, height: 400, label: 'short mobile', width: 390 },
{ compareListHeight: true, height: 844, label: 'tall mobile', width: 390 },
{ compareListHeight: false, height: 360, label: '200% browser zoom equivalent (640x360 CSS viewport)', width: 640 }
]) {
await page.setViewportSize({ height: viewport.height, width: viewport.width });
await page.goto('/next/stack?time=all');
await expect(page.getByRole('heading', { name: 'Stacks' })).toBeVisible();
await expect(page.getByText('No data was found with the current filter.', { exact: true })).toBeVisible();

const projectFilter = page.getByRole('button', { name: /^Project/ }).first();
await expect(projectFilter).toBeVisible();
await projectFilter.click();

const popover = page.locator('[data-slot="popover-content"]:visible').last();
await expect(popover.getByRole('combobox')).toBeVisible();
await expect(popover.locator('[data-slot="command-list"]')).toBeVisible();
await expect(popover.getByRole('button', { name: 'Remove filter' })).toBeVisible();
const metrics = await popover.evaluate((element) => {
const list = element.querySelector('[data-slot="command-list"]');
const footer = element.querySelector('[id$="-help"] + div');
const bounds = element.getBoundingClientRect();
const footerBounds = footer?.getBoundingClientRect();

return {
bottom: bounds.bottom,
footerBottom: footerBounds?.bottom,
footerVisible: footerBounds ? footerBounds.bottom <= window.innerHeight : false,
listClientHeight: list?.clientHeight,
listScrollHeight: list?.scrollHeight,
top: bounds.top,
viewportHeight: window.innerHeight
};
});

expect(metrics.top, `${viewport.label} popover top`).toBeGreaterThanOrEqual(8);
expect(metrics.bottom, `${viewport.label} popover bottom`).toBeLessThanOrEqual(metrics.viewportHeight - 8);
expect(metrics.footerVisible).toBe(true);
expect(metrics.listClientHeight).toBeGreaterThan(0);
expect(metrics.listScrollHeight).toBeGreaterThan(metrics.listClientHeight);
if (viewport.compareListHeight) {
listHeights.push(metrics.listClientHeight!);
}

await page.keyboard.press('Escape');
await expect(popover).toBeHidden();
}

expect(listHeights[0]).toBeLessThan(listHeights[1]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
filterRemoved(filter);
}}
hidden={filter.hidden}
layout="tall"
{title}
{toggleHidden}
values={filter.value}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
interface Props {
changed: (values: string[]) => void;
hidden?: boolean;
layout?: 'default' | 'tall';
loading?: boolean;
noOptionsText?: string;
open: boolean;
Expand All @@ -29,6 +30,7 @@
let {
changed,
hidden = false,
layout = 'default',
loading = false,
noOptionsText = 'No results found.',
open = $bindable(),
Expand Down Expand Up @@ -112,10 +114,18 @@
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content align="start" class="p-0" side="bottom" trapFocus={false} {onEscapeKeydown} onFocusOutside={(e) => e.preventDefault()}>
<Command.Root {filter}>
<Popover.Content
align="start"
class={cn('p-0', layout === 'tall' && 'max-h-[var(--bits-popover-content-available-height)] min-h-0 [&>div:last-child]:shrink-0')}
collisionPadding={layout === 'tall' ? 8 : undefined}
side="bottom"
trapFocus={false}
{onEscapeKeydown}
onFocusOutside={(e) => e.preventDefault()}
>
<Command.Root {filter} class={layout === 'tall' ? 'min-h-0 [&>[data-slot=command-input-wrapper]]:shrink-0' : undefined}>
<Command.Input placeholder={title} autofocus={open} aria-describedby={`${title}-help`} />
<Command.List>
<Command.List class={layout === 'tall' ? 'max-h-96 min-h-0 flex-1' : undefined}>
<Command.Empty>{noOptionsText}</Command.Empty>
{#if loading}
<Command.Loading><div class="flex p-2"><Spinner /> Loading...</div></Command.Loading>
Expand Down
Loading