diff --git a/frontend/src/__tests__/app.test.ts b/frontend/src/__tests__/app.test.ts index 3de69b4e7..7620048d5 100644 --- a/frontend/src/__tests__/app.test.ts +++ b/frontend/src/__tests__/app.test.ts @@ -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', () => { diff --git a/frontend/src/__tests__/mobile-nav.test.ts b/frontend/src/__tests__/mobile-nav.test.ts index f93886278..a5405c4cf 100644 --- a/frontend/src/__tests__/mobile-nav.test.ts +++ b/frontend/src/__tests__/mobile-nav.test.ts @@ -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', '

Plans

'); + const pageHeading = document.getElementById('page-h') as HTMLElement; + const link = sidebar().querySelectorAll('.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('.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 diff --git a/frontend/src/__tests__/route-focus.test.ts b/frontend/src/__tests__/route-focus.test.ts new file mode 100644 index 000000000..63bc02894 --- /dev/null +++ b/frontend/src/__tests__/route-focus.test.ts @@ -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 = ` + + ${TABS.map(t => ` +
+

${t} page

+
+
`).join('')} + `; + const admin = document.getElementById('admin-tab')!; + admin.insertAdjacentHTML('beforeend', ` +
+ + +
+
+ `); +} + +const heading = (tab: string): HTMLElement => + document.querySelector(`#${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('#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('.settings-tabs [role="tab"]')); + expect(purchasing!.getAttribute('aria-selected')).toBe('true'); + expect(purchasing!.getAttribute('tabindex')).toBe('0'); + expect(general!.getAttribute('tabindex')).toBe('-1'); + }); +}); diff --git a/frontend/src/__tests__/sidebar-anchors.test.ts b/frontend/src/__tests__/sidebar-anchors.test.ts index 506b7c3e9..d1b2cd720 100644 --- a/frontend/src/__tests__/sidebar-anchors.test.ts +++ b/frontend/src/__tests__/sidebar-anchors.test.ts @@ -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); }, ); @@ -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(); @@ -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)', () => { diff --git a/frontend/src/__tests__/tablist.test.ts b/frontend/src/__tests__/tablist.test.ts new file mode 100644 index 000000000..d062ca862 --- /dev/null +++ b/frontend/src/__tests__/tablist.test.ts @@ -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 = ` + +
+ + + + +
+ + `; + return document.querySelector('[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('.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('[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'); + }); + }); +}); diff --git a/frontend/src/app.ts b/frontend/src/app.ts index 5795c22a3..f979dcc20 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -23,6 +23,7 @@ import { handlePurchaseDeeplink } from './purchases-deeplink'; import { handleRIExchangeDeeplink } from './riexchange-deeplink'; import { handleArcheraDeeplink, openArcheraOfferModal } from './archera'; import { closeModal } from './modal'; +import { wireTablistKeyboard } from './lib/tablist'; import { setOpenAccountsHandler } from './first-run-empty-state'; import { setupMobileNav } from './nav/mobile-nav'; import { initPurchasingNav } from './purchasing-nav'; @@ -196,11 +197,17 @@ export function setupEventListeners(): void { const tab = btn.dataset['tab']; if (tab) { e.preventDefault(); - switchTab(tab); + switchTab(tab, { focusContent: true }); } }); }); + // In-page sub-tab lists (Inventory & Coverage, Admin): arrow/Home/End + // focus movement and a roving tabindex, the keyboard model their + // role="tablist" promises (issue #730). + document.querySelectorAll('.inventory-subnav[role="tablist"], .settings-tabs[role="tablist"]') + .forEach(wireTablistKeyboard); + // Settings sub-tab switching document.querySelectorAll('.sub-tab-btn').forEach(btn => { btn.addEventListener('click', () => { diff --git a/frontend/src/index.html b/frontend/src/index.html index 8e16563d8..d2d16f45f 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -56,33 +56,33 @@

CUDly

-