veza/apps/web/src/components/navigation/Tabs.tsx
senke 6974c12a25 aesthetic-improvements: align spacing to 8px grid (Action 11.2.1.3)
- Created automated script (scripts/align-8px-grid.py) to align all spacing to 8px grid
- Replaced non-8px-aligned spacing: gap-3/p-3/m-3 (12px) → gap-4/p-4/m-4 (16px), gap-5/p-5/m-5 (20px) → gap-6/p-6/m-6 (24px), gap-10/p-10/m-10 (40px) → gap-12/p-12/m-12 (48px), gap-20/p-20/m-20 (80px) → gap-24/p-24/m-24 (96px)
- Preserved: 4px values (gap-1, p-1, m-1) as they may be intentional fine-tuning, responsive breakpoints (sm:, md:, lg:), test files, documentation
- Modified files across all components to ensure consistent 8px grid alignment
- Action 11.2.1.3: Align all elements to 8px grid - COMPLETE
2026-01-16 11:50:46 +01:00

216 lines
6.5 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import { cn } from '@/lib/utils';
export interface TabItem {
id: string;
label: string;
content: React.ReactNode;
disabled?: boolean;
icon?: React.ReactNode;
}
export interface TabsProps {
items: TabItem[];
defaultActiveId?: string;
activeId?: string;
onChange?: (id: string) => void;
variant?: 'default' | 'pills' | 'underline';
className?: string;
}
/**
* Composant Tabs avec gestion de l'état actif et navigation clavier.
*/
export function Tabs({
items,
defaultActiveId,
activeId: controlledActiveId,
onChange,
variant = 'default',
className,
}: TabsProps) {
const getInitialActiveId = () => {
if (defaultActiveId) {
const defaultItem = items.find((item) => item.id === defaultActiveId);
if (defaultItem && !defaultItem.disabled) {
return defaultActiveId;
}
}
return items.find((item) => !item.disabled)?.id || items[0]?.id;
};
const [internalActiveId, setInternalActiveId] =
useState(getInitialActiveId());
const tabRefs = useRef<Record<string, HTMLButtonElement>>({});
const isControlled = controlledActiveId !== undefined;
const activeId = isControlled ? controlledActiveId : internalActiveId;
const handleTabChange = useCallback(
(id: string) => {
if (!isControlled) {
setInternalActiveId(id);
}
onChange?.(id);
},
[isControlled, onChange],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, currentIndex: number) => {
let newIndex = currentIndex;
let direction = 0;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
e.stopPropagation();
direction = -1;
newIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
break;
case 'ArrowRight':
e.preventDefault();
e.stopPropagation();
direction = 1;
newIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
break;
case 'Home':
e.preventDefault();
e.stopPropagation();
direction = -1;
newIndex = 0;
break;
case 'End':
e.preventDefault();
e.stopPropagation();
direction = 1;
newIndex = items.length - 1;
break;
default:
return;
}
// Trouver le prochain onglet non désactivé
let attempts = 0;
while (items[newIndex]?.disabled && attempts < items.length) {
if (direction < 0) {
newIndex = newIndex > 0 ? newIndex - 1 : items.length - 1;
} else {
newIndex = newIndex < items.length - 1 ? newIndex + 1 : 0;
}
attempts++;
}
if (items[newIndex] && !items[newIndex].disabled) {
handleTabChange(items[newIndex].id);
// Focus sur le nouvel onglet après un court délai pour permettre la mise à jour
setTimeout(() => {
tabRefs.current[items[newIndex].id]?.focus();
}, 0);
}
},
[items, handleTabChange],
);
// Mettre à jour l'ID actif quand les items changent
useEffect(() => {
if (!activeId || !items.find((item) => item.id === activeId)) {
const firstEnabled = items.find((item) => !item.disabled);
if (firstEnabled) {
handleTabChange(firstEnabled.id);
}
}
}, [items, activeId, handleTabChange]);
const activeTab = items.find((item) => item.id === activeId);
const variantClasses = {
default: {
list: 'border-b border-border',
tab: (isActive: boolean) =>
cn(
'px-4 py-2 text-sm font-medium transition-colors',
'border-b-2 border-transparent',
'hover:text-foreground hover:border-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isActive && 'border-primary text-foreground border-b-primary',
'disabled:pointer-events-none disabled:opacity-50',
),
},
pills: {
list: 'gap-1 bg-muted p-1 rounded-md',
tab: (isActive: boolean) =>
cn(
'px-4 py-1.5 text-sm font-medium rounded-sm transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
),
},
underline: {
list: 'border-b border-border',
tab: (isActive: boolean) =>
cn(
'px-4 py-2 text-sm font-medium transition-colors',
'relative',
'hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isActive && 'text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
isActive &&
'after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary',
),
},
};
const classes = variantClasses[variant];
return (
<div className={cn('w-full', className)}>
<div
role="tablist"
aria-orientation="horizontal"
className={cn('flex', classes.list)}
>
{items.map((item, index) => {
const isActive = activeId === item.id;
return (
<button
key={item.id}
ref={(el) => {
if (el) {
tabRefs.current[item.id] = el;
}
}}
role="tab"
aria-selected={isActive}
aria-controls={`tabpanel-${item.id}`}
aria-disabled={item.disabled}
tabIndex={isActive ? 0 : -1}
id={`tab-${item.id}`}
onClick={() => !item.disabled && handleTabChange(item.id)}
onKeyDown={(e) => !item.disabled && handleKeyDown(e, index)}
disabled={item.disabled}
className={classes.tab(isActive)}
>
<div className="flex items-center gap-2">
{item.icon && <span className="shrink-0">{item.icon}</span>}
<span>{item.label}</span>
</div>
</button>
);
})}
</div>
<div
role="tabpanel"
id={`tabpanel-${activeId}`}
aria-labelledby={`tab-${activeId}`}
className="mt-4"
>
{activeTab?.content}
</div>
</div>
);
}