Files
kjol/web/kit/Floating.tsx

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 };
}