// Small click-toggled help popover. Used by ParamSlider and friends to
// surface a one-sentence explanation of what a parameter actually does.
// Click the (?) → popover opens; click outside or the (?) again → closes.
//
// Renders the popover via Portal into document.body so it isn't clipped by
// the editor panel's overflow:hidden (which exists for rounded-corner
// clipping of the scroll area). Position is calculated from the trigger
// button's bounding rect and recomputed on scroll/resize.
//
// Deliberately not hover-triggered: many of these controls live on mobile
// where there is no hover, and most desktop sliders have already-cluttered
// hover affordances (drag, reset hover). Click is unambiguous on both.
import { type Component, Show, createSignal, onCleanup, onMount } from 'solid-js';
import { Portal } from 'solid-js/web';
import { Help } from './icons/Icon';
interface Props {
text: string;
/** Size of the (?) icon in px. Default 11 — matches reset button. */
size?: number;
}
const POPOVER_WIDTH = 240;
const POPOVER_GAP = 6;
const VIEWPORT_PAD = 8;
export const Tooltip: Component<Props> = (props) => {
const [open, setOpen] = createSignal(false);
const [pos, setPos] = createSignal<{ top: number; left: number } | null>(null);
let btnRef: HTMLButtonElement | undefined;
const computePos = (): void => {
if (!btnRef) return;
const rect = btnRef.getBoundingClientRect();
let left = rect.right - POPOVER_WIDTH;
// Clamp into the viewport horizontally.
if (left < VIEWPORT_PAD) left = VIEWPORT_PAD;
if (left + POPOVER_WIDTH > window.innerWidth - VIEWPORT_PAD) {
left = window.innerWidth - VIEWPORT_PAD - POPOVER_WIDTH;
}
setPos({ top: rect.bottom + POPOVER_GAP, left });
};
const close = (): void => {
setOpen(false);
setPos(null);
};
const onDocClick = (e: MouseEvent): void => {
if (!open()) return;
const target = e.target as Node;
if (btnRef?.contains(target)) return;
close();
};
// Recompute on scroll/resize so the popover stays glued to the button
// even when the editor body scrolls.
const onScrollOrResize = (): void => {
if (open()) computePos();
};
onMount(() => {
document.addEventListener('click', onDocClick);
window.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize);
});
onCleanup(() => {
document.removeEventListener('click', onDocClick);
window.removeEventListener('scroll', onScrollOrResize, true);
window.removeEventListener('resize', onScrollOrResize);
});
return (
<>
<button
ref={btnRef}
type="button"
class="phys-iconbtn"
style={{ padding: '3px', color: 'var(--fg-tertiary)' }}
onClick={(e) => {
e.stopPropagation();
if (open()) {
close();
} else {
computePos();
setOpen(true);
}
}}
title="What does this do?"
aria-expanded={open()}
>
<Help size={props.size ?? 11} />
</button>
<Show when={open() && pos()}>
<Portal>
<div
style={{
position: 'fixed',
top: `${pos()?.top}px`,
left: `${pos()?.left}px`,
'z-index': '9999',
width: `${POPOVER_WIDTH}px`,
padding: '10px 12px',
background: 'var(--surface-primary)',
border: '1px solid var(--border-subtle)',
'border-radius': 'var(--radius-md)',
'box-shadow': 'var(--shadow-modal)',
'font-size': '11.5px',
'line-height': '1.5',
color: 'var(--fg-secondary)',
'white-space': 'normal',
}}
>
{props.text}
</div>
</Portal>
</Show>
</>
);
};