geraete/components/ui/Dropdown.tsx
2025-11-24 08:59:14 +01:00

267 lines
7.9 KiB
TypeScript

// components/ui/Dropdown.tsx
'use client';
import * as React from 'react';
import {
Menu,
MenuButton,
MenuItem,
MenuItems,
Portal,
} from '@headlessui/react';
import {
ChevronDownIcon,
EllipsisVerticalIcon,
} from '@heroicons/react/20/solid';
import clsx from 'clsx';
type DropdownTone = 'default' | 'danger';
export type DropdownItem = {
id?: string;
label: string;
href?: string;
onClick?: () => void;
icon?: React.ReactNode;
tone?: DropdownTone;
disabled?: boolean;
};
export type DropdownSection = {
id?: string;
label?: string;
items: DropdownItem[];
};
export type DropdownTriggerVariant = 'button' | 'icon';
export interface DropdownProps {
label?: string;
ariaLabel?: string;
align?: 'left' | 'right';
triggerVariant?: DropdownTriggerVariant;
header?: React.ReactNode;
sections: DropdownSection[];
triggerClassName?: string;
menuClassName?: string;
/** Dropdown komplett deaktivieren (Trigger klickt nicht) */
disabled?: boolean;
}
/* ───────── interne Helfer ───────── */
const itemBaseClasses =
'block px-4 py-2 text-sm ' +
// Default Text
'text-gray-700 dark:text-gray-300 ' +
// Hover (Maus)
'hover:bg-gray-100 hover:text-gray-900 ' +
'dark:hover:bg-white/5 dark:hover:text-white ' +
// Focus Outline weglassen
'focus:outline-none';
const itemWithIconClasses =
'group flex items-center gap-x-3 px-4 py-2 text-sm ' +
// Default Text
'text-gray-700 dark:text-gray-300 ' +
// Hover
'hover:bg-gray-100 hover:text-gray-900 ' +
'dark:hover:bg-white/5 dark:hover:text-white ' +
'focus:outline-none';
const iconClasses =
'flex size-5 shrink-0 items-center justify-center text-gray-400 ' +
'group-hover:text-gray-500 ' +
'dark:text-gray-500 dark:group-hover:text-white';
const toneClasses: Record<DropdownTone, string> = {
default: '',
danger:
'text-rose-600 data-focus:text-rose-700 dark:text-rose-400 dark:data-focus:text-rose-300',
};
function renderItemContent(item: DropdownItem) {
const hasIcon = !!item.icon;
if (hasIcon) {
return (
<span
className={clsx(
itemWithIconClasses,
item.tone && toneClasses[item.tone],
)}
>
<span aria-hidden="true" className={iconClasses}>
{item.icon}
</span>
{item.label}
</span>
);
}
return (
<span
className={clsx(
itemBaseClasses,
item.tone && toneClasses[item.tone],
)}
>
{item.label}
</span>
);
}
/* ───────── Dropdown-Komponente (mit Portal) ───────── */
export function Dropdown({
label = 'Options',
ariaLabel = 'Open options',
align = 'right',
triggerVariant = 'button',
header,
sections,
triggerClassName,
menuClassName,
disabled = false,
}: DropdownProps) {
const hasDividers = sections.length > 1;
const triggerIsButton = triggerVariant === 'button';
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
const [position, setPosition] = React.useState<{
top: number;
left: number;
} | null>(null);
return (
<Menu as="div" className="relative inline-block text-left">
{({ open }) => {
React.useEffect(() => {
if (!open || !buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
const scrollX =
window.pageXOffset || document.documentElement.scrollLeft;
const scrollY =
window.pageYOffset || document.documentElement.scrollTop;
const left =
align === 'left' ? rect.left + scrollX : rect.right + scrollX;
setPosition({
top: rect.bottom + scrollY + 4,
left,
});
}, [open, align]);
return (
<>
<MenuButton
ref={buttonRef}
disabled={disabled}
className={clsx(
triggerIsButton
? 'inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20'
: 'flex items-center rounded-full text-gray-400 hover:text-gray-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-gray-400 dark:hover:text-gray-300 dark:focus-visible:outline-indigo-500',
disabled && 'opacity-50 cursor-not-allowed hover:bg-white dark:hover:bg-white/10',
triggerClassName,
)}
>
{triggerIsButton ? (
<>
{label}
<ChevronDownIcon
aria-hidden="true"
className="-mr-1 size-5 text-gray-400 dark:text-gray-500"
/>
</>
) : (
<>
<span className="sr-only">{ariaLabel}</span>
<EllipsisVerticalIcon
aria-hidden="true"
className="size-5"
/>
</>
)}
</MenuButton>
{open && position && !disabled && (
<Portal>
<MenuItems
static
style={{
position: 'fixed',
top: position.top,
left: align === 'left' ? position.left : undefined,
right:
align === 'right'
? window.innerWidth - position.left
: undefined,
}}
className={clsx(
'z-[9999] w-56 max-h-[60vh] overflow-y-auto rounded-md bg-white shadow-lg outline-1 outline-black/5 ' +
'dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10',
hasDividers &&
'divide-y divide-gray-100 dark:divide-white/10',
menuClassName,
)}
>
{header && <div className="px-4 py-3">{header}</div>}
{sections.map((section, sectionIndex) => (
<div
key={section.id ?? sectionIndex}
className="py-1"
>
{/* NEU: Gruppen-Label als "Trenner" */}
{section.label && (
<div className="px-4 py-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{section.label}
</div>
)}
{section.items.map((item, itemIndex) => {
const key = item.id ?? `${sectionIndex}-${itemIndex}`;
return (
<MenuItem
key={key}
disabled={item.disabled}
>
{item.href ? (
<a
href={item.href}
onClick={item.onClick}
className="block"
>
{renderItemContent(item)}
</a>
) : (
<button
type="button"
onClick={item.onClick}
className="block w-full text-left"
>
{renderItemContent(item)}
</button>
)}
</MenuItem>
);
})}
</div>
))}
</MenuItems>
</Portal>
)}
</>
);
}}
</Menu>
);
}
export default Dropdown;