195 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|