zebflow 0.11.14

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

/**
 * ContextMenu — shadcn's context-menu, on Zebflow's engine. `ContextMenu`/
 * `ContextMenuTrigger`/`ContextMenuContent`/items compose through
 * `createContext` (no `child.type` detection): `ContextMenuTrigger` is the
 * element that listens for the browser's `contextmenu` event and doubles as
 * the click-away boundary; `ContextMenuContent` reads the click point off
 * context and anchors to it instead of to an element — done by handing
 * `useAnchoredPosition` a fake anchor object whose own
 * `getBoundingClientRect` returns a zero-size box at the click point, so it
 * gets the same viewport-flipping behaviour as every other anchored panel in
 * this library for free. That fake object is now stored directly as
 * `anchorRef.current` (not nested inside another `{ current }`, which made
 * `useAnchoredPosition` read `anchor.getBoundingClientRect` off `undefined`
 * and throw on the very first open), rebuilt fresh on every render so it
 * always closes over the latest point, and `useAnchoredPosition` is told to
 * `watch` that point — mutating `anchorRef.current` alone would not
 * otherwise re-trigger its position effect, so a second right-click while
 * the menu was already open used to keep the first click's coordinates.
 * `ContextMenuContent` portals to `document.body` in the browser and
 * renders inline during SSR.
 *
 * Same item/keyboard/`data-dismiss` behaviour as `zeb/ui/dropdown-menu`,
 * including checkbox items staying open and toggling instead of dismissing.
 * Dropped versus upstream: submenus, for the same reason `DropdownMenu`
 * drops them.
 */

const ITEM_SELECTOR = '[role^="menuitem"]:not([aria-disabled="true"])';

function pointerAnchor(point) {
  return {
    getBoundingClientRect: () => ({
      top: point.y,
      bottom: point.y,
      left: point.x,
      right: point.x,
      width: 0,
      height: 0,
    }),
  };
}

const ContextMenuContext = createContext(null);

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

export function ContextMenu({ children }) {
  const [point, setPoint] = useState(null);
  const rootRef = useRef(null);
  const panelRef = useRef(null);

  useClickAway([rootRef, panelRef], () => setPoint(null), !!point);
  useEscape(!!point, () => setPoint(null));

  return <ContextMenuContext.Provider value={{ point, setPoint, rootRef, panelRef }}>{children}</ContextMenuContext.Provider>;
}

export function ContextMenuTrigger({ className, children, onContextMenu, ...props }) {
  const { setPoint, rootRef } = useContextMenuContext("ContextMenuTrigger");
  function handleContextMenu(event) {
    if (typeof onContextMenu === "function") onContextMenu(event);
    if (event.defaultPrevented) return;
    event.preventDefault();
    setPoint({ x: event.clientX, y: event.clientY });
  }
  return (
    <div ref={rootRef} data-slot="context-menu-trigger" className={cx("", className)} onContextMenu={handleContextMenu} {...props}>
      {children}
    </div>
  );
}

export function ContextMenuContent({ className, children, ...props }) {
  const { point, setPoint, panelRef } = useContextMenuContext("ContextMenuContent");
  const anchorRef = useRef(null);
  if (point) anchorRef.current = pointerAnchor(point);
  const { top, left } = useAnchoredPosition(anchorRef, panelRef, {
    side: "bottom",
    align: "start",
    offset: 2,
    watch: point ? [point.x, point.y] : [],
  });

  useEffect(() => {
    if (!point) return;
    const first = panelRef.current && panelRef.current.querySelector(ITEM_SELECTOR);
    if (first) first.focus();
  }, [point]);

  if (!point) return null;

  function onKeyDown(event) {
    const container = panelRef.current;
    if (!container) return;
    const items = Array.from(container.querySelectorAll(ITEM_SELECTOR));
    if (items.length === 0) return;
    const index = items.indexOf(document.activeElement);
    if (event.key === "ArrowDown") {
      event.preventDefault();
      items[(index + 1 + items.length) % items.length].focus();
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      items[(index - 1 + items.length) % items.length].focus();
    } else if ((event.key === "Enter" || event.key === " ") && !event.defaultPrevented) {
      // Skip if the focused item already handled this key itself (a
      // checkbox item's own onKeyDown calls preventDefault before this
      // bubbles here) — bridging to a synthetic click on top of that would
      // fire the item's activation twice, toggling it and back.
      event.preventDefault();
      if (document.activeElement && document.activeElement.click) document.activeElement.click();
    }
  }

  function onClick(event) {
    const target = event.target;
    if (target && target.closest && target.closest('[data-dismiss="true"]')) setPoint(null);
  }

  const node = (
    <div ref={panelRef} role="menu" className="fixed z-50" style={{ top: `${top}px`, left: `${left}px` }} onKeyDown={onKeyDown} onClick={onClick}>
      <div
        data-slot="context-menu-content"
        className={cx("min-w-32 overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md", className)}
        {...props}
      >
        {children}
      </div>
    </div>
  );

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

export function ContextMenuGroup({ className, children, ...props }) {
  return (
    <div role="group" data-slot="context-menu-group" className={cx("", className)} {...props}>
      {children}
    </div>
  );
}

export function ContextMenuLabel({ className, inset, children, ...props }) {
  return (
    <div
      data-slot="context-menu-label"
      className={cx("px-2 py-1.5 text-sm font-medium text-foreground", inset ? "pl-8" : "", className)}
      {...props}
    >
      {children}
    </div>
  );
}

export function ContextMenuSeparator({ className, ...props }) {
  return <div role="separator" data-slot="context-menu-separator" className={cx("-mx-1 my-1 h-px bg-border", className)} {...props} />;
}

export function ContextMenuItem({ className, inset, variant = "default", disabled, children, ...props }) {
  return (
    <div
      role="menuitem"
      tabIndex={-1}
      aria-disabled={disabled || undefined}
      data-dismiss="true"
      data-slot="context-menu-item"
      className={cx(
        "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground",
        inset ? "pl-8" : "",
        disabled ? "pointer-events-none opacity-50" : "",
        variant === "destructive" ? "text-destructive focus:bg-destructive/10 focus:text-destructive" : "",
        className
      )}
      {...props}
    >
      {children}
    </div>
  );
}

export function ContextMenuCheckboxItem({ className, checked, onCheckedChange, onClick, children, ...props }) {
  function toggle() {
    onCheckedChange && onCheckedChange(!checked);
  }
  function handleClick(event) {
    if (typeof onClick === "function") onClick(event);
    if (!event.defaultPrevented) toggle();
  }
  function handleKeyDown(event) {
    if (event.key === "Enter" || event.key === " ") {
      event.preventDefault();
      toggle();
    }
  }
  return (
    <div
      role="menuitemcheckbox"
      tabIndex={-1}
      aria-checked={!!checked}
      data-slot="context-menu-checkbox-item"
      onClick={handleClick}
      onKeyDown={handleKeyDown}
      className={cx(
        "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground",
        className
      )}
      {...props}
    >
      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
        {checked ? (
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" className="size-4">
            <path d="M20 6 9 17l-5-5" />
          </svg>
        ) : null}
      </span>
      {children}
    </div>
  );
}

export function ContextMenuRadioGroup({ className, children, ...props }) {
  return (
    <div role="group" data-slot="context-menu-radio-group" className={cx("", className)} {...props}>
      {children}
    </div>
  );
}

export function ContextMenuRadioItem({ className, checked, children, ...props }) {
  return (
    <div
      role="menuitemradio"
      tabIndex={-1}
      aria-checked={!!checked}
      data-dismiss="true"
      data-slot="context-menu-radio-item"
      className={cx(
        "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground",
        className
      )}
      {...props}
    >
      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
        {checked ? <span className="size-2 rounded-full bg-foreground" /> : null}
      </span>
      {children}
    </div>
  );
}

export default ContextMenu;