GitHub

Text Field

Self-contained label, input, and help/error text in one form-associated Web Component. Participates in native forms via ElementInternals and emits a composed change event.

Stable wc import '@esa/ecology/esa-text-field'; Angular usage

Preview

<esa-text-field label="Full name" placeholder="Jane Doe" help-text="As it appears on the permit."></esa-text-field>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-text-field';

@Component({
  selector: 'app-text-field-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-text-field
      label="Full name"
      placeholder="Jane Doe"
      help-text="As it appears on the permit."
    ></esa-text-field>
  `,
})
export class TextFieldDemo {}

Types

The type attribute maps straight to the native input type.

<esa-text-field label="Email" type="email" placeholder="you@example.com"></esa-text-field>
<esa-text-field label="Password" type="password"></esa-text-field>
<esa-text-field label="Count" type="number"></esa-text-field>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-text-field';

@Component({
  selector: 'app-text-field-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-text-field label="Email" type="email" placeholder="you@example.com"></esa-text-field>
    <esa-text-field label="Password" type="password"></esa-text-field>
    <esa-text-field label="Count" type="number"></esa-text-field>
  `,
})
export class TextFieldDemo {}

Sizes

<esa-text-field label="Extra small" size="xs" placeholder="Extra small"></esa-text-field>
<esa-text-field label="Small" size="sm" placeholder="Small"></esa-text-field>
<esa-text-field label="Medium" size="md" placeholder="Medium"></esa-text-field>
<esa-text-field label="Large" size="lg" placeholder="Large"></esa-text-field>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-text-field';

@Component({
  selector: 'app-text-field-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-text-field label="Extra small" size="xs" placeholder="Extra small"></esa-text-field>
    <esa-text-field label="Small" size="sm" placeholder="Small"></esa-text-field>
    <esa-text-field label="Medium" size="md" placeholder="Medium"></esa-text-field>
    <esa-text-field label="Large" size="lg" placeholder="Large"></esa-text-field>
  `,
})
export class TextFieldDemo {}

States

<esa-text-field label="Required" required placeholder="Cannot be empty"></esa-text-field>
<esa-text-field label="With error" error-text="This field is required." required></esa-text-field>
<esa-text-field label="Disabled" disabled value="Read only"></esa-text-field>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-text-field';

@Component({
  selector: 'app-text-field-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <esa-text-field label="Required" required placeholder="Cannot be empty"></esa-text-field>
    <esa-text-field label="With error" error-text="This field is required." required></esa-text-field>
    <esa-text-field label="Disabled" disabled value="Read only"></esa-text-field>
  `,
})
export class TextFieldDemo {}

Form participation

The element is form-associated. Submitting logs the FormData entry for contact.

<form>
  <esa-text-field label="Contact" name="contact" placeholder="Type then submit"></esa-text-field>
  <button type="submit">Submit</button>
</form>
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@esa/ecology/esa-text-field';

@Component({
  selector: 'app-text-field-demo',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <form>
      <esa-text-field label="Contact" name="contact" placeholder="Type then submit"></esa-text-field>
      <button type="submit">Submit</button>
    </form>
  `,
})
export class TextFieldDemo {}

API

PropTypeDefaultDescription
label string '' Visible label rendered above the input.
size 'xs' | 'sm' | 'md' | 'lg' 'md' Control size.
placeholder string '' Placeholder shown when empty.
help-text string '' Helper text shown below the input. Property: `helpText`.
error-text string '' Error message; replaces help text and turns the field red when present. Property: `errorText`.
required boolean false Marks the field required and renders an asterisk on the label.
disabled boolean false Disables interaction.
name string | undefined — Form field name — the key this control submits under.
type 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' 'text' Native input type.
value string '' Current value; mirrored to the form on every input.
prefix string ''
suffix string ''
pattern string '' Regular expression the value must match — forwarded to the inner input.
minlength number | undefined —
maxlength number | undefined —
autocomplete string '' Autofill hint (`email`, `given-name`, `one-time-code`, …). This is the ONLY way to satisfy SC 1.3.5 Identify Input Purpose (AA): `type="email"` says what KIND of data the field wants, `autocomplete="email"` says WHOSE. It also turns on browser autofill, which is a large win for motor impairments. Use the real fixed values, not invented ones.
inputmode string '' Mobile keyboard hint (`numeric`, `decimal`, `tel`, …). Usability only — enforces nothing.
live-error boolean false Announce the error the moment it appears, instead of only when the field is focused. OFF by default, and that default is the considered one. The house pattern is validate-on-submit with an `<esa-error-summary>` that takes focus — under which a live region here would fire an assertive announcement for EVERY invalid field at once, racing the summary the user was just sent to. `aria-describedby` already carries the message when they arrive at the field. Turn it on for fields you validate INLINE (on blur), where the appearing message is the only signal and there is exactly one of it. Note that JAWS may then announce it twice — once live, once as the description on focus — which is the known cost of pairing a live region with `aria-describedby` on the same node. Property: `liveError`.

Events

EventTypeDefaultDescription
change CustomEvent<{ value: string }> — Fired on every input. Composed and bubbling; detail.value is the current string.
Binding these events from Angular

Every one of these is a CustomEvent, so the payload is on $event.detail — $event.target.value reads empty, with no error. The names are lowercase and Angular passes them to addEventListener verbatim, so camel-casing one means it never fires.

  • (change)="onChange($event.detail)"

Accessibility

  • Renders a native <input> associated to its <label> via for/id.
  • Sets aria-invalid="true" while error-text is present.
  • The required asterisk carries an aria-label="required".
  • Visible focus ring via --focus-ring-* tokens.

Theming surface

Every public token this component reads, extracted from its source at build time, split by blast radius: the hooks that belong to this component, then the shared and system tokens it consumes alongside everything else. The Resolves to column walks each token's real lineage — component → semantic → primitive → raw value — so you can see exactly where re-pointing it lands. A spoke re-skins by overriding these in its theme-<slug>.css; component internals are never edited. No hook for what you need? File it with /request-lego.

Type roles it names

This component does not set a size, weight, face or tracking of its own — it names a composite, and the composite carries all five. A composite is a class rather than a token, so none of it appears in the tables below; re-theme text by re-pointing the token family, not by editing the component.

RoleRe-pointReach
.typography-body-sm
  • --typography-body-sm-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-body-sm-font-size clamp(0.6875rem, 0.61rem + 0.38vw, 0.875rem) via --font-size-150
  • --typography-body-sm-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-body-sm-letter-spacing 0.01em via --letter-spacing-default
  • --typography-body-sm-line-height 1.6 via --line-height-normal
+20 more components esa-alert-box, esa-breadcrumbs, esa-button-toggle, esa-card, esa-checkbox-group, esa-combobox, esa-command-palette, esa-date-picker, esa-empty-state, esa-file-upload, esa-form-field, esa-input-tag, esa-link-column, esa-loading-overlay, esa-progress-bar, esa-radio-group, esa-search-panel, esa-select, esa-stat, esa-textarea
.typography-label-2xs
  • --typography-label-2xs-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-label-2xs-font-size clamp(0.5rem, 0.44rem + 0.3vw, 0.625rem) via --font-size-050
  • --typography-label-2xs-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-label-2xs-letter-spacing 0.01em via --letter-spacing-default
  • --typography-label-2xs-line-height 1.6 via --line-height-normal
+11 more components esa-button-toggle, esa-checkbox-group, esa-color-picker, esa-combobox, esa-date-picker, esa-form-field, esa-input-tag, esa-progress-bar, esa-radio-group, esa-range-slider, esa-textarea
.typography-label-lg
  • --typography-label-lg-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-label-lg-font-size clamp(0.875rem, 0.77rem + 0.52vw, 1.125rem) via --font-size-300
  • --typography-label-lg-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-label-lg-letter-spacing 0.01em via --letter-spacing-default
  • --typography-label-lg-line-height 1.6 via --line-height-normal
+12 more components esa-breadcrumbs, esa-button-toggle, esa-checkbox-group, esa-color-picker, esa-combobox, esa-date-picker, esa-form-field, esa-input-tag, esa-radio-group, esa-range-slider, esa-select, esa-textarea
.typography-label-md
  • --typography-label-md-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-label-md-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-label-md-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-label-md-letter-spacing 0.01em via --letter-spacing-default
  • --typography-label-md-line-height 1.6 via --line-height-normal
+33 more components esa-app-bar, esa-breadcrumbs, esa-button-group, esa-button-toggle, esa-card, esa-checkbox-group, esa-color-picker, esa-combobox, esa-command-palette, esa-confirm-dialog, esa-danger-zone, esa-date-picker, esa-dialog, esa-empty-state, esa-entity-search, esa-file-upload, esa-filter-container, esa-form-field, esa-header-nav, esa-input-tag, esa-link-column, esa-page-header, esa-pillbox, esa-popover, esa-progress-bar, esa-radio-group, esa-range-slider, esa-select, esa-side-dialog, esa-sidebar-nav, esa-stat, esa-textarea, esa-tooltip
.typography-label-xs
  • --typography-label-xs-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-label-xs-font-size clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --font-size-100
  • --typography-label-xs-font-weight 500 via --typography-font-weight-medium → --font-weight-500
  • --typography-label-xs-letter-spacing 0.01em via --letter-spacing-default
  • --typography-label-xs-line-height 1.6 via --line-height-normal
+14 more components esa-breadcrumbs, esa-button-toggle, esa-checkbox-group, esa-color-picker, esa-combobox, esa-date-picker, esa-entity-search, esa-form-field, esa-input-tag, esa-progress-bar, esa-radio-group, esa-range-slider, esa-select, esa-textarea
.typography-microcopy-2xs-subtle
  • --typography-microcopy-2xs-subtle-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-2xs-subtle-font-size clamp(0.5rem, 0.44rem + 0.3vw, 0.625rem) via --font-size-050
  • --typography-microcopy-2xs-subtle-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-microcopy-2xs-subtle-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-2xs-subtle-line-height 1 via --line-height-none
+3 more components esa-combobox, esa-date-picker, esa-input-tag
.typography-microcopy-lg-subtle
  • --typography-microcopy-lg-subtle-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-lg-subtle-font-size clamp(0.875rem, 0.77rem + 0.52vw, 1.125rem) via --font-size-300
  • --typography-microcopy-lg-subtle-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-microcopy-lg-subtle-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-lg-subtle-line-height 1 via --line-height-none
+7 more components esa-combobox, esa-command-palette, esa-date-picker, esa-entity-search, esa-filter-dropdown, esa-input-tag, esa-select
.typography-microcopy-md-subtle
  • --typography-microcopy-md-subtle-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-md-subtle-font-size clamp(0.75rem, 0.66rem + 0.44vw, 0.9375rem) via --font-size-200
  • --typography-microcopy-md-subtle-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-microcopy-md-subtle-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-md-subtle-line-height 1 via --line-height-none
+7 more components esa-combobox, esa-date-picker, esa-filter-dropdown, esa-input-tag, esa-pagination, esa-search-panel, esa-select
.typography-microcopy-xs-subtle
  • --typography-microcopy-xs-subtle-font-family 'DM Sans', sans-serif via --typography-font-family-sans → --font-family-dm-sans
  • --typography-microcopy-xs-subtle-font-size clamp(0.625rem, 0.56rem + 0.32vw, 0.75rem) via --font-size-100
  • --typography-microcopy-xs-subtle-font-weight 350 via --typography-font-weight-regular → --font-weight-350
  • --typography-microcopy-xs-subtle-letter-spacing 0.01em via --letter-spacing-default
  • --typography-microcopy-xs-subtle-line-height 1 via --line-height-none
+7 more components esa-combobox, esa-date-picker, esa-filter-dropdown, esa-filter-pills, esa-input-tag, esa-select, esa-tooltip

Wired to this component

No other component reads these — re-point them and esa-text-field is the only thing that moves.

Token Tier Resolves to
--form-affix-bg component #f0f0f0 via --color-background-elevation-sunken → --color-gray-3
--form-affix-border-color component one role per property, so a theme can move the ring's weight and its colour independently. Tier 3's --focus-ring-width now reads this instead of holding a bare 2px, which is the same shape --focus-ring-color has always had (a thin tier-3 alias over a tier-2 role) — width was simply never given the tier-2 half. * * This role was rejected once, and the rejection is worth recording because its PREMISE was wrong rather than its reasoning. The argument against an `emphasis` width was that every 2px border in the kit is micro-geometry — spinner rings, the kbd keycap lip, a chevron. The focus ring was not in that enumeration and does not fit it: 33 components render it, more than read any other tier-3 token, and it is not micro-geometry but an accessibility affordance whose weight is a legitimate brand axis (a spoke that needs a heavier ring for contrast has to be able to reach it). It is also not a `border` in any component — every consumer paints it with `outline` or `box-shadow` — which is precisely why a survey of BORDERS missed it. The name still says `border` because tier 2 already calls the ring's colour --color-border-default-focus via --form-border-color → --color-border-default
--form-affix-color component #646464 via --color-content-default-secondary → --color-gray-11

Shared tokens it reads

Owned by a family or by the semantic layer. Still overridable — but scope the override (esa-foo { --form-border-color: … }) unless you mean to move everything that reads it. Reach says what else that is.

Token Reach Resolves to
--focus-ring-color component focus ring group · +34 more #3e9b4f via --color-border-default-focus → --color-grass-10
--focus-ring-offset component focus ring group · +31 more 2px
--focus-ring-width component focus ring group · +35 more 2px via --border-width-focus → --border-width-200
--form-border-color component forms group · +11 more one role per property, so a theme can move the ring's weight and its colour independently. Tier 3's --focus-ring-width now reads this instead of holding a bare 2px, which is the same shape --focus-ring-color has always had (a thin tier-3 alias over a tier-2 role) — width was simply never given the tier-2 half. * * This role was rejected once, and the rejection is worth recording because its PREMISE was wrong rather than its reasoning. The argument against an `emphasis` width was that every 2px border in the kit is micro-geometry — spinner rings, the kbd keycap lip, a chevron. The focus ring was not in that enumeration and does not fit it: 33 components render it, more than read any other tier-3 token, and it is not micro-geometry but an accessibility affordance whose weight is a legitimate brand axis (a spoke that needs a heavier ring for contrast has to be able to reach it). It is also not a `border` in any component — every consumer paints it with `outline` or `box-shadow` — which is precisely why a survey of BORDERS missed it. The name still says `border` because tier 2 already calls the ring's colour --color-border-default-focus via --color-border-default
--form-border-color-focus component forms group · +11 more #3e9b4f via --color-border-default-focus → --color-grass-10
--form-border-color-hover component forms group · +3 more #bbbbbb via --color-border-default-strong → --color-gray-8
--form-border-width component forms group · +10 more 1px via --border-width-default → --border-width-100
--form-error-border-color component forms group · +9 more #ce2c31 via --color-background-utility-danger → --color-red-11
--form-error-color component forms group · +9 more #ce2c31 via --color-content-utility-danger → --color-red-11
--form-help-color component forms group · +9 more #646464 via --color-content-default-secondary → --color-gray-11
--form-help-gap component forms group · +5 more 0.25rem via --spacing-100
--form-label-color component forms group · +7 more #646464 via --color-content-default-secondary → --color-gray-11
--form-label-gap component forms group · +2 more 0.25rem via --spacing-100
--form-placeholder-color component forms group · +4 more #646464 via --color-content-default-secondary → --color-gray-11
--form-text-color component forms group · +6 more #202020 via --color-content-default → --color-gray-12
--color-background-disabled semantic every component #f0f0f0 via --color-gray-3
--color-background-field semantic every component rgba(0, 0, 0, 0)
--color-border-disabled semantic every component #d9d9d9 via --color-gray-6
--color-content-disabled semantic every component #8d8d8d via --color-gray-9
--color-content-utility-danger semantic every component #ce2c31 via --color-red-11
--radius-md semantic every component 0.5rem via --radius-200
--radius-sm semantic every component 0.25rem via --radius-100
--transition-fast semantic every component 150ms via --duration-150
--spacing-100 primitive every component 0.25rem
--spacing-200 primitive every component 0.5rem
--spacing-250 primitive every component 0.625rem
--spacing-300 primitive every component 0.75rem
--spacing-400 primitive every component 1rem