zebflow 0.11.14

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

/**
 * Select — shadcn's select, on Zebflow's engine. Upstream's is a listbox,
 * not the native element, so that is what this is too: `role="listbox"` /
 * `role="option"` with real keyboard support.
 *
 * `Select`/`SelectTrigger`/`SelectContent`/`SelectItem` compose through
 * `createContext` instead of `child.type` detection or manual prop
 * threading: `Select` owns both whether the listbox is open *and* the
 * selected value (controlled via `value`/`onValueChange`, uncontrolled via
 * `defaultValue`, upstream's names either way), and hands both down through
 * context the way `RadioGroup` injects `checked` onto its items — every
 * `SelectItem` reads `selected`/`onSelect` from context unless it is given
 * its own (an optional override), and `SelectValue` falls back to the
 * context value when not given one explicitly. (`SelectValue` shows that
 * raw value, not the selected item's rendered label — there is no item
 * registry to look a label up from — so pass `value` explicitly for a
 * prettier display.)
 *
 * On open, focus moves into the panel — onto the selected option, or the
 * first one — instead of staying on the trigger with the keys doing
 * nothing; ArrowUp/ArrowDown on the trigger itself opens the listbox (which
 * then focuses in the same way). `SelectTrigger` carries `aria-expanded`,
 * `aria-controls`, and `aria-haspopup="listbox"`. `SelectContent` portals to
 * `document.body` in the browser, rendering inline during SSR; click-away
 * checks both the trigger and the (portaled) panel and is gated on
 * `isOpen`.
 */

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

const SelectContext = createContext(null);

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

export function Select({ value, defaultValue, onValueChange, open, defaultOpen = false, onOpenChange, children }) {
  const [isOpen, setIsOpen] = useControllable(open, defaultOpen, onOpenChange);
  const [currentValue, setValue] = useControllable(value, defaultValue ?? "", onValueChange);
  const anchorRef = useRef(null);
  const panelRef = useRef(null);
  const listboxId = useId();

  useClickAway([anchorRef, panelRef], () => setIsOpen(false), isOpen);
  useEscape(isOpen, () => setIsOpen(false));

  function selectValue(v) {
    setValue(v);
    setIsOpen(false);
  }

  return (
    <SelectContext.Provider value={{ isOpen, setIsOpen, value: currentValue, selectValue, anchorRef, panelRef, listboxId }}>
      <div data-slot="select" className="block w-full">
        {children}
      </div>
    </SelectContext.Provider>
  );
}

export function SelectTrigger({ className, children, onClick, onKeyDown, ...props }) {
  const { isOpen, setIsOpen, anchorRef, listboxId } = useSelectContext("SelectTrigger");

  function handleClick(event) {
    if (typeof onClick === "function") onClick(event);
    if (!event.defaultPrevented) setIsOpen(!isOpen);
  }
  function handleKeyDown(event) {
    if (typeof onKeyDown === "function") onKeyDown(event);
    if (event.defaultPrevented) return;
    if (event.key === "ArrowDown" || event.key === "ArrowUp") {
      event.preventDefault();
      setIsOpen(true);
    }
  }

  return (
    <button
      ref={anchorRef}
      type="button"
      role="combobox"
      aria-haspopup="listbox"
      aria-expanded={isOpen}
      aria-controls={listboxId}
      data-slot="select-trigger"
      onClick={handleClick}
      onKeyDown={handleKeyDown}
      className={cx(
        "flex w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
        className
      )}
      {...props}
    >
      {children}
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="size-4 shrink-0 opacity-50">
        <path d="m6 9 6 6 6-6" />
      </svg>
    </button>
  );
}

export function SelectValue({ value, placeholder, className, ...props }) {
  const { value: currentValue } = useSelectContext("SelectValue");
  const shown = value !== undefined ? value : currentValue || undefined;
  return (
    <span className={cx("line-clamp-1 flex items-center gap-2", className)} {...props}>
      {shown || <span className="text-muted-foreground">{placeholder}</span>}
    </span>
  );
}

export function SelectContent({ className, children, ...props }) {
  const { isOpen, setIsOpen, anchorRef, panelRef, listboxId } = useSelectContext("SelectContent");
  const { top, left } = useAnchoredPosition(anchorRef, panelRef, { side: "bottom", align: "start", offset: 4 });
  const width = anchorRef.current ? anchorRef.current.getBoundingClientRect().width : 0;

  useEffect(() => {
    if (!isOpen) return;
    const container = panelRef.current;
    if (!container) return;
    const selected = container.querySelector('[role="option"][aria-selected="true"]');
    const target = selected || container.querySelector(ITEM_SELECTOR);
    if (target) target.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 (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.preventDefault();
      if (document.activeElement && document.activeElement.click) document.activeElement.click();
    } else if (event.key === "Escape") {
      setIsOpen(false);
    }
  }

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

  const node = (
    <div
      ref={panelRef}
      id={listboxId}
      role="listbox"
      className="fixed z-50 max-h-96 overflow-auto"
      style={{ top: `${top}px`, left: `${left}px`, width: `${width}px` }}
      onKeyDown={onKeyDown}
      onClick={onClick}
    >
      <div data-slot="select-content" className={cx("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 SelectGroup({ className, children, ...props }) {
  return (
    <div role="group" data-slot="select-group" className={cx("", className)} {...props}>
      {children}
    </div>
  );
}

export function SelectLabel({ className, children, ...props }) {
  return (
    <div data-slot="select-label" className={cx("px-2 py-1.5 text-xs text-muted-foreground", className)} {...props}>
      {children}
    </div>
  );
}

export function SelectItem({ value, selected, disabled, className, children, onSelect, ...props }) {
  const { value: currentValue, selectValue } = useSelectContext("SelectItem");
  const isSelected = selected !== undefined ? selected : currentValue === value;

  function handleClick() {
    if (disabled) return;
    selectValue(value);
    onSelect && onSelect(value);
  }

  return (
    <div
      role="option"
      tabIndex={-1}
      aria-selected={!!isSelected}
      aria-disabled={disabled || undefined}
      data-dismiss="true"
      data-slot="select-item"
      onClick={handleClick}
      className={cx(
        "relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground",
        disabled ? "pointer-events-none opacity-50" : "",
        isSelected ? "bg-accent text-accent-foreground" : "",
        className
      )}
      {...props}
    >
      {children}
      {isSelected ? (
        <span className="absolute right-2 flex size-3.5 items-center justify-center">
          <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>
        </span>
      ) : null}
    </div>
  );
}

export default Select;