zebflow 0.11.14

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

/**
 * RadioGroup — a set of mutually exclusive options. Composed through context
 * exactly as upstream Radix does: `RadioGroupItem` reads the group's current
 * value/name/disabled/onChange from `RadioGroupContext` no matter how deep it
 * is nested (the usual `<div><RadioGroupItem/><Label/></div>` layout works
 * unchanged). Controlled via `value`/`onValueChange` on the group (upstream
 * names), uncontrolled via `defaultValue`. `name` defaults to `useId()` so
 * two groups on the same page never collide through native radio grouping;
 * pass an explicit `name` only to opt into sharing one native group. Each
 * Item is a real native `<input type="radio">` (`sr-only`) inside a
 * `<label>`. As with Checkbox, the focus ring is tracked with
 * `onFocus`/`onBlur` state rather than a `peer-focus-visible:` variant — this
 * engine's Tailwind subset compiles no rule for the bare `peer` marker class.
 */

const RadioGroupContext = createContext(null);

export function RadioGroup({ value, defaultValue, onValueChange, name, disabled, className, children, ...props }) {
  const [internal, setInternal] = useState(defaultValue ?? "");
  const isControlled = value !== undefined;
  const current = isControlled ? value : internal;
  const generatedName = useId();
  const groupName = name ?? generatedName;

  function handleChange(itemValue) {
    if (!isControlled) setInternal(itemValue);
    onValueChange?.(itemValue);
  }

  const ctx = { name: groupName, value: current, disabled, onChange: handleChange };

  return (
    <div role="radiogroup" data-slot="radio-group" className={cx("grid gap-3", className)} {...props}>
      <RadioGroupContext.Provider value={ctx}>{children}</RadioGroupContext.Provider>
    </div>
  );
}

export function RadioGroupItem({ value, disabled, className, id, "aria-invalid": ariaInvalid, ...props }) {
  const ctx = useContext(RadioGroupContext);
  const [focused, setFocused] = useState(false);
  const checked = ctx?.value === value;
  const isDisabled = disabled || ctx?.disabled;

  return (
    <label className={cx("relative inline-flex size-4 shrink-0", isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer")}>
      <input
        type="radio"
        id={id}
        name={ctx?.name}
        value={value}
        checked={checked}
        disabled={isDisabled}
        onChange={() => ctx?.onChange?.(value)}
        onFocus={() => setFocused(true)}
        onBlur={() => setFocused(false)}
        aria-invalid={ariaInvalid}
        className="sr-only"
        {...props}
      />
      <span
        data-slot="radio-group-item"
        data-state={checked ? "checked" : "unchecked"}
        aria-hidden="true"
        className={cx(
          "flex aspect-square size-4 items-center justify-center rounded-full border border-input shadow-xs transition-colors",
          focused ? "border-ring ring-[3px] ring-ring/50" : "",
          ariaInvalid ? "border-destructive ring-destructive/20" : "",
          "bg-input/30",
          className
        )}
      >
        {checked ? <span data-slot="radio-group-indicator" className="size-2 rounded-full bg-primary" /> : null}
      </span>
    </label>
  );
}

export default RadioGroup;