mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 21:14:56 +00:00
* fix(design-system): DS::Tabs a11y — WAI-ARIA tab pattern + keyboard nav Closes #1745. DS::Tabs rendered as a bare `<nav>` + `<button>` list with no role wiring. AT users would hear "navigation, button, button, button" instead of the tab semantics. Keyboard users got no arrow-key nav between tabs. Five fixes: 1. **Role scaffolding.** `<nav>` → `role="tablist"`, `aria-orientation="horizontal"`. Each tab `<button>` → `role="tab"`, `aria-selected`, `aria-controls="panel-#{id}"`. Each panel `<div>` → `role="tabpanel"`, `id="panel-#{tab_id}"`, `aria-labelledby="#{tab_id}"`, `tabindex="0"` (so the panel itself is reachable via keyboard for in-panel content nav). 2. **Roving tabindex.** Active tab is `tabindex="0"`, inactive are `tabindex="-1"`. ArrowLeft/Right cycles focus across the tablist without leaving the widget; Tab jumps past the whole widget. Stimulus controller updates both `aria-selected` and `tabindex` on tab switch. 3. **Manual activation.** Per WAI-ARIA APG "Tabs with Manual Activation" — arrow keys MOVE focus, Enter/Space ACTIVATES the focused tab. Avoids accidental tab swaps when the user is just navigating. Important here because several tab contents trigger Turbo fetches (transactions index, account sidebar, budgets). 4. **Home/End shortcuts.** Home jumps focus to the first tab, End to the last. WAI-ARIA APG-standard. 5. **Raw palette → token.** Replace `bg-white theme-dark:bg-gray-700` on the active button with the existing `tab-item-active` utility (defined in `_generated.css` from `design/tokens/sure.tokens.json`). Single class, dual-mode. Also gate the transition behind `motion-safe:` so reduced-motion users get an instant snap. API unchanged — the slot signatures (`btns(id:, label:)`, `panels(tab_id:)`) take the same args. Caller-provided `id:` is still the public identifier; `panel-#{id}` is internal naming for the `aria-controls`/`aria-labelledby` pair. * fix(review): scope DS::Tabs DOM ids to component instance Per CodeRabbit review on #1847: raw `panel-#{tab_id}` and `id: tab_id` on buttons collide when multiple DS::Tabs widgets on the same page share generic tab ids (e.g., "all", "overview", "transactions"), breaking aria-controls / aria-labelledby associations. Scope ids via per-instance `dom_prefix` ("tabs-#{object_id}") and share the same prefix between DS::Tabs and DS::Tabs::Nav so button ids and panel labelledby/controls stay consistent. * fix(review): use <div> host for role=tablist in DS::Tabs::Nav Codex P2 follow-up on #1847: \`<nav>\` has a fixed landmark role per ARIA-in-HTML and may not be repurposed as a tablist. The current \`tag.nav class: ..., role: \"tablist\"\` produces invalid markup — some AT implementations ignore the role override, in which case the child \`role=\"tab\"\` buttons end up without a valid tablist parent and the keyboard / AT contract this PR is meant to add silently regresses. Swap the container for a neutral \`tag.div\`. Tab semantics (\`role\`, \`aria-orientation\`, keyboard nav, manual-activation pattern) are unchanged.
100 lines
3.1 KiB
JavaScript
100 lines
3.1 KiB
JavaScript
import { Controller } from "@hotwired/stimulus";
|
|
|
|
// Connects to data-controller="tabs--components"
|
|
export default class extends Controller {
|
|
static classes = ["navBtnActive", "navBtnInactive"];
|
|
static targets = ["panel", "navBtn"];
|
|
static values = { sessionKey: String, urlParamKey: String };
|
|
|
|
show(e) {
|
|
const btn = e.target.closest("button");
|
|
const selectedTabId = btn.dataset.id;
|
|
|
|
this.navBtnTargets.forEach((navBtn) => {
|
|
const isSelected = navBtn.dataset.id === selectedTabId;
|
|
if (isSelected) {
|
|
navBtn.classList.add(...this.navBtnActiveClasses);
|
|
navBtn.classList.remove(...this.navBtnInactiveClasses);
|
|
} else {
|
|
navBtn.classList.add(...this.navBtnInactiveClasses);
|
|
navBtn.classList.remove(...this.navBtnActiveClasses);
|
|
}
|
|
// Roving tabindex per WAI-ARIA APG: only the active tab is in
|
|
// the tab order. ArrowLeft/Right (see handleKeydown) moves focus
|
|
// across the tablist; Tab moves past the widget.
|
|
navBtn.setAttribute("aria-selected", isSelected.toString());
|
|
navBtn.setAttribute("tabindex", isSelected ? "0" : "-1");
|
|
});
|
|
|
|
this.panelTargets.forEach((panel) => {
|
|
if (panel.dataset.id === selectedTabId) {
|
|
panel.classList.remove("hidden");
|
|
} else {
|
|
panel.classList.add("hidden");
|
|
}
|
|
});
|
|
|
|
if (this.urlParamKeyValue) {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set(this.urlParamKeyValue, selectedTabId);
|
|
window.history.replaceState({}, "", url);
|
|
}
|
|
|
|
// Update URL with the selected tab
|
|
if (this.sessionKeyValue) {
|
|
this.#updateSessionPreference(selectedTabId);
|
|
}
|
|
}
|
|
|
|
// WAI-ARIA APG "Tabs with Manual Activation" — arrow keys move
|
|
// focus, Enter/Space activates. Prevents accidental tab swap when
|
|
// tabbing through, which is important here because some tab
|
|
// contents trigger Turbo fetches.
|
|
handleKeydown(e) {
|
|
const navBtns = this.navBtnTargets;
|
|
const currentIndex = navBtns.indexOf(e.target);
|
|
if (currentIndex === -1) return;
|
|
|
|
let nextIndex = null;
|
|
switch (e.key) {
|
|
case "ArrowRight":
|
|
nextIndex = (currentIndex + 1) % navBtns.length;
|
|
break;
|
|
case "ArrowLeft":
|
|
nextIndex = (currentIndex - 1 + navBtns.length) % navBtns.length;
|
|
break;
|
|
case "Home":
|
|
nextIndex = 0;
|
|
break;
|
|
case "End":
|
|
nextIndex = navBtns.length - 1;
|
|
break;
|
|
case "Enter":
|
|
case " ":
|
|
e.preventDefault();
|
|
this.show(e);
|
|
return;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
navBtns[nextIndex].focus();
|
|
}
|
|
|
|
#updateSessionPreference(selectedTabId) {
|
|
fetch("/current_session", {
|
|
method: "PUT",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"X-CSRF-Token": document.querySelector('[name="csrf-token"]').content,
|
|
Accept: "application/json",
|
|
},
|
|
body: new URLSearchParams({
|
|
"current_session[tab_key]": this.sessionKeyValue,
|
|
"current_session[tab_value]": selectedTabId,
|
|
}).toString(),
|
|
});
|
|
}
|
|
}
|