Keep active alert tabs visible

This commit is contained in:
rcourtman 2026-08-03 19:35:36 +01:00
parent caaf5ed232
commit d00249fd7d
11 changed files with 193 additions and 109 deletions

View file

@ -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

View file

@ -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

View file

@ -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"
]

View file

@ -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"
]
}

View file

@ -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}",
);

View file

@ -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));
}

View file

@ -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<HTMLElement>(
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();
});
});
}

View file

@ -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);
});

View file

@ -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<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;
ariaLabel: string;
}) {
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;
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 (

View file

@ -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() {
<Show when={flatTabs().length > 0}>
<div class="lg:hidden border-b border-border">
<div class="p-1">
<div class="flex w-full overflow-x-auto rounded-md bg-surface-hover p-0.5 touch-scroll scrollbar-hide">
<nav
ref={(element) => {
mobileTabListRef = element;
}}
aria-label={t('alerts.nav.ariaLabel')}
class="flex w-full overflow-x-auto rounded-md bg-surface-hover p-0.5 touch-scroll scrollbar-hide"
>
<For each={flatTabs()}>
{(tab) => (
<button
type="button"
aria-current={activeTab() === tab.id ? 'page' : undefined}
class={getAlertsMobileTabClass({
isActive: activeTab() === tab.id,
isDisabled: false,
@ -351,7 +364,7 @@ export function Alerts() {
</button>
)}
</For>
</div>
</nav>
</div>
</div>
</Show>

View file

@ -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(() => <Alerts />);
const navigation = document.querySelector<HTMLElement>('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');