veza/apps/web/src/components/ui/dropdown-menu.tsx

345 lines
9.6 KiB
TypeScript

import * as React from 'react';
import { Check, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Dropdown } from './dropdown';
// Pure Kodo DropdownMenu implementation - No Radix UI dependency
// Uses the existing Dropdown component as base
/**
* DropdownMenuProps - Propriétés du composant DropdownMenu
*
* @interface DropdownMenuProps
*/
export interface DropdownMenuProps {
/**
* Si `true`, le menu est ouvert (mode contrôlé)
* Si non fourni, le menu gère son propre état (mode non-contrôlé)
*
* @example
* ```tsx
* <DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
* <DropdownMenuTrigger>Menu</DropdownMenuTrigger>
* <DropdownMenuContent>...</DropdownMenuContent>
* </DropdownMenu>
* ```
*/
open?: boolean;
/**
* Fonction appelée lorsque l'état ouvert change
*
* @param {boolean} open - Nouvel état ouvert
*/
onOpenChange?: (open: boolean) => void;
/**
* Enfants du composant (DropdownMenuTrigger et DropdownMenuContent)
*/
children: React.ReactNode;
}
/**
* DropdownMenu - Composant de menu déroulant avec design system Kodo
*
* Composant de menu déroulant pour afficher des actions ou des options.
* Implémentation pure Kodo sans dépendance Radix UI.
*
* @example
* ```tsx
* // Menu déroulant simple
* <DropdownMenu>
* <DropdownMenuTrigger>
* <Button>Options</Button>
* </DropdownMenuTrigger>
* <DropdownMenuContent>
* <DropdownMenuItem>Éditer</DropdownMenuItem>
* <DropdownMenuItem>Supprimer</DropdownMenuItem>
* </DropdownMenuContent>
* </DropdownMenu>
* ```
*
* @component
* @param {DropdownMenuProps} props - Propriétés du composant
* @returns {JSX.Element} Menu déroulant avec trigger et contenu
*/
const DropdownMenu: React.FC<DropdownMenuProps> = ({
open,
onOpenChange,
children,
}) => {
const [_internalOpen, setInternalOpen] = React.useState(false);
const isControlled = open !== undefined;
const handleOpenChange = (newOpen: boolean) => {
if (!isControlled) {
setInternalOpen(newOpen);
}
onOpenChange?.(newOpen);
};
// Extract trigger and content from children
const trigger = React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) && child.type === DropdownMenuTrigger,
);
const content = React.Children.toArray(children).find(
(child) =>
React.isValidElement(child) && child.type === DropdownMenuContent,
);
if (!trigger || !content) {
return <>{children}</>;
}
return (
<Dropdown trigger={trigger} onOpenChange={handleOpenChange}>
{React.isValidElement(content) ? content.props.children : content}
</Dropdown>
);
};
export interface DropdownMenuTriggerProps
extends React.HTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
}
const DropdownMenuTrigger = React.forwardRef<
HTMLButtonElement,
DropdownMenuTriggerProps
>(({ className, children, asChild, ...props }, ref) => {
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
ref,
className: cn(className, children.props.className),
...props,
} as any);
}
return (
<button ref={ref} className={cn('outline-none', className)} {...props}>
{children}
</button>
);
});
DropdownMenuTrigger.displayName = 'DropdownMenuTrigger';
export interface DropdownMenuContentProps
extends React.HTMLAttributes<HTMLDivElement> {
align?: 'start' | 'end' | 'center';
sideOffset?: number;
}
const DropdownMenuContent = React.forwardRef<
HTMLDivElement,
DropdownMenuContentProps
>(({ className, align = 'start', sideOffset = 4, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-kodo-steel bg-kodo-ink p-1 text-white shadow-lg',
'animate-fadeIn',
className,
)}
style={{ marginTop: `${sideOffset}px` }}
{...props}
>
{children}
</div>
);
});
DropdownMenuContent.displayName = 'DropdownMenuContent';
export interface DropdownMenuItemProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
inset?: boolean;
}
const DropdownMenuItem = React.forwardRef<
HTMLButtonElement,
DropdownMenuItemProps
>(({ className, inset, onKeyDown, onClick, ...props }, ref) => {
// CRITIQUE FIX #47: Gestion complète du clavier pour l'accessibilité
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
// Gérer Enter et Space pour activer l'item
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (onClick && !props.disabled) {
onClick(e as any);
}
}
// Gérer Escape pour fermer le menu (géré par le composant parent Dropdown)
// Les flèches sont gérées par le composant Dropdown parent
// Appeler le handler personnalisé s'il existe
if (onKeyDown) {
onKeyDown(e);
}
};
return (
<button
ref={ref}
role="menuitem"
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
'transition-colors focus:bg-white/5 focus:text-white disabled:pointer-events-none disabled:opacity-50',
'text-kodo-text-main hover:text-white w-full text-left',
inset && 'pl-8',
className,
)}
onKeyDown={handleKeyDown}
onClick={onClick}
{...props}
/>
);
});
DropdownMenuItem.displayName = 'DropdownMenuItem';
export interface DropdownMenuCheckboxItemProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
const DropdownMenuCheckboxItem = React.forwardRef<
HTMLButtonElement,
DropdownMenuCheckboxItemProps
>(({ className, children, checked, onCheckedChange, ...props }, ref) => (
<button
ref={ref}
onClick={() => onCheckedChange?.(!checked)}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none',
'transition-colors focus:bg-white/5 focus:text-white disabled:pointer-events-none disabled:opacity-50',
'text-kodo-text-main hover:text-white w-full text-left',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
{checked && <Check className="h-4 w-4 text-kodo-cyan" />}
</span>
{children}
</button>
));
DropdownMenuCheckboxItem.displayName = 'DropdownMenuCheckboxItem';
export interface DropdownMenuRadioItemProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
value: string;
checked?: boolean;
}
const DropdownMenuRadioItem = React.forwardRef<
HTMLButtonElement,
DropdownMenuRadioItemProps
>(({ className, children, checked, ...props }, ref) => (
<button
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none',
'transition-colors focus:bg-white/5 focus:text-white disabled:pointer-events-none disabled:opacity-50',
'text-kodo-text-main hover:text-white w-full text-left',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
{checked && <Circle className="h-2 w-2 fill-current text-kodo-cyan" />}
</span>
{children}
</button>
));
DropdownMenuRadioItem.displayName = 'DropdownMenuRadioItem';
export interface DropdownMenuLabelProps
extends React.HTMLAttributes<HTMLDivElement> {
inset?: boolean;
}
const DropdownMenuLabel = React.forwardRef<
HTMLDivElement,
DropdownMenuLabelProps
>(({ className, inset, ...props }, ref) => (
<div
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold text-kodo-content-dim',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = 'DropdownMenuLabel';
export interface DropdownMenuSeparatorProps
extends React.HTMLAttributes<HTMLDivElement> {}
const DropdownMenuSeparator = React.forwardRef<
HTMLDivElement,
DropdownMenuSeparatorProps
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('-mx-1 my-1 h-px bg-kodo-steel', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest opacity-60 text-kodo-content-dim',
className,
)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
// Placeholder components for compatibility (not fully implemented but exported)
const DropdownMenuGroup: React.FC<{ children: React.ReactNode }> = ({
children,
}) => <>{children}</>;
const DropdownMenuPortal: React.FC<{ children: React.ReactNode }> = ({
children,
}) => <>{children}</>;
const DropdownMenuSub: React.FC<{ children: React.ReactNode }> = ({
children,
}) => <>{children}</>;
const DropdownMenuSubContent: React.FC<{ children: React.ReactNode }> = ({
children,
}) => <>{children}</>;
const DropdownMenuSubTrigger: React.FC<{ children: React.ReactNode }> = ({
children,
}) => <>{children}</>;
const DropdownMenuRadioGroup: React.FC<{ children: React.ReactNode }> = ({
children,
}) => <>{children}</>;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};