diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 2d7ac6f24..05555e9fc 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,45 +1,33 @@ { "version": 1, - "base_sha": "9fba43ffed507f092f876acaf23bef013f0f6fab", - "verified_at": "2026-09-02T03:39:02Z", + "base_sha": "d7a4dcf8e7c59fc35348236d8aeb3f25dc6062fe", + "verified_at": "2026-09-02T05:33:34Z", "result": "passed", - "changed_paths": ["frontend-modern/src/components/shared/Dialog.tsx"], + "changed_paths": ["frontend-modern/src/components/shared/Subtabs.tsx"], "content_sha256": { - "frontend-modern/src/components/shared/Dialog.tsx": "185f09e185a9e5ccddf73906a81552ea0cc8b07390fb5ab587fbe1b952697735" + "frontend-modern/src/components/shared/Subtabs.tsx": "b7cb9398a8c39d56cf7db32202c1b99ad6cef7ca3f98ce527ae457ac6d86bd77" }, - "routes": [ - "/settings/infrastructure", - "/actions", - "/alerts/overview", - "/settings/system-general", - "/patrol", - "/" - ], + "routes": ["/actions"], "viewports": [ { "width": 1280, - "height": 720 + "height": 800 }, { "width": 390, "height": 844 - }, - { - "width": 393, - "height": 851 } ], "states": [ - "Add infrastructure dialog open at desktop and narrow widths with reduced motion, a visible labelled heading, accessible description, focused close control, contained panel geometry, and no horizontal document overflow", - "Add infrastructure dialog dismissed at desktop and narrow widths with the underlying Infrastructure surface restored", - "authenticated Actions, Alerts, Infrastructure, General Settings, and Patrol surfaces with reduced motion and no automatically detectable WCAG A/AA violations", - "logged-out welcome surface with reduced motion and no automatically detectable WCAG A/AA violations" + "Open selected while the unselected History tab has keyboard focus at desktop and narrow widths", + "History selected after explicit Enter activation at desktop and narrow widths", + "Actions empty state after Open and History selection with reduced motion enabled" ], "interactions": [ - "opened Add infrastructure from its named trigger at desktop and narrow widths and verified the dialog accessible name and description", - "inspected final desktop and 390x844 screenshots for placement, clipping, stacking, scrolling, focus treatment, and responsive layout", - "verified the dialog bounds stay inside both viewports and the document has no horizontal overflow", - "dismissed the dialog with Escape at desktop and narrow widths and verified focus returned to Add infrastructure", - "scanned representative authenticated and logged-out surfaces for WCAG A/AA violations and unexpected reduced-motion effects" + "focused Open and used ArrowRight to move focus to History without changing selection or requesting settled actions", + "activated focused History with Enter and verified selection and content changed only after activation", + "exercised ArrowLeft and ArrowRight wrapping plus Home and End focus movement", + "inspected full-page desktop and narrow screenshots for focus visibility, placement, clipping, scrolling, and responsive layout", + "verified the document had no horizontal overflow at desktop or narrow width" ] } diff --git a/frontend-modern/src/components/shared/Subtabs.tsx b/frontend-modern/src/components/shared/Subtabs.tsx index 5edb0da7b..577956365 100644 --- a/frontend-modern/src/components/shared/Subtabs.tsx +++ b/frontend-modern/src/components/shared/Subtabs.tsx @@ -40,7 +40,7 @@ export const subtabsListClass = export const subtabsRailClass = 'relative min-w-0 flex-1'; export const subtabsTrailingRowClass = 'flex flex-wrap items-center justify-between gap-3'; export const subtabButtonClass = - 'inline-flex min-h-9 shrink-0 select-none items-center whitespace-nowrap border-b-2 px-1 py-1 text-xs font-medium transition-colors sm:min-h-10 sm:py-2 sm:text-sm'; + 'inline-flex min-h-9 shrink-0 select-none items-center whitespace-nowrap border-b-2 px-1 py-1 text-xs font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-500 sm:min-h-10 sm:py-2 sm:text-sm'; export const subtabButtonActiveClass = 'border-blue-600 text-base-content'; export const subtabButtonInactiveClass = 'border-transparent text-muted hover:text-base-content'; export const Subtabs: Component = (props) => { @@ -107,6 +107,36 @@ export const Subtabs: Component = (props) => { }); }; + const focusTab = (currentTab: HTMLButtonElement, key: string) => { + const enabledTabs = Array.from( + currentTab + .closest('[role="tablist"]') + ?.querySelectorAll('[role="tab"]:not(:disabled)') ?? [], + ); + const currentIndex = enabledTabs.indexOf(currentTab); + if (currentIndex < 0 || enabledTabs.length < 2) return; + + let targetIndex: number; + switch (key) { + case 'ArrowLeft': + targetIndex = (currentIndex - 1 + enabledTabs.length) % enabledTabs.length; + break; + case 'ArrowRight': + targetIndex = (currentIndex + 1) % enabledTabs.length; + break; + case 'Home': + targetIndex = 0; + break; + case 'End': + targetIndex = enabledTabs.length - 1; + break; + default: + return; + } + + enabledTabs[targetIndex]?.focus(); + }; + const tablist = () => (
= (props) => { tabIndex={selected() ? 0 : -1} disabled={tab.disabled} onClick={() => local.onChange(tab.value)} + onKeyDown={(event) => { + if ( + event.key === 'ArrowLeft' || + event.key === 'ArrowRight' || + event.key === 'Home' || + event.key === 'End' + ) { + event.preventDefault(); + focusTab(event.currentTarget, event.key); + } + }} class={`${subtabButtonClass} ${ selected() ? subtabButtonActiveClass : subtabButtonInactiveClass } ${local.tabClass ?? ''}`.trim()} diff --git a/frontend-modern/src/components/shared/__tests__/Subtabs.test.tsx b/frontend-modern/src/components/shared/__tests__/Subtabs.test.tsx index 85537f66d..b4e180e7f 100644 --- a/frontend-modern/src/components/shared/__tests__/Subtabs.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/Subtabs.test.tsx @@ -77,6 +77,70 @@ describe('Subtabs', () => { } }); + it('moves focus across enabled tabs with standard tab-list keys without changing selection', () => { + const onChange = vi.fn(); + render(() => ( + + )); + + const overview = screen.getByRole('tab', { name: 'Overview' }); + const history = screen.getByRole('tab', { name: 'History' }); + const manage = screen.getByRole('tab', { name: 'Manage' }); + + overview.focus(); + fireEvent.keyDown(overview, { key: 'ArrowRight' }); + expect(history).toHaveFocus(); + + fireEvent.keyDown(history, { key: 'End' }); + expect(manage).toHaveFocus(); + + fireEvent.keyDown(manage, { key: 'ArrowRight' }); + expect(overview).toHaveFocus(); + + fireEvent.keyDown(overview, { key: 'ArrowLeft' }); + expect(manage).toHaveFocus(); + + fireEvent.keyDown(manage, { key: 'Home' }); + expect(overview).toHaveFocus(); + expect(overview).toHaveAttribute('aria-selected', 'true'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('keeps keyboard-focused tabs available for manual activation', () => { + const onChange = vi.fn(); + render(() => ( + + )); + + const overview = screen.getByRole('tab', { name: 'Overview' }); + const history = screen.getByRole('tab', { name: 'History' }); + overview.focus(); + fireEvent.keyDown(overview, { key: 'ArrowRight' }); + fireEvent.click(history); + + expect(history).toHaveFocus(); + expect(onChange).toHaveBeenCalledOnce(); + expect(onChange).toHaveBeenCalledWith('history'); + }); + it('shows phone scroll affordances when the tab rail is clipped', async () => { render(() => (