mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Fix shared subtab keyboard navigation
Only the selected subtab participates in the normal Tab order, so without arrow-key handling keyboard users cannot reach the other tabs. Centralizing manual focus movement in the shared control restores expected tab-list interaction across every caller while leaving activation explicit. Contract-Neutral: Accessibility bug fix restores expected keyboard behavior without changing the component API or product contract. Change-source: pulse-maintainer
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user