Radio Group

Categorystyled
ArchitectureReact Client Component
Dependencies

Usage

Simple Demo

Privacy Options

Custom Demo

Please select your preferred colorscheme

Install Instructions

Copy and paste the code into that file

import {
  type ComponentPropsWithoutRef,
  type ElementType,
  type MutableRefObject,
  type ReactNode,
  createContext,
  createElement,
  useCallback,
  useContext,
  useRef,
} from "react";

//----- Third-Party Utils -----//
import { twMerge } from "tailwind-merge";
import { cva, type VariantProps } from "class-variance-authority";

//----- RadioGroup Context -----//
interface RadioGroupContextInterface {
  selected: string | undefined;
  defaultSelected: string | undefined;
  onSelected: undefined | ((v: string) => void);
  onButtonClick: (
    inputRef: MutableRefObject<HTMLInputElement | null>,
    radioElRef: MutableRefObject<unknown | null>
  ) => void;
  name: string;
  disabled: undefined | boolean;
}

const RadioGroupContext = createContext<RadioGroupContextInterface>({
  selected: undefined,
  defaultSelected: undefined,
  onSelected: () => {},
  onButtonClick: () => {},
  name: "",
  disabled: false,
});

//----- RadioGroup -----//
interface RadioGroupProps
  extends Omit<ComponentPropsWithoutRef<"fieldset">, "id"> {
  id: string;
  label: string;
  defaultSelected?: string;
  selected?: string;
  onSelected?: (v: string) => void;
}

export const RadioGroup = ({
  id,
  label,
  children,
  defaultSelected,
  selected,
  onSelected,
  disabled = false,
  ...props
}: RadioGroupProps) => {
  const onButtonClick = useCallback<
    RadioGroupContextInterface["onButtonClick"]
  >(
    (inputRef, radioElRef) => {
      if (disabled) return;
      const { current: input } = inputRef;
      const { current: radioEl } = radioElRef;

      if (input === null || radioEl === null) return;
      input.click();

      // Storing the selected item
      const targetId = input.id;

      // Manual Updates incase the there props doesn't include `selected`
      if (typeof selected === "undefined") {
        const buttons = document.querySelectorAll(`[data-fieldset-id=${id}]`);

        buttons.forEach((button) => {
          const isSelected =
            button.getAttribute("data-button-for") === targetId;

          button.setAttribute("data-selected", `${isSelected}`);
        });
      }

      // Storing the selected item in outer state
      onSelected && onSelected(targetId);
    },
    [selected, onSelected, id, disabled]
  );

  return (
    <fieldset id={id} {...props}>
      <span>
        <legend>{label}</legend>
      </span>
      <RadioGroupContext.Provider
        value={{
          selected,
          onSelected,
          onButtonClick,
          name: id,
          defaultSelected,
          disabled,
        }}
      >
        {children}
      </RadioGroupContext.Provider>
    </fieldset>
  );
};

//----- RadioItem -----//
interface BaseRadioItemProps<T extends ElementType> {
  label: string;
  id: string;
  displayLabel?: boolean;
  children?: ReactNode;
  component?: T;
  wrapperClassName?: string;
}

const radioButtonClassNames = cva(["not-prose", "relative"], {
  variants: {
    styled: {
      false: ["[&_*]:pointer-events-none"],
      true: [
        "shadow-neutral-100",
        "border-transparent",
        "dark:bg-neutral-600",
        "bg-neutral-400",
        "dark:data-[selected=true]:bg-primary-400",
        "data-[selected=true]:bg-primary-500",
        "rounded-full",
        "w-4",
        "h-4",
        "transition-all",
        "[box-shadow:_0_0_0_2px_transparent]",
        "hover:data-[selected=false]:[box-shadow:_0_0_0_2px_theme(colors.primary.400/75%)]",
      ],
    },
  },
  defaultVariants: {
    styled: true,
  },
});
const radioItemClassNames = cva(
  ["flex", "gap-2", "justify-start", "items-center", "content-center"],
  {
    variants: {
      stacking: {
        vertical: ["flex-col"],
        horizontal: [],
      },
    },
    defaultVariants: {
      stacking: "horizontal",
    },
  }
);

type RadioItemProps<T extends ElementType> = BaseRadioItemProps<T> &
  Omit<ComponentPropsWithoutRef<T>, keyof BaseRadioItemProps<T>> &
  VariantProps<typeof radioItemClassNames> &
  VariantProps<typeof radioButtonClassNames>;

export const RadioItem = <T extends ElementType = "button">({
  label,
  displayLabel = false,
  children,
  id,
  component,
  wrapperClassName = "",
  className,
  stacking,
  styled,
  ...props
}: RadioItemProps<T>) => {
  const inputRef = useRef<HTMLInputElement | null>(null);
  const radioElRef = useRef<T | null>(null);

  const { onButtonClick, selected, name, defaultSelected, disabled } =
    useContext(RadioGroupContext);

  return (
    <div
      className={twMerge(radioItemClassNames({ stacking }), wrapperClassName)}
    >
      {createElement(
        component ? component : "button",
        {
          ...props,
          className: twMerge(
            radioButtonClassNames({ styled }),
            className ? className : ""
          ),
          ref: radioElRef,
          onClick: onButtonClick.bind(null, inputRef, radioElRef),
          "data-selected": id === selected || id === defaultSelected,
          "data-button-for": id,
          "data-fieldset-id": name,
          "data-disabled": disabled,
        },
        children
      )}
      <input
        ref={inputRef}
        id={id}
        name={name}
        type="radio"
        style={{ display: "none" }}
      />
      <label
        style={
          !displayLabel
            ? {
                fontSize: "0rem",
                userSelect: "none",
                opacity: 0,
                color: "transparent",
                pointerEvents: "none",
                width: "0px",
                maxWidth: "0px",
                overflow: "hidden",
                position: "absolute",
              }
            : undefined
        }
        onClick={(e) => {
          e.preventDefault();
          onButtonClick(inputRef, radioElRef);
        }}
        htmlFor={id}
        className="text-sm opacity-75 hover:cursor-pointer hover:opacity-100 transition-opacity select-none"
      >
        {label}
      </label>
    </div>
  );
};

//----- Added Exports -----//
RadioGroup.Item = RadioItem;

Loading...