mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-05 05:48:21 +00:00
Keep active alert tabs visible
This commit is contained in:
parent
caaf5ed232
commit
d00249fd7d
11 changed files with 193 additions and 109 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue