478 lines
14 KiB
TypeScript
478 lines
14 KiB
TypeScript
'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[];
|
|
};
|
|
|
|
// 🔹 NEU: 'input'
|
|
export type DropdownTriggerVariant = 'button' | 'icon' | 'input';
|
|
|
|
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;
|
|
|
|
/** Nur für triggerVariant="input": gesteuerter Eingabewert */
|
|
inputValue?: string;
|
|
inputPlaceholder?: string;
|
|
onInputChange?: (value: string) => void;
|
|
}
|
|
|
|
/* ───────── interne Helfer ───────── */
|
|
|
|
const itemBaseClasses =
|
|
'block px-4 py-2 text-sm ' +
|
|
'text-gray-700 dark:text-gray-300 ' +
|
|
'hover:bg-gray-100 hover:text-gray-900 ' +
|
|
'dark:hover:bg-white/5 dark:hover:text-white ' +
|
|
'focus:outline-none';
|
|
|
|
const itemWithIconClasses =
|
|
'group flex items-center gap-x-3 px-4 py-2 text-sm ' +
|
|
'text-gray-700 dark:text-gray-300 ' +
|
|
'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>
|
|
);
|
|
}
|
|
|
|
/* ───────── Spezielle Variante: Textfeld + Dropdown (OHNE Menu) ───────── */
|
|
|
|
function InputDropdown({
|
|
label = 'Options',
|
|
ariaLabel = 'Open options',
|
|
align = 'left',
|
|
header,
|
|
sections,
|
|
triggerClassName,
|
|
menuClassName,
|
|
disabled = false,
|
|
inputValue,
|
|
inputPlaceholder,
|
|
onInputChange,
|
|
}: DropdownProps) {
|
|
const [open, setOpen] = React.useState(false);
|
|
const anchorRef = React.useRef<HTMLDivElement | null>(null);
|
|
const menuRef = React.useRef<HTMLDivElement | null>(null); // 🔹 NEU
|
|
const [position, setPosition] = React.useState<{
|
|
top: number;
|
|
left: number;
|
|
width: number;
|
|
} | null>(null);
|
|
|
|
const hasDividers = sections.length > 1;
|
|
const effectivePlaceholder = inputPlaceholder ?? label;
|
|
|
|
// Position neu berechnen, wenn geöffnet
|
|
React.useEffect(() => {
|
|
if (!open || !anchorRef.current) return;
|
|
|
|
const rect = anchorRef.current.getBoundingClientRect();
|
|
const scrollX =
|
|
window.pageXOffset || document.documentElement.scrollLeft;
|
|
const scrollY =
|
|
window.pageYOffset || document.documentElement.scrollTop;
|
|
|
|
const leftBase = rect.left + scrollX;
|
|
|
|
setPosition({
|
|
top: rect.bottom + scrollY + 4,
|
|
left: leftBase,
|
|
width: rect.width,
|
|
});
|
|
}, [open, align]);
|
|
|
|
// Klick außerhalb / ESC schließt das Dropdown
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
|
|
function handleClickOutside(ev: MouseEvent) {
|
|
const target = ev.target as Node;
|
|
// 🔹 Wenn Klick im Input-Bereich oder im Menü: NICHT schließen
|
|
if (
|
|
(anchorRef.current && anchorRef.current.contains(target)) ||
|
|
(menuRef.current && menuRef.current.contains(target))
|
|
) {
|
|
return;
|
|
}
|
|
setOpen(false);
|
|
}
|
|
|
|
function handleKey(ev: KeyboardEvent) {
|
|
if (ev.key === 'Escape') {
|
|
setOpen(false);
|
|
}
|
|
}
|
|
|
|
window.addEventListener('mousedown', handleClickOutside);
|
|
window.addEventListener('keydown', handleKey);
|
|
return () => {
|
|
window.removeEventListener('mousedown', handleClickOutside);
|
|
window.removeEventListener('keydown', handleKey);
|
|
};
|
|
}, [open]);
|
|
|
|
return (
|
|
<div className="relative inline-block w-full text-left">
|
|
<div
|
|
ref={anchorRef}
|
|
className={clsx('relative', triggerClassName)}
|
|
>
|
|
<input
|
|
type="text"
|
|
value={inputValue ?? ''}
|
|
placeholder={effectivePlaceholder}
|
|
disabled={disabled}
|
|
onChange={(e) => {
|
|
onInputChange?.(e.target.value);
|
|
if (!disabled && !open) setOpen(true);
|
|
}}
|
|
onFocus={() => {
|
|
if (!disabled) setOpen(true);
|
|
}}
|
|
className="block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 pr-8 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none dark:bg-gray-900/60 dark:ring-gray-600"
|
|
/>
|
|
<button
|
|
type="button"
|
|
aria-label={ariaLabel}
|
|
disabled={disabled}
|
|
onClick={() => {
|
|
if (!disabled) setOpen((prev) => !prev);
|
|
}}
|
|
className={clsx(
|
|
'absolute inset-y-0 right-0 flex items-center pr-2',
|
|
disabled && 'cursor-not-allowed opacity-50',
|
|
)}
|
|
>
|
|
<ChevronDownIcon
|
|
aria-hidden="true"
|
|
className="size-4 text-gray-400 dark:text-gray-500"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{open && position && !disabled && (
|
|
<Portal>
|
|
<div
|
|
ref={menuRef} // 🔹 WICHTIG: für Outside-Click-Erkennung
|
|
style={{
|
|
position: 'fixed',
|
|
top: position.top,
|
|
left: position.left,
|
|
width: position.width,
|
|
}}
|
|
className={clsx(
|
|
'z-[9999] 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"
|
|
>
|
|
{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}`;
|
|
|
|
const handleClick = () => {
|
|
if (item.disabled) return;
|
|
item.onClick?.(); // 🔹 Auswahl nach außen melden
|
|
setOpen(false); // 🔹 Danach Dropdown schließen
|
|
};
|
|
|
|
if (item.href) {
|
|
return (
|
|
<a
|
|
key={key}
|
|
href={item.href}
|
|
onClick={handleClick}
|
|
className="block"
|
|
>
|
|
{renderItemContent(item)}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
disabled={item.disabled}
|
|
onClick={handleClick}
|
|
className="block w-full text-left disabled:opacity-60"
|
|
>
|
|
{renderItemContent(item)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Portal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ───────── Standard-Dropdown (Menu) für Button/Icon ───────── */
|
|
|
|
export function Dropdown(props: DropdownProps) {
|
|
const {
|
|
label = 'Options',
|
|
ariaLabel = 'Open options',
|
|
align = 'right',
|
|
triggerVariant = 'button',
|
|
header,
|
|
sections,
|
|
triggerClassName,
|
|
menuClassName,
|
|
disabled = false,
|
|
} = props;
|
|
|
|
const triggerIsInput = triggerVariant === 'input';
|
|
const triggerIsIcon = triggerVariant === 'icon';
|
|
const triggerIsButton = triggerVariant === 'button';
|
|
|
|
// 🔹 Spezialfall: Textfeld + Dropdown (ohne Menu)
|
|
if (triggerIsInput) {
|
|
return (
|
|
<InputDropdown
|
|
{...props}
|
|
triggerVariant="input"
|
|
/>
|
|
);
|
|
}
|
|
|
|
const hasDividers = sections.length > 1;
|
|
|
|
return (
|
|
<Menu as="div" className="relative inline-block text-left">
|
|
{({ open }) => {
|
|
const buttonRef =
|
|
React.useRef<HTMLButtonElement | null>(null);
|
|
const [position, setPosition] = React.useState<{
|
|
top: number;
|
|
left: number;
|
|
} | null>(null);
|
|
|
|
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"
|
|
>
|
|
{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;
|