zebflow 0.11.14

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

/**
 * Tabs — shadcn/ui's tabs, on Zebflow's engine.
 *
 * `Tabs` holds the active value — controlled via `value`/`onValueChange`,
 * uncontrolled off `defaultValue` — in a `TabsContext` and hands it straight
 * to `TabsList`/`TabsTrigger`/`TabsContent` at any depth, the same way
 * Radix's `TabsPrimitive.Root` does: a shadcn snippet's nesting works
 * unchanged, with no render-prop and no required `active`/`onClick` on the
 * sub-parts (they remain as optional overrides for a caller that wants to
 * drive its own state). `Tabs` also mints one `useId()` per instance so
 * every trigger/panel pair gets matching, collision-free `id` /
 * `aria-controls` / `aria-labelledby`.
 *
 * Arrow/Home/End keys move focus across `[role="tab"]` triggers via one
 * delegated `onKeyDown` on `TabsList` (no per-trigger index needed). Roving
 * `tabIndex` normally follows the active trigger; if no trigger matches the
 * current value at all (e.g. `defaultValue` was omitted), a render effect on
 * `TabsList` makes the first enabled trigger reachable instead, so the
 * tablist is never entirely unreachable by keyboard. `TabsContent` stays
 * mounted and toggles the native `hidden` attribute instead of unmounting,
 * so an inactive panel's own state (scroll position, form input, …)
 * survives switching away and back.
 */

const TabsContext = createContext({ value: "", setValue: () => {}, baseId: "" });

export function Tabs({ value, defaultValue, onValueChange, className, children, ...props }) {
  const [current, setValue] = useControllable(value, defaultValue ?? "", onValueChange);
  const baseId = useId();
  return (
    <TabsContext.Provider value={{ value: current, setValue, baseId }}>
      <div data-slot="tabs" className={cx("flex flex-col gap-2", className)} {...props}>
        {children}
      </div>
    </TabsContext.Provider>
  );
}

export function TabsList({ className, children, ...props }) {
  const ref = useRef(null);

  useEffect(() => {
    const root = ref.current;
    if (!root) return;
    const tabs = Array.from(root.querySelectorAll('[role="tab"]'));
    if (tabs.length === 0 || tabs.some((t) => t.getAttribute("aria-selected") === "true")) return;
    const first = tabs.find((t) => !t.disabled) ?? tabs[0];
    tabs.forEach((t) => {
      t.tabIndex = t === first ? 0 : -1;
    });
  });

  const onKeyDown = (e) => {
    if (!["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return;
    const tabs = Array.from(e.currentTarget.querySelectorAll('[role="tab"]:not(:disabled)'));
    if (tabs.length === 0) return;
    const from = tabs.indexOf(document.activeElement);
    let to = from;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") to = from < 0 ? 0 : (from + 1) % tabs.length;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") to = from < 0 ? tabs.length - 1 : (from - 1 + tabs.length) % tabs.length;
    else if (e.key === "Home") to = 0;
    else if (e.key === "End") to = tabs.length - 1;
    e.preventDefault();
    tabs[to].focus();
    tabs[to].click();
  };

  return (
    <div
      ref={ref}
      role="tablist"
      data-slot="tabs-list"
      onKeyDown={onKeyDown}
      className={cx(
        "inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
        className
      )}
      {...props}
    >
      {children}
    </div>
  );
}

export function TabsTrigger({ value, active, disabled, className, children, onClick, id, ...props }) {
  const ctx = useContext(TabsContext);
  const isActive = active !== undefined ? active : ctx.value === value;
  const triggerId = id ?? `${ctx.baseId}-trigger-${value}`;
  const handleClick = onClick ?? (() => ctx.setValue(value));
  return (
    <button
      type="button"
      role="tab"
      id={triggerId}
      aria-controls={`${ctx.baseId}-content-${value}`}
      data-slot="tabs-trigger"
      data-state={isActive ? "active" : "inactive"}
      aria-selected={isActive}
      tabIndex={isActive ? 0 : -1}
      disabled={disabled}
      onClick={disabled ? undefined : handleClick}
      className={cx(
        "inline-flex flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium text-foreground/60 transition-all hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:text-muted-foreground dark:hover:text-foreground",
        className
      )}
      {...props}
    >
      {children}
    </button>
  );
}

export function TabsContent({ value, active, id, className, children, ...props }) {
  const ctx = useContext(TabsContext);
  const isActive = active !== undefined ? active : ctx.value === value;
  return (
    <div
      role="tabpanel"
      id={id ?? `${ctx.baseId}-content-${value}`}
      aria-labelledby={`${ctx.baseId}-trigger-${value}`}
      hidden={!isActive}
      data-slot="tabs-content"
      data-state={isActive ? "active" : "inactive"}
      className={cx("flex-1 outline-none", className)}
      {...props}
    >
      {children}
    </div>
  );
}

export default Tabs;