199 lines
6.2 KiB
TypeScript
199 lines
6.2 KiB
TypeScript
// components/ui/Dropdown.tsx
|
|
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { Menu, MenuButton, MenuItem, MenuItems } 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;
|
|
items: DropdownItem[];
|
|
};
|
|
|
|
export type DropdownTriggerVariant = 'button' | 'icon';
|
|
|
|
export interface DropdownProps {
|
|
/** Button-Label (für Trigger "button") */
|
|
label?: string;
|
|
/** aria-label für Trigger "icon" */
|
|
ariaLabel?: string;
|
|
/** Ausrichtung des Menüs */
|
|
align?: 'left' | 'right';
|
|
/** Darstellung des Triggers (normaler Button oder nur Icon) */
|
|
triggerVariant?: DropdownTriggerVariant;
|
|
/** Optionaler Header im Dropdown (z.B. "Signed in as …") */
|
|
header?: React.ReactNode;
|
|
/** Sektionen (werden bei >1 Sektion automatisch mit Divider getrennt) */
|
|
sections: DropdownSection[];
|
|
|
|
/** Optional: zusätzliche Klassen für den Trigger-Button */
|
|
triggerClassName?: string;
|
|
/** Optional: zusätzliche Klassen für das MenuItems-Panel */
|
|
menuClassName?: string;
|
|
}
|
|
|
|
/* ───────── interne Helfer ───────── */
|
|
|
|
const itemBaseClasses =
|
|
'block px-4 py-2 text-sm text-gray-700 ' +
|
|
'data-focus:bg-gray-100 data-focus:text-gray-900 data-focus:outline-hidden ' +
|
|
'dark:text-gray-300 dark:data-focus:bg-white/5 dark:data-focus:text-white';
|
|
|
|
const itemWithIconClasses =
|
|
'group flex items-center gap-x-3 px-4 py-2 text-sm text-gray-700 ' +
|
|
'data-focus:bg-gray-100 data-focus:text-gray-900 data-focus:outline-hidden ' +
|
|
'dark:text-gray-300 dark:data-focus:bg-white/5 dark:data-focus:text-white';
|
|
|
|
const iconClasses =
|
|
'flex size-5 shrink-0 items-center justify-center text-gray-400 group-data-focus:text-gray-500 ' +
|
|
'dark:text-gray-500 dark:group-data-focus: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 ───────── */
|
|
|
|
export function Dropdown({
|
|
label = 'Options',
|
|
ariaLabel = 'Open options',
|
|
align = 'right',
|
|
triggerVariant = 'button',
|
|
header,
|
|
sections,
|
|
triggerClassName,
|
|
menuClassName,
|
|
}: DropdownProps) {
|
|
const hasDividers = sections.length > 1;
|
|
|
|
const alignmentClasses =
|
|
align === 'left'
|
|
? 'left-0 origin-top-left'
|
|
: 'right-0 origin-top-right';
|
|
|
|
const triggerIsButton = triggerVariant === 'button';
|
|
|
|
return (
|
|
<Menu as="div" className="relative inline-block">
|
|
<MenuButton
|
|
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',
|
|
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>
|
|
|
|
<MenuItems
|
|
transition
|
|
className={clsx(
|
|
'absolute z-20 mt-2 w-56 rounded-md bg-white shadow-lg outline-1 outline-black/5 ' +
|
|
'transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 ' +
|
|
'data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in ' +
|
|
'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',
|
|
alignmentClasses,
|
|
menuClassName,
|
|
)}
|
|
>
|
|
{/* Optionaler Header (z.B. "Signed in as …") */}
|
|
{header && (
|
|
<div className="px-4 py-3">
|
|
{header}
|
|
</div>
|
|
)}
|
|
|
|
{/* Sektionen mit/ohne Divider */}
|
|
{sections.map((section, sectionIndex) => (
|
|
<div key={section.id ?? sectionIndex} className="py-1">
|
|
{section.items.map((item, itemIndex) => {
|
|
const key = item.id ?? `${sectionIndex}-${itemIndex}`;
|
|
const commonProps = {
|
|
className: itemBaseClasses,
|
|
};
|
|
|
|
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>
|
|
</Menu>
|
|
);
|
|
}
|
|
|
|
export default Dropdown;
|