Skip to content
UniAria
Color scheme
Contrast
Color scheme
Contrast

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.

Text fields
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>

Usage

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.

Multiple lines

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.

Prefixes and suffixes

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>

Controlled value

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 value that 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 value when the input loses focus.
  • While an input method is composing text, a new value waits until the composition ends.

In a form

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.

Anatomy

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.

Props

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.

Events

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.

State

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.

Styling

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.

Default 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.

Keyboard

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.

Accessibility

  • 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 isInvalid shows the error and announces it, as Field does. Don’t add role="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 tooltip describes the input, and typing closes it.

Compared with other libraries

  • 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 no elementType to 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, validate and the native constraints you write on the input are checked, as React Aria’s validate and native validation do; outside one, pass isInvalid.
  • Form reset goes back to the defaultValue the field has when the form resets, as a native input does; React Aria keeps the one it started with. A controlled field without defaultValue goes back to the value it started with, as in React Aria.