Zum Inhalt

src/ui/Tooltip.tsx

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