Field

PreviousNext

Shadcn Field with react aria components characteristics.

Docs
ouiui

Preview

Loading preview…
registry/default/ui/oui-field.tsx
"use client";

import type { VariantProps } from "class-variance-authority";
import * as React from "react";
import {
  FieldContent,
  FieldGroup,
  FieldLegend,
  FieldSeparator,
  FieldSet,
  FieldTitle,
} from "@/registry/default/ui/field";
import { Label } from "@/registry/default/ui/oui-label";
import { cva } from "class-variance-authority";
import * as Rac from "react-aria-components";
import { twMerge } from "tailwind-merge";
import { composeTailwindRenderProps } from "./oui-base";

export {
  FieldSet,
  FieldGroup,
  FieldLegend,
  FieldSeparator,
  FieldContent,
  FieldTitle,
};

/**
 * Derived from shadcn Field.
 */
export const fieldStyles = cva(
  "group/field flex w-full gap-3 data-invalid:text-destructive",
  {
    variants: {
      orientation: {
        vertical: ["flex-col *:w-full [&>.sr-only]:w-auto"],
        horizontal: [
          "flex-row items-center",
          "*:data-[slot=field-label]:flex-auto",
          "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
        ],
        responsive: [
          "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto [&>.sr-only]:w-auto",
          "@md/field-group:*:data-[slot=field-label]:flex-auto",
          "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
        ],
      },
    },
    defaultVariants: {
      orientation: "vertical",
    },
  },
);

export type FieldProps = React.ComponentProps<"div"> &
  VariantProps<typeof fieldStyles>;

/**
 * Oui RAC fields use specific slots (e.g., data-slot="text-field") plus data-slot-type="field".
 * Shadcn legacy styles still target data-slot="field", so we mirror selectors with data-slot-type="field".
 *
 * Why? Shadcn's Field component is a generic wrapper for layout (e.g., <Field><Label/><Input/></Field>).
 * RAC components like TextField are self-contained fields that render their own structure (label, input, etc.),
 * so they don't need an extra wrapper. To reuse fieldStyles for layout, we use data-slot-type="field"
 * on RAC components, allowing shared Tailwind selectors without breaking shadcn compatibility.
 */
export function Field({
  orientation = "vertical",
  className,
  ...props
}: FieldProps) {
  return (
    <div
      role="group"
      data-slot="field"
      data-slot-type="field"
      data-orientation={orientation}
      className={twMerge(fieldStyles({ orientation }), className)}
      {...props}
    />
  );
}

export type FieldStylesProps = VariantProps<typeof fieldStyles>;

/**
 * FieldLabel
 * Derived from shadcn FieldLabel.
 */
export function FieldLabel({
  className,
  ...props
}: React.ComponentProps<typeof Label>) {
  return (
    <Label
      data-slot="field-label"
      className={twMerge(
        "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-disabled/field:opacity-50",
        "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border *:data-[slot=field]:p-4",
        // Duplicate for Oui RAC components using data-slot-type=field
        "has-[>[data-slot-type=field]]:w-full has-[>[data-slot-type=field]]:flex-col has-[>[data-slot-type=field]]:rounded-md has-[>[data-slot-type=field]]:border *:data-[slot-type=field]:p-4",
        "has-data-selected:border-primary has-data-selected:bg-primary/5 dark:has-data-selected:bg-primary/10",
        // shadcn does not have. We use this for the case where an RAC control appears after the label.
        "group-has-data-disabled/field:opacity-50",
        className,
      )}
      {...props}
    />
  );
}

/**
 * FieldDescription
 * Derived from shadcn FieldDescription in field.tsx
 */
export function FieldDescription({ className, ...props }: Rac.TextProps) {
  return (
    <Rac.Text
      data-slot="field-description"
      slot="description"
      elementType="p"
      className={twMerge(
        "text-sm leading-normal font-normal text-muted-foreground group-has-data-[orientation=horizontal]/field:text-balance",
        // shadcn uses `last:mt-0` which breaks with RAC `aria-hidden`.
        // `&:not(:has(~_:not([aria-hidden])))` selects the last visible FieldDescription (no subsequent visible siblings).
        // - ~_ : any subsequent sibling (space from _)
        // - :not([aria-hidden]) : not hidden (visible)
        // - :has(...) : has a subsequent visible sibling
        // - :not(...) : does NOT have subsequent visible siblings → last visible
        "[&:not(:has(~_:not([aria-hidden])))]:mt-0",
        // shadcn uses `nth-last-2:-mt-1` which breaks with RAC `aria-hidden`.
        // `&:has(+_:not([aria-hidden])):not(:has(+_:not([aria-hidden])~_:not([aria-hidden])))` selects the second-to-last visible FieldDescription (exactly one subsequent visible sibling).
        // - +_ : immediately following sibling (space from _)
        // - :not([aria-hidden]) : not hidden (visible)
        // - First :has(...) : has an adjacent visible sibling
        // - Second :has(...) : that adjacent visible has another visible sibling after it
        // - :not(second :has) : the adjacent visible does NOT have another after → adjacent is last visible
        // - Combined: has adjacent visible (which is last) → this is second-to-last visible
        "[&:has(+_:not([aria-hidden])):not(:has(+_:not([aria-hidden])~_:not([aria-hidden])))]:-mt-1",
        "[[data-variant=legend]+&]:-mt-1.5",
        "[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
        // shadcn does not have. We use this for the case where an RAC control appears after the description.
        "group-has-data-disabled/field:opacity-50",
        className,
      )}
      {...props}
    />
  );
}

export function FieldError({
  className,
  children,
  ...props
}: Rac.FieldErrorProps) {
  // https://github.com/adobe/react-spectrum/issues/7525
  return (
    <Rac.TextContext.Provider value={{ elementType: "div" }}>
      <Rac.FieldError
        data-slot="field-error"
        className={composeTailwindRenderProps(
          className,
          "text-sm font-normal text-destructive",
        )}
        {...props}
      >
        {(renderProps) =>
          children ? (
            typeof children === "function" ? (
              children(renderProps)
            ) : (
              children
            )
          ) : renderProps.isInvalid ? (
            renderProps.validationErrors.length === 1 ? (
              renderProps.validationErrors[0]
            ) : (
              <ul className="ml-4 flex list-disc flex-col gap-1">
                {renderProps.validationErrors.map((error, index) => (
                  <li key={index}>{error}</li>
                ))}
              </ul>
            )
          ) : null
        }
      </Rac.FieldError>
    </Rac.TextContext.Provider>
  );
}

/**
 * Hook that generates an ID but only returns it if an element with that ID exists in the DOM.
 * Prevents dangling aria-describedby references when slotted elements are not rendered.
 *
 * Simplified version of RAC's useSlotId. Edge case: if a slotted child suspends after the
 * parent renders but before mounting, the ID may dangle until the next render. This doesn't
 * apply when Suspense wraps the entire Field—only when a slot child itself suspends.
 */
export function useSlotId(deps: React.DependencyList = []): string | undefined {
  const id = React.useId();
  const [resolvedId, setResolvedId] = React.useState<string | undefined>(id);
  React.useLayoutEffect(() => {
    setResolvedId(document.getElementById(id) ? id : undefined);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [id, ...deps]);
  return resolvedId;
}

/**
 * Field to compose a checkbox with a label and description.
 *
 * @example
 * ```tsx
 * #import * as Oui from "@/components/ui/oui-index";
 *
 * <Oui.FieldCheckbox>
 *   <Oui.Checkbox defaultSelected>Sync Desktop & Documents folders</Oui.Checkbox>
 *   <Oui.FieldContent>
 *     <Oui.FieldLabel>Sync Desktop & Documents folders</Oui.FieldLabel>
 *     <Oui.FieldDescription>
 *       Your Desktop & Documents folders are being synced with iCloud Drive.
 *       You can access them from other devices.
 *     </Oui.FieldDescription>
 *   </Oui.FieldContent>
 * </Oui.FieldCheckbox>
 * ```
 *
 * Not necessary if you have no description.
 */
export function FieldCheckbox({
  orientation = "horizontal",
  className,
  children,
  ...props
}: FieldProps) {
  const checkboxId = React.useId();
  const descriptionId = useSlotId();
  return (
    <div
      role="group"
      data-slot="field-checkbox"
      data-slot-type="field"
      data-orientation={orientation}
      className={twMerge(fieldStyles({ orientation }), className)}
      {...props}
    >
      <Rac.Provider
        values={[
          [
            Rac.CheckboxContext,
            { id: checkboxId, "aria-describedby": descriptionId },
          ],
          [Rac.LabelContext, { htmlFor: checkboxId }],
          [
            Rac.TextContext,
            {
              slots: {
                description: { id: descriptionId },
              },
            },
          ],
        ]}
      >
        {children}
      </Rac.Provider>
    </div>
  );
}

/**
 * Field to compose a switch with a label and field description.
 *
 * @example
 * ```tsx
 * #import * as Oui from "@/components/ui/oui-index";
 *
 * <Oui.FieldSwitch orientation="horizontal">
 *   <Oui.FieldContent>
 *     <Oui.FieldLabel>Multi-factor authentication</Oui.FieldLabel>
 *     <Oui.FieldDescription>
 *       Enable multi-factor authentication.
 *     </Oui.FieldDescription>
 *   </Oui.FieldContent>
 *   <Oui.Switch />
 * </Oui.FieldSwitch>
 * ```
 *
 * Not necessary if you have no description.
 */
export function FieldSwitch({
  orientation = "horizontal",
  className,
  children,
  ...props
}: FieldProps) {
  const switchId = React.useId();
  const descriptionId = useSlotId();
  return (
    <div
      role="group"
      data-slot="field-switch"
      data-slot-type="field"
      data-orientation={orientation}
      className={twMerge(fieldStyles({ orientation }), className)}
      {...props}
    >
      <Rac.Provider
        values={[
          [
            Rac.SwitchContext,
            { id: switchId, "aria-describedby": descriptionId },
          ],
          [Rac.LabelContext, { htmlFor: switchId }],
          [
            Rac.TextContext,
            {
              slots: {
                description: { id: descriptionId },
              },
            },
          ],
        ]}
      >
        {children}
      </Rac.Provider>
    </div>
  );
}

/**
 * Field to compose a radio with a label and description.
 *
 * Uses `slot="radioDescription"` to avoid conflict with RadioGroup's `description` slot.
 *
 * @example
 * ```tsx
 * #import * as Oui from "@/components/ui/oui-index";
 *
 * <Oui.FieldRadio>
 *   <Oui.Radio value="option1">Option 1</Oui.Radio>
 *   <Oui.FieldContent>
 *     <Oui.FieldLabel>Option 1</Oui.FieldLabel>
 *     <Oui.FieldDescription slot="radioDescription">
 *       Description for option 1.
 *     </Oui.FieldDescription>
 *   </Oui.FieldContent>
 * </Oui.FieldRadio>
 * ```
 *
 * Not necessary if you have no description.
 */
export function FieldRadio({
  orientation = "horizontal",
  className,
  children,
  ...props
}: FieldProps) {
  const radioId = React.useId();
  const descriptionId = useSlotId();
  return (
    <div
      role="group"
      data-slot="field-radio"
      data-slot-type="field"
      data-orientation={orientation}
      className={twMerge(fieldStyles({ orientation }), className)}
      {...props}
    >
      <Rac.Provider
        values={[
          [
            Rac.RadioContext,
            { id: radioId, "aria-describedby": descriptionId },
          ],
          [Rac.LabelContext, { htmlFor: radioId }],
          [
            Rac.TextContext,
            {
              slots: {
                radioDescription: { id: descriptionId },
              },
            },
          ],
        ]}
      >
        {children}
      </Rac.Provider>
    </div>
  );
}

Installation

npx shadcn@latest add @oui/oui-field

Usage

import { OuiField } from "@/components/ui/oui-field"
<OuiField />