Skip to the code
cf.

Tabs with arrow-key navigation

HardDOM

The page has a tab list. Each tab is a <button role="tab"> whose aria-controls holds the id of its panel:

<div role="tablist" aria-label="Settings">
  <button type="button" role="tab" id="tab-profile" aria-controls="panel-profile">Profile</button>
  <button type="button" role="tab" id="tab-billing" aria-controls="panel-billing">Billing</button>
  <button type="button" role="tab" id="tab-team" aria-controls="panel-team">Team</button>
</div>
<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile" tabindex="0">Your name and photo.</div>
<!-- a panel for each tab -->

Write setupTabs(tablist). Selecting a tab sets its aria-selected to "true" and the other tabs' to "false". It shows the tab's panel by removing hidden, and hides every other tab's panel with hidden.

Selecting a tab also gives it tabindex="0" and the other tabs tabindex="-1": a roving tabindex.

On setup, select the tab that already has aria-selected="true", or the first tab if none has. Clicking a tab selects it.

When a tab has focus, ArrowRight selects and focuses the next tab and ArrowLeft the previous one, wrapping around at the ends. Home goes to the first tab and End to the last. Read the key from event.key.

Call preventDefault() for those four keys so the page doesn't scroll, and leave every other key alone, Tab above all.

setupTabs(document.querySelector('[role="tablist"]'));
// Profile is selected and its panel shows
// focus Profile and press ArrowLeft: Team is selected and focused (it wraps)

What the tests check

  1. selects the first tab on setup
    const tabs = [...document.querySelectorAll('[role="tab"]')];
    const panels = ["panel-profile", "panel-billing", "panel-team"].map((id) => document.getElementById(id));
    setupTabs(document.querySelector('[role="tablist"]'));
    expect(tabs.map((tab) => tab.getAttribute("aria-selected"))).toEqual(["true", "false", "false"]);
    expect(panels.map((panel) => panel.hidden)).toEqual([false, true, true]);
    
  2. starts on a tab already marked as selected
    const tabs = [...document.querySelectorAll('[role="tab"]')];
    const panels = ["panel-profile", "panel-billing", "panel-team"].map((id) => document.getElementById(id));
    tabs[1].setAttribute("aria-selected", "true");
    setupTabs(document.querySelector('[role="tablist"]'));
    expect(tabs.map((tab) => tab.getAttribute("aria-selected"))).toEqual(["false", "true", "false"]);
    expect(panels.map((panel) => panel.hidden)).toEqual([true, false, true]);
    
  3. clicking a tab selects it and shows its panel
    const tabs = [...document.querySelectorAll('[role="tab"]')];
    const panels = ["panel-profile", "panel-billing", "panel-team"].map((id) => document.getElementById(id));
    setupTabs(document.querySelector('[role="tablist"]'));
    fire(tabs[2], "click");
    expect(tabs.map((tab) => tab.getAttribute("aria-selected"))).toEqual(["false", "false", "true"]);
    expect(panels.map((panel) => panel.hidden)).toEqual([true, true, false]);
    
  4. gives only the selected tab tabindex 0
    const tabs = [...document.querySelectorAll('[role="tab"]')];
    setupTabs(document.querySelector('[role="tablist"]'));
    expect(tabs.map((tab) => tab.getAttribute("tabindex"))).toEqual(["0", "-1", "-1"]);
    fire(tabs[2], "click");
    expect(tabs.map((tab) => tab.getAttribute("tabindex"))).toEqual(["-1", "-1", "0"]);
    
  5. ArrowRight and ArrowLeft move focus and selection, wrapping at the ends
    const tabs = [...document.querySelectorAll('[role="tab"]')];
    const press = (key) => fire(document.activeElement, "keydown", { key });
    setupTabs(document.querySelector('[role="tablist"]'));
    tabs[0].focus();
    press("ArrowRight");
    expect(document.activeElement).toBe(tabs[1]);
    expect(tabs[1].getAttribute("aria-selected")).toBe("true");
    press("ArrowRight");
    press("ArrowRight");
    expect(document.activeElement).toBe(tabs[0]);
    press("ArrowLeft");
    expect(document.activeElement).toBe(tabs[2]);
    expect(tabs.map((tab) => tab.getAttribute("aria-selected"))).toEqual(["false", "false", "true"]);
    expect(document.getElementById("panel-team").hidden).toBe(false);
    
  6. Home and End go to the first and last tab
    const tabs = [...document.querySelectorAll('[role="tab"]')];
    const press = (key) => fire(document.activeElement, "keydown", { key });
    setupTabs(document.querySelector('[role="tablist"]'));
    fire(tabs[1], "click");
    tabs[1].focus();
    press("End");
    expect(document.activeElement).toBe(tabs[2]);
    expect(tabs[2].getAttribute("aria-selected")).toBe("true");
    press("Home");
    expect(document.activeElement).toBe(tabs[0]);
    expect(tabs[0].getAttribute("aria-selected")).toBe("true");
    
  7. prevents the default only for the keys it handles
    const tabs = [...document.querySelectorAll('[role="tab"]')];
    setupTabs(document.querySelector('[role="tablist"]'));
    tabs[0].focus();
    expect(fire(tabs[0], "keydown", { key: "ArrowRight" }).defaultPrevented).toBe(true);
    expect(fire(tabs[1], "keydown", { key: "Tab" }).defaultPrevented).toBe(false);
    expect(fire(tabs[1], "keydown", { key: "a" }).defaultPrevented).toBe(false);
    expect(fire(tabs[1], "keydown", { key: "ArrowDown" }).defaultPrevented).toBe(false);
    expect(document.activeElement).toBe(tabs[1]);
    
  8. finds each panel through aria-controls
    const tabs = [...document.querySelectorAll('[role="tab"]')];
    document.body.append(document.getElementById("panel-profile"));
    setupTabs(document.querySelector('[role="tablist"]'));
    fire(tabs[1], "click");
    expect(document.getElementById("panel-billing").hidden).toBe(false);
    expect(document.getElementById("panel-profile").hidden).toBe(true);
    expect(document.getElementById("panel-team").hidden).toBe(true);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).