|
|
|
@@ -1,4 +1,5 @@
|
|
|
|
|
import { useClickOutside } from "@mantine/hooks";
|
|
|
|
|
import { clampNumber } from "core/utils";
|
|
|
|
|
import {
|
|
|
|
|
type ComponentPropsWithoutRef,
|
|
|
|
|
Fragment,
|
|
|
|
@@ -7,7 +8,7 @@ import {
|
|
|
|
|
useState
|
|
|
|
|
} from "react";
|
|
|
|
|
import { twMerge } from "tailwind-merge";
|
|
|
|
|
import { useEvent } from "../../hooks/use-event";
|
|
|
|
|
import { useEvent } from "ui/hooks/use-event";
|
|
|
|
|
|
|
|
|
|
export type DropdownItem =
|
|
|
|
|
| (() => JSX.Element)
|
|
|
|
@@ -43,7 +44,7 @@ export function Dropdown({
|
|
|
|
|
children,
|
|
|
|
|
defaultOpen = false,
|
|
|
|
|
openEvent = "onClick",
|
|
|
|
|
position = "bottom-start",
|
|
|
|
|
position: initialPosition = "bottom-start",
|
|
|
|
|
dropdownWrapperProps,
|
|
|
|
|
items,
|
|
|
|
|
title,
|
|
|
|
@@ -54,24 +55,58 @@ export function Dropdown({
|
|
|
|
|
className
|
|
|
|
|
}: DropdownProps) {
|
|
|
|
|
const [open, setOpen] = useState(defaultOpen);
|
|
|
|
|
const [position, setPosition] = useState(initialPosition);
|
|
|
|
|
const clickoutsideRef = useClickOutside(() => setOpen(false));
|
|
|
|
|
const menuItems = items.filter(Boolean) as DropdownItem[];
|
|
|
|
|
const [_offset, _setOffset] = useState(0);
|
|
|
|
|
|
|
|
|
|
const toggle = useEvent((delay: number = 50) =>
|
|
|
|
|
setTimeout(() => setOpen((prev) => !prev), typeof delay === "number" ? delay : 0)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const openEventHandler = useEvent((e) => {
|
|
|
|
|
const onClickHandler = openEvent === "onClick" ? toggle : undefined;
|
|
|
|
|
const onContextMenuHandler = useEvent((e) => {
|
|
|
|
|
if (openEvent !== "onContextMenu") return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
|
|
if (open) {
|
|
|
|
|
toggle(0);
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
setPosition(initialPosition);
|
|
|
|
|
_setOffset(0);
|
|
|
|
|
}, 10);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// minimal popper impl, get pos and boundaries
|
|
|
|
|
const x = e.clientX - e.currentTarget.getBoundingClientRect().left;
|
|
|
|
|
const { left = 0, right = 0 } = clickoutsideRef.current?.getBoundingClientRect() ?? {};
|
|
|
|
|
|
|
|
|
|
// only if boundaries gien
|
|
|
|
|
if (left > 0 && right > 0) {
|
|
|
|
|
const safe = clampNumber(x, left, right);
|
|
|
|
|
// if pos less than half, go left
|
|
|
|
|
if (x < (left + right) / 2) {
|
|
|
|
|
setPosition("bottom-start");
|
|
|
|
|
_setOffset(safe);
|
|
|
|
|
} else {
|
|
|
|
|
setPosition("bottom-end");
|
|
|
|
|
_setOffset(right - safe);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
setPosition(initialPosition);
|
|
|
|
|
_setOffset(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggle();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const offset = 4;
|
|
|
|
|
const dropdownStyle = {
|
|
|
|
|
"bottom-start": { top: "100%", left: 0, marginTop: offset },
|
|
|
|
|
"bottom-end": { right: 0, top: "100%", marginTop: offset },
|
|
|
|
|
"bottom-start": { top: "100%", left: _offset, marginTop: offset },
|
|
|
|
|
"bottom-end": { right: _offset, top: "100%", marginTop: offset },
|
|
|
|
|
"top-start": { bottom: "100%", marginBottom: offset },
|
|
|
|
|
"top-end": { bottom: "100%", right: 0, marginBottom: offset }
|
|
|
|
|
"top-end": { bottom: "100%", right: _offset, marginBottom: offset }
|
|
|
|
|
}[position];
|
|
|
|
|
|
|
|
|
|
const internalOnClickItem = useEvent((item) => {
|
|
|
|
@@ -116,12 +151,9 @@ export function Dropdown({
|
|
|
|
|
role="dropdown"
|
|
|
|
|
className={twMerge("relative flex", className)}
|
|
|
|
|
ref={clickoutsideRef}
|
|
|
|
|
onContextMenu={openEvent === "onContextMenu" ? openEventHandler : undefined}
|
|
|
|
|
onContextMenu={onContextMenuHandler}
|
|
|
|
|
>
|
|
|
|
|
{cloneElement(
|
|
|
|
|
children as any,
|
|
|
|
|
openEvent === "onClick" ? { onClick: openEventHandler } : {}
|
|
|
|
|
)}
|
|
|
|
|
{cloneElement(children as any, { onClick: onClickHandler })}
|
|
|
|
|
{open && (
|
|
|
|
|
<div
|
|
|
|
|
{...dropdownWrapperProps}
|
|
|
|
@@ -131,7 +163,9 @@ export function Dropdown({
|
|
|
|
|
)}
|
|
|
|
|
style={dropdownStyle}
|
|
|
|
|
>
|
|
|
|
|
{title && <div className="text-sm font-bold px-3 mb-1 mt-1 opacity-50">{title}</div>}
|
|
|
|
|
{title && (
|
|
|
|
|
<div className="text-sm font-bold px-2.5 mb-1 mt-1 opacity-50">{title}</div>
|
|
|
|
|
)}
|
|
|
|
|
{menuItems.map((item, i) =>
|
|
|
|
|
itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) })
|
|
|
|
|
)}
|
|
|
|
|