// Tablist with roving tabindex: one Tab in the page tab order, arrows move // between them, Home/End jump to the ends, and arrow direction follows `dir`. import type { JSX } from 'solid-js'; import { ariaBool, rovingTabindex, useId } from '../a11y'; import { cx } from '../cx'; import { TAB_SELECTOR, tabStopIndex } from '../roving'; import { useUi } from '../theme/context'; import styles from './Tabs.module.scss'; export interface TabItem { id: string; /** Already-translated tab label. */ label: string; panel: JSX.Element; disabled?: boolean | undefined; } export interface TabsProps { items: readonly TabItem[]; /** Selected tab id. Controlled — the caller owns the state. */ value: string; onChange: (id: string) => void; /** Already-translated accessible name for the tablist. */ label: string; orientation?: 'horizontal' | 'vertical' | undefined; class?: string | undefined; } export function Tabs(props: TabsProps): JSX.Element { const ui = useUi(); const base = useId('tabs'); let list: HTMLDivElement | undefined; // A disabled tab is excluded from BOTH answers — the list arrows walk and the one tab that // carries the tab stop — because `focus()` on a disabled button silently does nothing, so a // disabled tab left in the list pins the reducer on its index and hides every tab after it. const onKeyDown = rovingTabindex( () => (list === undefined ? [] : Array.from(list.querySelectorAll(TAB_SELECTOR))), { orientation: props.orientation ?? 'horizontal', dir: ui.dir }, ); // The selected tab holds the tab stop; a selected tab that is disabled, or a `value` matching no // tab at all, falls back to the first enabled one rather than leaving the tablist unreachable. const tabStop = (): number => tabStopIndex( props.items, props.items.findIndex((item) => item.id === props.value), ); return (
{ list = el; }} class={styles['list']} role="tablist" aria-label={props.label} aria-orientation={props.orientation ?? 'horizontal'} onKeyDown={onKeyDown} > {props.items.map((item, index) => ( ))}
{props.items.map((item) => ( ))}
); }