diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 02b6cac06..d94a956e1 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -764,7 +764,11 @@ The alerts page also owns its mobile tab-shell presentation directly. `frontend-modern/src/pages/Alerts.tsx` may keep alert-specific active and disabled tab styling, but horizontal tab scrolling must route through the shared `touch-scroll` / `scrollbar-hide` class contract instead of writing -inline overflow styles that break CSP on the public shell. +inline overflow styles that break CSP on the public shell. The selected tab +must expose canonical current-page state and compose the shared active +horizontal-rail visibility owner, so direct navigation and viewport changes +bring Thresholds, Notifications, or Schedule fully into view instead of +leaving the active destination clipped beyond the mobile rail. Alert tab routing is part of that same presentation boundary. `frontend-modern/src/features/alerts/types.ts` owns the canonical mapping between visible alert tabs and URLs. The operator-facing Notifications tab diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 5c45d7e6b..3bd0f071c 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -3698,6 +3698,13 @@ workflow tab shell, hidden-single-tab behavior, active-link styling, link 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. +The visibility behavior is a shared horizontal-rail boundary: +`horizontalRailVisibilityModel.ts` owns the bounded minimal-scroll calculation, +and `useActiveHorizontalRailItemVisibility.ts` owns route-state, resize, and +rail-resize synchronization. Platform section tabs, Alerts mobile navigation, +and future horizontally scrolling destination rails must compose that owner so +the selected destination cannot remain clipped after direct navigation or a +viewport change. 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/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 22ab36f9f..44f4a864d 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2267,6 +2267,7 @@ "frontend-modern/src/features/alerts/thresholds/hooks/__tests__/truenasThresholdPersistence.test.tsx", "frontend-modern/src/features/alerts/thresholds/hooks/__tests__/useThresholdsTableState.test.tsx", "frontend-modern/src/pages/__tests__/Alerts.helpers.test.ts", + "frontend-modern/src/pages/__tests__/Alerts.readOnly.test.tsx", "frontend-modern/src/utils/__tests__/alertOverviewPresentation.test.ts", "frontend-modern/src/utils/__tests__/alertTargetTypes.test.ts" ] diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 2f4d05d86..52339dccb 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,13 +1,26 @@ { "version": 1, - "base_sha": "1501c1c2397af56ae17f4a7ade6ac25794be4d9c", - "verified_at": "2026-08-03T18:21:01Z", + "base_sha": "caaf5ed232c293331f9c3aec4d19788bf220f18a", + "verified_at": "2026-08-03T18:33:55Z", "result": "passed", - "changed_paths": ["frontend-modern/src/components/Storage/StoragePageControls.tsx"], + "changed_paths": [ + "frontend-modern/src/components/shared/horizontalRailVisibilityModel.ts", + "frontend-modern/src/components/shared/useActiveHorizontalRailItemVisibility.ts", + "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx", + "frontend-modern/src/pages/Alerts.tsx" + ], "content_sha256": { - "frontend-modern/src/components/Storage/StoragePageControls.tsx": "ec80cff0e760d1e78caa0f9a41bba2df5144130c833c642b0d89b08bfc96989c" + "frontend-modern/src/components/shared/horizontalRailVisibilityModel.ts": "670f68a047a3e9dd8fda676e7a46819d2fd98b99ae19d493161ffb0c626c177d", + "frontend-modern/src/components/shared/useActiveHorizontalRailItemVisibility.ts": "19488bf7edf26d30c966b962254acfe588b26af86cc4d606dc32cf4d95360f54", + "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "9c6abbb54e00786351d3185e70ecf231df1f4caabf110d3a69010ed0c5584a17", + "frontend-modern/src/pages/Alerts.tsx": "0c411a83c86d2aa448fe21fb01264794832154abc45b456e2a549d336f03d703" }, - "routes": ["/proxmox/storage"], + "routes": [ + "/alerts/schedule", + "/alerts/overview", + "/alerts/notifications", + "/docker/networks" + ], "viewports": [ { "width": 1280, @@ -19,18 +32,17 @@ } ], "states": [ - "Phone-width Storage filters collapsed with no permanent Status rail", - "Phone-width Storage filters expanded with Add filter, Saved, and View aligned", - "Phone-width Warning status active as a removable FilterBar chip", - "Desktop Storage toolbar with status choices in Add filter", - "Desktop View preferences with Group by, Sort by, and sort direction" + "Phone-width Alerts Schedule route loaded directly with the active destination fully visible", + "Phone-width Alerts Overview selected after starting from the right edge of the rail", + "Phone-width Alerts Notifications selected and fully contained", + "Desktop Alerts Notifications surface with canonical sidebar navigation", + "Phone-width Docker Networks surface after the platform rail adopted the shared visibility owner" ], "interactions": [ - "Reloaded the final Storage build at 390 by 844 and confirmed the seven-state segmented rail is absent", - "Expanded Filters and confirmed all six non-default status choices remain available from Add filter", - "Selected Status: Warning and confirmed it becomes a removable chip while the filter toolbar remains contained at 390 pixels", - "Measured the phone-width document and filter toolbar and confirmed neither exceeds the 390-pixel viewport", - "Reloaded at 1280 by 720 and confirmed Add filter, Saved, and View remain aligned without the former Status rail", - "Opened View and confirmed Group by, Sort by, and sort direction remain available" + "Loaded /alerts/schedule directly at 390 by 844 and confirmed the rail moved from scrollLeft 0 to 55 so Schedule was fully visible", + "Selected Overview and confirmed the active destination changed and the rail returned to scrollLeft 0", + "Selected Notifications and confirmed its active button remained fully inside the rail bounds", + "Resized to 1280 by 720 and confirmed Alerts retained its desktop sidebar navigation with no document overflow", + "Reloaded /docker/networks at 390 by 844 and confirmed the active Networks destination remained fully visible after the shared hook extraction" ] } diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 19283c843..03415a7b9 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -45,6 +45,7 @@ import historyChartOverlaySource from '@/components/shared/HistoryChartOverlay.t import historyChartSource from '@/components/shared/HistoryChart.tsx?raw'; import historyChartModelSource from '@/components/shared/historyChartModel.ts?raw'; import historyChartTooltipSource from '@/components/shared/HistoryChartTooltip.tsx?raw'; +import horizontalRailVisibilityModelSource from '@/components/shared/horizontalRailVisibilityModel.ts?raw'; import mobileNavBarSource from '@/components/shared/MobileNavBar.tsx?raw'; import mobileNavBarModelSource from '@/components/shared/mobileNavBarModel.ts?raw'; import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw'; @@ -77,6 +78,7 @@ import tooltipSource from '@/components/shared/Tooltip.tsx?raw'; import tooltipPortalSource from '@/components/shared/TooltipPortal.tsx?raw'; import tooltipModelSource from '@/components/shared/tooltipModel.ts?raw'; import upgradeLinkSource from '@/components/shared/UpgradeLink.tsx?raw'; +import activeHorizontalRailVisibilitySource from '@/components/shared/useActiveHorizontalRailItemVisibility.ts?raw'; import contextualFocusSource from '@/components/shared/contextualFocus.ts?raw'; import summaryCardInteractionSource from '@/components/shared/summaryCardInteraction.ts?raw'; import summaryRowActionButtonSource from '@/components/shared/SummaryRowActionButton.tsx?raw'; @@ -7228,12 +7230,19 @@ describe('shared primitive guardrails', () => { ); expect(sharedPlatformPageSource).toContain('export function PlatformSectionTabs'); - expect(sharedPlatformPageSource).toContain('export function getPlatformSectionTabScrollLeft'); + expect(sharedPlatformPageSource).toContain('useActiveHorizontalRailItemVisibility({'); 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(horizontalRailVisibilityModelSource).toContain( + 'export function getHorizontalRailScrollLeft', + ); + expect(activeHorizontalRailVisibilitySource).toContain( + "window.addEventListener('resize', keepActiveItemVisible)", + ); + expect(activeHorizontalRailVisibilitySource).toContain( + 'new ResizeObserver(keepActiveItemVisible)', + ); expect(sharedPlatformPageSource).toContain( "aria-current={props.active === tab.id ? 'page' : undefined}", ); diff --git a/frontend-modern/src/components/shared/horizontalRailVisibilityModel.ts b/frontend-modern/src/components/shared/horizontalRailVisibilityModel.ts new file mode 100644 index 000000000..2df3cfcec --- /dev/null +++ b/frontend-modern/src/components/shared/horizontalRailVisibilityModel.ts @@ -0,0 +1,25 @@ +const HORIZONTAL_RAIL_EDGE_PADDING = 8; + +export interface HorizontalRailVisibilityMetrics { + scrollLeft: number; + scrollWidth: number; + clientWidth: number; + itemOffsetLeft: number; + itemOffsetWidth: number; +} + +export function getHorizontalRailScrollLeft(options: HorizontalRailVisibilityMetrics): number { + const maxScrollLeft = Math.max(0, options.scrollWidth - options.clientWidth); + const visibleStart = options.scrollLeft; + const visibleEnd = visibleStart + options.clientWidth; + const itemStart = options.itemOffsetLeft; + const itemEnd = itemStart + options.itemOffsetWidth; + + if (itemStart < visibleStart + HORIZONTAL_RAIL_EDGE_PADDING) { + return Math.max(0, itemStart - HORIZONTAL_RAIL_EDGE_PADDING); + } + if (itemEnd > visibleEnd - HORIZONTAL_RAIL_EDGE_PADDING) { + return Math.min(maxScrollLeft, itemEnd + HORIZONTAL_RAIL_EDGE_PADDING - options.clientWidth); + } + return Math.min(maxScrollLeft, Math.max(0, options.scrollLeft)); +} diff --git a/frontend-modern/src/components/shared/useActiveHorizontalRailItemVisibility.ts b/frontend-modern/src/components/shared/useActiveHorizontalRailItemVisibility.ts new file mode 100644 index 000000000..54bfa9580 --- /dev/null +++ b/frontend-modern/src/components/shared/useActiveHorizontalRailItemVisibility.ts @@ -0,0 +1,50 @@ +import { createEffect, onCleanup, onMount } from 'solid-js'; +import { getHorizontalRailScrollLeft } from './horizontalRailVisibilityModel'; + +interface ActiveHorizontalRailItemVisibilityOptions { + active: () => unknown; + rail: () => HTMLElement | undefined; + activeSelector?: string; +} + +export function useActiveHorizontalRailItemVisibility( + options: ActiveHorizontalRailItemVisibilityOptions, +): void { + const keepActiveItemVisible = () => { + const rail = options.rail(); + const activeItem = rail?.querySelector( + options.activeSelector ?? '[aria-current="page"]', + ); + if (!rail || !activeItem) return; + + rail.scrollLeft = getHorizontalRailScrollLeft({ + scrollLeft: rail.scrollLeft, + scrollWidth: rail.scrollWidth, + clientWidth: rail.clientWidth, + itemOffsetLeft: activeItem.offsetLeft, + itemOffsetWidth: activeItem.offsetWidth, + }); + }; + + createEffect(() => { + const active = options.active(); + const timeoutId = window.setTimeout(() => { + if (options.active() !== active) return; + keepActiveItemVisible(); + }); + onCleanup(() => window.clearTimeout(timeoutId)); + }); + + onMount(() => { + window.addEventListener('resize', keepActiveItemVisible); + const rail = options.rail(); + const resizeObserver = + typeof ResizeObserver === 'function' ? new ResizeObserver(keepActiveItemVisible) : undefined; + if (rail) resizeObserver?.observe(rail); + + onCleanup(() => { + window.removeEventListener('resize', keepActiveItemVisible); + resizeObserver?.disconnect(); + }); + }); +} 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 03901112d..fe1e42fac 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,8 @@ 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, getPlatformSectionTabScrollLeft } from '../sharedPlatformPage'; +import { getHorizontalRailScrollLeft } from '@/components/shared/horizontalRailVisibilityModel'; +import { PlatformSectionTabs } from '../sharedPlatformPage'; afterEach(() => { cleanup(); @@ -11,54 +12,54 @@ afterEach(() => { describe('PlatformSectionTabs', () => { it('does not recenter an active tab that is already visible', () => { expect( - getPlatformSectionTabScrollLeft({ + getHorizontalRailScrollLeft({ scrollLeft: 0, scrollWidth: 406, clientWidth: 346, - tabOffsetLeft: 135, - tabOffsetWidth: 75, + itemOffsetLeft: 135, + itemOffsetWidth: 75, }), ).toBe(0); }); it('moves only far enough to reveal a tab clipped on either edge', () => { expect( - getPlatformSectionTabScrollLeft({ + getHorizontalRailScrollLeft({ scrollLeft: 180, scrollWidth: 500, clientWidth: 200, - tabOffsetLeft: 150, - tabOffsetWidth: 70, + itemOffsetLeft: 150, + itemOffsetWidth: 70, }), ).toBe(142); expect( - getPlatformSectionTabScrollLeft({ + getHorizontalRailScrollLeft({ scrollLeft: 0, scrollWidth: 500, clientWidth: 200, - tabOffsetLeft: 350, - tabOffsetWidth: 100, + itemOffsetLeft: 350, + itemOffsetWidth: 100, }), ).toBe(258); }); it('clamps active-tab visibility scrolling to the rail bounds', () => { expect( - getPlatformSectionTabScrollLeft({ + getHorizontalRailScrollLeft({ scrollLeft: 40, scrollWidth: 500, clientWidth: 200, - tabOffsetLeft: 0, - tabOffsetWidth: 70, + itemOffsetLeft: 0, + itemOffsetWidth: 70, }), ).toBe(0); expect( - getPlatformSectionTabScrollLeft({ + getHorizontalRailScrollLeft({ scrollLeft: 250, scrollWidth: 500, clientWidth: 200, - tabOffsetLeft: 470, - tabOffsetWidth: 70, + itemOffsetLeft: 470, + itemOffsetWidth: 70, }), ).toBe(300); }); diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx index 9227725fa..6e3de669b 100644 --- a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -1,17 +1,7 @@ import { A } from '@solidjs/router'; import RotateCcwIcon from 'lucide-solid/icons/rotate-ccw'; import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert'; -import { - For, - Show, - createEffect, - createMemo, - createSignal, - onCleanup, - onMount, - type Component, - type JSX, -} from 'solid-js'; +import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js'; import { EmptyState } from '@/components/shared/EmptyState'; import { type FilterOption as PlatformTableFilterOption } from '@/components/shared/FilterButtonGroup'; import { FilterBar, filterChipStatusDot, type FilterDef } from '@/components/shared/FilterBar'; @@ -19,6 +9,7 @@ import { type SearchInputProps } from '@/components/shared/SearchInput'; import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/shared/Table'; import { TableCard } from '@/components/shared/TableCard'; import { TableCardHeader } from '@/components/shared/TableCardHeader'; +import { useActiveHorizontalRailItemVisibility } from '@/components/shared/useActiveHorizontalRailItemVisibility'; import { useBreakpoint } from '@/hooks/useBreakpoint'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { UnifiedResourceTable } from '@/components/Infrastructure/UnifiedResourceTable'; @@ -41,72 +32,15 @@ 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; ariaLabel: string; }) { 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; - const timeoutId = window.setTimeout(() => { - if (props.active !== activeTabId) return; - keepActiveTabVisible(); - }); - onCleanup(() => window.clearTimeout(timeoutId)); - }); - - 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(); - }); + useActiveHorizontalRailItemVisibility({ + active: () => props.active, + rail: () => tabListRef, }); return ( diff --git a/frontend-modern/src/pages/Alerts.tsx b/frontend-modern/src/pages/Alerts.tsx index 87856eb32..8a903d68a 100644 --- a/frontend-modern/src/pages/Alerts.tsx +++ b/frontend-modern/src/pages/Alerts.tsx @@ -7,6 +7,7 @@ import { logger } from '@/utils/logger'; import { t } from '@/i18n'; import { Card } from '@/components/shared/Card'; import { PageHeader } from '@/components/shared/PageHeader'; +import { useActiveHorizontalRailItemVisibility } from '@/components/shared/useActiveHorizontalRailItemVisibility'; import { notificationStore } from '@/stores/notifications'; import Calendar from 'lucide-solid/icons/calendar'; @@ -201,6 +202,11 @@ export function Alerts() { ); const flatTabs = createMemo(() => tabGroups().flatMap((group) => group.items)); + let mobileTabListRef: HTMLElement | undefined; + useActiveHorizontalRailItemVisibility({ + active: activeTab, + rail: () => mobileTabListRef, + }); // Sidebar always starts expanded for discoverability (consistent with Settings) // Users can collapse during session but it resets on page reload const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false); @@ -332,11 +338,18 @@ export function Alerts() { 0}>
-
+
+
diff --git a/frontend-modern/src/pages/__tests__/Alerts.readOnly.test.tsx b/frontend-modern/src/pages/__tests__/Alerts.readOnly.test.tsx index 620b8c607..7e9def8e6 100644 --- a/frontend-modern/src/pages/__tests__/Alerts.readOnly.test.tsx +++ b/frontend-modern/src/pages/__tests__/Alerts.readOnly.test.tsx @@ -123,6 +123,34 @@ describe('Alerts read-only presentation', () => { expect(alertsPageSource).not.toContain('style="-webkit-overflow-scrolling: touch;"'); }); + it('keeps the active mobile destination visible when the tab rail narrows', async () => { + locationState.pathname = '/alerts/schedule'; + + render(() => ); + + const navigation = document.querySelector('nav.touch-scroll'); + expect(navigation).not.toBeNull(); + const scheduleTab = Array.from(navigation?.querySelectorAll('button') ?? []).find( + (button) => button.textContent?.trim() === 'Schedule', + ); + expect(scheduleTab).toHaveAttribute('aria-current', 'page'); + if (!navigation || !scheduleTab) throw new Error('Expected the active Schedule mobile tab'); + + Object.defineProperties(navigation, { + clientWidth: { configurable: true, value: 336 }, + scrollWidth: { configurable: true, value: 391 }, + scrollLeft: { configurable: true, writable: true, value: 0 }, + }); + Object.defineProperties(scheduleTab, { + offsetLeft: { configurable: true, value: 315 }, + offsetWidth: { configurable: true, value: 74 }, + }); + + window.dispatchEvent(new Event('resize')); + + await waitFor(() => expect(navigation?.scrollLeft).toBe(55)); + }); + it('hides alerts management affordances in read-only sessions', async () => { presentationPolicyIsReadOnlyMock.mockReturnValue(true); activationStateMock.mockReturnValue('pending_review');