Component contract
Every text input in the admin is <UiTextInput>. Linter-enforced — no naked <input type="text"> in product code. The component handles label association, error rendering, character counter, autofill detection, and paste sanitization in one place.
name: string— form field name; emitted on the underlying<input>.label: string— visible label, ALWAYS rendered (no placeholder-only labels).type?: "text" | "email" | "tel" | "url" | "number" | "search"— defaultstext. Influences mobile keyboard.required?: boolean— adds visible asterisk +aria-required.maxLength?: number— character cap; counter appears at 75% of cap.helperText?: string— appears below the field, above any error. Always present in the DOM (even when empty) to prevent layout shift when an error replaces it.error?: string— when set, shifts the field into error visual state, renders the message viaaria-describedby, setsaria-invalid.autoComplete?: string— passed through to the underlying input. Useoffonly when truly necessary; users want autofill.placeholder?: string— display-only; not a substitute forlabel.
Interaction surface
-
Render: label above input.
Label is always above the input, never floating, never placeholder-only. Floating labels are an accessibility regression in low-contrast scenarios. Required marker (asterisk) appears after the label text. Helper text reserves vertical space whether or not it has content (avoids layout shift on error rendering).
-
Focus: visible focus ring.
Focus ring is a 2px ring with high contrast against the field background. NEVER removed via
outline: nonewithout a replacement; the ring is part of the visual contract for keyboard users. -
User types.
If
maxLengthis set, the counter appears below the field once the user is within 25% of the cap (e.g., maxLength=200, counter shows at 150 chars). Counter format: "172 / 200". Counter color shifts to warn at 90% (180+) and error at 100% (200, no further input accepted). -
Paste handling.
If pasted content exceeds
maxLength, content is truncated to the cap AND a soft warning appears below the field for 3 seconds: "Pasted text was truncated to 200 characters." Character counter updates immediately. Pasting plain text into atype="email"ortype="tel"field strips control chars and trims whitespace; rejected content is logged but not surfaced unless the entire paste produced an empty result. -
Autofill.
Browser autofill is detected (via
:autofillCSS pseudo-class plus a layout-trick fallback) and the field's appearance includes a subtle indicator that the value came from autofill (so the user knows the field was filled and can verify). Autofill DOES NOT trigger validation immediately — validation runs on blur or submit, not on autofill. This prevents form-error-spam during page load when many fields autofill at once. -
Validation.
Errors render below the field, above the helper text. The error message replaces the helper text visually but both nodes remain in the DOM (hidden when empty) to keep layout stable. The error region has
role="alert"+aria-live="polite"so screen readers announce on first render. The field getsaria-invalid="true"andaria-describedbylinking to the error message id. -
Disabled state.
Disabled inputs have
cursor: not-allowedon hover, opacity ~0.6, ANDaria-disabled="true"with the underlyingdisabledattribute. Both because some screen readers treat the two differently. Color contrast on disabled text is still ≥ 4.5:1 against the field background — disabled means "not editable now," not "invisible."
Failure modes
Layout shifts when error appears
Trigger: user fills field, blurs, validation error renders. Content below the field jumps down by the height of the error.
The error region must reserve vertical space at component mount, not just when an error appears. Implementation: an empty div with min-height equal to one line of error text. The harness asserts cumulative layout shift for the field region is 0 across the focus → blur → error transition.
Recovery: Component fix — reserve the space.
Character counter drifts after paste
Trigger: user pastes 250 chars into a maxLength=200 field. The counter shows "250 / 200" briefly before the truncation handler runs, OR shows "200 / 200" but the underlying value still has 250.
Truncation must be synchronous with the paste event, not deferred to the next render tick. The harness simulates a paste of over-cap content and reads both the visible counter AND the underlying input value, asserting both equal the cap.
Recovery: Component fix — handle paste in the input event handler with synchronous truncation.
Floating label hides label on focus
Trigger: a designer requested a Material-style floating label. When the field is focused, the label moves into the placeholder space.
This pattern is rejected — labels are always above the input, period. The component does not have a floating-label variant. The harness asserts the label is rendered above the input AND remains above on focus AND remains above when the input has content. Variants that hide the label in any state fail the contract.
Recovery: Don't add floating-label variants. If a designer wants this, push back — the contract is intentional.
Outline removed without replacement
Trigger: a CSS rule sets outline: none on the field on focus, with no replacement focus indicator.
Focus ring must always be visible — either via the default outline, a custom box-shadow, or a styled ::after. The linter scans the component's CSS for outline: rules without a sibling focus-state replacement. The harness checks at runtime: focus the field, screenshot a 4-pixel border around it, assert visual difference from the unfocused state.
Recovery: CSS fix — restore a visible focus indicator.
Validation runs on autofill, blanket-erroring all fields
Trigger: page loads, browser autofills 6 fields, each one's onChange fires validation, errors render on every field that's "incomplete" because the autofill chain hasn't finished yet.
Validation runs on blur or on submit, NEVER on initial fill. The component differentiates between autofill-triggered fills and user-typed input by listening for the autofill detection events; autofill events do NOT mark the field as touched. The harness simulates autofill of a multi-field form and asserts no error renders until the user blurs or submits.
Recovery: Component fix — don't validate on autofill.
Helper text and error text both visible at once
Trigger: field has helperText="Enter a date in MM/DD/YYYY format" AND validation error="Date must be in the future." Both render simultaneously, doubling the visual noise.
When in error state, the error replaces the helper text. The helper-text node is still in the DOM (for layout stability) but visually hidden via a data-state attribute that switches between "helper" and "error". Both nodes never have visible content simultaneously.
Recovery: Component fix — error replaces helper visually.
Accessibility
- Label is always rendered as a
<label>withformatching the input'sid. - Error message has
role="alert"+aria-live="polite". - Field uses
aria-invalid="true"+aria-describedbywhen in error state. - Required fields have
aria-required="true"AND a visible asterisk. - Disabled fields have both
disabledandaria-disabled="true". - Color contrast: input text ≥ 4.5:1 against field background (NOT 3:1 — text). Focus ring ≥ 3:1 against field background. Placeholder text ≥ 4.5:1 (it's still text, even if greyed).
- Pass axe-core with no
seriousorcriticalviolations.
Stable test attributes
Component-level contract. Every <UiTextInput> instance MUST expose these.
Visibility teeth. Each attribute must be present AND effectively visible when the input is rendered (and not when it shouldn't be — error elements are absent / fail visibility when there is no error). Hiding without removal is a Ratchet violation.
| data-test | Where | Purpose |
|---|---|---|
ui-text-input | The wrapping <div> | Component identity marker |
ui-text-input-label | The <label> | Visible label text |
ui-text-input-input | The <input> | The actual input element; carries name |
ui-text-input-helper | Below the input | Helper text region; aria-describedby target when no error |
ui-text-input-error | Below the input | Error region; appears only when in error state; aria-describedby target when in error |
ui-text-input-counter | Below the input | Character counter; appears when within 25% of maxLength |
ui-text-input-paste-warning | Below the input | Soft warning after paste truncation; auto-dismisses after 3s |
Agent test plan
Standalone probes run against /admin-test/ui-text-input-fixture with several variants: required, with maxLength, with helperText, in error state, disabled, with autoComplete.
Probe list
- label-association: assert label[for] equals input[id]
- label-always-above-input: focus the field, assert label is positioned visually above input
- label-stays-above-with-content: type into field, assert label still above
- focus-ring-visible: focus the field, screenshot 4px border, assert visual delta vs unfocused
- counter-appears-at-75-percent: type until 76% of maxLength, assert ui-text-input-counter visible
- counter-shifts-warn-at-90: type to 90%, assert counter has data-state=warn
- counter-shifts-error-at-100: type to 100%, assert counter has data-state=error AND no further keystrokes accepted
- paste-truncation-synchronous: paste over-cap content, immediately assert input value === maxLength chars
- paste-warning-shows: same as above, assert ui-text-input-paste-warning visible
- paste-warning-dismisses-3s: wait 3.5s, assert paste-warning absent
- error-replaces-helper: render with helperText AND error, assert ui-text-input-helper hidden AND ui-text-input-error visible
- no-layout-shift-on-error: focus → blur with validation, assert CLS for field region == 0
- aria-invalid-on-error: render with error, assert input has aria-invalid="true"
- aria-describedby-error: render with error, assert input's aria-describedby ends with error element id
- aria-required-on-required: render with required=true, assert input has aria-required="true"
- visible-asterisk-on-required: same, assert label contains visible asterisk
- autofill-no-validation: simulate browser autofill via DOM event, assert no error renders
- autofill-validates-on-blur: autofill then blur, assert validation runs
- disabled-cursor-not-allowed: render disabled, hover, assert cursor:not-allowed
- disabled-aria-disabled: render disabled, assert both disabled and aria-disabled present
- color-contrast-text: assert input text contrast ≥ 4.5:1
- color-contrast-placeholder: assert placeholder text contrast ≥ 4.5:1
- color-contrast-focus-ring: assert focus ring contrast ≥ 3:1