Fix responsive navigation and mobile table layouts

This commit is contained in:
rcourtman 2026-07-10 15:19:50 +01:00
parent 8c8affc041
commit 391d3d0be0
13 changed files with 531 additions and 219 deletions

View file

@ -1,12 +1,63 @@
import { test, expect, devices } from '@playwright/test';
import { ensureAuthenticated } from './helpers';
const getViewportWidth = async (page: import('@playwright/test').Page): Promise<number> => {
const getViewportWidth = async (
page: import('@playwright/test').Page,
): Promise<number> => {
const size = page.viewportSize();
if (size) return size.width;
return await page.evaluate(() => window.innerWidth);
};
const MOBILE_VIEWPORTS = [
{ width: 320, height: 568, label: 'compact phone' },
{ width: 390, height: 844, label: 'modern phone' },
] as const;
const CANONICAL_MOBILE_SURFACE_ROUTES = [
'/',
'/preview/setup-complete',
'/route-that-does-not-exist',
'/proxmox/overview',
'/proxmox/storage',
'/proxmox/replication',
'/proxmox/backups',
'/proxmox/ceph',
'/proxmox/mail',
'/docker/overview',
'/docker/images',
'/docker/storage',
'/docker/networks',
'/docker/swarm',
'/kubernetes/overview',
'/kubernetes/nodes',
'/kubernetes/workloads',
'/kubernetes/services',
'/kubernetes/storage',
'/kubernetes/configuration',
'/kubernetes/events',
'/truenas/overview',
'/truenas/storage',
'/truenas/services',
'/truenas/apps',
'/truenas/vms',
'/truenas/shares',
'/truenas/protection',
'/vmware/overview',
'/vmware/storage',
'/vmware/networks',
'/vmware/health',
'/vmware/activity',
'/standalone/machines',
'/standalone/availability',
'/alerts/overview',
'/alerts/thresholds',
'/alerts/notifications',
'/alerts/schedule',
'/alerts/history',
'/patrol',
] as const;
test.describe('Mobile viewport flows', () => {
test.beforeEach(async ({ page }) => {
await ensureAuthenticated(page);
@ -16,9 +67,7 @@ test.describe('Mobile viewport flows', () => {
await page.goto('/infrastructure');
await expect(page.locator('#root')).toBeVisible();
const bottomNav = page.locator(
'nav.md\\:hidden, nav[class*="md:hidden"], .md\\:hidden nav, [class*="md:hidden"] nav',
);
const bottomNav = page.getByRole('tablist', { name: 'Mobile navigation' });
// Wait for the nav to mount before evaluating (evaluateAll does not auto-wait;
// WebKit can be slower to render SolidJS components than Chromium).
@ -27,21 +76,29 @@ test.describe('Mobile viewport flows', () => {
const visibleCount = await bottomNav.evaluateAll((els) => {
const isVisible = (el: Element) => {
const style = window.getComputedStyle(el as HTMLElement);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0'
)
return false;
const rect = (el as HTMLElement).getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
return els.filter(isVisible).length;
});
expect(visibleCount, 'Expected a visible mobile bottom nav (md:hidden)').toBeGreaterThan(0);
expect(
visibleCount,
'Expected a visible mobile bottom nav',
).toBeGreaterThan(0);
});
test('MobileNavBar has safe-area padding on nav', async ({ page }) => {
await page.goto('/infrastructure');
await expect(page.locator('#root')).toBeVisible();
const nav = page.locator('nav.md\\:hidden, nav[class*="md:hidden"]').first();
const nav = page.getByRole('tablist', { name: 'Mobile navigation' });
await expect(nav).toBeVisible();
// Verify the safe-area CSS class is applied to the nav. The computed padding-bottom
@ -49,33 +106,150 @@ test.describe('Mobile viewport flows', () => {
const hasSafeClass = await nav.evaluate((el: HTMLElement) =>
el.classList.contains('pb-safe'),
);
expect(hasSafeClass, 'Expected nav to have pb-safe class for safe-area-inset-bottom').toBeTruthy();
expect(
hasSafeClass,
'Expected nav to have pb-safe class for safe-area-inset-bottom',
).toBeTruthy();
});
test('Infrastructure filter bar does not overflow horizontally', async ({ page }) => {
await page.goto('/infrastructure');
test('Platform filter bar does not overflow horizontally', async ({
page,
}) => {
await page.goto('/proxmox/overview');
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
await expect(page.getByPlaceholder('Search resources, IDs, IPs, or tags...')).toBeVisible();
await expect(
page.getByPlaceholder(
'Search VMs and LXCs by name, VMID, node, or status',
),
).toBeVisible();
// On mobile the full filter controls are hidden behind a toggle; only the
// search bar + Filters button row should be visible. Check the overall page
// body does not overflow horizontally.
const viewportWidth = await getViewportWidth(page);
const bodyScrollWidth = await page.evaluate(() => document.body.scrollWidth);
expect(bodyScrollWidth, 'Infrastructure page body must not overflow horizontally').toBeLessThanOrEqual(viewportWidth + 1);
const bodyScrollWidth = await page.evaluate(
() => document.body.scrollWidth,
);
expect(
bodyScrollWidth,
'Platform page body must not overflow horizontally',
).toBeLessThanOrEqual(viewportWidth + 1);
});
test('Infrastructure table wrapper enables horizontal overflow when needed', async ({ page }) => {
await page.goto('/infrastructure');
test('utility destinations stay pinned beside the scrollable platform rail', async ({
page,
}) => {
await page.goto('/proxmox/overview');
await expect(
page.getByPlaceholder(
'Search VMs and LXCs by name, VMID, node, or status',
),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
await expect(page.getByPlaceholder('Search resources, IDs, IPs, or tags...')).toBeVisible();
const primaryRail = page.locator('[data-mobile-nav-rail="primary"]');
const utilityRail = page.locator('[data-mobile-nav-rail="utility"]');
await expect(primaryRail).toBeVisible();
await expect(utilityRail).toBeVisible();
const tableWrapper = page.locator('div.overflow-x-auto').filter({ has: page.locator('table') }).first();
const railGeometry = await primaryRail.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
overflowX: window.getComputedStyle(element).overflowX,
}));
expect(['auto', 'scroll']).toContain(railGeometry.overflowX);
expect(railGeometry.scrollWidth).toBeGreaterThanOrEqual(
railGeometry.clientWidth,
);
const viewportWidth = await getViewportWidth(page);
for (const tabId of ['alerts', 'ai', 'settings']) {
const button = utilityRail.locator(`button[data-tab-id="${tabId}"]`);
await expect(button).toBeVisible();
const box = await button.boundingBox();
expect(
box,
`${tabId} utility destination should have a layout box`,
).toBeTruthy();
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(
viewportWidth + 1,
);
}
});
test('canonical product routes keep mobile overflow contained and dense tables readable', async ({
page,
}) => {
test.setTimeout(240_000);
for (const viewport of MOBILE_VIEWPORTS) {
await page.setViewportSize(viewport);
for (const route of CANONICAL_MOBILE_SURFACE_ROUTES) {
await page.goto(route, { waitUntil: 'domcontentloaded' });
await expect(
page.locator('#root'),
`${route} should render the app surface on a ${viewport.label}`,
).toBeVisible();
const viewportWidth = await getViewportWidth(page);
const layout = await page.evaluate(() => ({
bodyWidth: document.body.scrollWidth,
documentWidth: document.documentElement.scrollWidth,
}));
expect(
Math.max(layout.bodyWidth, layout.documentWidth),
`${route} must contain horizontal overflow inside the owning control or table shell on a ${viewport.label}`,
).toBeLessThanOrEqual(viewportWidth + 1);
const denseTables = await page
.locator('#root div.overflow-x-auto')
.filter({ has: page.locator('table') })
.evaluateAll((wrappers) =>
wrappers
.map((wrapper) => {
const table = wrapper.querySelector('table');
const headerCount =
table?.querySelectorAll('thead th').length ?? 0;
return {
headerCount,
tableWidth: table?.scrollWidth ?? 0,
wrapperWidth: wrapper.clientWidth,
};
})
.filter((table) => table.headerCount >= 4),
);
for (const table of denseTables) {
expect(
table.tableWidth,
`${route} should scroll dense tables instead of compressing their columns on a ${viewport.label}`,
).toBeGreaterThan(table.wrapperWidth);
}
}
}
});
test('Platform table wrapper enables horizontal overflow when needed', async ({
page,
}) => {
await page.goto('/proxmox/overview');
await expect(
page.getByPlaceholder(
'Search VMs and LXCs by name, VMID, node, or status',
),
).toBeVisible();
const tableWrapper = page
.locator('div.overflow-x-auto')
.filter({ has: page.locator('table') })
.first();
const wrapperVisible = await tableWrapper.isVisible().catch(() => false);
if (!wrapperVisible) {
test.skip(true, 'No infrastructure table rendered (no resources or table not present)');
test.skip(
true,
'No platform table rendered (no resources or table not present)',
);
}
const overflowBehavior = await tableWrapper.evaluate((el) => {
@ -98,32 +272,46 @@ test.describe('Mobile viewport flows', () => {
expect(overflowBehavior.tableScrollWidth).toBeGreaterThan(0);
});
test('Tapping a resource row opens the detail drawer', async ({ page }) => {
await page.goto('/infrastructure');
test('Tapping a resource disclosure opens its mobile detail state', async ({
page,
}) => {
await page.goto('/proxmox/overview');
await expect(page.getByTestId('infrastructure-page')).toBeVisible();
await expect(page.getByPlaceholder('Search resources, IDs, IPs, or tags...')).toBeVisible();
await expect(
page.getByPlaceholder(
'Search VMs and LXCs by name, VMID, node, or status',
),
).toBeVisible();
const row = page.locator('table tbody tr.cursor-pointer').first();
const rowVisible = await row.isVisible().catch(() => false);
if (!rowVisible) {
const disclosure = page
.getByRole('button', { name: /^Expand details for / })
.first();
const disclosureVisible = await disclosure.isVisible().catch(() => false);
if (!disclosureVisible) {
test.skip(true, 'No resource rows available to expand');
}
await row.click();
const expandedLabel = (
await disclosure.getAttribute('aria-label')
)?.replace(/^Expand /, 'Collapse ');
expect(expandedLabel).toBeTruthy();
await disclosure.click();
// On mobile, the drawer renders as a Portal bottom sheet (not inline in a <td>).
// Look for the Discovery tab button anywhere on the page.
const discoveryTab = page.getByText('Discovery', { exact: true }).first();
await expect(discoveryTab).toBeVisible({ timeout: 10000 });
await expect(
page.getByRole('button', { name: expandedLabel! }),
).toBeVisible();
});
test('Infrastructure landing loads without horizontal overflow at mobile viewport', async ({ page }) => {
test('Infrastructure landing loads without horizontal overflow at mobile viewport', async ({
page,
}) => {
await page.goto('/infrastructure');
await expect(page.locator('#root')).toBeVisible();
const viewportWidth = await getViewportWidth(page);
const bodyScrollWidth = await page.evaluate(() => document.body.scrollWidth);
const bodyScrollWidth = await page.evaluate(
() => document.body.scrollWidth,
);
expect(bodyScrollWidth).toBeLessThanOrEqual(viewportWidth + 1);
});
@ -131,10 +319,12 @@ test.describe('Mobile viewport flows', () => {
await page.goto('/infrastructure');
await expect(page.locator('#root')).toBeVisible();
const nav = page.locator('nav.md\\:hidden, nav[class*="md:hidden"]').first();
const nav = page.getByRole('tablist', { name: 'Mobile navigation' });
await expect(nav).toBeVisible();
const aiButton = page.getByRole('button', { name: 'Expand Pulse Assistant' });
const aiButton = page.getByRole('button', {
name: /Ask Pulse Assistant about/,
});
await expect(aiButton).toBeVisible();
const navBox = await nav.boundingBox();
@ -144,7 +334,9 @@ test.describe('Mobile viewport flows', () => {
expect(aiBox, 'Expected AI button bounding box').toBeTruthy();
const navTop = (navBox as { y: number }).y;
const aiBottom = (aiBox as { y: number; height: number }).y + (aiBox as { height: number }).height;
const aiBottom =
(aiBox as { y: number; height: number }).y +
(aiBox as { height: number }).height;
expect(aiBottom).toBeLessThanOrEqual(navTop + 1);
});
});

View file

@ -6,150 +6,181 @@ import { createAuthenticatedStorageState } from './helpers';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type WorkerFixtures = {
authStorageStatePath: string;
authStorageStatePath: string;
};
const SETTINGS_ROUTES = [
'/settings/infrastructure',
'/settings/infrastructure/pve',
'/settings/infrastructure/pbs',
'/settings/infrastructure/pmg',
'/settings/workloads',
'/settings/workloads/docker',
'/settings/organization',
'/settings/organization/access',
'/settings/organization/sharing',
'/settings/organization/billing',
'/settings/organization/billing-admin',
'/settings/integrations/api',
'/operations/diagnostics',
'/operations/reporting',
'/operations/logs',
'/settings/system-general',
'/settings/system-network',
'/settings/system-updates',
'/settings/system-recovery',
'/settings/system-ai',
'/settings/system-relay',
'/settings/system-pro',
'/settings/security-overview',
'/settings/security-auth',
'/settings/security-sso',
'/settings/security-roles',
'/settings/security-users',
'/settings/security-audit',
'/settings/security-webhooks',
'/settings/infrastructure',
'/settings/monitoring/availability',
'/settings/pulse-intelligence/provider',
'/settings/pulse-intelligence/patrol',
'/settings/pulse-intelligence/assistant',
'/settings/pulse-intelligence/discovery',
'/settings/pulse-intelligence/billing/plan',
'/settings/pulse-intelligence/billing/usage',
'/settings/organization',
'/settings/organization/access',
'/settings/organization/sharing',
'/settings/organization/billing',
'/settings/organization/billing-admin',
'/settings/system-general',
'/settings/system-network',
'/settings/system-updates',
'/settings/system-recovery',
'/settings/system-relay',
'/settings/support/diagnostics',
'/settings/support/reporting',
'/settings/support/logs',
'/settings/security/api',
'/settings/security-overview',
'/settings/security-data-handling',
'/settings/security-auth',
'/settings/security-sso',
'/settings/security-roles',
'/settings/security-users',
'/settings/security-audit',
'/settings/security-webhooks',
] as const;
const MOBILE_VIEWPORTS = [
{ width: 320, height: 568, label: 'compact phone' },
{ width: 390, height: 844, label: 'modern phone' },
] as const;
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => {
await use(authStorageStatePath);
storageState: async ({ authStorageStatePath }, use) => {
await use(authStorageStatePath);
},
authStorageStatePath: [
async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(
__dirname,
'..',
'..',
'tmp',
'playwright-auth',
`settings-mobile-audit-${workerInfo.project.name}.json`,
);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try {
await use(storageStatePath);
} finally {
fs.rmSync(storageStatePath, { force: true });
}
},
authStorageStatePath: [async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(
__dirname,
'..',
'..',
'tmp',
'playwright-auth',
`settings-mobile-audit-${workerInfo.project.name}.json`,
);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try {
await use(storageStatePath);
} finally {
fs.rmSync(storageStatePath, { force: true });
}
}, { scope: 'worker' }],
{ scope: 'worker' },
],
});
type OverflowAudit = {
viewportWidth: number;
pageWidth: number;
overflowPx: number;
offenders: Array<{ tag: string; className: string; overflow: number }>;
viewportWidth: number;
pageWidth: number;
overflowPx: number;
offenders: Array<{ tag: string; className: string; overflow: number }>;
};
const auditHorizontalOverflow = async (
page: import('@playwright/test').Page,
page: import('@playwright/test').Page,
): Promise<OverflowAudit> =>
page.evaluate(() => {
const viewportWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
const pageWidth = Math.max(
document.body.scrollWidth,
document.documentElement.scrollWidth,
document.body.offsetWidth,
document.documentElement.offsetWidth,
);
const offenders = Array.from(document.querySelectorAll('body *'))
.map((el) => {
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
const style = window.getComputedStyle(el);
if (style.position === 'fixed' || style.position === 'absolute') return null;
const overflow = rect.right - viewportWidth;
if (overflow <= 1) return null;
return {
tag: el.tagName.toLowerCase(),
className: (el.getAttribute('class') || '').trim().slice(0, 120),
overflow: Number(overflow.toFixed(1)),
};
})
.filter((entry): entry is { tag: string; className: string; overflow: number } => Boolean(entry))
.slice(0, 8);
page.evaluate(() => {
const viewportWidth = Math.max(
document.documentElement.clientWidth,
window.innerWidth || 0,
);
const pageWidth = Math.max(
document.body.scrollWidth,
document.documentElement.scrollWidth,
document.body.offsetWidth,
document.documentElement.offsetWidth,
);
const offenders = Array.from(document.querySelectorAll('body *'))
.map((el) => {
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
const style = window.getComputedStyle(el);
if (style.position === 'fixed' || style.position === 'absolute')
return null;
const overflow = rect.right - viewportWidth;
if (overflow <= 1) return null;
return {
viewportWidth,
pageWidth,
overflowPx: Number((pageWidth - viewportWidth).toFixed(1)),
offenders,
tag: el.tagName.toLowerCase(),
className: (el.getAttribute('class') || '').trim().slice(0, 120),
overflow: Number(overflow.toFixed(1)),
};
});
})
.filter(
(
entry,
): entry is { tag: string; className: string; overflow: number } =>
Boolean(entry),
)
.slice(0, 8);
const scrollToBottom = async (page: import('@playwright/test').Page): Promise<void> => {
const viewportHeight = await page.evaluate(() => window.innerHeight || 800);
const step = Math.max(240, Math.floor(viewportHeight * 0.75));
let wheelSupported = true;
for (let i = 0; i < 20; i += 1) {
if (wheelSupported) {
try {
await page.mouse.wheel(0, step);
} catch {
wheelSupported = false;
await page.evaluate((deltaY) => window.scrollBy(0, deltaY), step);
}
} else {
await page.evaluate((deltaY) => window.scrollBy(0, deltaY), step);
}
await page.waitForTimeout(60);
return {
viewportWidth,
pageWidth,
overflowPx: Number((pageWidth - viewportWidth).toFixed(1)),
offenders,
};
});
const scrollToBottom = async (
page: import('@playwright/test').Page,
): Promise<void> => {
const viewportHeight = await page.evaluate(() => window.innerHeight || 800);
const step = Math.max(240, Math.floor(viewportHeight * 0.75));
let wheelSupported = true;
for (let i = 0; i < 20; i += 1) {
if (wheelSupported) {
try {
await page.mouse.wheel(0, step);
} catch {
wheelSupported = false;
await page.evaluate((deltaY) => window.scrollBy(0, deltaY), step);
}
} else {
await page.evaluate((deltaY) => window.scrollBy(0, deltaY), step);
}
await page.waitForTimeout(60);
}
};
test.describe('Settings mobile optimization audit', () => {
test.setTimeout(180_000);
test.setTimeout(180_000);
for (const route of SETTINGS_ROUTES) {
test(`no horizontal overflow after full scroll on ${route}`, async ({ page }) => {
await page.goto(route, { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/(settings|operations)/, { timeout: 15000 });
await expect(page.locator('#root')).toBeVisible();
await page.waitForTimeout(600);
for (const route of SETTINGS_ROUTES) {
test(`no horizontal overflow after full scroll on ${route}`, async ({
page,
}) => {
for (const viewport of MOBILE_VIEWPORTS) {
await page.setViewportSize(viewport);
await page.goto(route, { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/settings/, { timeout: 15000 });
await expect(page.locator('#root')).toBeVisible();
await page.waitForTimeout(600);
await scrollToBottom(page);
const atBottom = await page.evaluate(() => {
const scrollTop = window.scrollY;
const maxScrollTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
return scrollTop >= maxScrollTop - 3;
});
expect(atBottom, `Expected to reach the bottom while auditing ${route}`).toBeTruthy();
const audit = await auditHorizontalOverflow(page);
expect(
audit.pageWidth,
`Mobile overflow on ${route} (viewport=${audit.viewportWidth}, page=${audit.pageWidth}, offenders=${JSON.stringify(audit.offenders)})`,
).toBeLessThanOrEqual(audit.viewportWidth + 1);
await scrollToBottom(page);
const atBottom = await page.evaluate(() => {
const scrollTop = window.scrollY;
const maxScrollTop = Math.max(
0,
document.documentElement.scrollHeight - window.innerHeight,
);
return scrollTop >= maxScrollTop - 3;
});
}
expect(
atBottom,
`Expected to reach the bottom while auditing ${route} on a ${viewport.label}`,
).toBeTruthy();
const audit = await auditHorizontalOverflow(page);
expect(
audit.pageWidth,
`Mobile overflow on ${route} at ${viewport.width}px (viewport=${audit.viewportWidth}, page=${audit.pageWidth}, offenders=${JSON.stringify(audit.offenders)})`,
).toBeLessThanOrEqual(audit.viewportWidth + 1);
}
});
}
});