d7fb7b7a7b
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
117 lines
3.6 KiB
TypeScript
117 lines
3.6 KiB
TypeScript
'use client';
|
|
|
|
import { classNames } from '@/lib/components/classNames';
|
|
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
import styles from './Tabs.module.css';
|
|
|
|
export interface TabItem {
|
|
id: string;
|
|
label: string;
|
|
content: ReactNode;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
interface TabsProps {
|
|
items: TabItem[];
|
|
label: string;
|
|
defaultActiveId?: string;
|
|
activation?: 'automatic' | 'manual';
|
|
className?: string;
|
|
}
|
|
|
|
export function Tabs({
|
|
items,
|
|
label,
|
|
defaultActiveId,
|
|
activation = 'automatic',
|
|
className,
|
|
}: TabsProps) {
|
|
const available = items.filter((item) => !item.disabled);
|
|
const initial =
|
|
defaultActiveId && available.some((item) => item.id === defaultActiveId)
|
|
? defaultActiveId
|
|
: (available[0]?.id ?? '');
|
|
const [activeId, setActiveId] = useState(initial);
|
|
const [focusedId, setFocusedId] = useState(initial);
|
|
const refs = useRef(new Map<string, HTMLButtonElement>());
|
|
const prefix = useId().replaceAll(':', '');
|
|
|
|
const focusByOffset = (currentId: string, offset: number) => {
|
|
const currentIndex = available.findIndex((item) => item.id === currentId);
|
|
if (currentIndex < 0 || available.length === 0) return;
|
|
const next = available[(currentIndex + offset + available.length) % available.length];
|
|
if (!next) return;
|
|
setFocusedId(next.id);
|
|
if (activation === 'automatic') setActiveId(next.id);
|
|
refs.current.get(next.id)?.focus();
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, id: string) => {
|
|
if (event.key === 'ArrowRight') {
|
|
event.preventDefault();
|
|
focusByOffset(id, document.documentElement.dir === 'rtl' ? -1 : 1);
|
|
} else if (event.key === 'ArrowLeft') {
|
|
event.preventDefault();
|
|
focusByOffset(id, document.documentElement.dir === 'rtl' ? 1 : -1);
|
|
} else if (event.key === 'Home') {
|
|
event.preventDefault();
|
|
const first = available[0];
|
|
if (first) focusByOffset(first.id, 0);
|
|
} else if (event.key === 'End') {
|
|
event.preventDefault();
|
|
const last = available.at(-1);
|
|
if (last) focusByOffset(last.id, 0);
|
|
} else if ((event.key === 'Enter' || event.key === ' ') && activation === 'manual') {
|
|
event.preventDefault();
|
|
setActiveId(id);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={classNames(styles.tabs, className)}>
|
|
<div className={styles.list} role="tablist" aria-label={label}>
|
|
{items.map((item) => {
|
|
const selected = item.id === activeId;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
ref={(node) => {
|
|
if (node) refs.current.set(item.id, node);
|
|
else refs.current.delete(item.id);
|
|
}}
|
|
type="button"
|
|
role="tab"
|
|
id={`${prefix}-tab-${item.id}`}
|
|
aria-controls={`${prefix}-panel-${item.id}`}
|
|
aria-selected={selected}
|
|
tabIndex={item.id === focusedId ? 0 : -1}
|
|
disabled={item.disabled}
|
|
className={styles.tab}
|
|
onClick={() => {
|
|
setFocusedId(item.id);
|
|
setActiveId(item.id);
|
|
}}
|
|
onKeyDown={(event) => handleKeyDown(event, item.id)}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{items.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
role="tabpanel"
|
|
id={`${prefix}-panel-${item.id}`}
|
|
aria-labelledby={`${prefix}-tab-${item.id}`}
|
|
hidden={item.id !== activeId}
|
|
tabIndex={0}
|
|
className={styles.panel}
|
|
>
|
|
{item.content}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|