Merge reviewed shared subtab keyboard fix

Integrate the exact browser-verified accessibility candidate after the previously reviewed storage correction; the histories and changed paths are independent.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-02 06:50:00 +01:00
3 changed files with 120 additions and 27 deletions
+14 -26
View File
@@ -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"
]
}
@@ -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<SubtabsProps> = (props) => {
@@ -107,6 +107,36 @@ export const Subtabs: Component<SubtabsProps> = (props) => {
});
};
const focusTab = (currentTab: HTMLButtonElement, key: string) => {
const enabledTabs = Array.from(
currentTab
.closest('[role="tablist"]')
?.querySelectorAll<HTMLButtonElement>('[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 = () => (
<div class={subtabsRailClass}>
<div
@@ -126,6 +156,17 @@ export const Subtabs: Component<SubtabsProps> = (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()}
@@ -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(() => (
<Subtabs
value="overview"
onChange={onChange}
ariaLabel="Resource detail sections"
tabs={[
{ value: 'overview', label: 'Overview' },
{ value: 'performance', label: 'Performance', disabled: true },
{ value: 'history', label: 'History' },
{ value: 'manage', label: 'Manage' },
]}
/>
));
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(() => (
<Subtabs
value="overview"
onChange={onChange}
ariaLabel="Resource detail sections"
tabs={[
{ value: 'overview', label: 'Overview' },
{ value: 'history', label: 'History' },
]}
/>
));
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(() => (
<Subtabs