Form / foldkit-mixins-form
What is drawn
| Control | Element | Slot |
|---|---|---|
Text | input type="text" | text |
Multiline | textarea | multiline |
Number | input type="text" inputmode="decimal" | number |
Toggle | input type="checkbox" | toggle |
Select | select of the control's own options, with a blank while nothing is chosen | select, option |
RelationOne | select of options[key], with a blank | select, option |
RelationMany | a role="group" of checkboxes over options[key], each in a label with its words | choices, choiceLabel, choice |
Nested | a fieldset with a legend, a div per row holding the nested form's fields, and button type="button"s to add and remove a row | group, legend, row, add, remove |
A RelationOne or RelationMany that searches (Input.search()) gets an
input type="search" above it, in the search slot, labelled Search <label>
and naming the picker it controls with aria-controls. words.search in the
view inputs replaces the word. The blank choice of a select has no words
unless words.none gives it some ("none"). All of the view's words (submit,
submitting, checking, search, none, add, remove) are text, with {label} and {position} as
blanks; see words in one place.
A chosen value its choices lack stays in sight: after the choices comes
? value, chosen, in a select and in a RelationMany's checkboxes. It is a
stored id whose row is gone, or one the application has not loaded yet, and
the author can see it and let it go.
Renderers
The table above is a set of renderers found by a control's kind, and the
shipped ones are entries like any other. Add a kind of your own, or draw a
shipped one another way, by passing renderers beside them:
FormView.define(PriceForm, {
renderers: {
Cents: ({ control, draft, change, blurred, state, h }) =>
h.input([...state, h.Value(String(draft)), h.OnInput(change), h.OnBlur(blurred)]),
RelationOne: myCombobox, // replaces the shipped select
},
})A renderer is told input.following: whether the key is still written from the
key it follows (Input.following).
A renderer that wants a "regenerate" button shows it when that is false and
sends the key an empty draft.
A renderer draws the control only; the label, description, error, and a search
box are the field's. state is the control's id and its accessibility
attributes, to put on the element that holds the value. A control whose kind has
no renderer throws when it is drawn, naming the kind and the key.
FormView.field(form, { renderers }) takes them too, for a field view you style.
Per-key overrides: fields
FormView.fields is define with per-key overrides, and one flat key's
control for a layout the caller owns:
const Fields = FormView.fields(PriceForm, {
overrides: {
// Drawn through the whole-form view, in the form's own universe.
code: (input, h) => h.input([h.Id(input.id), h.Value(String(input.field.value))]),
},
styles: {
// A style around one key's base field view.
total: Style.forSlots(FieldSlots)({ root: Style.class('total') }),
},
attrs: {
// What one key's element takes: an email type, a placeholder, textarea rows.
email: { type: 'email' },
},
})Keys with no override render through the base field view, so a new key of a
known kind needs nothing new; unknown keys in every map are type errors.
Fields.view is the whole form. Fields.field(control, model, id, h) draws
one flat key for a custom layout, or with the override it is given, in any h:
Fields.field(control, formModel, control.key, h, (input, draw) =>
draw.input([draw.Id(input.id), draw.Value(String(input.field.value))]),
)A override receives the control, its field, id, validity, errors, element
attrs, and the changed/blurred Messages, so a custom layout reconstructs
no form plumbing. A renderer reads the attrs that fit its element — type and
placeholder on a text input, placeholder and rows on a textarea — and
ignores the rest. Nested keys and Bundle-backed keys draw only through view
(or a override); field without one refuses them, naming the key.
A number is a text input because its draft is text: "4." is a fine thing to
have typed, and type="number" would refuse to report it.
A control backed by a Bundle (Input.bundle)
is drawn with the Bundle's own view when no renderer names its kind, inside the
field's control slot, which carries the control's id and accessibility state.
A renderer for its kind receives bundle: the Bundle's Model, and send, which
turns one of the Bundle's Messages into the form's (wrapped for its row inside a
nested form). Such a key has no draft, so draft and input.field.value are
''; its validation state is input.field's. A Bundle with no view needs a
renderer.
Around each control, FieldSlots also publishes root, label, description,
checking, error, control (around a Bundle's own view), and group and affix, for a
renderer that draws text beside its control (a prefix, a unit) with the two
together. FormSlots publishes root (the form), errors (failures that
belong to no one field), submit, and the five slots of a nested key.
A Bundle's own view that takes inputs gets them from the view input
controls, by key: placed.view(model, h, { controls: { document: inputs } }).
The entry is the application's, typed where it is made, such as the page
Builder's BuilderView.inputs({ data }); a key given none draws its view
without inputs.
A nested row's fields are drawn through the same field view, so a styled field
styles them too. A row that must be there has no remove button, and a one
loses its add button once it has its row. View inputs for nested keys:
nestedOptions names a picker inside a row by path ('author.countryId', the
same for every row), and words.add / words.remove word the buttons (defaults
Add <label>, Remove <label> <position>).
A check running, and a submit in flight
A form's own check (checks) leaves its key Validating
until it answers. Meanwhile the control carries aria-busy, and under it a line
in the checking slot, role="status", says so (words.checking, default
Checking…); the control names that line in aria-describedby. Each field's
root carries data-validation, the field's state by Foldkit's own tag
(NotValidated, Validating, Valid or Invalid), so a stylesheet can mark it
without a Style: [data-validation='Valid'] label::after { content: ' ✓' }. A
Style reads the same state as input.field through Style.whenInput.
The form is submitting while a submit waits for a check to pass (the form's
submitPending), or while the application says its own work with the value is
in flight: the view input submitting. The form hands the value over through
onOut and cannot see the request that follows, so that part is the
application's to pass. While submitting, the form carries aria-busy and
data-submitting, and the submit button is disabled and reads
words.submitting (default Submitting…). Where saving is the parent's own
record of a save in flight:
EditForm.view(model, h, {
submitting: model.saving,
words: { submit: 'Save', submitting: 'Saving…', checking: 'Checking…' },
})The submit button is enabled while a check runs and nothing is invalid: a submit
then waits for the answer, rather than being refused. That leniency is
form.canSubmit, and FormView.submodel takes the predicate explicitly so
the choice stays visible: pass a strict one to disable through checks, or
() => true to never pre-disable and validate wholly at submit.
const Drawn = Edit.bundle.pipe(
Bundle.withView(FormView.submodel(Edit, View, { canSubmit: () => true })),
)Accessibility
Each control has an id of <form name>-<key> (in a row,
<form name>-<key>-<row id>-<key>) and a label for it. It carries
aria-busy while a check runs, aria-invalid, aria-required when the key is
required, and aria-describedby naming its description, the checking line
while a check runs, and its error while invalid. An error is role="alert".
The submit button is disabled until the form would submit, and while it is
submitting.
Those attributes belong to the view: an attachment that also supplies one is an
attribute conflict, reported when the view renders. The slots declare
AriaInvalid and AriaDescribedby so a Behavior can require them.
Limits
- One layout: label, control, description, the checking line, error, in that
order, and fields in the input's order. For another arrangement, draw from
form.controlsyourself; this package is the default, not the only way. - A relation picker is a
select, or checkboxes for amany. WithInput.search()it searches, but it is not a combobox: the choices are the rows the query found, with no paging inside the picker. - The Bundle gains a view through
Bundle.withView, so place the drawn Bundle, notform.bundle.