← ui

field-context

ui

what a value control reads from the field around it, without its layout

npx shadcn add @ag/field-context
Already installed? Preview an update
npx shadcn add @ag/field-context --dry-run

Your installed source stays unchanged until you update it. How updates work

shelf controls

part of field

uses material

used by slider, number, checkbox, radio-group, switch, select, toggle-group, rating, vote, calendar, date-picker

the demo above, verbatim

field-context.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { Field } from "@/registry/base-nova/ui/field"
import { heldStill, useField } from "@/registry/base-nova/ui/field-context"
import { Switch } from "@/registry/base-nova/ui/switch"

// #region swatches
const SWATCHES = [
  { name: "ink", colour: "var(--foreground)" },
  { name: "green", colour: "var(--tone-ok)" },
  { name: "amber", colour: "var(--tone-warn)" },
  { name: "red", colour: "var(--tone-bad)" },
  { name: "blue", colour: "var(--tone-info)" },
]

/** A value control of your own: inside a field it takes the field's wiring (named by
 *  the label, described by the error) and holds still while the field is read-only. */
function Swatches({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  const field = useField()
  const change = heldStill(field, onValueChange)
  return (
    <div
      role="radiogroup"
      aria-labelledby={field?.labelId}
      aria-describedby={field?.wiring["aria-describedby"]}
      aria-invalid={field?.wiring["aria-invalid"]}
      className="flex gap-2"
    >
      {SWATCHES.map((s) => (
        // biome-ignore lint/a11y/useSemanticElements: a swatch is a radio drawn as its colour
        <button
          key={s.name}
          type="button"
          role="radio"
          aria-checked={value === s.name}
          aria-label={s.name}
          onClick={() => change?.(s.name)}
          className="size-6 rounded-full ring-current/40 ring-offset-2 ring-offset-background hover:ring-2 aria-checked:ring-2 aria-checked:ring-current"
          style={{ background: s.colour }}
        />
      ))}
    </div>
  )
}
// #endregion

export default function FieldContextDemo() {
  const [colour, setColour] = React.useState("green")
  const [held, setHeld] = React.useState(false)
  return (
    <div className="space-y-8">
      <Sample
        name="own"
        glance
        title="a control of your own"
        description="it reads its field: the label names it, the error describes it, and while the field is read-only (a form sending) it takes no change but keeps its focus"
        with="swatches"
      >
        <div className="flex flex-col gap-6">
          <Field
            label="label colour"
            readOnly={held}
            error={
              colour === "red"
                ? "red reads as an error, pick another"
                : undefined
            }
          >
            <Swatches value={colour} onValueChange={setColour} />
          </Field>
          <Switch checked={held} onCheckedChange={setHeld}>
            read-only (a form sending)
          </Switch>
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

field-context.tsx
"use client"

import * as React from "react"
import type { Material } from "@/registry/base-nova/ui/material"

// WHAT A CONTROL READS FROM THE FIELD AROUND IT, and nothing else: the context `field`
// provides, its own item so a value control (a number, a slider, a select, a switch)
// can read it without installing the field's layout, its label and its error row.
//
// Inside a field a value control applies `wiring` to its own focusable element (id,
// the description and the error, invalid, required, disabled), holds still while
// `readOnly` (a form sending: it takes no change, keeps its focus, is never disabled),
// and names a group of itself by `labelId`. Binding it is then only its value. What a
// consumer passes explicitly wins over the field's.

/** What a control spreads on its focusable element. */
export interface FieldWiring {
  id: string
  "aria-describedby"?: string
  "aria-invalid"?: true
  "aria-required"?: true
  disabled?: boolean
}

/** What a control inside a field reads (`useField()`). */
export interface FieldContextValue {
  wiring: FieldWiring
  /** The label's own id, for a control named by `aria-labelledby`. */
  labelId: string
  /** The label as words, where it is words (a control that takes a name as text). */
  label?: string
  /** Something is wrong with it (the same fact as `wiring["aria-invalid"]`). */
  invalid: boolean
  /** Hold still: take no change, keep the focus (a form sending). Never disabled. */
  readOnly: boolean
  material: Material
}

export const FieldContext = React.createContext<FieldContextValue | null>(null)

/** The field around a control, or null outside one. */
export const useField = () => React.useContext(FieldContext)

/** The field's wiring as a value control's own props, spread BEFORE the consumer's so
 *  what it passes wins. `named`: the control is a role on an element a `<label for>`
 *  cannot name (Base UI's checkbox, switch, radio group are spans), so the field's
 *  label names it by id. `readOnly` for a control that takes it (it keeps its focus). */
export function fieldProps(
  field: FieldContextValue | null,
  { named = false }: { named?: boolean } = {},
): Partial<FieldWiring> & { "aria-labelledby"?: string; readOnly?: boolean } {
  if (!field) return {}
  return {
    ...field.wiring,
    ...(named ? { "aria-labelledby": field.labelId } : {}),
    ...(field.readOnly ? { readOnly: true } : {}),
  }
}

/** A value control's change, held still while its field is read-only: the change
 *  is dropped, never the control disabled (it keeps its focus). */
export function heldStill<A extends unknown[]>(
  field: FieldContextValue | null,
  change: ((...args: A) => void) | undefined,
): ((...args: A) => void) | undefined {
  if (!change) return change
  return (...args: A) => {
    if (field?.readOnly) return
    change(...args)
  }
}