('.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
-