Files
image-studio/view/bottom-controls/SelectMenu.tsx

195 lines
7.7 KiB
TypeScript

import { Check, CaretDown } from "@phosphor-icons/react";
import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type RefObject } from "react";
import { createPortal } from "react-dom";
export type BottomControlSelectOption<TValue extends string> = {
value: TValue;
label: string;
};
export type BottomControlSelectMenuProps<TValue extends string> = {
value: TValue;
options: readonly BottomControlSelectOption<TValue>[];
"aria-label": string;
label?: string;
placement?: "top" | "bottom" | "inline";
onValueChange: (value: TValue) => void;
};
export function BottomControlSelectMenu<TValue extends string>({ value, options, label, placement = "top", onValueChange, ...props }: BottomControlSelectMenuProps<TValue>) {
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const listboxId = useId();
const [open, setOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<CSSProperties>();
const selectedOption = options.find((option) => option.value === value) ?? options[0];
useEffect(() => {
if (!open) return;
const updateMenuPosition = () => {
if (placement === "inline") return;
const rect = buttonRef.current?.getBoundingClientRect();
if (!rect) return;
const gap = 8;
const maxHeight = Math.max(160, placement === "bottom" ? window.innerHeight - rect.bottom - gap * 2 : rect.top - gap * 2);
setMenuStyle({
position: "fixed",
left: rect.left,
top: placement === "bottom" ? rect.bottom + gap : undefined,
bottom: placement === "top" ? window.innerHeight - rect.top + gap : undefined,
width: Math.max(rect.width, 192),
maxHeight,
});
};
updateMenuPosition();
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false);
};
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
buttonRef.current?.focus();
}
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("resize", updateMenuPosition);
window.addEventListener("scroll", updateMenuPosition, true);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("resize", updateMenuPosition);
window.removeEventListener("scroll", updateMenuPosition, true);
};
}, [open, placement]);
const openAndFocusOption = (index: number) => {
setOpen(true);
window.requestAnimationFrame(() => {
const optionButtons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
optionButtons?.[Math.max(0, Math.min(index, options.length - 1))]?.focus();
});
};
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Home" && event.key !== "End") return;
event.preventDefault();
const selectedIndex = Math.max(0, options.findIndex((option) => option.value === value));
openAndFocusOption(event.key === "End" ? options.length - 1 : event.key === "Home" ? 0 : selectedIndex);
};
return (
<div ref={rootRef} className={`relative min-w-0 ${placement === "inline" ? "w-full" : ""}`}>
<button
ref={buttonRef}
type="button"
className={`inline-flex h-10 min-w-32 max-w-full items-center rounded-full text-base text-white/85 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 ${placement === "inline" ? "w-full px-4" : ""}`}
aria-label={props["aria-label"]}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
onKeyDown={handleTriggerKeyDown}
onClick={() => setOpen((current) => !current)}
>
{placement === "inline" && label ? <span className="shrink-0 pr-3 text-sm font-medium text-white/55">{label}</span> : null}
<span className={`min-w-0 flex-1 truncate text-left ${placement === "inline" ? "" : "px-4"}`}>{selectedOption?.label}</span>
<span className="grid size-10 flex-none place-items-center text-white/60">
<CaretDown size={18} weight="bold" />
</span>
</button>
{open && placement === "inline" ? (
<SelectOptions
menuRef={menuRef}
id={listboxId}
options={options}
value={value}
onValueChange={onValueChange}
setOpen={setOpen}
className="subtle-scrollbar mt-2 max-h-56 w-full overflow-auto rounded-[1.25rem] bg-white/[0.04] p-1 text-white ring-1 ring-white/10"
aria-label={props["aria-label"]}
/>
) : open ? (
createPortal(
<SelectOptions
menuRef={menuRef}
id={listboxId}
options={options}
value={value}
onValueChange={onValueChange}
setOpen={setOpen}
className="subtle-scrollbar z-50 overflow-auto rounded-[1.5rem] bg-slate-950/90 p-1 text-white shadow-2xl ring-1 ring-white/10 backdrop-blur-xl"
style={menuStyle}
aria-label={props["aria-label"]}
/>,
document.body,
)
) : null}
</div>
);
}
type SelectOptionsProps<TValue extends string> = {
menuRef: RefObject<HTMLDivElement | null>;
id: string;
options: readonly BottomControlSelectOption<TValue>[];
value: TValue;
className: string;
style?: CSSProperties;
"aria-label": string;
onValueChange: (value: TValue) => void;
setOpen: (open: boolean) => void;
};
function SelectOptions<TValue extends string>({ menuRef, id, options, value, className, style, onValueChange, setOpen, ...props }: SelectOptionsProps<TValue>) {
const handleOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
const optionButtons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
if (!optionButtons) return;
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
return;
}
const nextIndex = event.key === "ArrowDown" ? (index + 1) % options.length
: event.key === "ArrowUp" ? (index - 1 + options.length) % options.length
: event.key === "Home" ? 0
: event.key === "End" ? options.length - 1
: undefined;
if (nextIndex === undefined) return;
event.preventDefault();
optionButtons[nextIndex]?.focus();
};
return (
<div ref={menuRef} id={id} className={className} style={style} role="listbox" aria-label={props["aria-label"]}>
{options.map((option, index) => {
const selected = option.value === value;
return (
<button
key={option.value}
type="button"
className={`flex h-10 w-full items-center gap-3 rounded-full px-3 text-left text-sm transition ${selected ? "bg-white !text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
role="option"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
onKeyDown={(event) => handleOptionKeyDown(event, index)}
onClick={() => {
onValueChange(option.value);
setOpen(false);
}}
>
<span className="grid size-5 place-items-center">{selected ? <Check size={16} weight="bold" /> : null}</span>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
</button>
);
})}
</div>
);
}