Ansora UI
Components

AI field

A labelled frame around one extracted field: the caller's control, an optional confidence band, a confirm decision and — when a human changed it — the visible original and a way back.

conversationdata

Install

pnpm dlx shadcn@4.23.0 add @ansora/ai-field

Examples

Usage

It owns nothing, and that is the whole design. No value, no defaultValue, no onChange on the value, no name, no error, no required, no form-library binding. You build the control yourself and hand it over in control; the value, its validation and whichever form layer your product picked all stay on the screen. The one control this component draws for you is the confirm checkbox, and it is controlled like every other piece of view state in this registry: confirm={{ title, checked, onChange }} — checked in, onChange out. Leave confirm off and no checkbox is rendered at all.

state is provenance ONLY — suggested, edited or rejected, i.e. who last touched the field. Whether a human has confirmed it is a separate fact and lives in confirm.checked. The two are orthogonal on purpose: edited and then confirmed is the scribe's central flow, and a four-member enum with confirmed in it cannot express it — ticking the box would move the field into a branch that forbids stateLabel, history and revert, so confirming would erase the visible original and the way back. If your data model says "confirmed", map it onto state: "suggested" plus confirm.checked === true.

control has to be a LABELABLE element carrying id={controlId} — <input>, <select>, <textarea>. The component renders a real <label htmlFor={controlId}>, and <label for> does not associate with a contenteditable div: it fails silently, with no error and no warning, leaving a control with no accessible name. Nothing can check this for you — it is a contract with the caller. If you genuinely need an ARIA widget instead of a native control, the <label> also carries id={controlId + "-label"}, so point at it with aria-labelledby.

confidence is a pre-computed band, never a score. Pass ConfidenceBand ({ level, label }) from @ansora/core, the same type @ansora/transcript takes, so the two cannot show one model output as two different things. The raw number does not belong here: it would be banded once per extracted field, and a value outside 0..1 would throw in the middle of a form. ok renders nothing at all — no chip, no word, no colour. Silence is the correct signal for "no action needed", and it is what keeps forty fields quiet; it is also why there is no affirmative green chip to be tempted to paint, since green is the brand colour and never a state. review and low render a @ansora/status-pill inside the label, each with its own non-hue glyph.

Every name this component draws identifies its field. The confirm checkbox and the revert button each append the field's title as visually-hidden text after their visible word, so a form-controls rotor on a forty-field screen reads "Potvrdit Diagnóza" and "Vrátit Dávka" rather than forty entries all called "Potvrdit". No prop pays for it — the caption is already required, already Czech and already yours.

confirm.undecidedLabel is required, and it is the reason the rail is not the only carrier. The inline-start bar that marks a field as still waiting is a border: it reaches a sighted user and nobody else, and WCAG 1.4.1 is not satisfied by shape any more than by hue. That string is rendered visually-hidden as the first thing in the field, so a screen-reader user browsing the form meets "Čeká na potvrzení" before the caption — the same three-carrier arrangement @ansora/transcript uses for its cited row (tint, rail, hidden word). It is Czech and yours because it says what waiting means on your screen.

readOnly={{ label: … }} is the signed-record path. Pass it once the report is signed, sent or otherwise closed. The confirm checkbox stays on screen and stays ticked — dropping it would erase exactly the evidence a signature exists to preserve — but it becomes aria-disabled and its activation is cancelled outright, so it cannot toggle and snap back under the user's finger. aria-disabled rather than the disabled attribute is deliberate: disabled removes the control from the tab order, and with it the only place a keyboard user could learn why the field is frozen. The revert button is not rendered — a way back that cannot be taken is an affordance that lies — while history stays, because what changed is part of the record. label is the invisible reason ("Zpráva je podepsaná, změny už nejsou možné"), required because aria-disabled says a control cannot be used and never says why. The inline-start rail is off for a frozen field: the signature already answered for it.

Pass confirm only to fields that actually want an individual decision. In the scribe that is the review and low bands plus whatever the product designates — roughly six checkboxes instead of forty. The bulk act is the signature at the bottom of the document, not a "Potvrdit vše" button: a control that ticks forty boxes at once is a rubber stamp with a label on it. Counting undecided fields and blocking the signature is the screen's job; this component renders low, it does not disable anything.

stateLabel, history and revert travel together and are required exactly when state is edited or rejected — recoverable AND visible, both or neither. Fill history with @ansora/value-change, composed at the call site so the caption is not rendered twice and so each product can put the pair where it wants it (inline for a receptionist's panel, a side pane for the scribe):

<ValueChange title="Původní návrh" from={{ label: "Návrh AI", value: "5 mg 1-0-0" }} to={{ label: "Lékař", value: "2,5 mg 1-0-0" }} />

source is a free ReactNode slot for "where did this come from" — a button that scrolls the transcript, a link. It is a slot, never a popover: nothing here opens on click, because a static first paint is all pnpm shots can measure, and the confirm checkbox, the revert button and whatever you put in source are all measured against the 24×24 px floor precisely because none of them is hidden behind one.

It ships "use client". confirm.onChange and revert.onRevert are functions this component hangs on real elements, and a Server Component cannot attach an event handler at all. A function still does not cross the Server→Client boundary, so a Server Component cannot build those handlers either — a no-op is still a function — which means every real use puts this component under a client parent that owns the state. An earlier version of this paragraph claimed the opposite and four of the seven fixture scenarios returned HTTP 500 from a production build while the whole test suite stayed green.

Budget 3 elements for a bare field, 16 with every slot filled (the hidden waiting carrier, band pill, control, provenance pill, history, confirm checkbox with its hidden field name, revert button with its own, and source). Both figures come from a real react-dom/server render rather than a hand count, and they are what tells you whether a screen crosses Lighthouse's 800-element warning: forty fully-filled fields are ~640 elements before the rest of the page.

import { AiField } from "@/components/ansora/ai-field"
import { ValueChange } from "@/components/ansora/value-change"

<AiField
  state="edited"
  controlId="davka"
  title="Dávka"
  control={<Input id="davka" value={value} onChange={(e) => setValue(e.currentTarget.value)} />}
  confidence={{ level: "low", label: "Nízká jistota" }}
  confirm={{ title: "Potvrdit", checked: confirmed, onChange: setConfirmed }}
  stateLabel="Upraveno lékařem"
  history={
    <ValueChange
      title="Původní návrh"
      from={{ label: "Návrh AI", value: "5 mg 1-0-0" }}
      to={{ label: "Lékař", value: "2,5 mg 1-0-0" }}
    />
  }
  revert={{ title: "Vrátit návrh AI", onRevert: restore }}
/>

Files

Dependencies

  • @ansora/base
  • @ansora/status-pill
  • @ansora/value-change

On this page