FormControl provides the label, description and error feedback that surround a form
control, and the ids that tie them together for assistive technology. Reach for it when
you need a field Frontile doesn't ship — a file picker, a range slider, a third-party
date picker — and want it to look and announce itself like Input, Select and
Checkbox, which are all built on it.
import { FormControl } from 'frontile';
Pass @label and give the wrapped control the yielded id. That single wiring is what
associates the rendered <label> with your control.
import { FormControl } from 'frontile';
<template>
<FormControl @label='Attachment' as |c|>
<input
type='file'
id={{c.id}}
class='text-neutral-strong font-body text-base'
/>
</FormControl>
</template>
@description renders help text above the control and @errors renders messages below
it. Neither is connected to the control on its own: the block is responsible for the
ARIA, using the two values FormControl yields for exactly that.
c.describedBy takes two flags — whether there is a description, and whether there is
feedback — and returns the matching ids for aria-describedby.c.isInvalid is true when @isInvalid is set or @errors is non-empty, and is what
you put on aria-invalid.import { FormControl } from 'frontile';
<template>
<FormControl
@label='Budget'
@description='Whole dollars, no separators.'
@errors='Enter an amount above 0'
as |c|
>
<input
type='number'
id={{c.id}}
value='0'
aria-invalid={{if c.isInvalid 'true'}}
aria-describedby={{c.describedBy true c.isInvalid}}
class='bg-surface-input text-neutral-strong border-neutral-soft aria-invalid:border-danger-soft focus:ring-focus w-full rounded-xl border p-3 leading-tight focus:ring-3 focus:outline-hidden'
/>
</FormControl>
</template>
The default order — label, description, control, feedback — is what you get from the
string arguments. When a control needs a different order, such as a checkbox whose label
sits after it, use the yielded Label, Description and Feedback components instead
and drop the corresponding argument. They arrive with for, id, size and (for
Feedback) the error messages already bound.
@preventErrorFeedback suppresses the automatic feedback at the bottom so c.Feedback
is the only copy rendered.
import { FormControl } from 'frontile';
<template>
<FormControl
@errors='Accept the terms to continue'
@preventErrorFeedback={{true}}
as |c|
>
<c.Feedback />
<div class='flex items-center gap-2'>
<input
type='checkbox'
id={{c.id}}
aria-invalid={{if c.isInvalid 'true'}}
aria-describedby={{c.describedBy false c.isInvalid}}
/>
<c.Label>I accept the terms</c.Label>
</div>
</FormControl>
</template>
When the label itself needs markup rather than a plain string, use the :label block —
it renders inside the same <label> element that @label would have produced.
import { FormControl } from 'frontile';
<template>
<FormControl @isRequired={{true}}>
<:label>
API token
<a href='#docs' class='text-primary underline'>Where do I find this?</a>
</:label>
<:default as |c|>
<input
id={{c.id}}
required
class='bg-surface-input text-neutral-strong border-neutral-soft focus:ring-focus w-full rounded-xl border p-3 leading-tight focus:ring-3 focus:outline-hidden'
/>
</:default>
</FormControl>
</template>
:description works the same way, for help text that needs a link or inline markup. It
renders inside the same description element that @description would have produced, so
c.describedBy still points the control at it.
import { FormControl } from 'frontile';
<template>
<FormControl @label='Webhook URL'>
<:description>
Must be publicly reachable over HTTPS.
<a href='#docs' class='text-primary underline'>Read the requirements</a>
</:description>
<:default as |c|>
<input
id={{c.id}}
aria-describedby={{c.describedBy true false}}
class='bg-surface-input text-neutral-strong border-neutral-soft focus:ring-focus w-full rounded-xl border p-3 leading-tight focus:ring-3 focus:outline-hidden'
/>
</:default>
</FormControl>
</template>
@size scales the label, description and feedback text. It does not touch the control
inside the block — size that yourself so the two stay in proportion.
import { array } from '@ember/helper';
import { FormControl } from 'frontile';
<template>
<div class='flex flex-col gap-6'>
{{#each (array 'sm' 'md' 'lg') as |size|}}
<FormControl
@size={{size}}
@label='Team name'
@description='Shown to everyone in the workspace.'
@errors='This name is taken'
as |c|
>
<input
id={{c.id}}
value='Platform'
aria-invalid='true'
aria-describedby={{c.describedBy true true}}
class='bg-surface-input text-neutral-strong border-neutral-soft aria-invalid:border-danger-soft focus:ring-focus w-full rounded-xl border p-3 leading-tight focus:ring-3 focus:outline-hidden'
/>
</FormControl>
{{/each}}
</div>
</template>
FormControl renders a plain <div> and has no keyboard behavior of its own — the control
you place inside it keeps whatever behavior it already had. What FormControl does is hand
you the pieces needed to describe that control correctly, and it is the block's job to
apply them:
| Yielded value | Where it goes |
|---|---|
c.id |
The control's id. The rendered <label> already points at it with for |
c.isInvalid |
aria-invalid on the control |
c.describedBy |
aria-describedby on the control, called with whether a description and feedback are present |
Notes worth knowing before you rely on it:
@isRequired adds an asterisk to the label. Set required or
aria-required on the control yourself.@isDisabled is passed through for styling only; the control
owns its own disabled attribute.aria-live, assertive at
the default danger intent and polite otherwise, so messages appearing after the
initial render are read out.role='group' and the label's for can only point
at one control, so wrapping several inputs in a single FormControl leaves them
unlabelled. Use CheckboxGroup or RadioGroup, or add role='group' and
aria-labelledby yourself.Element: <span class="hljs-title class_">HTMLDivElement</span>
| Name | Type | Default | Description |
|---|---|---|---|
class
|
string
|
- | Class names for the wrapping element. |
description
|
string
|
- |
Help text rendered between the label and the control, and referenced by the
ids describedBy returns.
|
errors
|
enum
|
- |
Validation messages for the field. A non-empty value also marks the control
invalid, and an array is joined with ; when displayed.
|
id
|
string
|
- | The id given to the control, and the base for the description and feedback ids. Generated when omitted, so pass one only when something outside the block has to reference it. |
isDisabled
|
boolean
|
false
|
Whether the field is disabled. FormControl passes this through for styling;
the control it wraps is responsible for the disabled attribute.
|
isInvalid
|
boolean
|
false
|
Marks the control invalid without supplying messages, for validation that is reported elsewhere. |
isRequired
|
boolean
|
false
|
Whether the field is required. Adds an asterisk to the label; it does not
set the required attribute on the control itself.
|
label
|
string
|
- |
The label text rendered above the control and associated with it via for.
Use the :label block instead when the label needs markup.
|
preventErrorFeedback
|
boolean
|
false
|
Suppresses the automatic error feedback, for when the block renders the
yielded Feedback itself or places messages elsewhere.
|
size
|
enum
|
'md'
|
The size of the label, description and feedback text. |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- | |
label
*
|
Array
|
- | |
description
*
|
Array
|
- |
Element: <span class="hljs-title class_">HTMLLabelElement</span>
| Name | Type | Default | Description |
|---|---|---|---|
class
|
string
|
- | The class name to be passed to the label base slot. |
classes
|
SlotsToClasses<'base' | 'asterisk'>
|
- | Class names for each slot of the component, merged with the theme's. |
for
|
string
|
- | The 'for' attribute of a . |
isRequired
|
boolean
|
false
|
Whether the field is required or not, if true, an asterisk will be added to the label. |
size
|
enum
|
'md'
|
The size of the label text. |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |
Element: <span class="hljs-title class_">HTMLDivElement</span>
| Name | Type | Default | Description |
|---|---|---|---|
class
|
string
|
- | Class names for the description element, merged with the theme's. |
id
|
string
|
- |
The id of the description element, referenced by the control's
aria-describedby.
|
size
|
enum
|
'md'
|
The size of the description text. |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |
Element: <span class="hljs-title class_">HTMLDivElement</span>
| Name | Type | Default | Description |
|---|---|---|---|
class
|
string
|
- | Class names for the feedback element, merged with the theme's. |
id
|
string
|
- |
The id of the feedback element, referenced by the control's
aria-describedby.
|
intent
|
enum
|
'danger'
|
The intent of the feedback, which also decides whether it is announced
assertively (danger) or politely.
|
messages
|
enum
|
- |
A list of messages or a single message string. An array is joined with ; .
|
size
|
enum
|
'md'
|
The size of the feedback text. |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |