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";

/**
 * DropdownMenu — shadcn's dropdown-menu, on Zebflow's engine.
 *
 * `DropdownMenu`/`DropdownMenuTrigger`/`DropdownMenuContent`/items compose
 * through `createContext` (no `child.type` detection). `DropdownMenuTrigger`
 * anchors and opens the panel — a real `<button>` for plain content, or an
 * `inline-flex` span around an element child (usually `<Button>`) so there
 * is no button-in-button — and carries `aria-haspopup="menu"` /
 * `aria-expanded`. `DropdownMenuContent` portals to `document.body` in the
 * browser (rendering inline during SSR) and positions against the trigger;
 * click-away checks both the trigger and the (portaled) panel and is gated
 * on `isOpen`.
 *
 * Arrow keys move real DOM focus between the panel's `[role="menuitem"]`
 * elements (read off the DOM, not React state — there is nothing to clone
 * item props into); Enter/Space activates whichever one has focus; Escape
 * and an outside click close the menu (`useEscape`'s stack means a nested
 * overlay's Escape does not also close this one). Tab closes the menu too,
 * but — unlike the rest — does not call `preventDefault`, so focus continues
 * to whatever the browser's normal tab order would land on next, matching
 * Radix rather than trapping focus inside an open menu. A click that
 * bubbles through any element carrying `data-dismiss` closes the menu,
 * except `DropdownMenuCheckboxItem` (a checkbox toggles and stays open, the
 * same as upstream) — `DropdownMenuRadioItem` keeps `data-dismiss`, since
 * selecting a radio item is upstream's cue to close.
 *
 * Dropped versus upstream: submenus (`DropdownMenuSub*`). A flyout submenu
 * needs its own hover-intent timer and anchored panel recursively, which is
 * disproportionate to what this gallery needs; nothing here stops one being
 * added the same way `Popover` was built.
 */

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

const DropdownMenuContext = createContext(null);

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

export function DropdownMenu({ 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));

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

export function DropdownMenuTrigger({ className, children, onClick, ...props }) {
  const { isOpen, setIsOpen, anchorRef } = useDropdownMenuContext("DropdownMenuTrigger");
  const handleClick = composeEventHandlers(onClick, () => setIsOpen(!isOpen));
  if (isVNode(children)) {
    return (
      <span
        ref={anchorRef}
        data-slot="dropdown-menu-trigger"
        className={cx("inline-flex", className)}
        aria-haspopup="menu"
        aria-expanded={isOpen}
        onClick={handleClick}
        {...props}
      >
        {children}
      </span>
    );
  }
  return (
    <button
      ref={anchorRef}
      type="button"
      data-slot="dropdown-menu-trigger"
      className={cx("", className)}
      aria-haspopup="menu"
      aria-expanded={isOpen}
      onClick={handleClick}
      {...props}
    >
      {children}
    </button>
  );
}

export function DropdownMenuContent({ className, children, ...props }) {
  const { isOpen, setIsOpen, anchorRef, panelRef } = useDropdownMenuContext("DropdownMenuContent");
  const { top, left } = useAnchoredPosition(anchorRef, panelRef, { side: "bottom", align: "start", offset: 4 });

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

  if (!isOpen) return null;

  function onKeyDown(event) {
    const container = panelRef.current;
    if (!container) return;
    const items = Array.from(container.querySelectorAll(ITEM_SELECTOR));
    if (event.key === "Tab") {
      setIsOpen(false);
      return;
    }
    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"]')) setIsOpen(false);
  }

  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="dropdown-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 DropdownMenuGroup({ className, children, ...props }) {
  return (
    <div role="group" data-slot="dropdown-menu-group" className={cx("", className)} {...props}>
      {children}
    </div>
  );
}

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

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

export function DropdownMenuShortcut({ className, children, ...props }) {
  return (
    <span data-slot="dropdown-menu-shortcut" className={cx("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props}>
      {children}
    </span>
  );
}

export function DropdownMenuItem({ className, inset, variant = "default", disabled, children, ...props }) {
  return (
    <div
      role="menuitem"
      tabIndex={-1}
      aria-disabled={disabled || undefined}
      data-dismiss="true"
      data-slot="dropdown-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 DropdownMenuCheckboxItem({ 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="dropdown-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 DropdownMenuRadioGroup({ className, children, ...props }) {
  return (
    <div role="group" data-slot="dropdown-menu-radio-group" className={cx("", className)} {...props}>
      {children}
    </div>
  );
}

export function DropdownMenuRadioItem({ className, checked, children, ...props }) {
  return (
    <div
      role="menuitemradio"
      tabIndex={-1}
      aria-checked={!!checked}
      data-dismiss="true"
      data-slot="dropdown-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 DropdownMenu;