diff --git a/app/src/core/utils/index.ts b/app/src/core/utils/index.ts index 85809e2..c2239e4 100644 --- a/app/src/core/utils/index.ts +++ b/app/src/core/utils/index.ts @@ -12,3 +12,4 @@ export * from "./uuid"; export { FromSchema } from "./typebox/from-schema"; export * from "./test"; export * from "./runtime"; +export * from "./numbers"; diff --git a/app/src/core/utils/numbers.ts b/app/src/core/utils/numbers.ts new file mode 100644 index 0000000..1435f68 --- /dev/null +++ b/app/src/core/utils/numbers.ts @@ -0,0 +1,5 @@ +export function clampNumber(value: number, min: number, max: number): number { + const lower = Math.min(min, max); + const upper = Math.max(min, max); + return Math.max(lower, Math.min(value, upper)); +} diff --git a/app/src/ui/components/overlay/Dropdown.tsx b/app/src/ui/components/overlay/Dropdown.tsx index 8081e1c..3fc49b4 100644 --- a/app/src/ui/components/overlay/Dropdown.tsx +++ b/app/src/ui/components/overlay/Dropdown.tsx @@ -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 && (
- {title &&
{title}
} + {title && ( +
{title}
+ )} {menuItems.map((item, i) => itemRenderer(item, { key: i, onClick: () => internalOnClickItem(item) }) )} diff --git a/app/src/ui/routes/data/_data.root.tsx b/app/src/ui/routes/data/_data.root.tsx index a04c100..ad02097 100644 --- a/app/src/ui/routes/data/_data.root.tsx +++ b/app/src/ui/routes/data/_data.root.tsx @@ -205,7 +205,7 @@ const EntityContextMenu = ({ separator, { icon: IconSettings, - label: "Advanced settings", + label: "Settings", onClick: () => navigate(routes.settings.path(["data", "entities", entity.name]), { absolute: true