Corp/frontend/src/components/ui/ProjectSelectMenu.tsx

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>
);
}