TextField
A text field is a native <input> or <textarea> with a label, a description
and an error message. It has Field built in for the labelling,
and adds the value, read-only mode, form reset, and hover and focus state on
every part. The browser keeps doing the editing: typing, selection, input
methods, autofill, undo, Enter submitting the form from an <input> and adding
a line in a <textarea>.
A native text control needs no WAI-ARIA pattern. Labelling follows the APG practice on names and descriptions.
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The text fields' look is @uniaria/styles' default. */
:root {
font:
15px/1.5 system-ui,
sans-serif;
}
body {
display: grid;
gap: 1rem;
margin: 1.5rem;
background: var(--uni-surface-color);
color: var(--uni-text-color);
}
.form {
display: grid;
gap: 1rem;
max-inline-size: 20rem;
}
</style><form class="form" novalidate>
<uni-text-field id="username" required>
<div data-uni="text-field">
<label data-element="Label">Username</label>
<div data-element="Wrapper">
<span aria-hidden="true">@</span>
<input data-element="Input" name="username" autocomplete="username" />
</div>
<p data-element="Description">Letters, digits and underscores.</p>
<p data-element="Error" hidden>
Use only letters, digits and underscores.
</p>
</div>
</uni-text-field>
<uni-text-field id="bio">
<div data-uni="text-field">
<label data-element="Label">Bio</label>
<div data-element="Wrapper">
<textarea
data-element="Input"
name="bio"
rows="3"
maxlength="160"
></textarea>
</div>
<p data-element="Description">0 of 160 characters</p>
</div>
</uni-text-field>
</form>
<output id="log">Type a username with a space, then leave the field.</output>
<script src="/demo/text-field.js"></script><script>const username = document.getElementById('username');
const bio = document.getElementById('bio');
const count = bio.querySelector('[data-element="Description"]');
const log = document.getElementById('log');
const isValid = (value) => /^\w*$/.test(value);
// Check on blur, as many forms do; once invalid, check on every change.
username.addEventListener('focuschange', (event) => {
log.textContent = `focuschange: ${event.detail}`;
if (!event.detail) {
username.toggleAttribute(
'invalid',
!isValid(username.instance.getState().value),
);
}
});
username.addEventListener('valuechange', (event) => {
log.textContent = `valuechange: "${event.detail}"`;
if (username.hasAttribute('invalid') && isValid(event.detail)) {
username.removeAttribute('invalid');
}
});
bio.addEventListener('valuechange', (event) => {
count.textContent = `${event.detail.length} of 160 characters`;
});
</script>React
import { textField } from '@uniaria/core/text-field';
import { useBehavior } from '@uniaria/react';
export function EmailField(props: {
error?: string;
onEmailChange(email: string): void;
}) {
const email = useBehavior(textField, {
isRequired: true,
isInvalid: props.error !== undefined,
onValueChange: props.onEmailChange,
});
return (
<div {...email.parts.root}>
<label {...email.parts.label}>Email</label>
<div {...email.parts.wrapper}>
<input {...email.parts.input} name="email" type="email" />
</div>
<p {...email.parts.description}>We never share it</p>
<p {...email.parts.error}>{props.error}</p>
</div>
);
}Vue
<script setup lang="ts">
import { textField } from '@uniaria/core/text-field';
import { useBehavior } from '@uniaria/vue';
const props = defineProps<{ error?: string }>();
const emit = defineEmits<{ emailChange: [email: string] }>();
const email = useBehavior(textField, () => ({
isRequired: true,
isInvalid: props.error !== undefined,
onValueChange: (value) => emit('emailChange', value),
}));
</script>
<template>
<div v-bind="email.parts.root">
<label v-bind="email.parts.label">Email</label>
<div v-bind="email.parts.wrapper">
<input v-bind="email.parts.input" name="email" type="email" />
</div>
<p v-bind="email.parts.description">We never share it</p>
<p v-bind="email.parts.error">{{ error }}</p>
</div>
</template>HTML
<uni-text-field id="email" required>
<div data-uni="text-field">
<label data-element="Label">Email</label>
<div data-element="Wrapper">
<input data-element="Input" name="email" type="email" />
</div>
<p data-element="Description">We never share it</p>
<p data-element="Error" hidden>Enter a valid email</p>
</div>
</uni-text-field>
<script type="module">
import '@uniaria/elements/text-field';
document.querySelector('#email').addEventListener('valuechange', (event) => {
console.log(event.detail);
});
</script>The element takes the input’s current text as its default: the markup’s
value, or what the user typed before the script loaded.
Without value, the field is uncontrolled: it keeps the typed text and
reports each change. defaultValue (the default-value attribute) sets the
text it starts with.
Render a <textarea> as the input part. Nothing else changes: there is no
separate TextArea component, and Enter adds a line instead of submitting.
<uni-text-field>
<label data-element="Label">Bio</label>
<textarea data-element="Input" name="bio" rows="3" maxlength="160"></textarea>
</uni-text-field>Native attributes such as maxlength, pattern or rows are yours and keep
their native effects. Growing a textarea with its content isn’t built in.
The wrapper part is the box around the input, where icons, units, a prefix
or a clear button go. A press anywhere on it outside the input and outside any
other control (a link, a button, a label) focuses the input, so the whole box
behaves like the input. Hover and focus are written on every part, so the box
can show the hover and focus styles.
<uni-text-field>
<label data-element="Label">Website</label>
<div data-element="Wrapper">
<span aria-hidden="true">https://</span>
<input data-element="Input" name="website" />
</div>
</uni-text-field>Pass value and update it from onValueChange. You can change what the user
typed, for example to upper case:
React
const [code, setCode] = useState('');
const coupon = useBehavior(textField, {
value: code,
onValueChange: (next) => setCode(next.toUpperCase()),
});Vue
<script setup lang="ts">
const code = ref('');
const coupon = useBehavior(textField, () => ({
value: code.value,
onValueChange: (next) => (code.value = next.toUpperCase()),
}));
</script>
<template>
<div v-bind="coupon.parts.root">
<label v-bind="coupon.parts.label">Coupon</label>
<input v-bind="coupon.parts.input" name="coupon" />
</div>
</template>HTML
<script type="module">
const coupon = document.querySelector('#coupon');
coupon.value = ''; // setting the property makes it controlled
coupon.addEventListener('valuechange', (event) => {
coupon.value = event.detail.toUpperCase();
});
</script>A controlled field keeps the typed text on screen while you answer, so a store that updates late loses no keystrokes and the caret stays put:
- A new
valuethat echoes what the field reported leaves the input alone. - Any other
value, such as a transformed one or one set from elsewhere, replaces the text at once, and the caret moves to the end. - Text you never accept goes back to your
valuewhen the input loses focus. - While an input method is composing text, a new
valuewaits until the composition ends.
Put name on the input: the native input is what the form submits. On the
form’s reset, the field goes back to its default, as a native input goes
back to its value attribute. An uncontrolled field follows defaultValue
again (empty without one), even one you set after it rendered, and reports
the change through valueChange. A controlled field reports defaultValue,
or without one the value it started with, and keeps your value until you
accept. A reset cancelled with preventDefault() before it reaches the form
changes nothing.
In a Form, the form holds the value under the field’s name,
checks it when focus leaves the input (isRequired, the constraints you
write on the input, such as type="email" or minlength, and validate)
and shows the first error in the errorText part. Without a form, set
isInvalid from your own checks, for example when focusChange reports
false, as the demo does. See Forms.
| Part | Element | Notes |
|---|---|---|
root |
any; usually div |
Optional. The outermost element, around the other parts. No role. |
wrapper |
any; usually div |
Optional. The box around the input and your prefix or suffix. No role. A press on it outside any control focuses the input. |
label |
label by default |
As Field’s label. |
input |
input or textarea |
UniAria writes Field’s id (unless it has one), aria-labelledby, aria-describedby, aria-invalid and aria-required, plus disabled, readonly and the value property. You own name, type, placeholder, autocomplete and the other native attributes. |
description |
any | As Field’s description. |
error |
any | As Field’s error: hidden until isInvalid. |
errorText |
any, inside error |
Optional. In a Form, UniAria writes the first error’s text here; leave it out to render errors yourself. |
A part keeps an id you give it, and everything that refers to the part
follows it; UniAria writes an id only on a part that has none (see
Ids).
Every part but the input is optional. The parts link as
Field’s do. Set disabled and readonly through the
props, not on the input: UniAria writes those attributes and removes yours.
| Prop | Attribute | Type | Default | Description |
|---|---|---|---|---|
value / defaultValue |
default-value; the value property makes it controlled |
string |
'' |
The text. Changes come from valueChange. |
isDisabled |
disabled |
boolean |
false |
Native disabled on the input. Marks every part and hides the error. |
isReadOnly |
readonly |
boolean |
false |
Native readonly: the input stays focusable and selectable, but can’t be edited. |
isRequired |
required |
boolean |
false |
aria-required="true" on the input, never the native required. |
isInvalid |
invalid |
boolean |
— | aria-invalid="true" on the input. Shows, links and announces the error. Unset in a Form, the form’s errors decide it. |
inputId |
— | string |
generated | The input’s id while the input has none of its own. |
labelElementType |
— (read from the label’s tag) | string |
'label' |
The label’s tag; for goes on a label only. |
labelledBy |
labelled-by |
string |
— | More ids for the input’s aria-labelledby, after the label’s. |
describedBy |
described-by |
string |
— | More ids for the input’s aria-describedby, after the description and the error. |
tooltip |
tooltip (the tooltip’s id) |
a Tooltip’s instance | — | A Tooltip for the input. Without it, the element links the <uni-tooltip> it sits in directly. |
In a Form, TextField also takes name, form, validate,
validationTrigger, validationDelay, validationMessages, dependsOn,
isEqual and shouldPreserve: see Field props for the
form.
| Event | Callback prop | Detail | When |
|---|---|---|---|
valuechange |
onValueChange |
string |
The user changed the text (every input event, input-method composition included), or a form reset asks for the default. Never for a value you set. |
focuschange |
onFocusChange |
boolean |
The input gained or lost focus. A good moment to validate. |
The custom element dispatches each event on itself, bubbling, with the value
in detail. In React, the input’s onFocus, onBlur, onInput,
onPointerEnter, onPointerLeave, onCompositionStart and
onCompositionEnd come from UniAria, and passing your own replaces them: use
the events above instead. onChange, onKeyDown and the rest are yours. In
Vue, a listener of your own runs beside UniAria’s.
| Field | Type | Meaning |
|---|---|---|
value |
string |
The current value. When controlled, your value, even while the input shows text you haven’t accepted yet. |
isFocused |
boolean |
The input has focus. |
isFocusVisible |
boolean |
The input has focus from a keyboard or a screen reader. Typing after a click doesn’t set it. |
isHovered |
boolean |
A mouse or pen is over the input (not the label or the wrapper’s padding). |
isDisabled |
boolean |
The isDisabled prop, or the form’s. |
isReadOnly |
boolean |
The isReadOnly prop, or the form’s. |
isRequired |
boolean |
The isRequired prop. |
isInvalid |
boolean |
The isInvalid prop, else in a form whether it holds errors. |
isErrorShown |
boolean |
The error is shown: invalid and not disabled. |
Typing, hovering and focusing never render your component again. To render something from state in React or Vue, select it (Vue gives a ref):
const isFocused = useBehaviorState(email, (state) => state.isFocused);On the custom element, read element.instance.getState(). See
State.
| Attribute | When |
|---|---|
data-uni="text-field" |
On the root, always. |
data-element="<Part>" |
On every other part, always. |
data-hovered |
A mouse or pen is over the input. |
data-focused |
The input has focus. |
data-focus-visible |
The input has focus from a keyboard or a screen reader. |
data-disabled |
The field is disabled. |
data-readonly |
The field is read-only. |
data-invalid |
The field is invalid. |
data-required |
The field is required. |
Every state attribute goes on every part, so the box, the label and the
messages can follow the input: draw the focus ring on the wrapper with
[data-focused], color the label with [data-invalid]. The error part also
gets hidden while it isn’t shown. TextField sets no inline styles.
The optional @uniaria/styles package styles
every part, as in the demo above, whose CSS tab holds only the page’s layout.
The wrapper is the box, with the focus ring on any focus inside it and the
danger border while invalid; the input is bare inside it, beside your own prefix
or suffix. Without a wrapper the input has no box: use Field
around a native input for that.
TextField handles no keys; the input keeps its native editing.
| Key | Behavior |
|---|---|
| Enter | In an <input>, submits its form, natively. In a <textarea>, adds a line. |
| Escape | Not handled: it reaches your handlers and closes a dialog around the field. |
| Tab | Moves focus normally. A disabled input leaves the Tab order; a read-only one stays. |
- Spoken output. A field labelled “Email” reads “textbox, Email” when
empty, “textbox, Email, jane@example.com” with a value, “textbox, Email,
jane@example.com, read only” while read-only and “textbox, Email, disabled”
while disabled. A
<textarea>is a textbox too;type="search"reads “searchbox”. The description, the error and the required and invalid states follow, as in Field. - Labels. A placeholder isn’t a label: always render the label part, or
pass
labelledBy. - Errors. Setting
isInvalidshows the error and announces it, as Field does. Don’t addrole="alert"to the error part. - Read-only or disabled. A read-only input stays in the Tab order and is read with its value. A disabled one leaves it, so prefer read-only for a value the user should still be able to find and copy.
- Tooltips. A Tooltip linked through
tooltipdescribes the input, and typing closes it.
- Controlled typing is buffered. React and React Aria put a refused value back right after the keystroke. TextField keeps the typed text until the input loses focus, so an echo that arrives late doesn’t move the caret or drop keystrokes. A transformed value still replaces the text at once.
- One component for both elements. As in React Aria, a
<textarea>is a TextField; there is noelementTypeto set. - The box focuses the input. A press on the wrapper outside any control focuses the input. React Aria has no such part.
- Validation is the form’s. In a Form,
validateand the native constraints you write on the input are checked, as React Aria’svalidateand native validation do; outside one, passisInvalid. - Form reset goes back to the
defaultValuethe field has when the form resets, as a native input does; React Aria keeps the one it started with. A controlled field withoutdefaultValuegoes back to the value it started with, as in React Aria.