Zum Inhalt springen
UI Kit
Accent

Popover

Ein Panel neben dem Auslöser für kurze Formulare und Details. Klappt um, wenn kein Platz ist, Escape und ein Klick daneben schließen.

Eine von 9 Components in Overlays

2 Variants

Variants

Teilen

Ein kleines Formular im Panel. Der Fokus geht beim Öffnen hinein und beim Schließen zurück zum Knopf.

Details

Informationen auf Abruf, hier die Öffnungszeiten eines Cafés neben der Adresse.

Details

Was Popover mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Overlays
Bereich
Elements
Variants
2
Version
1.0.0 · seit 1.0.0
Datei
components/ui/popover.tsx
Bringt mit
utilskit
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen

Props

Die Schnittstelle PopoverProps aus components/ui/popover.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • triggerPflicht
    React.ReactElement

    The button that opens it. It gets aria-expanded and aria-controls.

  • sideoptional
    Side

    topbottomleftright

  • alignoptional
    Align

    startcenterend

  • openoptional
    boolean
  • defaultOpenoptional
    boolean
  • onOpenChangeoptional
    (open: boolean) => void
  • labeloptional
    string

    Accessible name of the panel, when it has no visible title

  • arrowoptional
    boolean

    Small arrow pointing at the trigger

  • childrenPflicht
    React.ReactNode | ((close: () => void) => React.ReactNode)

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Popover reagiert selbst auf die Tasten Escape.
  • Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rolle dialog.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 308 Zeilen
components/ui/popover.tsx
'use client';

import { cloneElement, isValidElement, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn, prefersReducedMotion } from '@/lib/kit';

/*
 * Popover and the positioning every floating element of the kit shares
 * (tooltip, dropdown menu, select, combobox, date input). The panel sits in
 * a portal with position: fixed, flips to the other side when there is no
 * room, stays 8 px inside the viewport and follows scroll and resize.
 */

export type Side = 'top' | 'bottom' | 'left' | 'right';
export type Align = 'start' | 'center' | 'end';

export interface FloatingPosition {
  x: number;
  y: number;
  side: Side;
  /** Where the arrow should point, along the panel edge, in px */
  arrow: number;
}

const MARGIN = 8;
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), Math.max(min, max));

/** Computes the position of a floating element next to an anchor rectangle. */
export function computePosition(a: DOMRect, fw: number, fh: number, side: Side, align: Align, offset: number): FloatingPosition {
  const vw = document.documentElement.clientWidth;
  const vh = window.innerHeight;
  let s = side;
  if (s === 'bottom' && a.bottom + offset + fh > vh - MARGIN && a.top - offset - fh >= MARGIN) s = 'top';
  else if (s === 'top' && a.top - offset - fh < MARGIN && a.bottom + offset + fh <= vh - MARGIN) s = 'bottom';
  else if (s === 'right' && a.right + offset + fw > vw - MARGIN && a.left - offset - fw >= MARGIN) s = 'left';
  else if (s === 'left' && a.left - offset - fw < MARGIN && a.right + offset + fw <= vw - MARGIN) s = 'right';
  // Left and right fall back to below when the screen is too narrow for either
  if ((s === 'left' && a.left - offset - fw < MARGIN) || (s === 'right' && a.right + offset + fw > vw - MARGIN)) s = 'bottom';
  let x: number;
  let y: number;
  if (s === 'top' || s === 'bottom') {
    y = s === 'bottom' ? a.bottom + offset : a.top - offset - fh;
    x = align === 'start' ? a.left : align === 'end' ? a.right - fw : a.left + a.width / 2 - fw / 2;
    x = clamp(x, MARGIN, vw - MARGIN - fw);
  } else {
    x = s === 'right' ? a.right + offset : a.left - offset - fw;
    y = align === 'start' ? a.top : align === 'end' ? a.bottom - fh : a.top + a.height / 2 - fh / 2;
    y = clamp(y, MARGIN, vh - MARGIN - fh);
  }
  const arrow = s === 'top' || s === 'bottom' ? clamp(a.left + a.width / 2 - x, 12, fw - 12) : clamp(a.top + a.height / 2 - y, 10, fh - 10);
  return { x: Math.round(x), y: Math.round(y), side: s, arrow: Math.round(arrow) };
}

export interface FloatingProps {
  /** The element the panel belongs to, or a function returning a rectangle (context menus) */
  anchor: React.RefObject<HTMLElement | null> | (() => DOMRect | null);
  side?: Side;
  align?: Align;
  offset?: number;
  /** Panel at least as wide as the anchor (selects, comboboxes) */
  matchWidth?: boolean;
  className?: string;
  style?: React.CSSProperties;
  children: (pos: FloatingPosition | null) => React.ReactNode;
  ref?: React.Ref<HTMLDivElement>;
}

/** Client-only portal that positions its child next to the anchor. Render it only while open. */
export function Floating({ anchor, side = 'bottom', align = 'center', offset = 8, matchWidth, className, style, children, ref }: FloatingProps) {
  const own = useRef<HTMLDivElement>(null);
  const [pos, setPos] = useState<FloatingPosition | null>(null);
  const [minWidth, setMinWidth] = useState<number>();

  const setRefs = useCallback(
    (el: HTMLDivElement | null) => {
      own.current = el;
      if (typeof ref === 'function') ref(el);
      else if (ref) (ref as React.RefObject<HTMLDivElement | null>).current = el;
    },
    [ref],
  );

  useLayoutEffect(() => {
    const update = () => {
      const el = own.current;
      const rect = typeof anchor === 'function' ? anchor() : anchor.current?.getBoundingClientRect() ?? null;
      if (!el || !rect) return;
      if (matchWidth) setMinWidth(Math.round(rect.width));
      setPos(computePosition(rect, el.offsetWidth, el.offsetHeight, side, align, offset));
    };
    update();
    window.addEventListener('scroll', update, true);
    window.addEventListener('resize', update);
    const ro = new ResizeObserver(update);
    if (own.current) ro.observe(own.current);
    return () => {
      window.removeEventListener('scroll', update, true);
      window.removeEventListener('resize', update);
      ro.disconnect();
    };
  }, [anchor, side, align, offset, matchWidth]);

  return createPortal(
    <div
      ref={setRefs}
      data-side={pos?.side ?? side}
      className={cn('fixed left-0 top-0 z-50', className)}
      style={{ ...style, transform: `translate(${pos?.x ?? 0}px, ${pos?.y ?? 0}px)`, visibility: pos ? undefined : 'hidden', minWidth }}
    >
      {children(pos)}
    </div>,
    document.body,
  );
}

/** true after the first client render. Floating panels need the DOM. */
export function useMounted() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  return mounted;
}

/** Closes on Escape and on a pointer down outside the given elements */
export function useDismiss(open: boolean, close: (reason: 'escape' | 'outside') => void, refs: React.RefObject<HTMLElement | null>[]) {
  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        e.stopPropagation();
        close('escape');
      }
    };
    const onDown = (e: PointerEvent) => {
      const t = e.target as Node;
      if (refs.some((r) => r.current?.contains(t))) return;
      close('outside');
    };
    document.addEventListener('keydown', onKey);
    document.addEventListener('pointerdown', onDown);
    return () => {
      document.removeEventListener('keydown', onKey);
      document.removeEventListener('pointerdown', onDown);
    };
  }, [open, close, refs]);
}

/** Open state that can be controlled or not */
export function useOpen(open: boolean | undefined, defaultOpen: boolean | undefined, onOpenChange?: (open: boolean) => void) {
  const [inner, setInner] = useState(defaultOpen ?? false);
  const value = open ?? inner;
  const set = useCallback(
    (next: boolean) => {
      setInner(next);
      onOpenChange?.(next);
    },
    [onOpenChange],
  );
  return [value, set] as const;
}

/** The entrance every floating panel uses: a short fade and scale from the anchor side */
export const floatingMotion =
  'origin-[var(--origin)] transition-[opacity,scale] duration-150 ease-[var(--ease-kit)] starting:scale-[0.96] starting:opacity-0 motion-reduce:transition-none';

/**
 * Entrance and exit for panels that stay mounted while they leave (see usePresence):
 * a soft spring from the anchor point in, a quick fade and shrink toward it out.
 * Put data-closing on the panel while it leaves.
 */
export const floatingPop =
  'origin-[var(--origin)] transition-[opacity,scale] duration-[260ms] ease-[cubic-bezier(0.34,1.4,0.64,1)] starting:scale-[0.92] starting:opacity-0 data-[closing]:pointer-events-none data-[closing]:scale-[0.96] data-[closing]:opacity-0 data-[closing]:duration-150 data-[closing]:ease-[var(--ease-kit)] motion-reduce:transition-none';

export const ORIGIN: Record<Side, string> = { bottom: 'top center', top: 'bottom center', left: 'center right', right: 'center left' };

/** Transform origin on the panel edge right where the anchor sits, so the panel grows out of its trigger */
export function originFor(pos: FloatingPosition | null, side: Side) {
  if (!pos) return ORIGIN[side];
  if (pos.side === 'bottom') return `${pos.arrow}px top`;
  if (pos.side === 'top') return `${pos.arrow}px bottom`;
  if (pos.side === 'right') return `left ${pos.arrow}px`;
  return `right ${pos.arrow}px`;
}

/**
 * Keeps a panel mounted for its exit animation. `present` stays true for
 * `exitMs` after `open` turns false, `closing` is true during that time.
 * With reduced motion it unmounts right away.
 */
export function usePresence(open: boolean, exitMs = 160) {
  const [present, setPresent] = useState(open);
  useEffect(() => {
    if (open) {
      setPresent(true);
      return;
    }
    if (prefersReducedMotion()) {
      setPresent(false);
      return;
    }
    const t = window.setTimeout(() => setPresent(false), exitMs);
    return () => window.clearTimeout(t);
  }, [open, exitMs]);
  return { present: open || present, closing: !open && present };
}

/** The surface of every floating panel */
export const panelSurface = 'rounded-xl border border-border bg-popover text-popover-foreground shadow-[var(--highlight),var(--shadow-lg)]';

const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';

export interface PopoverProps {
  /** The button that opens it. It gets aria-expanded and aria-controls. */
  trigger: React.ReactElement;
  side?: Side;
  align?: Align;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Accessible name of the panel, when it has no visible title */
  label?: string;
  /** Small arrow pointing at the trigger */
  arrow?: boolean;
  className?: string;
  children: React.ReactNode | ((close: () => void) => React.ReactNode);
}

/** The small pointer on a floating panel, drawn as a rotated square with two borders */
export function FloatingArrow({ pos, className }: { pos: FloatingPosition | null; className?: string }) {
  if (!pos) return null;
  const s = pos.side;
  const style: React.CSSProperties =
    s === 'bottom' ? { top: -5, left: pos.arrow - 5 } : s === 'top' ? { bottom: -5, left: pos.arrow - 5 } : s === 'right' ? { left: -5, top: pos.arrow - 5 } : { right: -5, top: pos.arrow - 5 };
  const borders = s === 'bottom' ? 'border-l border-t' : s === 'top' ? 'border-r border-b' : s === 'right' ? 'border-l border-b' : 'border-r border-t';
  return <span aria-hidden className={cn('absolute size-2.5 rotate-45 border-border bg-popover', borders, className)} style={style} />;
}

/**
 * A panel next to a trigger for small forms, details and pickers.
 *   <Popover trigger={<Button variant="outline">Share</Button>}>…</Popover>
 */
export function Popover({ trigger, side = 'bottom', align = 'center', open: openProp, defaultOpen, onOpenChange, label, arrow, className, children }: PopoverProps) {
  const [open, setOpen] = useOpen(openProp, defaultOpen, onOpenChange);
  const mounted = useMounted();
  const anchorRef = useRef<HTMLSpanElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const userOpened = useRef(false);
  const id = useId();
  const { present, closing } = usePresence(open);

  const close = useCallback(
    (reason?: 'escape' | 'outside') => {
      setOpen(false);
      if (reason !== 'outside') anchorRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
    },
    [setOpen],
  );
  const refs = useRef([anchorRef, panelRef]).current;
  useDismiss(open, close, refs);

  useEffect(() => {
    if (!open || !userOpened.current) return;
    const id = requestAnimationFrame(() => {
      const first = panelRef.current?.querySelector<HTMLElement>(FOCUSABLE);
      (first ?? panelRef.current)?.focus();
    });
    return () => cancelAnimationFrame(id);
  }, [open]);

  const triggerEl = isValidElement<Record<string, unknown>>(trigger)
    ? cloneElement(trigger, {
        'aria-expanded': open,
        'aria-haspopup': 'dialog',
        'aria-controls': open ? id : undefined,
        onClick: (e: React.MouseEvent) => {
          (trigger.props.onClick as ((e: React.MouseEvent) => void) | undefined)?.(e);
          userOpened.current = true;
          setOpen(!open);
        },
      })
    : trigger;

  return (
    <>
      <span ref={anchorRef} className="inline-flex">
        {triggerEl}
      </span>
      {mounted && present && (
        <Floating anchor={anchorRef} side={side} align={align} offset={arrow ? 10 : 8} ref={panelRef}>
          {(pos) => (
            <div
              id={id}
              role="dialog"
              aria-label={label}
              tabIndex={-1}
              data-closing={closing || undefined}
              inert={closing || undefined}
              className={cn('relative w-72 max-w-[calc(100vw-16px)] p-4 outline-none', panelSurface, floatingPop, className)}
              style={{ '--origin': originFor(pos, side) } as React.CSSProperties}
            >
              {arrow && <FloatingArrow pos={pos} />}
              {typeof children === 'function' ? children(() => close()) : children}
            </div>
          )}
        </Floating>
      )}
    </>
  );
}

Häufige Fragen

Wie installiere ich Popover?

Mit aiw add @ai-website/popover. Der Befehl legt die Datei components/ui/popover.tsx in dein Projekt, der Code gehört danach dir. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Popover?

2 Varianten, Teilen und Details.

Kann ein KI-Assistent Popover einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Popover mit aiw_components und installieren sie mit aiw_add @ai-website/popover.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert