minimal popper implementation for context menu placement

This commit is contained in:
dswbx
2025-01-18 09:59:10 +01:00
parent ebd4565166
commit 145b47e942
4 changed files with 53 additions and 13 deletions

View File

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