- 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
216 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
}
|