Skip to content
Merged
2 changes: 1 addition & 1 deletion frontend/src/__tests__/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ describe('App Module', () => {
const dashboardBtn = document.querySelector('[data-tab="dashboard"]') as HTMLButtonElement;
dashboardBtn.click();

expect(navigation.switchTab).toHaveBeenCalledWith('dashboard');
expect(navigation.switchTab).toHaveBeenCalledWith('dashboard', { focusContent: true });
});

test('does not call switchTab if tab data attribute is missing', () => {
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/__tests__/mobile-nav.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,29 @@ describe('setupMobileNav', () => {
expect(document.body.classList.contains('sidebar-open')).toBe(false);
});

test('closing via a sidebar link keeps focus on the new page heading (#730)', () => {
document.body.insertAdjacentHTML('beforeend', '<main><h2 id="page-h" tabindex="-1">Plans</h2></main>');
const pageHeading = document.getElementById('page-h') as HTMLElement;
const link = sidebar().querySelectorAll<HTMLElement>('.tab-btn')[1]!;
// Stands in for app.ts's tab handler, registered before the drawer's:
// switchTab(..., { focusContent: true }) has moved focus by now.
link.addEventListener('click', () => pageHeading.focus());
setupMobileNav();
hamburger().click(); // open
link.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
expect(document.body.classList.contains('sidebar-open')).toBe(false);
expect(document.activeElement).toBe(pageHeading);
});

test('closing via a sidebar link rescues focus left inside the drawer', () => {
setupMobileNav();
hamburger().click(); // open
const link = sidebar().querySelector<HTMLElement>('.tab-btn')!;
link.focus();
link.dispatchEvent(new MouseEvent('click', { button: 0, bubbles: true }));
expect(document.activeElement).toBe(hamburger());
});

test('modifier-key click on sidebar link does not close the drawer', () => {
setupMobileNav();
hamburger().click(); // open
Expand Down
128 changes: 128 additions & 0 deletions frontend/src/__tests__/route-focus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Route-change focus and sidebar aria-current (issue #730).
*
* After a user-driven page change, focus moves to the new page's heading
* so a screen reader announces the page and keyboard users continue from
* its top instead of staying on the sidebar link.
*/
import { switchTab, switchSettingsSubTab, initRouter } from '../navigation';

jest.mock('../dashboard', () => ({ loadDashboard: jest.fn().mockResolvedValue(undefined) }));
jest.mock('../recommendations', () => ({ loadRecommendations: jest.fn().mockResolvedValue(undefined) }));
jest.mock('../plans', () => ({ loadPlans: jest.fn().mockResolvedValue(undefined) }));
jest.mock('../history', () => ({
initHistoryDateRange: jest.fn(),
loadHistory: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../settings', () => ({
loadGlobalSettings: jest.fn().mockResolvedValue(undefined),
isUnsavedChanges: jest.fn().mockReturnValue(false),
loadAccountsTab: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../users', () => ({ loadUsers: jest.fn() }));
jest.mock('../apikeys', () => ({ loadApiKeys: jest.fn() }));
jest.mock('../modules/savings-history', () => ({ loadSavingsHistory: jest.fn() }));
jest.mock('../riexchange', () => ({
loadRIExchange: jest.fn().mockResolvedValue(undefined),
loadAutomationSettings: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../auth', () => ({ isAdmin: jest.fn().mockReturnValue(true) }));
jest.mock('../permissions', () => ({ canAccess: jest.fn().mockReturnValue(true) }));
jest.mock('../inventory', () => ({
DEFAULT_INVENTORY_SUB_SECTION: 'active-commitments',
isValidInventorySubSection: (n: string): boolean => ['active-commitments', 'coverage', 'ri-exchange'].includes(n),
switchInventorySubSection: jest.fn((n: string) => n),
loadInventory: jest.fn(),
}));

import { canAccess } from '../permissions';

const TABS = ['home', 'opportunities', 'plans', 'purchases', 'inventory', 'admin'];

function buildDOM(): void {
document.body.innerHTML = `
<nav class="app-sidebar-nav" aria-label="Main navigation">
${TABS.map(t => `<a class="tab-btn" id="${t}-tab-btn" href="/${t}" data-tab="${t}">${t}</a>`).join('')}
</nav>
${TABS.map(t => `
<div id="${t}-tab" class="tab-content">
<h2 class="sr-only page-heading" tabindex="-1">${t} page</h2>
<section><button class="inner">Inner</button></section>
</div>`).join('')}
`;
const admin = document.getElementById('admin-tab')!;
admin.insertAdjacentHTML('beforeend', `
<div class="settings-tabs" role="tablist">
<button class="sub-tab-btn" data-settings-tab="general" role="tab" aria-selected="true" tabindex="0">General</button>
<button class="sub-tab-btn" data-settings-tab="purchasing" role="tab" aria-selected="false" tabindex="-1">Purchasing</button>
</div>
<section id="settings-section"></section>
<section id="purchasing-panel" class="hidden"></section>`);
}

const heading = (tab: string): HTMLElement =>
document.querySelector<HTMLElement>(`#${tab}-tab > .page-heading`)!;

describe('route-change focus (issue #730)', () => {
beforeEach(() => {
buildDOM();
(canAccess as jest.Mock).mockReturnValue(true);
switchTab('home', { push: false });
});

test('sidebar marks only the current page with aria-current="page"', () => {
switchTab('plans');
const current = document.querySelectorAll('.tab-btn[aria-current="page"]');
expect(current.length).toBe(1);
expect((current[0] as HTMLElement).dataset['tab']).toBe('plans');
expect(document.querySelector('.tab-btn[aria-selected]')).toBeNull();
});

test('focusContent moves focus from the sidebar link to the new page heading', () => {
const link = document.getElementById('opportunities-tab-btn')!;
link.focus();
switchTab('opportunities', { focusContent: true });
expect(document.activeElement).toBe(heading('opportunities'));
});

test('programmatic switches leave focus where it is', () => {
const inner = document.querySelector<HTMLElement>('#home-tab .inner')!;
inner.focus();
switchTab('plans');
expect(document.activeElement).toBe(inner);
});

test('re-activating the current page still moves focus to its heading', () => {
switchTab('plans', { focusContent: true });
document.getElementById('plans-tab-btn')!.focus();
switchTab('plans', { focusContent: true });
expect(document.activeElement).toBe(heading('plans'));
});

test('the no-access placeholder keeps the heading as the focus target', () => {
(canAccess as jest.Mock).mockReturnValue(false);
switchTab('purchases', { focusContent: true });
const page = document.getElementById('purchases-tab')!;
expect(page.firstElementChild).toBe(heading('purchases'));
expect(page.querySelector('.empty-state')).not.toBeNull();
expect(page.querySelector('.inner')).toBeNull();
expect(document.activeElement).toBe(heading('purchases'));
});

test('browser back/forward moves focus to the restored page heading', () => {
initRouter();
window.history.replaceState(null, '', '/plans');
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
expect(document.getElementById('plans-tab')?.classList.contains('active')).toBe(true);
expect(document.activeElement).toBe(heading('plans'));
});

test('switching Admin sub-tabs moves the roving tab stop with the selection', () => {
switchTab('admin', { push: false });
switchSettingsSubTab('purchasing');
const [general, purchasing] = Array.from(document.querySelectorAll<HTMLElement>('.settings-tabs [role="tab"]'));
expect(purchasing!.getAttribute('aria-selected')).toBe('true');
expect(purchasing!.getAttribute('tabindex')).toBe('0');
expect(general!.getAttribute('tabindex')).toBe('-1');
});
});
8 changes: 4 additions & 4 deletions frontend/src/__tests__/sidebar-anchors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,15 @@ describe('Sidebar nav - issue #462 anchor markup', () => {
});

test.each(EXPECTED_NAV)(
'$tab is rendered as anchor at $href with role=tab',
'$tab is rendered as a plain link at $href (no ARIA tab role, issue #730)',
({ tab, href }) => {
document.body.innerHTML = html;
const el = document.querySelector(`.app-sidebar-nav [data-tab="${tab}"]`);
expect(el).toBeTruthy();
expect(el?.tagName).toBe('A');
expect((el as HTMLAnchorElement).getAttribute('href')).toBe(href);
expect(el?.getAttribute('role')).toBe('tab');
expect(el?.hasAttribute('role')).toBe(false);
expect(el?.hasAttribute('aria-selected')).toBe(false);
},
);

Expand All @@ -119,7 +120,6 @@ describe('Sidebar nav click handling - issue #462', () => {
a.className = 'tab-btn';
a.setAttribute('href', '/opportunities');
a.setAttribute('data-tab', 'opportunities');
a.setAttribute('role', 'tab');
a.textContent = 'Opportunities';
document.body.appendChild(a);
setupEventListeners();
Expand All @@ -134,7 +134,7 @@ describe('Sidebar nav click handling - issue #462', () => {
});
link.dispatchEvent(evt);
expect(evt.defaultPrevented).toBe(true);
expect(switchTab).toHaveBeenCalledWith('opportunities');
expect(switchTab).toHaveBeenCalledWith('opportunities', { focusContent: true });
});

test('Ctrl-click falls through to the browser (no preventDefault, no switchTab)', () => {
Expand Down
174 changes: 174 additions & 0 deletions frontend/src/__tests__/tablist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* Sub-tab keyboard model and sidebar link semantics (issue #730).
*
* The in-page sub-tab lists keep role="tablist" and so must implement its
* keyboard model (roving tabindex, arrows, Home/End). The sidebar is page
* navigation, so it is plain links with aria-current instead of ARIA tabs.
*/
import fs from 'fs';
import path from 'path';

import { syncTabStops, wireTablistKeyboard } from '../lib/tablist';

function buildTablist(orientation?: 'vertical'): HTMLElement {
document.body.innerHTML = `
<style>.admin-only { display: none; } .admin-only.visible { display: revert; }</style>
<div role="tablist" aria-label="Sub" ${orientation ? `aria-orientation="${orientation}"` : ''}>
<button role="tab" id="t1" aria-selected="true">One</button>
<button role="tab" id="t2" aria-selected="false">Two</button>
<button role="tab" id="t3" aria-selected="false" class="admin-only">Three</button>
<button role="tab" id="t4" aria-selected="false">Four</button>
</div>
<button id="outside">Outside</button>
`;
return document.querySelector<HTMLElement>('[role="tablist"]')!;
}

const tab = (id: string): HTMLElement => document.getElementById(id)!;

function press(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
const evt = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init });
(document.activeElement ?? document.body).dispatchEvent(evt);
return evt;
}

describe('syncTabStops', () => {
test('gives the selected tab the only Tab stop', () => {
const list = buildTablist();
syncTabStops(list);
expect(['t1', 't2', 't3', 't4'].map(id => tab(id).getAttribute('tabindex'))).toEqual(['0', '-1', '-1', '-1']);

tab('t1').setAttribute('aria-selected', 'false');
tab('t4').setAttribute('aria-selected', 'true');
syncTabStops(list);
expect(['t1', 't2', 't3', 't4'].map(id => tab(id).getAttribute('tabindex'))).toEqual(['-1', '-1', '-1', '0']);
});

test('falls back to the first reachable tab when the selected one is hidden', () => {
const list = buildTablist();
tab('t1').setAttribute('aria-selected', 'false');
tab('t1').hidden = true;
tab('t3').setAttribute('aria-selected', 'true'); // admin-only, not visible
syncTabStops(list);
expect(tab('t2').getAttribute('tabindex')).toBe('0');
expect(tab('t3').getAttribute('tabindex')).toBe('-1');
});

test('is a no-op for a missing tablist', () => {
expect(() => syncTabStops(null)).not.toThrow();
});
});

describe('wireTablistKeyboard', () => {
test('ArrowRight/ArrowLeft move focus, wrap, and skip hidden tabs', () => {
wireTablistKeyboard(buildTablist());
tab('t1').focus();

expect(press('ArrowRight').defaultPrevented).toBe(true);
expect(document.activeElement).toBe(tab('t2'));
press('ArrowRight'); // t3 is admin-only and hidden
expect(document.activeElement).toBe(tab('t4'));
press('ArrowRight'); // wraps
expect(document.activeElement).toBe(tab('t1'));
press('ArrowLeft'); // wraps backwards
expect(document.activeElement).toBe(tab('t4'));
});

test('includes admin-only tabs once they are shown', () => {
wireTablistKeyboard(buildTablist());
tab('t3').classList.add('visible');
tab('t2').focus();
press('ArrowRight');
expect(document.activeElement).toBe(tab('t3'));
});

test('Home and End jump to the first and last tab', () => {
wireTablistKeyboard(buildTablist());
tab('t2').focus();
press('End');
expect(document.activeElement).toBe(tab('t4'));
press('Home');
expect(document.activeElement).toBe(tab('t1'));
});

test('arrows move focus only; selection waits for Enter/Space (manual activation)', () => {
wireTablistKeyboard(buildTablist());
const clicks = jest.fn();
tab('t2').addEventListener('click', clicks);
tab('t1').focus();
press('ArrowRight');
expect(document.activeElement).toBe(tab('t2'));
expect(clicks).not.toHaveBeenCalled();
expect(tab('t2').getAttribute('aria-selected')).toBe('false');
});

test('vertical lists use Up/Down and ignore Left/Right', () => {
wireTablistKeyboard(buildTablist('vertical'));
tab('t1').focus();
expect(press('ArrowRight').defaultPrevented).toBe(false);
expect(document.activeElement).toBe(tab('t1'));
press('ArrowDown');
expect(document.activeElement).toBe(tab('t2'));
press('ArrowUp');
expect(document.activeElement).toBe(tab('t1'));
});

test('leaves modified keys and other keys alone', () => {
wireTablistKeyboard(buildTablist());
tab('t1').focus();
expect(press('ArrowRight', { altKey: true }).defaultPrevented).toBe(false);
expect(press('ArrowRight', { shiftKey: true }).defaultPrevented).toBe(false);
expect(press('a').defaultPrevented).toBe(false);
expect(document.activeElement).toBe(tab('t1'));
});

test('ignores keys from outside the tablist', () => {
wireTablistKeyboard(buildTablist());
tab('outside').focus();
expect(press('ArrowRight').defaultPrevented).toBe(false);
expect(document.activeElement).toBe(tab('outside'));
});
});

describe('index.html tab semantics (issue #730)', () => {
let html: string;
beforeAll(() => {
html = fs.readFileSync(path.resolve(__dirname, '../index.html'), 'utf8');
});
beforeEach(() => {
document.body.innerHTML = html;
});

test('the sidebar is page navigation, not an ARIA tablist', () => {
const nav = document.querySelector('.app-sidebar-nav')!;
expect(nav.tagName).toBe('NAV');
expect(nav.hasAttribute('role')).toBe(false);
expect(nav.querySelectorAll('[role="tab"], [aria-selected], [aria-controls]').length).toBe(0);
const current = nav.querySelectorAll('[aria-current="page"]');
expect(current.length).toBe(1);
expect((current[0] as HTMLElement).dataset['tab']).toBe('home');
});

test('top-level pages are not tabpanels and each has a focusable page heading', () => {
const pages = document.querySelectorAll<HTMLElement>('.tab-content');
expect(pages.length).toBe(6);
pages.forEach(page => {
expect(page.getAttribute('role')).toBeNull();
expect(page.getAttribute('aria-labelledby')).toBeNull();
const heading = page.firstElementChild as HTMLElement;
expect(heading.matches('h2.page-heading.sr-only')).toBe(true);
expect(heading.getAttribute('tabindex')).toBe('-1');
expect(heading.textContent?.trim()).toBeTruthy();
});
});

test('every remaining tab has a roving tabindex and a tablist parent', () => {
const tabs = Array.from(document.querySelectorAll<HTMLElement>('[role="tab"]'));
// 4 Inventory sub-tabs (incl. Trends, #1317) + 4 Admin sub-tabs.
expect(tabs.length).toBe(8);
tabs.forEach(t => {
expect(t.parentElement?.getAttribute('role')).toBe('tablist');
expect(t.getAttribute('tabindex')).toBe(t.getAttribute('aria-selected') === 'true' ? '0' : '-1');
});
});
});
Loading
Loading