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 } from "zeb/react";

/**
 * InputOTP — a one-time-passcode field: `maxLength` single-character native
 * inputs that behave like one control. Composed through context exactly as
 * upstream composes its `OTPInputContext`: `InputOTPSlot` reads the joined
 * value, which slot is active, and the mutation callbacks from
 * `OtpContext` no matter how deep `InputOTPGroup` nests it. Controlled via
 * `value`, uncontrolled via `defaultValue`, like upstream. Differences from
 * upstream's `input-otp` library: each slot is its own visible
 * `<input>` (there is no single hidden full-width input with a fake caret),
 * so `data-active` reflects real DOM focus rather than a synthesized caret
 * position, and OS-level SMS autofill can only ever land on one slot — the
 * first, which carries `autocomplete="one-time-code"` as the best available
 * hook for it. A paste anywhere in the field is still distributed across
 * slots from that point on, and ArrowLeft/Right move between slots like
 * upstream.
 */

const OtpContext = createContext(null);

export function InputOTP({ maxLength = 6, value, defaultValue = "", onChange, onComplete, disabled, containerClassName, className, children, ...props }) {
  const [internal, setInternal] = useState(defaultValue);
  const isControlled = value !== undefined;
  const current = isControlled ? value : internal;
  const [activeIndex, setActiveIndex] = useState(-1);
  const inputsRef = useRef([]);

  function commit(next) {
    const trimmed = next.replace(/ +$/, "").slice(0, maxLength);
    if (!isControlled) setInternal(trimmed);
    onChange?.(trimmed);
    if (trimmed.length === maxLength) onComplete?.(trimmed);
    return trimmed;
  }

  function setChar(index, char) {
    const chars = current.padEnd(maxLength, " ").split("");
    chars[index] = char || " ";
    commit(chars.join(""));
  }

  // Distributes a pasted (or autofilled) string across slots starting at
  // `startIndex`; returns the index just past the last character written so
  // the caller can move focus there.
  function setChars(startIndex, str) {
    const chars = current.padEnd(maxLength, " ").split("");
    let i = startIndex;
    for (const ch of str) {
      if (i >= maxLength) break;
      chars[i] = ch;
      i++;
    }
    commit(chars.join(""));
    return i;
  }

  function focus(index) {
    const clamped = Math.max(0, Math.min(maxLength - 1, index));
    inputsRef.current[clamped]?.focus();
  }

  const ctx = {
    value: current,
    disabled,
    maxLength,
    activeIndex,
    setActiveIndex,
    setChar,
    setChars,
    focus,
    registerRef: (i, el) => {
      inputsRef.current[i] = el;
    },
  };

  return (
    <div data-slot="input-otp" className={cx("flex items-center gap-2", disabled ? "opacity-50" : "", containerClassName, className)} {...props}>
      <OtpContext.Provider value={ctx}>{children}</OtpContext.Provider>
    </div>
  );
}

export function InputOTPGroup({ className, children, ...props }) {
  return (
    <div data-slot="input-otp-group" className={cx("flex items-center", className)} {...props}>
      {children}
    </div>
  );
}

export function InputOTPSlot({ index, className, ...props }) {
  const otp = useContext(OtpContext);
  const char = (otp?.value?.[index] ?? "").trim();
  const isActive = otp?.activeIndex === index;

  return (
    <div
      data-slot="input-otp-slot"
      data-active={isActive ? "true" : "false"}
      className={cx(
        "relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-xs outline-none first:rounded-l-md first:border-l last:rounded-r-md",
        isActive ? "z-10 border-ring ring-[3px] ring-ring/50" : "",
        className
      )}
    >
      <input
        ref={(el) => otp?.registerRef?.(index, el)}
        type="text"
        inputMode="numeric"
        autoComplete={index === 0 ? "one-time-code" : "off"}
        maxLength={1}
        value={char}
        disabled={otp?.disabled}
        onFocus={() => otp?.setActiveIndex?.(index)}
        onBlur={() => otp?.setActiveIndex?.(-1)}
        onChange={(e) => {
          const next = e.target.value.slice(-1);
          otp?.setChar?.(index, next);
          if (next) otp?.focus?.(index + 1);
        }}
        onPaste={(e) => {
          const text = e.clipboardData?.getData("text") ?? "";
          const digits = text.replace(/\s/g, "");
          if (!digits) return;
          e.preventDefault();
          const nextIndex = otp?.setChars?.(index, digits) ?? index;
          otp?.focus?.(nextIndex);
        }}
        onKeyDown={(e) => {
          if (e.key === "Backspace" && !char) otp?.focus?.(index - 1);
          else if (e.key === "ArrowLeft") {
            e.preventDefault();
            otp?.focus?.(index - 1);
          } else if (e.key === "ArrowRight") {
            e.preventDefault();
            otp?.focus?.(index + 1);
          }
        }}
        className="absolute inset-0 h-full w-full cursor-text rounded-[inherit] bg-transparent text-center outline-none"
        aria-label={`Digit ${index + 1}`}
        {...props}
      />
    </div>
  );
}

export function InputOTPSeparator({ ...props }) {
  return (
    <div data-slot="input-otp-separator" role="separator" {...props}>
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="size-4">
        <path d="M5 12h14" />
      </svg>
    </div>
  );
}

export default InputOTP;