<!-- https://getkonjo.com/design/patterns/form · source: docs/design/patterns/form.md -->

# Forms

Fields, validation timing, error presentation, submit states.

> Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine apply here unless this chapter contradicts them.

> **Before you write a word of consequence copy**, read
> [product-facts.md](../product-facts.md). Who is alerted, what becomes permanent, who
> can read it afterwards and whether it can be undone are decided there — and where they
> are not, that is a blocker rather than something to phrase well.

A blind test of this document against the incident-report form found the guidance here was
22 words against 35 lines and two sketches for tab homes — a 30:1 fidelity gap, and worst
exactly where forms are dangerous. This is the repair.

```
(insets.top + spacing.tight)
✕                                   24pt glyph, 44pt target, no bottom border
                                 ↕24
SAFETY RECORD                         eyebrow · text.secondary
                                 ↕8
Report an incident                    display 34/800
                                 ↕8
Staff only. Students are never        caption · text.secondary
notified and never see this.
                                 ↕32
WHAT AND WHEN                         eyebrow — section header
                                 ↕12
One-line summary                      label 13/700 SENTENCE case · text.primary
┌──────────────────────────────┐ ↕6
│ Elbow to the nose            │      h≥44 · radius.input · 1px border.control
└──────────────────────────────┘      surface.card fill · body 15/400 · pad-h 12
                                 ↕16  ← between fields
Where                   Optional      optional marker: caption · text.secondary
┌──────────────────────────────┐
│ Mat, lobby, parking lot…     │      placeholder text.tertiary (legal — it is on white)
└──────────────────────────────┘
                                 ↕24  ← between sections
Severity
( Low )( Moderate )(▓Serious▓)        chips h32 · gap 8 · WRAP, never h-scroll
                                      selected = surface.hero + text.onHero
                                 ↕32
┌──────────────────────────────┐
│        Submit report         │      h48 · radius.input · one filled primary
└──────────────────────────────┘      label = rowTitle 16/600, NOT `label` 13 — see below
Save as draft                         ↕16 · text link · 44pt target
```

**Spacing:** label→input **6** · field→field **16** · section→section **24** ·
header block→first section **32** · last field→primary **32** · primary→text link **16**.

**On a screen that owes a consequence line**, that last 32 is spent differently. The full
sequence, so there is only one reading:

```
last field  ↕24  [ summary line ]  ↕6  [ consequence line ]  ↕12  primary
                                       [ failure block ]     ↕12  ← when a submit failed
```

**The failure block takes the consequence line's place**, in the same slot, 12pt above the
primary. It does not stack with it: once a submit has failed, what the button *would* do
matters less than what just happened, and two blocks of explanatory text above one button is a
wall. The consequence line comes back when the person edits anything, because at that point the
failure is stale.

A screen with no summary line simply drops that row and its 6: last field ↕24 consequence ↕12
primary. The 24 always attaches to the last *field*, whatever comes next.

**Inputs sit on the page, never inside a white card.** An input's fill is `surface.card`, so
an input inside a card is white-on-white with zero fill separation — it would survive on its
border alone. Group fields with space and an `eyebrow` header instead. (If a field group must
be a card, the inputs inside it invert to a `surface.page` fill: same 1.119:1 delta, other
way round.)

**Mark what is optional, not what is required.** Most fields in Konjo's forms are required;
marking the minority is quieter and reads faster. `Optional` in `caption` / `text.secondary`,
right-aligned on the label row.

**Chips wrap. They never scroll horizontally** — a horizontal strip hides options off-screen,
which is how the current incident form loses most of its severity choices.

**The reference target for this chapter** is
[`reference/konjo/form-target-light.jpg`](../reference/konjo/form-target-light.jpg) and its dark
twin — open one with Read and compare. It is generated from the token source, so it is current.

## The select trigger — the control that opens a sheet

A form field that opens a [sheet or picker](sheet-picker.md) is not a text input, and it is the
most common control on a real Konjo form. Its anatomy:

```
Student                                        label 13/700 sentence case
                                          ↕6
┌────────────────────────────────────────┐     minHeight controlHeight.control 44 — ALWAYS
│ Choose a student                     › │     surface.card · 1px border.control
└────────────────────────────────────────┘     radius.input · pad-h 12
                                               placeholder body 15/400 · text.tertiary
                                               chevron 16pt · text.tertiary
┌────────────────────────────────────────┐     same control, grown by its content
│ (AT) Ana Torres                      › │     an avatar plus padding is already 56
│      ● Green Belt · 14 months          │     value body 15/400 · text.primary
└────────────────────────────────────────┘     supporting caption 13/400 · text.secondary
```

- **It looks like an input because it is one.** Same fill, same border, same radius, same
  height floor. The only differences are the trailing chevron and that its content is a value
  rather than a cursor.
- **The value is `body`, the label above it is `label`.** A trigger that renders its value in
  the label's weight reads as a heading, not a field.
- **One floor, and it grows.** `controlHeight.control` 44 whether it holds a value or not.
  A value with an avatar or a second line passes 44 on its own — an avatar plus padding is
  already 56 — so there is no second floor to remember and a single-line value does not get
  inflated to 64. Never a fixed height, never truncation: a name is the one string nobody may
  mangle.
- **Empty state is a placeholder, not a label repeat.** "Choose a student", not "Student".
- **`text.tertiary` for the placeholder and chevron is legal here** because the trigger's fill
  is `surface.card`. On a trigger sitting directly on the page it would fail — use
  `text.secondary`.
- **Errors present exactly as an input's do**: border swaps to `feedback.errorBorder`, still
  1px, message in `caption` / `feedback.errorText` 6pt below.
- **Focus is 2px `border.focus`**, same as an input. A keyboard user tabs through triggers.
- **Screen reader:** one stop, `accessibilityRole="button"`, label reading
  `"<field>. <value>. <helper>."` — or `"<field>. none chosen."` when empty, and with
  `"Error: <message>"` appended in the error state. `fieldControlProps({ label, value, helper,
  error })` composes exactly that; pass the **value**, because a label without it tells a blind
  instructor which field they are on and nothing about what it holds. Decoration inside the
  trigger — avatar, rank swatch, chevron — is hidden.

**Validation belongs to the trigger, not the sheet.** A picker never validates; there is no
blur event on a control you tap, so a trigger validates **on submit** and then re-validates
live once it has been in an error state, so the error clears the moment it is fixed.

## The form's own frame

**Scroll inset.** A screen inside a tab stack clears the tab bar at `insets.bottom + 80`. **A
modal has no tab bar**, so it clears at `insets.bottom + spacing.section` — enough that the last
control is not against the home indicator, and no more.

**The primary sits in flow, at the end of the content — not stuck to the bottom.** A sticky
footer on a form implies you can submit at any point, which is false while three fields are
empty, and it permanently costs the height of a button on the smallest screens. Reaching the
button by scrolling past the fields is the correct order of operations. (`spacing.stickyTop` and
`spacing.stickyBottom` exist for the surfaces that genuinely need a pinned action — a media
viewer, a wizard step — and a form is not one.)

**The keyboard.** Nothing about this is optional, and none of it was written down:

- **The focused control stays visible.** Wrap the scroll in keyboard avoidance so the field
  being typed into is never behind the keyboard.
- **Never autofocus a form.** The person decides where to start, and a keyboard covering the
  screen on arrival hides the thing they came to read.
- **Return moves on, it does not submit.** In a single-line field, `returnKeyType="next"` moves
  to the next control; in the last field and in any multiline, it is `"default"` and inserts a
  newline. A form is never submitted by a key a person pressed to get to the next line.
- **Tapping outside dismisses the keyboard**, and dismissing never submits or validates.
- **Scroll position is preserved** when the keyboard opens and closes.

**Leaving with unsaved work.** `✕` or a back gesture with anything typed asks once, in a sheet —
never `Alert.alert`, which the design system has no control over:

```
Discard this promotion?                title 22/700, left-aligned
You'll lose what you've typed.         caption · text.secondary
[ Keep editing ]                       row, ≥44pt — the safe option FIRST
[ Discard ]                            row, accent.red — the destructive option SECOND
```

The safe option is first because it is the one a person taps by reflex. Nothing typed is lost
until the second row is chosen.

## Selection controls — checkbox, toggle, switch

The mobile system had none of these written down, which meant any screen needing an on/off
control invented one. Three shapes, chosen by what the control *does*:

| Shape | Use | Anatomy |
|---|---|---|
| **Checkbox** | One item in a set, where several can be on. "Send this note." | [`Checkbox`](../../../src/components/ui/Checkbox.tsx) — a 24pt box, `radius.input`, 1px `border.control`; checked fills `surface.hero` with a `text.onHero` check glyph. The whole row is the 44pt target, not the box. |
| **Switch** | An immediate setting that takes effect on flip, with no submit. Settings screens. | The platform switch, tinted `surface.hero` when on. Label left, switch right, row ≥44pt. |
| **Chip** | Choosing among options in a row, where the choice is *the content*. Filters. | As [search and filter](search-filter.md) — 32pt, `radius.pill`, `surface.card` + `border.control`, selected fills `surface.hero` on `surface.card`. |

**A checkbox is not a chip.** A chip is a value you are picking; a checkbox is a decision about
a thing that is already there. Using a chip as a checkbox is how a screen ends up with a row of
pills nobody can tell apart from filters.

- **Checked state is `accessibilityState={{ checked }}` with `accessibilityRole="checkbox"`** —
  never `{ selected }`, which is a chip's state. A switch is `role="switch"` with `{ checked }`.
- **The label is the tap target**, not just the box. A 24pt box alone is under the floor.
- **Never colour alone.** The check glyph carries the state as shape; the fill reinforces it.
- **Selection controls are exempt from the loud budget.** A filled checkbox is a control state,
  the same way a selected chip is.

## Repeating field groups

A form that collects *several of a thing* — test notes, emergency contacts, tags on a session.
The whole group is one [`Field`](../../../src/components/ui/Field.tsx); the members are rows
inside it.

```
Test notes                    Optional     one Field labels the GROUP
                                     ↕6
┌────────────────────────────────────┐     each row: minHeight controlHeight.control 44
│ Strong low blocks, timing improved │     surface.card · 1px border.control · radius.input
└────────────────────────────────────┘     body · grows to multiline as it wraps
☑ Send to Ana                          ↕6  the row's own control, under it, not beside
                                     ↕12   between members
┌────────────────────────────────────┐
│ Keeps dropping her guard when tired│
└────────────────────────────────────┘
☐ Send to Ana                          ↕6
                                     ↕12
┌────────────────────────────────────┐
│ Add a note…                        │     a ghost trailing row, always present
└────────────────────────────────────┘     placeholder text.tertiary
```

- **A ghost trailing row, never an "+ Add" button.** Typing into it spawns the next empty one.
  One less control, and the affordance is where the thumb already is. The ghost row never
  counts toward the group's total.
- **6pt from a member to its own control, 12pt between members.** The member and its control
  are one thing; the gap between members has to be the larger of the two or they read as a
  single list.
- **A member is an [`Input`](../../../src/components/ui/Input.tsx) with `multiline="grow"`** —
  it wraps and grows, but floors at 44 rather than at the long-form 88. A note is usually one
  line and occasionally two; three fields starting at 88 is a wall.
- **Deleting is emptying.** Clearing a row's text removes it on blur. No delete button per row:
  a destructive control repeated six times down a form is six chances to lose work.
- **Each member names itself for the screen reader** — "Note 2 of 6" — because `Field` labels
  the group and a bare input in a repeating list is otherwise unidentifiable.
- **If the group has a summary, it sits above the primary**, not inside the group. See below.

## A summary line above the primary

Some groups need a count of what will happen — *"2 of 6 notes will be sent to Ana."* It is
`caption` / `text.secondary`, on the page, **directly above the consequence line**, with 6pt
between them.

- **It is `accessibilityLiveRegion="polite"`.** When it is the only confirmation a person gets,
  a blind user has to hear it change.
- **It names the person, not the count alone.** "2 of 6" is arithmetic; "2 of 6 notes will be
  sent to Ana" is a fact about a human being.
- **Before the subject is chosen, it says the neutral form** — "2 of 6 notes will be sent to
  the student" — rather than rendering an empty name or hiding. **This holds for every
  name-bearing string on a screen**: a checkbox label, a consequence line, a confirmation. Each
  has a `{Name}` form and a neutral form, and the screen never renders a gap where a name goes.
- **Never a pronoun.** The app does not hold anyone's pronouns, so "she is notified" invents a
  fact about a real person. Repeat the name, or say "they".

## Helper text, and the consequence line

**Helper text sits 6pt under its field, in `caption` / `text.secondary`**, and says something
the label cannot: what the value will be used for ("Appears on the certificate"), who will see
it ("The student can read this"), or what it is relative to ("From Green Belt"). One line. If it
needs two, it is not helper text — it is a decision the field should be making for the person.

- **It survives the error.** An error message appears *below* the helper, not instead of it; the
  helper explains the field and the error explains the failure, and losing the first to show the
  second removes context exactly when someone is confused.
- **It is part of the field's accessible label**, not an `accessibilityHint` — a hint is for an
  outcome the label does not imply, and this is describing the field itself.
- **Never a place for instructions.** "Enter the student's full name" is the label failing.

**The consequence line** — the sentence a serious screen owes before the tap
([serious actions](destructive.md)) — is `caption` / `text.secondary`, on the page rather than
in a card, **12pt above the primary** — and 24 below the last field, or 6 below the summary
line when there is one. See the sequence above. It sits close enough to the
button to be read as part of it. It is never inside a feedback fill: it is not a warning, it is
a statement of what the button does.

## Other field types

- **Multiline** — [`Input`](../../../src/components/ui/Input.tsx) with `multiline`, flooring at
  `controlSize.multilineMinHeight` (88, about three lines of `body`), grows with content, never scrolls internally on a form. Same
  fill, border and radius as a single-line input, `body` type, 12pt padding.
- **Dates and times** — always the native picker, opened by a trigger as above. **On RN-web,
  which is the surface CI walks, "the native picker" is the browser's `date` input**; give it
  the same 44pt height and `border.control` edge so the form does not change shape between
  platforms.
- **Optional is marked, required is not.** Right-align the word `Optional` in `caption` /
  `text.secondary` on the label row. Marking every required field with an asterisk in a form
  where almost everything is required marks nothing.
- **Character counts** only where a real limit exists, `caption` / `text.secondary`, below
  right, appearing at 80% of the limit rather than sitting there from the first keystroke.

**Errors.** Validate on blur, never on keystroke — NN/g: "displaying error messages while the
user types feels like an unwarranted scolding." A field never touched shows nothing. On
submit, everything validates at once, the **first** invalid field shows its error, and the
scroll animates to it at `motion.duration.base` with focus moved there.

The error state is: the input's border swaps `border.control` → `feedback.errorBorder` (still
1px — 2px is the focus ring, and using it for errors would make the two states collide), and
the message appears 6pt below in `caption` / `feedback.errorText`. The message reflows; the
fields below shift down. Nothing truncates.

**Focus** is a **2px** `border.focus` ring replacing the 1px `border.control` — 4.67:1
against the unfocused state, which is what WCAG 2.4.13 asks for, and it describes exactly a
2px perimeter. `borderWidth.emphasis` 2 is legal here and in two other places only — see
[borders](../foundations/borders.md). Without it the
form is unusable on RN-web with a keyboard, which is the only surface CI actually walks.

**Never disable the submit button.** There is no disabled text colour in this system —
`text.quaternary` is explicitly not a text colour — so a greyed button has no legal label.
An always-enabled button that validates on tap and jumps to the problem also beats a dead
button that never says why. In flight: the fill stays, the label becomes "Submitting…", a
16pt spinner appears to its left, inputs go `editable={false}`, **triggers stop responding but
do not change appearance** — there is no legal disabled colour, and greying four controls to
say "briefly busy" costs more than it tells anyone — taps are ignored, and the
button carries **`accessibilityState={{ busy: true }}`** — the relabel and the spinner are both
visual, and without the state a screen-reader user cannot tell the tap registered.

**The success confirmation** is a `ValidationBanner` with `severity="success"` above the list
the record joined, one line, naming what happened and to whom. Not a toast — the row is already
on screen, and [notifications](notifications.md) forbids a toast for something the screen shows.

**A failed submit never replaces the form.** The Error formula below is for a screen that
failed to load; running it here would destroy everything the user typed. Instead, a bordered
block appears above the primary — what failed, in plain words, no error codes — and the
button relabels "Try again". Distinguish "nothing was lost, check your connection" from
"this was already submitted".

**Success is not a full-screen confirmation.** Pop back to the list the record now belongs
to, render it at the top, and put a one-line confirmation above it that says what happened
and to whom: *"Report submitted. Owners and head instructors were alerted."* A takeover hides
the very fact that matters — that the record exists now.
