zebflow 0.11.14

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
import { useRef, useState, useEffect, useLayoutEffect, useCallback } from "zeb/react";

/**
 * hooks — the small behaviours every overlay in zeb/ui is built from. There is
 * no Radix here: these hooks (plus two small non-hook helpers) are the whole
 * primitive layer. Every other zeb/ui file imports what it needs from
 * "zeb/ui/hooks" the same way it would import a component.
 *
 * `useClickAway` and `useEscape` listen in the capture phase so an overlay
 * closes before a click or key reaches whatever is underneath it.
 * `useEscape` and `useFocusTrap` arbitrate stacked overlays through a
 * module-level stack: only the top-most *active* instance of each ever acts,
 * so nesting a Popover inside a Dialog does not close both on one Escape.
 */

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

function isFocusable(node) {
  if (!node) return false;
  if (node.hidden) return false;
  if (node.getAttribute("aria-hidden") === "true") return false;
  if (node.closest && node.closest("[inert]")) return false;
  if (typeof window !== "undefined" && window.getComputedStyle) {
    const style = window.getComputedStyle(node);
    if (style.display === "none" || style.visibility === "hidden") return false;
  }
  return true;
}

/** True for a zeb/react element vnode — used to detect "the caller already gave us an interactive child" without cloneElement. */
export function isVNode(value) {
  return !!value && typeof value === "object" && "type" in value;
}

/** Runs `ours` after `theirs` (a possibly-undefined prop handler), unless `theirs` called `event.preventDefault()`. */
export function composeEventHandlers(theirs, ours) {
  return (event) => {
    if (typeof theirs === "function") theirs(event);
    if (event && event.defaultPrevented) return;
    if (typeof ours === "function") ours(event);
  };
}

/** Calls `handler` when a pointer goes down (or a touch starts) outside every ref in `refs`, while `active`. */
export function useClickAway(refs, handler, active = true) {
  useEffect(() => {
    if (!active) return;
    const list = Array.isArray(refs) ? refs : [refs];
    function onPointerDown(event) {
      const inside = list.some((ref) => {
        const el = ref && ref.current;
        return el && el.contains(event.target);
      });
      if (inside) return;
      handler && handler(event);
    }
    document.addEventListener("mousedown", onPointerDown, true);
    document.addEventListener("touchstart", onPointerDown, true);
    return () => {
      document.removeEventListener("mousedown", onPointerDown, true);
      document.removeEventListener("touchstart", onPointerDown, true);
    };
  }, [refs, handler, active]);
}

let escapeStack = [];

/** Calls `handler` on Escape while `active` — but only for the top-most active caller, so stacked overlays dismiss one at a time. */
export function useEscape(active, handler) {
  const idRef = useRef(null);
  if (idRef.current === null) idRef.current = {};

  useEffect(() => {
    if (!active) return;
    const id = idRef.current;
    escapeStack = escapeStack.concat([id]);
    function onKeyDown(event) {
      if (event.key !== "Escape") return;
      if (escapeStack[escapeStack.length - 1] !== id) return;
      event.preventDefault();
      event.stopPropagation();
      handler && handler(event);
    }
    document.addEventListener("keydown", onKeyDown, true);
    return () => {
      document.removeEventListener("keydown", onKeyDown, true);
      escapeStack = escapeStack.filter((x) => x !== id);
    };
  }, [active, handler]);
}

let trapStack = [];

/**
 * Traps Tab/Shift+Tab inside `ref` while `active` — only for the top-most
 * active instance, so a popover opened from inside a dialog does not fight
 * the dialog for Tab. Focuses the first focusable element (or `ref` itself,
 * if there is none — callers give the container `tabIndex={-1}` for this) on
 * activation, and restores whatever had focus before it once `active` goes
 * back to false.
 */
export function useFocusTrap(ref, active) {
  const restoreRef = useRef(null);
  const idRef = useRef(null);
  if (idRef.current === null) idRef.current = {};

  useEffect(() => {
    if (!active) return;
    const el = ref && ref.current;
    if (!el) return;
    const id = idRef.current;
    trapStack = trapStack.concat([id]);
    restoreRef.current = document.activeElement;

    const focusables = () => Array.from(el.querySelectorAll(FOCUSABLE_SELECTOR)).filter(isFocusable);
    const first = focusables()[0];
    if (first) first.focus();
    else if (typeof el.focus === "function") el.focus();

    function onKeyDown(event) {
      if (event.key !== "Tab") return;
      if (trapStack[trapStack.length - 1] !== id) return;
      const items = focusables();
      if (items.length === 0) {
        event.preventDefault();
        el.focus();
        return;
      }
      const firstEl = items[0];
      const lastEl = items[items.length - 1];
      const activeIsInside = el.contains(document.activeElement) && items.indexOf(document.activeElement) !== -1;
      if (!activeIsInside) {
        event.preventDefault();
        (event.shiftKey ? lastEl : firstEl).focus();
        return;
      }
      if (event.shiftKey && document.activeElement === firstEl) {
        event.preventDefault();
        lastEl.focus();
      } else if (!event.shiftKey && document.activeElement === lastEl) {
        event.preventDefault();
        firstEl.focus();
      }
    }
    document.addEventListener("keydown", onKeyDown, true);

    return () => {
      document.removeEventListener("keydown", onKeyDown, true);
      trapStack = trapStack.filter((x) => x !== id);
      const toRestore = restoreRef.current;
      if (toRestore && typeof toRestore.focus === "function") toRestore.focus();
    };
  }, [ref, active]);
}

/**
 * The controlled/uncontrolled pattern shadcn's components all use: pass
 * `value` to run controlled, omit it (optionally with `defaultValue`) to let
 * the hook hold its own state. Returns `[value, set]` either way. `set`
 * accepts a plain value or a `(current) => next` updater; when uncontrolled,
 * the updater runs against real queued state (via `useState`'s own
 * functional-update form), so two updates in one tick compose instead of
 * clobbering each other.
 */
export function useControllable(value, defaultValue, onChange) {
  const [internal, setInternal] = useState(defaultValue);
  const isControlled = value !== undefined;

  const set = useCallback(
    (next) => {
      if (isControlled) {
        const resolved = typeof next === "function" ? next(value) : next;
        onChange && onChange(resolved);
        return;
      }
      let resolved;
      setInternal((current) => {
        resolved = typeof next === "function" ? next(current) : next;
        return resolved;
      });
      onChange && onChange(resolved);
    },
    [isControlled, value, onChange]
  );

  return [isControlled ? value : internal, set];
}

/**
 * Positions a floating panel against an anchor: fixed-coordinate `{top,
 * left}`, recomputed on resize/scroll and whenever the anchor or panel
 * change size (a `ResizeObserver` on both, not just window `resize`, so a
 * panel that grows after open — async content, fonts — repositions too).
 * Flips to the opposite side when the preferred side would overflow the
 * viewport.
 *
 * `options.watch` is an optional array of extra values to recompute on —
 * for an anchor that is not a real DOM node (`zeb/ui/context-menu`'s
 * pointer-anchor, whose `getBoundingClientRect()` reads wherever the click
 * landed) mutating a ref's `.current` does not by itself trigger this
 * effect, since the ref *object* passed in `anchorRef` never changes
 * identity; `watch` gives it something that does.
 */
export function useAnchoredPosition(anchorRef, panelRef, options) {
  const opts = options || {};
  const side = opts.side || "bottom";
  const align = opts.align || "center";
  const offset = opts.offset == null ? 8 : opts.offset;
  const watch = opts.watch || [];
  const [position, setPosition] = useState({ top: 0, left: 0 });
  // A panel usually mounts *after* the hook first ran (it renders only while
  // open), and a ref changing does not re-render anything. Notice the panel
  // element appearing or changing and run the placement again — otherwise
  // the first open sits at 0,0, the top-left of the page.
  const [mountTick, setMountTick] = useState(0);
  const seenPanel = useRef(null);
  useLayoutEffect(() => {
    const panel = panelRef && panelRef.current;
    if (panel !== seenPanel.current) {
      seenPanel.current = panel;
      setMountTick((n) => n + 1);
    }
  });

  useLayoutEffect(() => {
    const anchor = anchorRef && anchorRef.current;
    const panel = panelRef && panelRef.current;
    if (!anchor || !panel) return;

    function compute() {
      const a = anchor.getBoundingClientRect();
      const p = panel.getBoundingClientRect();
      const vw = window.innerWidth;
      const vh = window.innerHeight;
      let resolvedSide = side;
      if (resolvedSide === "bottom" && a.bottom + offset + p.height > vh) resolvedSide = "top";
      else if (resolvedSide === "top" && a.top - offset - p.height < 0) resolvedSide = "bottom";
      else if (resolvedSide === "right" && a.right + offset + p.width > vw) resolvedSide = "left";
      else if (resolvedSide === "left" && a.left - offset - p.width < 0) resolvedSide = "right";

      let top = 0;
      let left = 0;
      if (resolvedSide === "top" || resolvedSide === "bottom") {
        top = resolvedSide === "bottom" ? a.bottom + offset : a.top - offset - p.height;
        if (align === "start") left = a.left;
        else if (align === "end") left = a.right - p.width;
        else left = a.left + a.width / 2 - p.width / 2;
      } else {
        left = resolvedSide === "right" ? a.right + offset : a.left - offset - p.width;
        if (align === "start") top = a.top;
        else if (align === "end") top = a.bottom - p.height;
        else top = a.top + a.height / 2 - p.height / 2;
      }

      left = Math.max(4, Math.min(left, vw - p.width - 4));
      top = Math.max(4, Math.min(top, vh - p.height - 4));
      setPosition({ top, left });
    }

    compute();
    window.addEventListener("resize", compute);
    window.addEventListener("scroll", compute, true);
    // Not every anchor is a real element — zeb/ui/context-menu hands this a
    // plain { getBoundingClientRect } object standing in for the pointer,
    // and ResizeObserver.observe() throws a TypeError on anything that
    // isn't an Element.
    let observer = null;
    if (typeof ResizeObserver !== "undefined") {
      observer = new ResizeObserver(compute);
      if (anchor instanceof Element) observer.observe(anchor);
      if (panel instanceof Element) observer.observe(panel);
    }
    return () => {
      window.removeEventListener("resize", compute);
      window.removeEventListener("scroll", compute, true);
      if (observer) observer.disconnect();
    };
  }, [anchorRef, panelRef, side, align, offset, mountTick, ...watch]);

  return position;
}

const hooks = { useClickAway, useEscape, useFocusTrap, useControllable, useAnchoredPosition, isVNode, composeEventHandlers };
export default hooks;