shadimage

Popover

A floating panel that appears next to a trigger element.

ui/popover.tsx

"use client";

import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { useRender } from "@base-ui/react/use-render";
import * as React from "react";

import { cn } from "@/lib/utils";

type PopoverInteractOutsideEvent = Event & {
  preventDefault: () => void;
};

interface PopoverContextType {
  anchor: HTMLElement | null;
  setAnchor: (anchor: HTMLElement | null) => void;
  setOnInteractOutside: (handler?: (event: PopoverInteractOutsideEvent) => void) => void;
}

const PopoverContext = React.createContext<PopoverContextType | undefined>(undefined);

const usePopoverContext = () => React.useContext(PopoverContext);

const setRef = <T,>(ref: React.Ref<T> | undefined, value: T) => {
  if (typeof ref === "function") {
    ref(value);
    return;
  }

  if (ref && "current" in ref) {
    ref.current = value;
  }
};

const Popover = ({
  onOpenChange,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) => {
  const [anchor, setAnchor] = React.useState<HTMLElement | null>(null);
  const onInteractOutsideRef = React.useRef<
    ((event: PopoverInteractOutsideEvent) => void) | undefined
    // oxlint-disable-next-line unicorn/no-useless-undefined -- React 19's useRef requires an explicit initial value
  >(undefined);

  const handleOpenChange = React.useCallback<
    NonNullable<React.ComponentProps<typeof PopoverPrimitive.Root>["onOpenChange"]>
  >(
    (open, eventDetails) => {
      if (!open && eventDetails.reason === "outside-press") {
        const interactHandler = onInteractOutsideRef.current;

        if (interactHandler) {
          const wrappedEvent = Object.create(eventDetails.event) as PopoverInteractOutsideEvent;
          wrappedEvent.preventDefault = () => eventDetails.cancel();
          interactHandler(wrappedEvent);
        }
      }

      onOpenChange?.(open, eventDetails);
    },
    [onOpenChange],
  );

  const contextValue = React.useMemo<PopoverContextType>(
    () => ({
      anchor,
      setAnchor,
      setOnInteractOutside: (handler) => {
        onInteractOutsideRef.current = handler;
      },
    }),
    [anchor],
  );

  return (
    <PopoverContext.Provider value={contextValue}>
      <PopoverPrimitive.Root data-slot="popover" onOpenChange={handleOpenChange} {...props} />
    </PopoverContext.Provider>
  );
};

const PopoverTrigger = ({
  asChild = false,
  children,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger> & {
  asChild?: boolean;
}) => {
  const render =
    asChild && React.isValidElement(children) ? (children as React.ReactElement) : undefined;

  return (
    <PopoverPrimitive.Trigger data-slot="popover-trigger" render={render} {...props}>
      {asChild ? null : children}
    </PopoverPrimitive.Trigger>
  );
};

type PopoverContentProps = React.ComponentProps<typeof PopoverPrimitive.Popup> &
  Pick<
    React.ComponentProps<typeof PopoverPrimitive.Positioner>,
    "align" | "alignOffset" | "side" | "sideOffset"
  > & {
    asChild?: boolean;
    onInteractOutside?: (event: PopoverInteractOutsideEvent) => void;
    onOpenAutoFocus?: (event: Event) => void;
  };

const PopoverContent = ({
  asChild = false,
  children,
  className,
  align = "center",
  alignOffset = 0,
  side = "bottom",
  sideOffset = 4,
  initialFocus,
  onInteractOutside,
  onOpenAutoFocus,
  ...props
}: PopoverContentProps) => {
  const popoverContext = usePopoverContext();

  React.useEffect(() => {
    popoverContext?.setOnInteractOutside(onInteractOutside);

    return () => {
      popoverContext?.setOnInteractOutside(undefined);
    };
  }, [onInteractOutside, popoverContext]);

  React.useEffect(() => {
    if (!onOpenAutoFocus) {
      return;
    }

    const event = new Event("openAutoFocus", { cancelable: true });
    onOpenAutoFocus(event);
  }, [onOpenAutoFocus]);

  const render =
    asChild && React.isValidElement(children) ? (children as React.ReactElement) : undefined;

  return (
    <PopoverPrimitive.Portal>
      <PopoverPrimitive.Positioner
        align={align}
        alignOffset={alignOffset}
        anchor={popoverContext?.anchor}
        className="isolate z-50"
        side={side}
        sideOffset={sideOffset}
      >
        <PopoverPrimitive.Popup
          className={cn(
            "data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-soft outline-hidden data-closed:animate-out data-open:animate-in",
            className,
          )}
          data-slot="popover-content"
          initialFocus={onOpenAutoFocus ? false : initialFocus}
          render={render}
          {...props}
        >
          {asChild ? null : children}
        </PopoverPrimitive.Popup>
      </PopoverPrimitive.Positioner>
    </PopoverPrimitive.Portal>
  );
};

const PopoverAnchor = ({
  asChild = false,
  children,
  ref,
  ...props
}: React.ComponentProps<"div"> & {
  asChild?: boolean;
}) => {
  const popoverContext = usePopoverContext();

  const handleRef = React.useCallback(
    (node: HTMLElement | null) => {
      popoverContext?.setAnchor(node);
      setRef(ref as React.Ref<HTMLElement> | undefined, node);
    },
    [popoverContext, ref],
  );

  const child =
    asChild && React.isValidElement(children) ? (children as React.ReactElement) : undefined;

  return useRender({
    defaultTagName: "div",
    props: child ? props : { "data-slot": "popover-anchor", ...props, children },
    ref: handleRef,
    render: child,
  });
};

const PopoverHeader = ({ className, ...props }: React.ComponentProps<"div">) => (
  <div
    className={cn("flex flex-col gap-1 text-sm", className)}
    data-slot="popover-header"
    {...props}
  />
);

const PopoverTitle = ({ className, ...props }: React.ComponentProps<"h2">) => (
  <div className={cn("font-medium", className)} data-slot="popover-title" {...props} />
);

const PopoverDescription = ({ className, ...props }: React.ComponentProps<"p">) => (
  <p
    className={cn("text-muted-foreground", className)}
    data-slot="popover-description"
    {...props}
  />
);

export {
  Popover,
  PopoverTrigger,
  PopoverContent,
  PopoverAnchor,
  PopoverHeader,
  PopoverTitle,
  PopoverDescription,
};
Interaction
scroll-revealtooltip

DependenciesBundle weight · light

ui-primitives
@base-ui/react