cb3fb47561
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
85 lines
2.9 KiB
TypeScript
85 lines
2.9 KiB
TypeScript
import type { KeyboardEvent, ReactNode } from 'react';
|
|
export interface TabItem<T extends string> {
|
|
value: T;
|
|
label: string;
|
|
icon?: ReactNode;
|
|
content: ReactNode;
|
|
disabled?: boolean;
|
|
}
|
|
export function Tabs<T extends string>({
|
|
items,
|
|
value,
|
|
onValueChange,
|
|
label,
|
|
className = '',
|
|
keepMounted = true,
|
|
}: {
|
|
items: TabItem<T>[];
|
|
value: T;
|
|
onValueChange: (value: T) => void;
|
|
label: string;
|
|
className?: string;
|
|
keepMounted?: boolean;
|
|
}) {
|
|
const active =
|
|
items.find((item) => item.value === value) ?? items.find((item) => !item.disabled) ?? items[0];
|
|
const navigate = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
|
const enabled = items.filter((item) => !item.disabled);
|
|
if (!enabled.length) return;
|
|
const current = enabled.findIndex((item) => item.value === active.value);
|
|
const next =
|
|
event.key === 'Home'
|
|
? 0
|
|
: event.key === 'End'
|
|
? enabled.length - 1
|
|
: event.key === 'ArrowRight'
|
|
? (current + 1) % enabled.length
|
|
: (current - 1 + enabled.length) % enabled.length;
|
|
event.preventDefault();
|
|
const item = enabled[next];
|
|
onValueChange(item.value);
|
|
requestAnimationFrame(() => document.getElementById(`${label}-tab-${item.value}`)?.focus());
|
|
};
|
|
return (
|
|
<div className={`flex min-h-0 flex-1 flex-col ${className}`}>
|
|
<div
|
|
role="tablist"
|
|
aria-label={label}
|
|
className="cyber-tabs flex h-9 shrink-0 items-end gap-1 border-b border-border bg-surface px-2"
|
|
>
|
|
{items.map((item) => (
|
|
<button
|
|
key={item.value}
|
|
type="button"
|
|
role="tab"
|
|
id={`${label}-tab-${item.value}`}
|
|
tabIndex={item.value === active.value ? 0 : -1}
|
|
aria-selected={item.value === active.value}
|
|
aria-controls={`${label}-${item.value}`}
|
|
disabled={item.disabled}
|
|
onClick={() => onValueChange(item.value)}
|
|
onKeyDown={navigate}
|
|
className={`cyber-tab relative flex h-8 items-center gap-1.5 px-2 text-xs font-medium focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent ${item.value === active.value ? 'text-accent after:absolute after:inset-x-1 after:bottom-0 after:h-0.5 after:rounded-full after:bg-accent' : 'text-text-tertiary hover:text-text-primary'}`}
|
|
>
|
|
{item.icon}
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{(keepMounted ? items : [active]).map((item) => (
|
|
<div
|
|
key={item.value}
|
|
id={`${label}-${item.value}`}
|
|
role="tabpanel"
|
|
aria-labelledby={`${label}-tab-${item.value}`}
|
|
hidden={item.value !== active.value}
|
|
className="panel-scroll min-h-0 flex-1 overflow-auto"
|
|
>
|
|
{item.content}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|