mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 13:04: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.
91 lines
2.8 KiB
Ruby
91 lines
2.8 KiB
Ruby
class DS::Tabs < DesignSystemComponent
|
|
renders_one :nav, ->(classes: nil) do
|
|
DS::Tabs::Nav.new(
|
|
active_tab: active_tab,
|
|
active_btn_classes: active_btn_classes,
|
|
inactive_btn_classes: inactive_btn_classes,
|
|
btn_classes: base_btn_classes,
|
|
dom_prefix: dom_prefix,
|
|
classes: unstyled? ? classes : class_names(nav_container_classes, classes)
|
|
)
|
|
end
|
|
|
|
renders_many :panels, ->(tab_id:, &block) do
|
|
content_tag(
|
|
:div,
|
|
class: ("hidden" unless tab_id == active_tab),
|
|
role: "tabpanel",
|
|
id: panel_dom_id(tab_id),
|
|
"aria-labelledby": tab_dom_id(tab_id),
|
|
tabindex: "0",
|
|
data: { id: tab_id, DS__tabs_target: "panel" },
|
|
&block
|
|
)
|
|
end
|
|
|
|
# Scope tab/panel DOM ids to this component instance so multiple
|
|
# `DS::Tabs` widgets on the same page (which often reuse generic
|
|
# tab ids like "all" or "overview") don't collide and break the
|
|
# `aria-controls` / `aria-labelledby` associations.
|
|
def tab_dom_id(tab_id)
|
|
"#{dom_prefix}-tab-#{tab_id}"
|
|
end
|
|
|
|
def panel_dom_id(tab_id)
|
|
"#{dom_prefix}-panel-#{tab_id}"
|
|
end
|
|
|
|
VARIANTS = {
|
|
default: {
|
|
# `tab-item-active` is a Sure token utility (white light / gray-700 dark).
|
|
# Swapping out the raw `bg-white theme-dark:bg-gray-700` removes the
|
|
# last raw-palette reference in DS::Tabs.
|
|
active_btn_classes: "tab-item-active text-primary shadow-sm",
|
|
inactive_btn_classes: "text-secondary hover:bg-surface-inset-hover",
|
|
base_btn_classes: "w-full inline-flex justify-center items-center text-sm font-medium px-2 py-1 rounded-md motion-safe:transition-colors motion-safe:duration-200",
|
|
nav_container_classes: "flex bg-surface-inset p-1 rounded-lg mb-4"
|
|
}
|
|
}
|
|
|
|
attr_reader :active_tab, :url_param_key, :session_key, :variant, :testid
|
|
|
|
def initialize(active_tab:, url_param_key: nil, session_key: nil, variant: :default, active_btn_classes: "", inactive_btn_classes: "", testid: nil)
|
|
@active_tab = active_tab
|
|
@url_param_key = url_param_key
|
|
@session_key = session_key
|
|
@variant = variant.to_sym
|
|
@active_btn_classes = active_btn_classes
|
|
@inactive_btn_classes = inactive_btn_classes
|
|
@testid = testid
|
|
end
|
|
|
|
def active_btn_classes
|
|
unstyled? ? @active_btn_classes : VARIANTS.dig(variant, :active_btn_classes)
|
|
end
|
|
|
|
def inactive_btn_classes
|
|
unstyled? ? @inactive_btn_classes : VARIANTS.dig(variant, :inactive_btn_classes)
|
|
end
|
|
|
|
private
|
|
def dom_prefix
|
|
@dom_prefix ||= "tabs-#{object_id}"
|
|
end
|
|
|
|
def unstyled?
|
|
variant == :unstyled
|
|
end
|
|
|
|
def base_btn_classes
|
|
unless unstyled?
|
|
VARIANTS.dig(variant, :base_btn_classes)
|
|
end
|
|
end
|
|
|
|
def nav_container_classes
|
|
unless unstyled?
|
|
VARIANTS.dig(variant, :nav_container_classes)
|
|
end
|
|
end
|
|
end
|