Add js web stuff to landing page + documentation
This commit is contained in:
397
go/jsruntime/uikit/Floating.tsx
Normal file
397
go/jsruntime/uikit/Floating.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user