Keep active platform tabs visible

This commit is contained in:
rcourtman 2026-08-03 18:19:35 +01:00
parent 9d9f13e7b0
commit 6bd382173b
5 changed files with 187 additions and 31 deletions

View file

@ -3686,8 +3686,9 @@ Platform feature surfaces must not recreate an `EmptyState` plus local Refresh
button for `Could not load...` states.
Platform section tabs are registry-backed too. `PlatformSectionTabs` owns the
workflow tab shell, hidden-single-tab behavior, active-link styling, link
targeting, and active-page aria state for platform pages; platform page
surfaces own only tab specs, the active tab choice, and aria-label copy.
targeting, active-page aria state, and minimal active-tab visibility scrolling
after route or viewport-size changes; platform page surfaces own only tab specs,
the active tab choice, and aria-label copy.
Platform feature surfaces must not rebuild local nav tab bars with
`aria-current` and border-tab styling.
Filter bars are registry-backed too. `FilterBar` owns resource-list filtering

View file

@ -1,13 +1,24 @@
{
"version": 1,
"base_sha": "4e67a93d4966ca98892ef31c0c0219959442c58b",
"verified_at": "2026-08-03T16:42:17Z",
"base_sha": "9d9f13e7b0a7298c34dadc13d58cb991379edc0f",
"verified_at": "2026-08-03T17:08:00Z",
"result": "passed",
"changed_paths": ["frontend-modern/src/features/platformPage/sharedPlatformPage.tsx"],
"content_sha256": {
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "baa823e127fd74bc8d9abd6d0c05c82d4822a182ba374927e3e04e980b02406a"
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "ca7464c040278ef3a313ac35805590e12d318ed18500bd76b145b6b2842bc273"
},
"routes": ["/kubernetes/overview", "/docker/overview"],
"routes": [
"/kubernetes/nodes",
"/kubernetes/workloads",
"/kubernetes/services",
"/kubernetes/storage",
"/kubernetes/configuration",
"/kubernetes/events",
"/docker/images",
"/docker/storage",
"/docker/networks",
"/docker/swarm"
],
"viewports": [
{
"width": 1280,
@ -19,23 +30,19 @@
}
],
"states": [
"Desktop Kubernetes overview with cluster and deployment tables plus the default workload filter rail",
"Phone-width Kubernetes overview with the filter rail collapsed and expanded",
"Phone-width Kubernetes cluster and deployment tables at their leading columns and horizontally scrolled metric columns",
"Desktop Docker overview with host and container tables plus the default container filter rail",
"Phone-width Docker overview with the filter rail collapsed and expanded",
"Phone-width Docker host table at its leading columns and horizontally scrolled memory and disk columns"
"Desktop Docker Storage with the complete section rail and Storage marked as the active page",
"Phone-width Docker Storage after a live viewport resize with Overview, Images, Storage, and Networks visible",
"Phone-width Docker Networks and Swarm with each active destination reachable in the overflowing section rail",
"Desktop and phone-width Kubernetes Nodes, Workloads, Services, Storage, Configuration, and Events section rails",
"Phone-width Kubernetes filter bodies in both collapsed and expanded states on Nodes and Workloads"
],
"interactions": [
"Measured the Kubernetes cluster table at 390 by 844 and confirmed its computed minimum width is 768 pixels, its leading name cell is 192 pixels, and full cluster names remain readable",
"Scrolled the Kubernetes cluster table horizontally and confirmed Nodes, CPU, and Memory remain reachable inside the table-owned scroll shell",
"Expanded the Kubernetes workload filter rail after table scrolling and confirmed Add filter, Saved, and the row count remain aligned without clipping",
"Confirmed the Kubernetes desktop cluster and deployment tables retain their complete column layout",
"Confirmed Docker host names and container names remain readable at phone width",
"Scrolled the Docker host table horizontally and confirmed Memory and Disk metrics remain reachable",
"Measured both Docker table wrappers and confirmed 768-pixel tables scroll inside 344-pixel wrappers while the 390-pixel document has no horizontal overflow",
"Expanded the Docker container filter rail and confirmed Add filter, Saved, View, and the container count wrap without clipping",
"Confirmed the Docker desktop host and container tables retain their complete column layout",
"Read browser warnings and errors after the final responsive pass and confirmed none were present"
"Narrowed Docker Storage from 1280 by 720 to 390 by 844 without reloading and confirmed the active Storage tab remained fully visible",
"Inspected the Docker Storage rail after resize and confirmed scrollLeft remained zero because the active tab was already within the visible bounds",
"Navigated across the Docker Images, Storage, Networks, and Swarm destinations and confirmed active-page styling and horizontal reachability",
"Navigated across the Kubernetes Nodes, Workloads, Services, Storage, Configuration, and Events destinations and confirmed the active section remained visible",
"Expanded the narrow Kubernetes Nodes and Workloads filter bodies and confirmed the tab rail and filter controls remained unclipped",
"Confirmed the 390-pixel document width did not acquire page-level horizontal overflow during the responsive passes",
"Inspected the final desktop and narrow screenshots for clipped, overlapping, or unnecessarily recentered section labels"
]
}

View file

@ -7228,9 +7228,12 @@ describe('shared primitive guardrails', () => {
);
expect(sharedPlatformPageSource).toContain('export function PlatformSectionTabs');
expect(sharedPlatformPageSource).toContain('export function getPlatformSectionTabScrollLeft');
expect(sharedPlatformPageSource).toContain('props.tabs.length > 1');
expect(sharedPlatformPageSource).toContain('href={tab.path}');
expect(sharedPlatformPageSource).toContain('border-b-2');
expect(sharedPlatformPageSource).toContain("window.addEventListener('resize'");
expect(sharedPlatformPageSource).toContain('new ResizeObserver(keepActiveTabVisible)');
expect(sharedPlatformPageSource).toContain(
"aria-current={props.active === tab.id ? 'page' : undefined}",
);

View file

@ -1,7 +1,7 @@
import { cleanup, render, screen, within } from '@solidjs/testing-library';
import { cleanup, render, screen, waitFor, within } from '@solidjs/testing-library';
import { Route, Router } from '@solidjs/router';
import { afterEach, describe, expect, it } from 'vitest';
import { PlatformSectionTabs } from '../sharedPlatformPage';
import { PlatformSectionTabs, getPlatformSectionTabScrollLeft } from '../sharedPlatformPage';
afterEach(() => {
cleanup();
@ -9,6 +9,60 @@ afterEach(() => {
});
describe('PlatformSectionTabs', () => {
it('does not recenter an active tab that is already visible', () => {
expect(
getPlatformSectionTabScrollLeft({
scrollLeft: 0,
scrollWidth: 406,
clientWidth: 346,
tabOffsetLeft: 135,
tabOffsetWidth: 75,
}),
).toBe(0);
});
it('moves only far enough to reveal a tab clipped on either edge', () => {
expect(
getPlatformSectionTabScrollLeft({
scrollLeft: 180,
scrollWidth: 500,
clientWidth: 200,
tabOffsetLeft: 150,
tabOffsetWidth: 70,
}),
).toBe(142);
expect(
getPlatformSectionTabScrollLeft({
scrollLeft: 0,
scrollWidth: 500,
clientWidth: 200,
tabOffsetLeft: 350,
tabOffsetWidth: 100,
}),
).toBe(258);
});
it('clamps active-tab visibility scrolling to the rail bounds', () => {
expect(
getPlatformSectionTabScrollLeft({
scrollLeft: 40,
scrollWidth: 500,
clientWidth: 200,
tabOffsetLeft: 0,
tabOffsetWidth: 70,
}),
).toBe(0);
expect(
getPlatformSectionTabScrollLeft({
scrollLeft: 250,
scrollWidth: 500,
clientWidth: 200,
tabOffsetLeft: 470,
tabOffsetWidth: 70,
}),
).toBe(300);
});
it('hides overview-only section navigation', () => {
render(() => (
<Router>
@ -63,4 +117,46 @@ describe('PlatformSectionTabs', () => {
expect(activeLink).toHaveClass('shrink-0');
expect(activeLink).toHaveClass('whitespace-nowrap');
});
it('keeps the active destination visible when the tab rail narrows', async () => {
render(() => (
<Router>
<Route
path="/"
component={() => (
<PlatformSectionTabs
tabs={
[
{ id: 'overview', label: 'Overview', path: '/example/overview' },
{ id: 'images', label: 'Images', path: '/example/images' },
{ id: 'storage', label: 'Storage', path: '/example/storage' },
{ id: 'networks', label: 'Networks', path: '/example/networks' },
] as const
}
active="networks"
ariaLabel="Example sections"
/>
)}
/>
</Router>
));
await new Promise((resolve) => window.setTimeout(resolve));
const navigation = screen.getByRole('navigation', { name: 'Example sections' });
const activeLink = within(navigation).getByRole('link', { name: 'Networks' });
Object.defineProperties(navigation, {
clientWidth: { configurable: true, value: 200 },
scrollWidth: { configurable: true, value: 500 },
scrollLeft: { configurable: true, writable: true, value: 0 },
});
Object.defineProperties(activeLink, {
offsetLeft: { configurable: true, value: 350 },
offsetWidth: { configurable: true, value: 100 },
});
window.dispatchEvent(new Event('resize'));
await waitFor(() => expect(navigation.scrollLeft).toBe(258));
});
});

View file

@ -7,6 +7,8 @@ import {
createEffect,
createMemo,
createSignal,
onCleanup,
onMount,
type Component,
type JSX,
} from 'solid-js';
@ -39,6 +41,33 @@ export type PlatformTabSpec<TabId extends string> = {
path: string;
};
const PLATFORM_SECTION_TAB_EDGE_PADDING = 8;
export function getPlatformSectionTabScrollLeft(options: {
scrollLeft: number;
scrollWidth: number;
clientWidth: number;
tabOffsetLeft: number;
tabOffsetWidth: number;
}): number {
const maxScrollLeft = Math.max(0, options.scrollWidth - options.clientWidth);
const visibleStart = options.scrollLeft;
const visibleEnd = visibleStart + options.clientWidth;
const tabStart = options.tabOffsetLeft;
const tabEnd = tabStart + options.tabOffsetWidth;
if (tabStart < visibleStart + PLATFORM_SECTION_TAB_EDGE_PADDING) {
return Math.max(0, tabStart - PLATFORM_SECTION_TAB_EDGE_PADDING);
}
if (tabEnd > visibleEnd - PLATFORM_SECTION_TAB_EDGE_PADDING) {
return Math.min(
maxScrollLeft,
tabEnd + PLATFORM_SECTION_TAB_EDGE_PADDING - options.clientWidth,
);
}
return Math.min(maxScrollLeft, Math.max(0, options.scrollLeft));
}
export function PlatformSectionTabs<TabId extends string>(props: {
tabs: readonly PlatformTabSpec<TabId>[];
active: TabId;
@ -46,17 +75,37 @@ export function PlatformSectionTabs<TabId extends string>(props: {
}) {
let tabListRef: HTMLElement | undefined;
const keepActiveTabVisible = () => {
const activeTab = tabListRef?.querySelector<HTMLElement>('[aria-current="page"]');
if (!tabListRef || !activeTab) return;
tabListRef.scrollLeft = getPlatformSectionTabScrollLeft({
scrollLeft: tabListRef.scrollLeft,
scrollWidth: tabListRef.scrollWidth,
clientWidth: tabListRef.clientWidth,
tabOffsetLeft: activeTab.offsetLeft,
tabOffsetWidth: activeTab.offsetWidth,
});
};
createEffect(() => {
const activeTabId = props.active;
window.setTimeout(() => {
const timeoutId = window.setTimeout(() => {
if (props.active !== activeTabId) return;
const activeTab = tabListRef?.querySelector<HTMLElement>('[aria-current="page"]');
if (!tabListRef || !activeTab) return;
keepActiveTabVisible();
});
onCleanup(() => window.clearTimeout(timeoutId));
});
tabListRef.scrollLeft = Math.max(
0,
activeTab.offsetLeft - (tabListRef.clientWidth - activeTab.offsetWidth) / 2,
);
onMount(() => {
window.addEventListener('resize', keepActiveTabVisible);
const resizeObserver =
typeof ResizeObserver === 'function' ? new ResizeObserver(keepActiveTabVisible) : undefined;
if (tabListRef) resizeObserver?.observe(tabListRef);
onCleanup(() => {
window.removeEventListener('resize', keepActiveTabVisible);
resizeObserver?.disconnect();
});
});