398 lines
16 KiB
TypeScript
398 lines
16 KiB
TypeScript
import { createContext, useContext, createSignal, createEffect, createRenderEffect, onCleanup, Show, getOwner, runWithOwner, JSXElement } from "solid-js";
|
|
import { Portal } from "solid-js/web";
|
|
|
|
export type Placement = "top" | "top-start" | "top-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end" | "right" | "right-start" | "right-end";
|
|
|
|
export interface PositionOptions {
|
|
placement?: Placement;
|
|
offset?: number;
|
|
flip?: boolean;
|
|
shift?: boolean;
|
|
shiftPadding?: number;
|
|
}
|
|
|
|
interface Position {
|
|
top: number;
|
|
left: number;
|
|
placement: string;
|
|
}
|
|
|
|
class FloatingManager {
|
|
activeCloseCallback: (() => void) | null = null;
|
|
|
|
register(closeCallback: () => void) {
|
|
if (this.activeCloseCallback && this.activeCloseCallback !== closeCallback) {
|
|
this.activeCloseCallback();
|
|
}
|
|
this.activeCloseCallback = closeCallback;
|
|
}
|
|
|
|
unregister(closeCallback: () => void) {
|
|
if (this.activeCloseCallback === closeCallback) {
|
|
this.activeCloseCallback = null;
|
|
}
|
|
}
|
|
|
|
closeActive() {
|
|
if (this.activeCloseCallback) {
|
|
this.activeCloseCallback();
|
|
this.activeCloseCallback = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
const floatingManager = new FloatingManager();
|
|
|
|
// Open floating-content elements in open order, so outside-click handling can tell
|
|
// a descendant (opened later, e.g. a menu inside a popover) from an ancestor: a
|
|
// floating stays open for clicks inside itself or a later-opened floating, and
|
|
// closes for clicks anywhere else (including its parent popover).
|
|
const openFloatings: HTMLElement[] = [];
|
|
|
|
export interface FloatingContextValue {
|
|
isOpen: () => boolean;
|
|
setIsOpen: (open: boolean) => void;
|
|
readonly triggerRef: HTMLElement | undefined;
|
|
setTriggerRef: (el: HTMLElement) => void;
|
|
readonly floatingRef: HTMLElement | undefined;
|
|
setFloatingRef: (el: HTMLElement) => void;
|
|
position: () => Position | null;
|
|
options: Required<PositionOptions>;
|
|
cancelHoverClose: () => void;
|
|
scheduleHoverClose: (delay: number) => void;
|
|
}
|
|
|
|
const FloatingContext = createContext<FloatingContextValue | null>(null);
|
|
|
|
export function useFloatingContext(): FloatingContextValue {
|
|
const context = useContext(FloatingContext);
|
|
if (!context) {
|
|
throw new Error("Floating components must be used within a FloatingRoot");
|
|
}
|
|
return context;
|
|
}
|
|
|
|
function calculatePosition(triggerRect: DOMRect, floatingRect: DOMRect, options: Required<PositionOptions>): Position {
|
|
const { placement, offset, flip, shift, shiftPadding } = options;
|
|
const parts = placement.split("-");
|
|
const basePlacement = parts[0];
|
|
const alignment = parts[1] || "center";
|
|
|
|
let top = 0;
|
|
let left = 0;
|
|
let finalPlacement: string = placement;
|
|
|
|
switch (basePlacement) {
|
|
case "top": top = triggerRect.top - floatingRect.height - offset; break;
|
|
case "bottom": top = triggerRect.bottom + offset; break;
|
|
case "left": left = triggerRect.left - floatingRect.width - offset; break;
|
|
case "right": left = triggerRect.right + offset; break;
|
|
}
|
|
|
|
if (basePlacement === "top" || basePlacement === "bottom") {
|
|
switch (alignment) {
|
|
case "start": left = triggerRect.left; break;
|
|
case "end": left = triggerRect.right - floatingRect.width; break;
|
|
default: left = triggerRect.left + (triggerRect.width - floatingRect.width) / 2;
|
|
}
|
|
} else {
|
|
switch (alignment) {
|
|
case "start": top = triggerRect.top; break;
|
|
case "end": top = triggerRect.bottom - floatingRect.height; break;
|
|
default: top = triggerRect.top + (triggerRect.height - floatingRect.height) / 2;
|
|
}
|
|
}
|
|
|
|
if (flip) {
|
|
const vh = window.innerHeight;
|
|
const vw = window.innerWidth;
|
|
if (basePlacement === "bottom" && top + floatingRect.height > vh - shiftPadding) {
|
|
const flippedTop = triggerRect.top - floatingRect.height - offset;
|
|
if (flippedTop >= shiftPadding) { top = flippedTop; finalPlacement = placement.replace("bottom", "top"); }
|
|
} else if (basePlacement === "top" && top < shiftPadding) {
|
|
const flippedTop = triggerRect.bottom + offset;
|
|
if (flippedTop + floatingRect.height <= vh - shiftPadding) { top = flippedTop; finalPlacement = placement.replace("top", "bottom"); }
|
|
} else if (basePlacement === "right" && left + floatingRect.width > vw - shiftPadding) {
|
|
const flippedLeft = triggerRect.left - floatingRect.width - offset;
|
|
if (flippedLeft >= shiftPadding) { left = flippedLeft; finalPlacement = placement.replace("right", "left"); }
|
|
} else if (basePlacement === "left" && left < shiftPadding) {
|
|
const flippedLeft = triggerRect.right + offset;
|
|
if (flippedLeft + floatingRect.width <= vw - shiftPadding) { left = flippedLeft; finalPlacement = placement.replace("left", "right"); }
|
|
}
|
|
}
|
|
|
|
if (shift) {
|
|
const vh = window.innerHeight;
|
|
const vw = window.innerWidth;
|
|
if (left < shiftPadding) left = shiftPadding;
|
|
else if (left + floatingRect.width > vw - shiftPadding) left = vw - floatingRect.width - shiftPadding;
|
|
if (top < shiftPadding) top = shiftPadding;
|
|
else if (top + floatingRect.height > vh - shiftPadding) top = vh - floatingRect.height - shiftPadding;
|
|
}
|
|
|
|
return { top, left, placement: finalPlacement };
|
|
}
|
|
|
|
interface FloatingRootProps {
|
|
// Solid's `h` auto-invokes zero-arg function props on read, so each
|
|
// of these is simply the unwrapped value inside the component body.
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
placement?: Placement;
|
|
offset?: number;
|
|
flip?: boolean;
|
|
shift?: boolean;
|
|
shiftPadding?: number;
|
|
// Opt out of the global single-open manager. Use for a floating nested
|
|
// inside another (e.g. a tooltip inside a popover) so opening it doesn't
|
|
// close its ancestor, and so the ancestor opening doesn't close it.
|
|
standalone?: boolean;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
export function FloatingRoot(props: FloatingRootProps) {
|
|
const [internalOpen, setInternalOpen] = createSignal(false);
|
|
const isControlled = () => props.open !== undefined;
|
|
const isOpen = () => isControlled() ? !!props.open : internalOpen();
|
|
|
|
let triggerRef: HTMLElement | undefined;
|
|
let floatingRef: HTMLElement | undefined;
|
|
const [position, setPosition] = createSignal<Position | null>(null);
|
|
|
|
let hoverCloseTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const options: Required<PositionOptions> = {
|
|
placement: props.placement ?? "bottom-start",
|
|
offset: props.offset ?? 4,
|
|
flip: props.flip ?? true,
|
|
shift: props.shift ?? true,
|
|
shiftPadding: props.shiftPadding ?? 8,
|
|
};
|
|
|
|
const closeThis = () => {
|
|
if (isControlled()) props.onOpenChange?.(false);
|
|
else setInternalOpen(false);
|
|
};
|
|
|
|
const setIsOpen = (open: boolean) => {
|
|
if (!props.standalone) {
|
|
if (open) floatingManager.register(closeThis);
|
|
else floatingManager.unregister(closeThis);
|
|
}
|
|
if (isControlled()) props.onOpenChange?.(open);
|
|
else setInternalOpen(open);
|
|
if (open) {
|
|
requestAnimationFrame(() => updatePosition());
|
|
} else {
|
|
setPosition(null);
|
|
}
|
|
};
|
|
|
|
onCleanup(() => {
|
|
floatingManager.unregister(closeThis);
|
|
if (hoverCloseTimeout) clearTimeout(hoverCloseTimeout);
|
|
});
|
|
|
|
const cancelHoverClose = () => {
|
|
if (hoverCloseTimeout) { clearTimeout(hoverCloseTimeout); hoverCloseTimeout = null; }
|
|
};
|
|
|
|
const scheduleHoverClose = (delay: number) => {
|
|
cancelHoverClose();
|
|
hoverCloseTimeout = setTimeout(() => setIsOpen(false), delay);
|
|
};
|
|
|
|
const updatePosition = () => {
|
|
if (!triggerRef || !floatingRef) return;
|
|
const triggerRect = triggerRef.getBoundingClientRect();
|
|
const floatingRect = floatingRef.getBoundingClientRect();
|
|
setPosition(calculatePosition(triggerRect, floatingRect, options));
|
|
};
|
|
|
|
createRenderEffect(() => {
|
|
if (!isOpen()) { setPosition(null); return; }
|
|
const rafId = requestAnimationFrame(updatePosition);
|
|
const handleUpdate = () => updatePosition();
|
|
window.addEventListener("scroll", handleUpdate, true);
|
|
window.addEventListener("resize", handleUpdate);
|
|
onCleanup(() => {
|
|
cancelAnimationFrame(rafId);
|
|
window.removeEventListener("scroll", handleUpdate, true);
|
|
window.removeEventListener("resize", handleUpdate);
|
|
});
|
|
});
|
|
|
|
const value: FloatingContextValue = {
|
|
isOpen,
|
|
setIsOpen,
|
|
get triggerRef() { return triggerRef; },
|
|
setTriggerRef: (el: HTMLElement) => { triggerRef = el; },
|
|
get floatingRef() { return floatingRef; },
|
|
setFloatingRef: (el: HTMLElement) => { floatingRef = el; },
|
|
position,
|
|
options,
|
|
cancelHoverClose,
|
|
scheduleHoverClose,
|
|
};
|
|
|
|
return <FloatingContext.Provider value={value}>{props.children}</FloatingContext.Provider>;
|
|
}
|
|
|
|
interface FloatingTriggerProps {
|
|
openOnHover?: boolean;
|
|
hoverDelay?: number;
|
|
hoverCloseDelay?: number;
|
|
class?: string;
|
|
title?: string;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
export function FloatingTrigger(props: FloatingTriggerProps) {
|
|
const ctx = useFloatingContext();
|
|
let hoverOpenTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const clearOpenTimeout = () => {
|
|
if (hoverOpenTimeout) { clearTimeout(hoverOpenTimeout); hoverOpenTimeout = null; }
|
|
};
|
|
onCleanup(clearOpenTimeout);
|
|
|
|
const handleMouseEnter = () => {
|
|
if (!props.openOnHover) return;
|
|
ctx.cancelHoverClose();
|
|
clearOpenTimeout();
|
|
hoverOpenTimeout = setTimeout(() => ctx.setIsOpen(true), props.hoverDelay ?? 0);
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
if (!props.openOnHover) return;
|
|
clearOpenTimeout();
|
|
ctx.scheduleHoverClose(props.hoverCloseDelay ?? 150);
|
|
};
|
|
|
|
const handleClick = () => {
|
|
if (props.openOnHover) return;
|
|
ctx.setIsOpen(!ctx.isOpen());
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
ctx.setIsOpen(!ctx.isOpen());
|
|
} else if (e.key === "Escape" && ctx.isOpen()) {
|
|
ctx.setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<button type="button" class={props.class || ""} title={props.title} ref={(el: HTMLElement) => ctx.setTriggerRef(el)} onclick={handleClick} onKeyDown={handleKeyDown} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} aria-expanded={ctx.isOpen()} aria-haspopup="menu">{props.children}</button>
|
|
);
|
|
}
|
|
|
|
interface FloatingContentProps {
|
|
class?: string;
|
|
style?: Record<string, string | number>;
|
|
onMouseEnter?: () => void;
|
|
onMouseLeave?: () => void;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
export function FloatingContent(props: FloatingContentProps) {
|
|
const ctx = useFloatingContext();
|
|
|
|
// Capture FloatingContent's own owner (which sits inside whatever provider
|
|
// wraps us — e.g. Menu's MenuContext). We resolve props.children under this
|
|
// owner below instead of letting the <Portal> resolve them in its deferred
|
|
// scope: children handed straight to a Portal get instantiated in the
|
|
// Portal's owner, dropping the surrounding provider from their owner chain,
|
|
// so a MenuItem inside a Menu's Portal throws "must be used within a Menu"
|
|
// (this bites when a solid-js/html page like AppLayout.ts feeds children in).
|
|
// Using runWithOwner (not the children() helper) keeps the exact same one-shot
|
|
// insert behavior the div had before — no extra reactive memo over the content,
|
|
// which that "always render" design is sensitive to.
|
|
const owner = getOwner();
|
|
|
|
// Always render the div — toggle visibility via CSS. Avoids the
|
|
// Show-based mount/unmount thrash where reactive scope disposal
|
|
// was immediately destroying the inner component on open.
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (!ctx.isOpen()) return;
|
|
const el = e.target instanceof Element ? e.target : null;
|
|
const self = ctx.floatingRef;
|
|
// Inside our own content or trigger → keep open.
|
|
if (el && self && self.contains(el)) return;
|
|
if (el && ctx.triggerRef && ctx.triggerRef.contains(el)) return;
|
|
// Keep open when the click lands in a descendant floating layer: either a
|
|
// FloatingContent opened AFTER us (higher in the open stack), or an
|
|
// unregistered floating (e.g. a combobox/select dropdown opened from inside
|
|
// us — these never join the stack and are always leaf descendants). Only a
|
|
// click in an ancestor/sibling FloatingContent, or fully outside, closes us.
|
|
const clicked = el && (el.closest("[data-floating-content]") as HTMLElement | null);
|
|
if (clicked && self) {
|
|
const ci = openFloatings.indexOf(clicked);
|
|
if (ci < 0 || ci > openFloatings.indexOf(self)) return;
|
|
}
|
|
ctx.setIsOpen(false);
|
|
};
|
|
const handleEscape = (e: KeyboardEvent) => {
|
|
if (!ctx.isOpen() || e.key !== "Escape") return;
|
|
// Only the topmost open floating closes on Escape, so dismissing a nested
|
|
// menu (e.g. an insert menu inside an editor popover) doesn't also close
|
|
// its parent. The stack is in open order, so the last entry is innermost.
|
|
const self = ctx.floatingRef;
|
|
if (self && openFloatings.length > 0 && openFloatings[openFloatings.length - 1] !== self) return;
|
|
ctx.setIsOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
document.addEventListener("keydown", handleEscape);
|
|
// Track open order so descendant vs ancestor can be distinguished above.
|
|
createEffect(() => {
|
|
const self = ctx.floatingRef;
|
|
if (!self) return;
|
|
const i = openFloatings.indexOf(self);
|
|
if (ctx.isOpen()) { if (i < 0) openFloatings.push(self); }
|
|
else if (i >= 0) openFloatings.splice(i, 1);
|
|
});
|
|
onCleanup(() => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
document.removeEventListener("keydown", handleEscape);
|
|
const self = ctx.floatingRef;
|
|
const i = self ? openFloatings.indexOf(self) : -1;
|
|
if (i >= 0) openFloatings.splice(i, 1);
|
|
});
|
|
|
|
// Render through a Portal (to document.body) so the popover escapes any
|
|
// ancestor that establishes a containing block for `position: fixed` — most
|
|
// importantly the Modal's animated `transform`, which would otherwise make
|
|
// our viewport-relative top/left resolve relative to the modal instead.
|
|
return (
|
|
<Portal>
|
|
<div ref={(el: HTMLElement) => ctx.setFloatingRef(el)} role="menu" data-floating-content="true" class={props.class || ""} style={{
|
|
position: "fixed",
|
|
display: ctx.isOpen() ? "block" : "none",
|
|
// Kept laid-out-but-invisible until position() is computed (one
|
|
// rAF after open) so it never flashes at the top-left 0,0 origin.
|
|
visibility: ctx.isOpen() && ctx.position() ? "visible" : "hidden",
|
|
top: (ctx.position()?.top ?? 0) + "px",
|
|
left: (ctx.position()?.left ?? 0) + "px",
|
|
// Above the Modal container (z-[100]) so popovers opened from
|
|
// inside a modal aren't hidden behind it now that we portal.
|
|
"z-index": 110,
|
|
...(props.style || {}),
|
|
}} onMouseEnter={() => props.onMouseEnter?.()} onMouseLeave={() => props.onMouseLeave?.()}>{runWithOwner(owner, () => props.children)}</div>
|
|
</Portal>
|
|
);
|
|
}
|
|
|
|
export function useFloatingHover(openOnHover: boolean, hoverCloseDelay: number = 150) {
|
|
const ctx = useFloatingContext();
|
|
const handleMouseEnter = () => {
|
|
if (!openOnHover) return;
|
|
ctx.cancelHoverClose();
|
|
};
|
|
const handleMouseLeave = () => {
|
|
if (!openOnHover) return;
|
|
ctx.scheduleHoverClose(hoverCloseDelay);
|
|
};
|
|
return { onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave };
|
|
}
|