diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 9783e8283f..1a058023d3 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 25e8a6a289..3cd9a9dc3e 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index d301a6af02..19283c8432 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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}", ); diff --git a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.navigation.test.tsx b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.navigation.test.tsx index 952bf3c948..03901112d5 100644 --- a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.navigation.test.tsx +++ b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.navigation.test.tsx @@ -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(() => ( @@ -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(() => ( + + ( + + )} + /> + + )); + + 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)); + }); }); diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx index 3cc0fe8bc3..c3a890d233 100644 --- a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -7,6 +7,8 @@ import { createEffect, createMemo, createSignal, + onCleanup, + onMount, type Component, type JSX, } from 'solid-js'; @@ -39,6 +41,33 @@ export type PlatformTabSpec = { 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(props: { tabs: readonly PlatformTabSpec[]; active: TabId; @@ -46,17 +75,37 @@ export function PlatformSectionTabs(props: { }) { let tabListRef: HTMLElement | undefined; + const keepActiveTabVisible = () => { + const activeTab = tabListRef?.querySelector('[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('[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(); }); });