179 lines
6.2 KiB
TypeScript
179 lines
6.2 KiB
TypeScript
import { createSignal, JSXElement, onCleanup, Show } from "solid-js";
|
|
import { FloatingRoot, FloatingTrigger, FloatingContent, useFloatingContext, Placement } from "./Floating.tsx";
|
|
|
|
const TOOLTIP_DEFAULT_OFFSET = 8;
|
|
const TOOLTIP_CLS = "bg-neutral-800 text-white text-sm px-2.5 py-1.5 rounded-default shadow-lg max-w-80 relative";
|
|
|
|
function arrowCls(base: string) {
|
|
const common = "absolute w-0 h-0";
|
|
switch (base) {
|
|
case "top":
|
|
return common + " -bottom-[6px] left-1/2 -translate-x-1/2 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-neutral-800";
|
|
case "bottom":
|
|
return common + " -top-[6px] left-1/2 -translate-x-1/2 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-b-[6px] border-b-neutral-800";
|
|
case "left":
|
|
return common + " -right-[6px] top-1/2 -translate-y-1/2 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-l-[6px] border-l-neutral-800";
|
|
case "right":
|
|
return common + " -left-[6px] top-1/2 -translate-y-1/2 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-r-[6px] border-r-neutral-800";
|
|
default:
|
|
return common;
|
|
}
|
|
}
|
|
|
|
interface TooltipProps {
|
|
content: JSXElement;
|
|
trigger?: "hover" | "focus";
|
|
placement?: "top" | "bottom" | "left" | "right";
|
|
offset?: number;
|
|
delay?: number;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
export function Tooltip(props: TooltipProps) {
|
|
return (
|
|
<Show when={props.trigger === "focus"} fallback={
|
|
<HoverTooltip content={props.content} placement={props.placement ?? "top"} offset={props.offset ?? TOOLTIP_DEFAULT_OFFSET} delay={props.delay ?? 200}>
|
|
{props.children}
|
|
</HoverTooltip>
|
|
}>
|
|
<FocusTooltip content={props.content} placement={props.placement ?? "top"} offset={props.offset ?? TOOLTIP_DEFAULT_OFFSET}>
|
|
{props.children}
|
|
</FocusTooltip>
|
|
</Show>
|
|
);
|
|
}
|
|
|
|
interface HoverTooltipProps {
|
|
content: JSXElement;
|
|
placement: Placement;
|
|
offset: number;
|
|
delay: number;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
export function HoverTooltip(props: HoverTooltipProps) {
|
|
const [isOpen, setIsOpen] = createSignal(false);
|
|
|
|
return (
|
|
<FloatingRoot open={isOpen()} onOpenChange={setIsOpen} placement={props.placement} offset={props.offset} flip={true} shift={true}>
|
|
<HoverTooltipTrigger delay={props.delay} setIsOpen={setIsOpen}>
|
|
{props.children}
|
|
</HoverTooltipTrigger>
|
|
<TooltipContentWithArrow placement={props.placement} setIsOpen={setIsOpen}>
|
|
{props.content}
|
|
</TooltipContentWithArrow>
|
|
</FloatingRoot>
|
|
);
|
|
}
|
|
|
|
interface HoverTriggerProps {
|
|
delay: number;
|
|
setIsOpen: (v: boolean) => void;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
function HoverTooltipTrigger(props: HoverTriggerProps) {
|
|
const { setTriggerRef } = useFloatingContext();
|
|
let hoverOpenTimeoutRef: ReturnType<typeof setTimeout> | null = null;
|
|
let hoverCloseTimeoutRef: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const clearTimeouts = () => {
|
|
if (hoverOpenTimeoutRef) { clearTimeout(hoverOpenTimeoutRef); hoverOpenTimeoutRef = null; }
|
|
if (hoverCloseTimeoutRef) { clearTimeout(hoverCloseTimeoutRef); hoverCloseTimeoutRef = null; }
|
|
};
|
|
|
|
const handleMouseEnter = () => {
|
|
clearTimeouts();
|
|
hoverOpenTimeoutRef = setTimeout(() => props.setIsOpen(true), props.delay);
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
clearTimeouts();
|
|
hoverCloseTimeoutRef = setTimeout(() => props.setIsOpen(false), 100);
|
|
};
|
|
|
|
onCleanup(clearTimeouts);
|
|
|
|
return (
|
|
<span ref={(el: HTMLElement) => setTriggerRef(el)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} style={{ display: "inline-block" }}>
|
|
{props.children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
interface FocusTooltipProps {
|
|
content: JSXElement;
|
|
placement: Placement;
|
|
offset: number;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
export function FocusTooltip(props: FocusTooltipProps) {
|
|
const [isOpen, setIsOpen] = createSignal(false);
|
|
|
|
return (
|
|
<FloatingRoot open={isOpen()} onOpenChange={setIsOpen} placement={props.placement ?? "top"} offset={props.offset ?? TOOLTIP_DEFAULT_OFFSET} flip={true} shift={true}>
|
|
<FocusTooltipTrigger setIsOpen={setIsOpen}>
|
|
{props.children}
|
|
</FocusTooltipTrigger>
|
|
<TooltipContentWithArrow placement={props.placement ?? "top"}>
|
|
{props.content}
|
|
</TooltipContentWithArrow>
|
|
</FloatingRoot>
|
|
);
|
|
}
|
|
|
|
interface FocusTriggerProps {
|
|
setIsOpen: (v: boolean) => void;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
function FocusTooltipTrigger(props: FocusTriggerProps) {
|
|
const { setTriggerRef } = useFloatingContext();
|
|
|
|
return (
|
|
<div ref={(el: HTMLElement) => setTriggerRef(el)} onFocusIn={() => props.setIsOpen(true)} onFocusOut={() => props.setIsOpen(false)}>
|
|
{props.children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface TooltipContentProps {
|
|
placement: string;
|
|
setIsOpen?: (v: boolean) => void;
|
|
onMouseEnter?: () => void;
|
|
onMouseLeave?: () => void;
|
|
children?: JSXElement;
|
|
}
|
|
|
|
function TooltipContentWithArrow(props: TooltipContentProps) {
|
|
const { position } = useFloatingContext();
|
|
let hoverCloseTimeoutRef: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const handleMouseEnter = () => {
|
|
if (hoverCloseTimeoutRef) { clearTimeout(hoverCloseTimeoutRef); hoverCloseTimeoutRef = null; }
|
|
props.onMouseEnter?.();
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
if (props.setIsOpen) {
|
|
hoverCloseTimeoutRef = setTimeout(() => props.setIsOpen!(false), 50);
|
|
}
|
|
props.onMouseLeave?.();
|
|
};
|
|
|
|
onCleanup(() => {
|
|
if (hoverCloseTimeoutRef) clearTimeout(hoverCloseTimeoutRef);
|
|
});
|
|
|
|
const actualPlacement = () => position()?.placement ?? props.placement;
|
|
const basePlacement = () => actualPlacement().split("-")[0];
|
|
|
|
return (
|
|
<FloatingContent class={TOOLTIP_CLS} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
|
{props.children}
|
|
<div class={arrowCls(basePlacement())}/>
|
|
</FloatingContent>
|
|
);
|
|
}
|