what a value control reads from the field around it, without its layout
npx shadcn add @ag/field-contextAlready installed? Preview an update
npx shadcn add @ag/field-context --dry-runYour 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
"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
"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)
}
}