zebflow 0.11.14

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { cx, createContext, useContext, useRef, useEffect, createPortal } from "zeb/react";
import { useControllable, useClickAway, useEscape, useAnchoredPosition, isVNode, composeEventHandlers } from "zeb/ui/hooks";

/**
 * Popover — shadcn's popover, on Zebflow's engine.
 *
 * `Popover`/`PopoverTrigger`/`PopoverContent` compose through `createContext`
 * (no child-walking). `PopoverTrigger` anchors the panel: it renders its own
 * `<button>` for plain content, or wraps an element child (usually
 * `<Button>`) in an `inline-flex` span instead, so there is no
 * button-in-button — either way the rendered node is what `PopoverContent`
 * positions against. `PopoverContent` reads its own `side`/`align`/
 * `sideOffset` (falling back to `bottom`/`center`/`8`, upstream's defaults)
 * instead of them being ignored, and portals to `document.body` in the
 * browser (rendering inline, in place, during SSR). A click outside the
 * trigger+panel pair, focus moving outside both, or Escape all close it —
 * click-away is gated on `isOpen` and checks both the trigger and the
 * (portaled) panel, since a portaled panel is no longer a DOM descendant of
 * the trigger's tree.
 */

const PopoverContext = createContext(null);

function usePopoverContext(name) {
  const ctx = useContext(PopoverContext);
  if (!ctx) throw new Error(`${name} must be rendered inside <Popover>`);
  return ctx;
}

export function Popover({ open, defaultOpen = false, onOpenChange, children }) {
  const [isOpen, setIsOpen] = useControllable(open, defaultOpen, onOpenChange);
  const anchorRef = useRef(null);
  const panelRef = useRef(null);

  useClickAway([anchorRef, panelRef], () => setIsOpen(false), isOpen);
  useEscape(isOpen, () => setIsOpen(false));

  useEffect(() => {
    if (!isOpen) return;
    function onFocusIn(event) {
      const inTrigger = anchorRef.current && anchorRef.current.contains(event.target);
      const inPanel = panelRef.current && panelRef.current.contains(event.target);
      if (!inTrigger && !inPanel) setIsOpen(false);
    }
    document.addEventListener("focusin", onFocusIn, true);
    return () => document.removeEventListener("focusin", onFocusIn, true);
  }, [isOpen]);

  return <PopoverContext.Provider value={{ isOpen, setIsOpen, anchorRef, panelRef }}>{children}</PopoverContext.Provider>;
}

export function PopoverTrigger({ className, children, onClick, ...props }) {
  const { isOpen, setIsOpen, anchorRef } = usePopoverContext("PopoverTrigger");
  const handleClick = composeEventHandlers(onClick, () => setIsOpen(!isOpen));
  if (isVNode(children)) {
    return (
      <span ref={anchorRef} data-slot="popover-trigger" className={cx("inline-flex", className)} onClick={handleClick} {...props}>
        {children}
      </span>
    );
  }
  return (
    <button ref={anchorRef} type="button" data-slot="popover-trigger" className={cx("", className)} onClick={handleClick} {...props}>
      {children}
    </button>
  );
}

export function PopoverContent({ className, children, side = "bottom", align = "center", sideOffset = 8, ...props }) {
  const { isOpen, anchorRef, panelRef } = usePopoverContext("PopoverContent");
  const { top, left } = useAnchoredPosition(anchorRef, panelRef, { side, align, offset: sideOffset });

  if (!isOpen) return null;

  const node = (
    <div ref={panelRef} className="fixed z-50" style={{ top: `${top}px`, left: `${left}px` }}>
      <div
        tabIndex={-1}
        data-slot="popover-content"
        className={cx("w-72 rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none", className)}
        {...props}
      >
        {children}
      </div>
    </div>
  );

  return typeof document === "undefined" ? node : createPortal(node, document.body);
}

export default Popover;