A one-time-code (OTP/PIN) field. The cells you see are decoration drawn over a single real
<input>, so password managers, iOS and Android SMS autofill, paste, undo and screen readers
all behave exactly as they would on an ordinary text field.
import { InputOtp } from 'frontile';
import { InputOtp } from 'frontile';
<template>
<div class='demo-stack'>
<InputOtp @label='Verification code' />
</div>
</template>
Split the cells into visual groups with @groups, an array of group sizes. @separator
(default '–') sets the character shown between groups; it is rendered aria-hidden
because the underlying value never contains it.
The sizes should sum to @length. If they do not, the groups are adjusted to fit — you always
get exactly @length cells — and a warning is logged in development.
import { InputOtp } from 'frontile';
import { array } from '@ember/helper';
<template>
<div class='demo-stack'>
<InputOtp @label='Card verification' @length={{6}} @groups={{array 3 3}} @separator='-' />
</div>
</template>
@onCompleteThe common case: let the component own its value and react only when the code is complete.
@onComplete fires once, when the last character lands.
Submitted:
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { InputOtp } from 'frontile';
export default class VerifyCodeExample extends Component {
@tracked submittedCode = '';
handleComplete = (value: string) => {
this.submittedCode = value;
};
<template>
<div class='demo-stack'>
<InputOtp @label='Verification code' @onComplete={{this.handleComplete}} />
<p>Submitted: {{this.submittedCode}}</p>
</div>
</template>
}
Pair @value with @onInput to own the value yourself. @onInput fires on every
keystroke; @onChange follows the DOM change event, which on a text input fires on
blur, so a parent wired only to @onChange hears nothing until the field is left.
Current value:
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { InputOtp } from 'frontile';
export default class ControlledOtpExample extends Component {
@tracked code = '';
handleInput = (value: string) => {
this.code = value;
};
<template>
<div class='demo-stack'>
<InputOtp @label='Verification code' @value={{this.code}} @onInput={{this.handleInput}} />
<p>Current value: {{this.code}}</p>
</div>
</template>
}
Used through <form.Field>, InputOtp submits as a single string value under one @name,
the same as any other field.
Current value:
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { Form, type FormResultData } from 'frontile';
export default class OtpFormExample extends Component {
@tracked formData = { code: '' };
handleFormChange = (result: FormResultData) => {
this.formData = result.data;
};
<template>
<div class='demo-stack'>
<Form @data={{this.formData}} @onChange={{this.handleFormChange}} as |form|>
<form.Field @name='code' as |field|>
<field.InputOtp @label='Verification code' />
</form.Field>
</Form>
<p>Current value: {{this.formData.code}}</p>
</div>
</template>
}
@allowedChars picks a built-in rule (digits is the default), which also sets the on-screen
keyboard (inputmode) and autocapitalization.
A value that fails the rule is rejected whole rather than stripped of the offending
characters: pasting 12-456 into a digits field leaves the field unchanged, it does not
become 12456. Length is applied first — anything longer than @length is trimmed to its
first @length characters, and those are what the rule checks.
import { InputOtp } from 'frontile';
<template>
<div class='demo-stack'>
<InputOtp @label='Digits' @allowedChars='digits' @length={{4}} />
<InputOtp @label='Letters' @allowedChars='letters' @length={{4}} />
<InputOtp @label='Alphanumeric' @allowedChars='alphanumeric' @length={{4}} />
</div>
</template>
@pattern overrides @allowedChars with a custom rule.
A custom
@patternmust match partial values. It is tested against every intermediate value as the user types, not just the finished code. Use/^\d+$/, never an anchored/^\d{6}$/— a length-anchored pattern rejects the very first keystroke and makes the field impossible to type into.
The rule is also set as the input's native pattern attribute, with any regex flags (i,
u, …) dropped. Since the rule accepts partial input, native validation will also accept an
incomplete code — it does not enforce @length. Check for completeness with @onComplete or
your form's validation.
import Component from '@glimmer/component';
import { InputOtp } from 'frontile';
export default class EvenDigitsExample extends Component {
evenDigitsPattern = /^[02468]*$/;
<template>
<div class='demo-stack'>
<InputOtp @label='Even digits only' @length={{4}} @pattern={{this.evenDigitsPattern}} />
</div>
</template>
}
@isMasked draws a bullet in place of each entered character, for PIN-style entry. Only the
display changes: autofill and password managers keep working.
import { InputOtp } from 'frontile';
<template>
<div class='demo-stack'>
<InputOtp @label='PIN' @length={{4}} @isMasked={{true}} />
</div>
</template>
@placeholder previews the shape of the code in the empty cells, and is exposed on the input
as aria-placeholder. It clears from every cell as soon as anything is entered. Type a digit
in the demo below to watch it go.
import { InputOtp } from 'frontile';
<template>
<div class='demo-stack'>
<InputOtp @label='Verification code' @length={{6}} @placeholder='000000' />
</div>
</template>
import { InputOtp } from 'frontile';
<template>
<div class='demo-stack'>
<InputOtp @label='Small' @size='sm' />
<InputOtp @label='Medium' @size='md' />
<InputOtp @label='Large' @size='lg' />
</div>
</template>
import { InputOtp } from 'frontile';
<template>
<div class='demo-stack'>
<InputOtp @label='Disabled' @isDisabled={{true}} @value='123' />
<InputOtp @label='Verification code' @isRequired={{true}} />
<InputOtp
@label='Verification code'
@description="Check your phone for a text message"
/>
<InputOtp
@label='Verification code'
@errors='That code is incorrect or has expired'
/>
</div>
</template>
aria-hidden="true" and no role,
tabindex, or aria-label — there is exactly one tab stop, the real input underneath.@label (associated via for/id) or an aria-label passed through
...attributes.@errors are associated via aria-describedby and set
aria-invalid, the same as other form controls.autocomplete="one-time-code" is set for you, which is what lets browsers and mobile OSes
offer an incoming SMS code as a one-tap suggestion. Nothing else is required on your side.
To opt into iOS 14+ domain-bound codes, the SMS message needs a @example.com #123456
footer — that is set by whatever service sends the text.
Element: HTMLInputElement
Ownership model, stated once.
The <input> element is the source of truth for what the user typed -- the
browser guarantees that -- so a parent-owned @value is written down into
it (by syncFromValueArg and reconcileWithParent) rather than bound over
it with value={{...}}.
That binding is what broke typing inside a <Form>. elementValue is
dirtied mid-dispatch and the template reads it (through cellGroups), so
Glimmer revalidates in a microtask that runs between event listeners --
rewriting the value attribute from a still-stale @value before the
input event had finished bubbling to the <form>, wiping the keystroke and
handing <Form> an empty value to store.
The one invariant that keeps the mirror honest: every code path that writes
element.value goes through writeValue, which re-derives the selection
mirror straight afterwards.
| Name | Type | Default | Description |
|---|---|---|---|
allowedChars
|
enum
|
'digits'
|
Which characters the code may contain. Also decides the on-screen keyboard
(inputmode) and autocapitalisation.
|
classes
|
SlotsToClasses<'base' | 'input' | 'container' | 'separator' | 'group' | 'cell' | 'cellChar' | 'caret'>
|
- | Class names for each slot of the component, merged with the theme's. |
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.
|
groups
|
Array
|
- |
Splits the cells into visual groups, e.g. [3, 3] for a six-digit code.
Omit it for a single group. Entries that do not sum to length warn in
development; the groups then run out of cells or take a final group of the
remainder, so exactly length cells render either way.
|
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. |
isMasked
|
boolean
|
false
|
Renders a bullet in place of each entered character. The real input's text
is already transparent, so this is purely what the cells draw -- the input
stays type="text", which type="password" would break for autofill.
|
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.
|
length
|
number
|
6
|
How many characters the code has. Also the input's maxlength.
|
name
|
string
|
- | The name attribute of the underlying input, used when the code is submitted as part of a form. |
onBlur
|
function
|
- | Callback when onblur is triggered. |
onChange
|
function
|
- | Callback when onchange is triggered. |
onComplete
|
function
|
- |
Called when the code becomes complete. Fires on the transition from a
shorter value to exactly length characters, so re-rendering an already
full value does not fire it again.
|
onInput
|
function
|
- | Callback when oninput is triggered. |
pattern
|
Object
|
- |
A custom character rule, overriding allowedChars. It is tested against
every intermediate value, so it must accept partial input: /^\d+$/, not
/^\d{6}$/.
|
placeholder
|
string
|
- |
Characters shown in empty cells before anything is entered. Also exposed as
aria-placeholder.
|
separator
|
string
|
'–'
|
The character shown between groups. Rendered aria-hidden, because the
value itself contains no separator.
|
size
|
enum
|
'md'
|
The size of the cells and the label. |
value
|
string
|
- |
The value of the input. Pair with onInput or onChange to control the
input; leave it unset to let the component track its own value.
|