Repository navigation
09 Ful fields
The field model every ful-* field follows: form association, the disabled and readonly claims, the value and its change event, validation, implicit submission, and the structural tags the css hooks. Once these are known, an element's behaviour follows from its attribute table alone, which is why the per-element pages list attributes and little else.
Extending fields is the same contract from the other side, for a field you write yourself.
Every field is a form-associated custom element wrapping a native control:
flowchart TD
form["<form>"] -->|"form.elements"| host
subgraph host["<ful-input name='email'>: the element the form sees"]
direction TB
label["<label>"]
subgraph group["ful-control-group"]
inner["<input form=''><br/>detached from the form"]
end
err["ful-field-error<br/>the live region"]
label ~~~ group ~~~ err
end
host -->|"name + value"| values["extracted values"]
The inner control carries form="", detaching it from any outer form, so the host is the only control the form sees: it appears in form.elements, carries the name, and is what extraction reads.
The host answers its owning form as form, like a native control, null outside one, where the platform leaves a form-associated custom element without the property:
field.form; // the <form> the field submits to, or nullFields inside a ful-form are picked up automatically. A field in a plain <form> belongs to that form as well: it is in form.elements, Enter submits the form, and ful.Bindings reads and writes its value. A native submission of a plain form carries none of the fields' values, though, because the host sets no form value of its own: the values reach a server through ful-form, or through Bindings in a submit handler of yours.
Each field renders its default slot as a real <label> (a <legend> on a radio group) and names its control from the markup: for pointing at the control where the control is an element a for may target, aria-labelledby where it is not. An accessibility audit and the browser's autofill therefore read the label too, not assistive technology alone. A field with no label content takes no row for its empty <label>, required or not, so it shows no required marker either.
Attributes.forward('input-', host, innerControl) is the addressing convention: any input-* attribute on the host lands, prefix stripped, on the inner control:
<ful-input input-inputmode="numeric" input-maxlength="10" input-autocapitalize="off">It reaches the attributes the host does not name itself. Where a field declares one of its own, that named attribute is the way to set it and the prefixed form does not survive: the field writes the control's value from its own setter after the forwarding has run, so an input-placeholder, input-step, input-min or input-max is overwritten by what the host says, a blank included. Write placeholder, step, min and max on the host instead. Those setters exist because the values need handling the raw attribute would skip, min="+7d" resolving against the clock and a blank placeholder standing in so :placeholder-shown can float a label.
disabled follows native semantics, exactly:
flowchart LR
A["disabled attribute on the field<br/>(the author's claim)"] --> C(["claim <b>or</b> ancestry"])
B["disabled <fieldset> ancestry<br/>(composed by the platform)"] --> C
C --> D["matches :disabled<br/>control disabled, dimmed styling,<br/>absent from form.values"]
A -.->|"reflected by"| E[".disabled property<br/>the claim alone, never the ancestry"]
- the attribute on the field is the author's claim, and nothing but the author ever writes it.
- the effective state is the platform-composed one: claim or a disabled
<fieldset>ancestry, readable asel.matches(':disabled'). - the
disabledproperty reflects the claim only, like a native input's: a field disabled solely by its ancestry reads.disabled === false: askmatches(':disabled')for the effective state.
The scenarios, as with a native control:
| Scenario | Result |
|---|---|
| claim set, no disabled ancestry | disabled: matches :disabled, dimmed, absent from form.values
|
disabled <fieldset> ancestry, at any time |
disabled through the ancestry, follows the fieldset back to enabled |
| claim set before or while the fieldset is disabled, then the fieldset is re-enabled | still disabled: the claim survives, regardless of order |
| claim released while the ancestry is still disabled | still matches :disabled through the ancestry |
inside the first <legend> of a <fieldset disabled>
|
not disabled: the platform exempts the legend's children |
readonly is the same shape. The host attribute is the claim, and it reaches the inner control's native readOnly where there is one.
Where there is none, because the control has no editable text to protect, as on a checkbox, a radio group, a file field or a filter with menus, the field names a part of its own chrome as the frozen piece, and the base refuses gestures inside it while the claim holds: the checkbox does not toggle, the file picker does not open, the filter's menu does not show. The control stays focusable and readable, and the state is announced through aria-readonly on whichever element carries a role that accepts it. The value is still extracted either way.
value is a plain property, and null means empty, never undefined: every field answers null for "nothing here". Form extraction, values = assignment, the change detail and a form reset all go through the property. A form reset restores the value the value attribute declares, as a native control restores its markup default.
value also works before the field has rendered, on every ful field, the ful-select and the ful-filter-* included: the getter answers what the rendered field would, from the last value written or else the value attribute, and a write is held for the render to apply. Such a write leaves the value attribute alone, so a reset still restores the declared value. ful-input-file answers no files until it has rendered. A ful-select answers its keys coerced by k-type, the first one unless it is multiple; a filter answers its tuple, with the operator narrowed by operator or operators. What the browser does to a native control's text is not reproduced: a type="number" input, or a number or date filter, given text that is not a number or a date answers that text until the render, where the rendered field answers null.
Every field stops the inner control's native change and republishes it from the host: it bubbles, it is not cancelable, and its detail is { value }, carrying the same value the form extracts, so el.value === evt.detail.value holds for every field:
form.addEventListener('change', e => console.log(e.target, e.detail.value));The inner control's input is left alone and bubbles as the platform fires it, which is how to follow a field while it is being typed in. The exception is ful-select, which stops the input of its search box: follow a select through change. Its target is the inner control rather than the host, the one way it differs from change, and the host's value is already current when the listener runs, so there is nothing to wait for:
form.addEventListener('input', e => console.log(e.target.closest('ful-input').value));focus(options) delegates to the field's control, and a focus asked before the field has rendered is remembered and lands once the control exists, as a value written then is, so a panel that rebuilds its fields and puts the focus back in one needs no timing of its own.
The inner control carries form="", so the platform has nothing to implicitly submit: the base stands in for it, and every field gets the behaviour without writing a line of it. What Enter reaches is what a native form would submit from, which was measured on Chromium, Firefox and WebKit rather than read off a specification:
| Enter in | what happens |
|---|---|
ful-input, the temporal inputs, ful-select with its list closed, ful-checkbox, ful-radio-group, either operand of a compare filter |
the form submits |
ful-input type="textarea" |
a newline is inserted |
ful-input-file |
the file picker opens |
a filter's operator button, ful-filter-boolean
|
the button's menu opens |
ful-select with its list open |
the highlighted entry is accepted |
The form is submitted through its first enabled submit control, so a submit listener sees the same submitter it would have seen from a click. The numpad's Enter counts as Enter everywhere.
Two cases are deliberately left to the platform. A control you slot into a field yourself is still associated with the form, so the platform submits it and the base does not, which is what keeps it from submitting twice. A field that consumes the key, by calling preventDefault() on the keydown, owns it: nothing is submitted after that, which is how ful-select accepts an entry instead.
Validation is the server's: the form submits whatever the validity of its fields, and the problems that come back are pinned onto the fields they name.
-
requiredsetsaria-requiredon the field's control, or on the element that carries its role. No nativerequiredis set, so the browser does not validate on its own. -
setCustomValidity(msg)on any field: a message sets the custom error, which makes the host match:invalid, renders the message into the field'sful-field-errorand setsaria-invalid="true"; an empty or missing message clears all three. ful forms call it for you from the server'sproblems(see forms). -
ful-formsetsnovalidateon the inner form: the native tooltips are suppressed, and the browser does not block a submit on a field still marked invalid.
Announcements follow WCAG 4.1.3 by default: ful-field-error is a polite live region. A form with scroll-on-error turns the live region off while it moves focus, so each error is announced exactly once through the focused field.
Not custom elements, but style-only hooks the templates render and your css can target:
| Tag | Role |
|---|---|
ful-control-group |
the row wrapping a control and its affixes |
ful-affix |
an affix cell (icons, buttons, operator menus). A button inside one fills it: the cell drops its padding and the button stretches, so it needs no css of yours. Give it .ful-button for the accent fill, or leave it bare to take the affix's own colours |
ful-control |
a plain layout wrapper |
ful-icon |
an inline svg-mask icon (<ful-icon name="search">) |
ful-badge |
a removable value chip (multiple selects) |
ful-item-list / ful-item
|
the list of chosen files/entries under a field |
ful-field-error / ful-field-warning(s)
|
per-field error/warning targets |
ful-errors |
the global form error banner |
ful-choice |
the checkbox/switch wrapper |
ful-empty |
the panel standing in for content that is not there: a table with no rows, a dropdown with no options |
ful-spinner |
the busy indicator. A style-only tag, not an element: the glyph is its own pseudo-element, the label its children |
ful-note |
the tooltip's anchored popover bubble |
ful-toast |
a toast item; whoever hosts toasts is the region |
ful-tablist / ful-accordion-group / ful-steps
|
the tabs, accordion and wizard anchors |
ful-table-wrapper / ful-pagination-bar / ful-radio-list
|
the table, pagination and radio-group anchors |
ful.css is self-contained: it needs no other stylesheet, no icon fonts and no javascript. Every element is themed through the --ful-* custom properties (density included: --ful-controls-height sizes every control uniformly), and the structural styling hooks are the style-only tags above. Focus and invalid rings derive from the accents through color-mix, and the palette is dark-mode aware through light-dark(). The full token inventory, the customization and the icon system: theming.
The words the fields show, from the dropzone label to the filter operators, are built-in message keys any page can reword: the list is in Localization.
ftl
httpc
ful
- Getting started
- Fields
- Forms
- Inputs
- Select
- Files
- Temporal inputs
- Tables
- Disclosures
- Navigation
- Async sections
- Timing and storage
- Theming
Extending
Reference
Project