231 lines
8.1 KiB
TypeScript
231 lines
8.1 KiB
TypeScript
"use client";
|
|
|
|
import { Check, ChevronDown, ListFilter } from "lucide-react";
|
|
import {
|
|
useEffect,
|
|
useId,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { createPortal } from "react-dom";
|
|
|
|
export type ProjectSelectOption<T extends string | number = string> = {
|
|
value: T;
|
|
label: string;
|
|
icon?: ReactNode;
|
|
disabled?: boolean;
|
|
};
|
|
|
|
type ProjectSelectMenuProps<T extends string | number> = {
|
|
value: T;
|
|
options: readonly ProjectSelectOption<T>[];
|
|
onChange: (value: T) => void;
|
|
ariaLabel: string;
|
|
variant?: "field" | "compact" | "responsive";
|
|
triggerIcon?: ReactNode;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
menuWidth?: number;
|
|
};
|
|
|
|
type MenuPosition = {
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
};
|
|
|
|
export function ProjectSelectMenu<T extends string | number>({
|
|
value,
|
|
options,
|
|
onChange,
|
|
ariaLabel,
|
|
variant = "field",
|
|
triggerIcon,
|
|
disabled = false,
|
|
className = "",
|
|
menuWidth,
|
|
}: ProjectSelectMenuProps<T>) {
|
|
const [open, setOpen] = useState(false);
|
|
const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
const menuId = useId();
|
|
const activeOption = options.find((option) => option.value === value) ?? options[0];
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
const target = event.target as Node;
|
|
if (!containerRef.current?.contains(target) && !menuRef.current?.contains(target)) {
|
|
setOpen(false);
|
|
setMenuPosition(null);
|
|
}
|
|
};
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
setOpen(false);
|
|
setMenuPosition(null);
|
|
triggerRef.current?.focus();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("pointerdown", handlePointerDown);
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => {
|
|
document.removeEventListener("pointerdown", handlePointerDown);
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [open]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!open) return;
|
|
|
|
const updatePosition = () => {
|
|
const trigger = triggerRef.current;
|
|
const menu = menuRef.current;
|
|
if (!trigger || !menu) return;
|
|
|
|
const margin = 12;
|
|
const gap = 8;
|
|
const triggerRect = trigger.getBoundingClientRect();
|
|
const menuRect = menu.getBoundingClientRect();
|
|
const preferredWidth = menuWidth ?? (variant === "compact" ? 240 : Math.max(224, triggerRect.width));
|
|
const width = Math.min(preferredWidth, window.innerWidth - margin * 2);
|
|
const left = Math.min(
|
|
Math.max(triggerRect.right - width, margin),
|
|
window.innerWidth - width - margin,
|
|
);
|
|
const fitsBelow = triggerRect.bottom + gap + menuRect.height <= window.innerHeight - margin;
|
|
const top = fitsBelow
|
|
? triggerRect.bottom + gap
|
|
: Math.max(margin, triggerRect.top - gap - menuRect.height);
|
|
|
|
setMenuPosition({ left, top, width });
|
|
};
|
|
|
|
updatePosition();
|
|
window.addEventListener("resize", updatePosition);
|
|
window.addEventListener("scroll", updatePosition, true);
|
|
return () => {
|
|
window.removeEventListener("resize", updatePosition);
|
|
window.removeEventListener("scroll", updatePosition, true);
|
|
};
|
|
}, [menuWidth, open, variant]);
|
|
|
|
if (!activeOption) return null;
|
|
|
|
const activeIcon = activeOption.icon ?? triggerIcon ?? <ListFilter className="h-4 w-4" aria-hidden="true" />;
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={`${
|
|
variant === "compact"
|
|
? "relative shrink-0"
|
|
: variant === "responsive"
|
|
? "relative shrink-0 sm:min-w-52 sm:max-w-72"
|
|
: "relative min-w-0"
|
|
} ${className}`}
|
|
>
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => {
|
|
setMenuPosition(null);
|
|
setOpen((current) => !current);
|
|
}}
|
|
aria-label={`${ariaLabel}: ${activeOption.label}`}
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
aria-controls={open ? menuId : undefined}
|
|
title={variant !== "field" ? activeOption.label : undefined}
|
|
className={`${
|
|
open
|
|
? "app-selected app-accent-text"
|
|
: variant === "compact"
|
|
? "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"
|
|
: variant === "responsive"
|
|
? "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"
|
|
: "app-input app-text-primary hover:bg-[var(--surface-tertiary)]"
|
|
} ${
|
|
variant === "compact"
|
|
? "flex h-[42px] w-[42px] items-center justify-center rounded-lg"
|
|
: variant === "responsive"
|
|
? "flex h-[42px] w-[42px] items-center justify-center rounded-lg sm:w-full sm:min-w-52 sm:justify-start sm:gap-2 sm:px-3 sm:text-sm"
|
|
: "flex h-[42px] w-full min-w-0 items-center gap-2 rounded-lg px-3 text-sm"
|
|
} transition disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--accent-primary)]`}
|
|
>
|
|
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden="true">
|
|
{activeIcon}
|
|
</span>
|
|
{variant !== "compact" ? (
|
|
<>
|
|
<span className={`${variant === "responsive" ? "hidden sm:block" : "block"} min-w-0 flex-1 truncate text-left`}>{activeOption.label}</span>
|
|
<ChevronDown
|
|
className={`${variant === "responsive" ? "hidden sm:block" : "block"} app-text-muted h-4 w-4 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
|
aria-hidden="true"
|
|
/>
|
|
</>
|
|
) : null}
|
|
</button>
|
|
|
|
{open
|
|
? createPortal(
|
|
<div
|
|
ref={menuRef}
|
|
id={menuId}
|
|
role="menu"
|
|
aria-label={ariaLabel}
|
|
data-app-popover-root="select-menu"
|
|
className="app-card fixed z-[110] max-h-[calc(100dvh-1.5rem)] overflow-y-auto rounded-xl p-1.5 shadow-xl"
|
|
style={{
|
|
left: menuPosition?.left ?? 0,
|
|
top: menuPosition?.top ?? 0,
|
|
width: menuPosition?.width ?? menuWidth ?? 224,
|
|
visibility: menuPosition ? "visible" : "hidden",
|
|
}}
|
|
>
|
|
{options.map((option) => {
|
|
const selected = option.value === value;
|
|
return (
|
|
<button
|
|
key={String(option.value)}
|
|
type="button"
|
|
role="menuitemradio"
|
|
aria-checked={selected}
|
|
disabled={option.disabled}
|
|
onClick={() => {
|
|
if (!selected) onChange(option.value);
|
|
setOpen(false);
|
|
setMenuPosition(null);
|
|
triggerRef.current?.focus();
|
|
}}
|
|
className={`${
|
|
selected
|
|
? "app-selected app-accent-text"
|
|
: "app-text-muted hover:bg-[var(--surface-tertiary)] hover:text-[var(--foreground)]"
|
|
} flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm transition disabled:cursor-not-allowed disabled:opacity-40`}
|
|
>
|
|
{option.icon ?? triggerIcon ? (
|
|
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden="true">
|
|
{option.icon ?? triggerIcon}
|
|
</span>
|
|
) : null}
|
|
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
|
{selected ? <Check className="h-4 w-4 shrink-0" aria-hidden="true" /> : null}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>,
|
|
document.body,
|
|
)
|
|
: null}
|
|
</div>
|
|
);
|
|
}
|