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>
<div class='demo-stack'>
<FormControl @label='Attachment' as |c|>
<input
type='file'
id={{c.id}}
class='text-neutral-strong font-body text-base'
/>
</FormControl>
</div>
</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>
<div class='demo-stack'>
<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 focus:ring-focus aria-invalid:focus:ring-danger-muted w-full rounded-xl border p-3 leading-tight focus:ring-3 focus:outline-hidden'
/>
</FormControl>
</div>
</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.
Either way, FormControl also renders a visually hidden aria-live='assertive' region that
is always in the DOM and holds the error messages when there are any. That region is what
screen readers announce, so the visible FormFeedback inside a FormControl is rendered with
@announce={{false}} and the message is not announced twice. @preventErrorFeedback does
not remove the live region.
Because that region only ever carries the @errors text, c.Feedback with its own block
content announces nothing. When you render custom content there and want it announced, turn
announcing back on for that invocation with @announce={{true}} — an argument passed at the
invocation site wins over the one FormControl bound.
import { FormControl } from 'frontile';
<template>
<div class='demo-stack'>
<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>
</div>
</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>
<div class='demo-stack'>
<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>
</div>
</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>
<div class='demo-stack'>
<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>
</div>
</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='demo-stack'>
{{#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 focus:ring-focus aria-invalid:focus:ring-danger-muted 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 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 status 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: HTMLDivElement
| 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: HTMLLabelElement
| 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: HTMLDivElement
| 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: HTMLDivElement
| Name | Type | Default | Description |
|---|---|---|---|
announce
|
boolean
|
true
|
Whether the element is itself an aria-live region. Set this to false
when something else (such as FormControl, which keeps a persistent
live region in the DOM) already announces the messages, so they are not
announced twice.
|
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
Deprecated
|
enum
|
- |
Deprecated. Use `status`. The name changed because this value also decides whether the message is announced assertively. |
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. |
status
|
enum
|
'danger'
|
The status of the feedback, which also decides whether it is announced
assertively (danger) or politely.
|
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |