[@layout]: ../docs.layout.md

# Input forms

Core Input turns authored controls into response forms. A response belongs to
one Block; its saved draft and submitted response remain separate.

---

[@include](../extend/state.reference.md#storage-requirements)

The example below uses Auth's local Ada setup and the initialized project from
that walkthrough. Core Input is already installed in the standard Plugins.

---

## Author a response

Create `paths/reflection.page.md`:

```md
# A small improvement

---

<!-- id: reflection -->

## What would you change?

<x-input-textarea name="idea" label="Your idea" required />

<x-input-submit label="Save reflection" update-label="Update reflection" />

Saved idea: {{ $input.responses(source=this.source, response="reflection").values.idea ?? "No saved idea" }}
```

Run `bun run dev`, sign in as Ada, and open `/reflection.page`.
Enter “Add a worked example” and choose **Save reflection**. The saved idea now
shows that text. Change the editor to “Add a diagram” without saving: the saved
idea remains “Add a worked example”. Reload; the submitted value remains saved,
and draft recovery can restore the newer editor text when browser storage is available.
Choose **Update reflection** to commit the second version, then reload to verify it.

A required empty field cannot submit. If saving is unavailable after filling it,
check the session and writable Home before retrying. Draft recovery is best-effort;
only a successful submission commits the response Source.

A single response can omit its Block id, in which case its response id is
`source`. Multiple response Blocks must each author a stable id. Keep field
names and Block ids stable so saved responses continue to identify their fields.

---

## Choose controls

Input response Blocks wrap their content in a `[data-pmx-input-form]` element.
Use a descendant selector such as `[data-pmx-block] .pmx-heading` when styling
headings across ordinary and Input Blocks; the heading is not always a direct
child of the Block.

A custom component with `input: true` edits its one named field. An editor that
needs several fields declares their prop keys, for example `input: ["name", "mark"]`.
Each prop must name an existing field in the same response Block. Neither form
grants access to undeclared neighboring fields. See
[Custom Input clients](./components.reference.md#custom-input-clients) for field
handles and subscriptions.


| Component | Required attributes | Optional attributes |
| --- | --- | --- |
| `x-input-text` | `name`, `label` | `placeholder`, `required`, `max-length`, `max-words` |
| `x-input-textarea` | `name`, `label` | `placeholder`, `required`, `max-length`, `max-words` |
| `x-input-radio` | `name`, `value`, `label` | `required` |
| `x-input-checkbox` | `name`, `value`, `label` | `required`, `min`, `max`, `exclusive` |
| `x-input-submit` | `label` | `update-label`, `blocks` |
| `x-input-value` | `name` | — |

Choices with the same `name` form a field. Use radio controls for one choice
and checkboxes for multiple choices. Wrap related choices in a native
`fieldset` with a `legend`:

```md
<fieldset>
<legend>Where would you begin?</legend>
<x-input-radio name="approach" value="clarity" label="Make it clearer" required />
<x-input-radio name="approach" value="simplicity" label="Make it simpler" required />
</fieldset>

<x-input-submit label="Save choice" />
```

Use at most one local submit control in each response Block. `required` is a presence
attribute. For checkbox groups, `required` means at least one selected value;
it never makes every checkbox mandatory. `min="2"` requires at least two selections,
`max="3"` permits at most three, and `exclusive` marks a choice that must be selected
alone. Declare group constraints on one option or repeat identical values; conflicting
maxima and a minimum greater than the maximum are errors. Browser saves and direct
Action calls enforce the same rules; invalid selections remain editable drafts.

```html
<x-input-checkbox name="topics" value="learning" label="Learning" required max="2" />
<x-input-checkbox name="topics" value="community" label="Community" />
<x-input-checkbox name="topics" value="none" label="None of these" exclusive />
```
Run `bun run pmx lint` to check the response model.

To save several Blocks together, place a submit control in its own Block:

```html
<x-input-submit blocks="*" label="Submit survey" />
```

`blocks="*"` saves all participating input Blocks owned by this Source. Included
Sources and inactive Blocks are excluded. Use `blocks="pace topics reflection"`
to select specific Block ids. Without `blocks`, the control saves only its containing
Block. Inputs do not need their own submit control when a grouped control saves them.

Grouped submission uses the browser runtime to collect responses and saves them in
one transaction. A grouped button uses `update-label` after any selected response
has been saved; author both labels when its domain wording should stay fixed.
Routine save status stays in the button (Saving… / Saved); adjacent feedback is
reserved for errors and recovery. Saved responses outside the selected set remain unchanged. Lint
reports missing input targets, including a submit without `blocks` in an empty Block.


Text fields can set either or both limits:

```html
<x-input-textarea name="reflection" label="Reflection" max-words="100" max-length="1200" />
```

Limits are nonnegative integers. `max-length` counts Unicode code points, including
whitespace; `max-words` counts nonempty whitespace-separated tokens. A hyphenated
word counts as one token. The browser and the server enforce the same limits,
including direct Action submissions. Over-limit answers stay intact as drafts;
validation identifies the field to correct. No counter is added automatically.
Existing saved text is preserved if a limit becomes stricter, but the response
needs correction before it can count as complete or be saved again.

---

## Conditional questions

Block conditions can read Input values with `$response-id.field`. Checkbox arrays
support `contains` and `containsAny`:

```md
<!--
id: follow-up
when: { $experiences.answer: { containsAny: [learning, community] } }
-->

<x-input-textarea name="idea" label="What would you improve?" />
```

Editing a routing answer previews the dependent Blocks in the current Source
through the admitted View. Included Sources keep their own response boundary;
their edits do not replace the containing document.
It does not save answers or complete responses. Unaffected controls retain focus
and drafts; hiding a question preserves its answers for when its branch returns.
Browser draft recovery also restores branching when storage is available. Preview
requires the running application host; offline edits cannot refresh conditions.
Guided navigation waits for the latest successful preview, including when another
routing answer supersedes an in-flight request. A failure or timeout keeps the
current question open; Next retries the same draft.

On save, the server evaluates participation using the submitted candidate answers
over saved responses. Explicitly submitting an inactive response is an error.
Grouped buttons collect participating response Blocks; hidden required fields do
not block them. A section save validates its selected responses; final survey
completeness and immutable reporting snapshots remain application policy.

---

## Describe questions for reporting

Author reusable question and field labels in the response Block's metadata:

```md
<!--
id: experiences
input-response:
  label: "Which activities have you tried?"
  labels: { answer: "Activities" }
-->

<fieldset>
<legend>{{ input-response.label }}</legend>
<x-input-checkbox name="answer" value="learning" label="Learning" />
<x-input-checkbox name="answer" value="community" label="Community life" />
</fieldset>
```

A Plugin reads the current View's admitted description:

```ts
const responses = await view.query("input.describe", {
  input: { source: "/survey.page" },
})
```

The result is an ordered array of `{ id, label?, fields }` (`InputDescription`
from `@pathmx/core/plugins`). Each field describes
`name`, optional `label`, `type`, `required`, applicable `min`, `max`, `maxLength`
and `maxWords`, and ordered `options` with `{ id, label, exclusive? }`. Labels are
plain text. Choice labels are decoded from authored control attributes; question
labels come from metadata, never inferred from surrounding HTML. Unknown field
names in `labels` are authoring errors. Validation messages use these field labels.
For example, `labels: { email: "Email address" }` makes a missing required value
report “Email address” rather than the storage key. Input retains the response and
field location so guided forms and grouped saves can focus the failing editor.

Descriptions include all authored responses, even currently inactive branches,
and contain no participant answers or compiler identities. An unreadable Source
returns an empty array; reading its description grants no access to saved state.
An instance can be addressed as `/survey.page?iid=cohort-a`. Survey sections, grid
row grouping, and historical instrument versions remain consumer metadata.

---

## Display and complete responses

Render a saved value elsewhere in the same Source:

```md
Your idea: {{input.idea}}
```

Field names belong to their response: two response Blocks may both use `answer`
without sharing saved values. `<x-input-value>` and `{{ input.name }}` inside a
response read that response. Outside a response, a field name must identify only one
response; use an explicit `input.responses` query when the name is ambiguous.

`<x-input-value name="idea" />` provides the same projection. Typing updates
the draft; submitting confirms a snapshot. Later draft edits do not silently
replace that submitted snapshot.

For a custom saved-answer presentation, the typed `input.responses` query returns
one submitted response through the current View:

```md
{{ $input.responses(source=this.source, response="reflection").values.idea ?? "No saved idea" }}
```

Use the response Block's stable id, or `source` for a Source-level response.
`response` may be omitted only when the Source has one response. The row contains
`values` for that response's fields and `submittedAt`; drafts are excluded. No
submission, an unreadable State, or a saved response that needs newly required
fields produces no row. Malformed stored values and ambiguous State records
produce an error. An explicit Input State binding uses
the same reader. See [View bindings](./queries.reference.md#view-bindings) for named
aliases and rich fallbacks.

To make submission a derived Completion requirement, add this to the question
Block's topmatter and keep its stable id:

```yaml
id: reflection
input-response:
  completion: true
```

Compose `input.completion` with Completion's `derived` providers as shown in
[Personal state](../extend/state.reference.md#save-input-and-progress-in-the-browser).
Completion follows the submitted response, not merely the presence of draft text.

---

## Storage and custom behavior

Input saves responses as Sources in the current Actor's Home. A
[browser publication](../extend/state.reference.md#save-input-and-progress-in-the-browser)
provides a local Actor without sign-in and runs the same submission Actions offline.
Responses can be exported as saved files; they are not automatically sent to a
teacher or remote server. Draft recovery is disposable and separate from submitted
responses. Plugins used in a browser publication must support that host's capabilities.

Each saved response owns its values and submission timestamp under its stable id:

```yaml
type: state
plugin: input
for: /practice.page
responses:
  reflection:
    values:
      answer: Try a worked example.
    submittedAt: 2026-09-20T14:00:00.000Z
  revision:
    values:
      answer: Add a second example.
    submittedAt: 2026-09-20T14:05:00.000Z
```

Saving `reflection` replaces that response's saved values without changing `revision`.
The `for` reference may include `?iid=...` for a named Source instance. A stored response
without a submission timestamp can supply editable values but is not submitted output.

A Source can explicitly bind Input to an existing State Source using
`input-response.state` in Source frontmatter. The canonical State id must refer
to matching Input state for that origin, and the current viewer still needs
access. Ordinary personal responses need no explicit binding.

---

## Extend a response editor

Start with [Assessment's complete configured Input example](../learning/assessment.reference.md#install-and-configure)
for an authoring adapter and submission contribution. The browser methods below
are the editor contract for such an adapter, not a standalone form setup.

For custom response components, `bind` from `@pathmx/core/input/browser`
connects a component to the single compiled answer in its Block. Its
`render(value, { editable })` callback receives restored state; `set(value)`
validates and updates the answer, and `dispose()` removes listeners.
When the editor contains an incomplete or invalid draft, call
`setIssue("Explain what needs to change.")`. Submission stays unavailable even
if the compiled answer still contains an earlier valid value. A successful
`set(value)` clears that binding's issue; `clearIssue()` clears it explicitly.
Dispose the binding when its component is removed.
An authoring adapter supplies that answer's field model.
[Assessment](../learning/assessment.reference.md) composes such authoring with
Input. [Submission contributions](../extend/actions.reference.md#compose-input-submissions)
add domain work to the normal validated Action transaction.

---

## Read a response in the browser

Use `readInputResponse` from `@pathmx/core/input/browser` when a review or summary
needs the current meaning of an Input response. Pass its mounted editor, form,
or question Block; Input supplies the authored label and current draft status.

```ts
import { readInputResponse } from "@pathmx/core/input/browser"

const question = document.getElementById("reflection")!
const response = readInputResponse(question)
if (response && !response.answered) {
  console.log(response.label, response.required ? "Required" : "Optional")
}
if (response?.issue) {
  console.log(response.issue.field, response.issue.message)
}
```

The immutable snapshot contains `label?`, `answered`, `required`, and an optional
`issue: { field?: string, message: string }`. Labels come from `input-response.label`;
they are not inferred from HTML. `answered` means at least one field has a nonempty
answer, not that every field is valid. `required` means a field requires an answer
or a positive selection minimum. `issue` carries the current validation or editor
issue. Native and custom editors use the same response state.

Read again when updating your summary; this function does not subscribe to changes,
submit answers or expose answer values. Before Input mounts, after its response is
removed, or for an unrelated element, it returns `undefined`. A region associated
with multiple responses throws; read each question separately. A connected Block
on another Player slide remains readable, while a removed conditional Block does not.

---

## Submit while reading

Original Save controls and [Player response actions](../learning/player.reference.md#respond-in-player)
submit through the same response owner. Repeated views of the same response in
one application share the pending guard, so simultaneous activation starts one
submission. Separately named responses keep their own drafts and status.

For a response with several text fields, `input-response.label` also names its
single Player entry action. Entry focuses the first incomplete or invalid text
editor, or the first editable field when all are valid. Without a response label,
the action uses the target field's authored label. Filling a field updates the
entry target while preserving the editors and draft; saving remains explicit.

Saving confirms the values at submission time. Edits made while saving remain
an unsaved draft when that submission finishes. Player shows **Saving…** during
the operation and briefly shows **Saved** after success. A response with no new
valid changes does not offer another ordinary Save action.

If the save commits but the page cannot refresh, the response reports that it
was saved and asks for a reload. An uncertain or interrupted submission also
requires recovery before another attempt; its draft remains available.


---

## Save responses together

Programmatic callers submit a map of response ids to field values. The same shape
handles one response or several:

```ts
await view.actions.run("input.submit", {
  responses: {
    starting: { choice: "explore" },
    solo: { choice: "plan" },
  },
  submission: "attempt-1",
}, { target: "/approach.page" })
```

Each response keeps its own fields and saved state. All submitted responses are
validated before any are saved; an invalid response rejects the entire operation.
Responses omitted from the map retain their saved values. Empty maps and unknown
response ids are rejected. Domain submission contributions receive the validated
`responses` map and participate in the same transaction.

Native Input forms produce the same structured request for their response. Grouping
several responses in one operation does not combine their evaluation or outputs.
