Inline Form Validation Inside Editable Table Cells

Permalink to "Inline Form Validation Inside Editable Table Cells"

aria-invalid combined with aria-describedby is the baseline ARIA pair that makes cell-level validation accessible: aria-invalid="true" tells assistive technologies the input is in an error state, and aria-describedby associates the error text so it is announced when the field receives focus. Without both attributes, a screen reader user who tabs to an invalid cell hears no indication of the problem — a direct failure of WCAG 2.2 Success Criterion 3.3.1 (Error Identification, Level A).


Spec Reference

Permalink to "Spec Reference"
Source Attribute / Criterion Valid values Default
ARIA 1.2 spec aria-invalid false (default), true, grammar, spelling false
ARIA 1.2 spec aria-describedby space-separated list of element IDs
ARIA 1.1 spec aria-errormessage single element ID
WCAG 2.2 SC 3.3.1 (Level A) Error Identification Errors detected automatically must be described in text
WCAG 2.2 SC 3.3.3 (Level AA) Error Suggestion If an input error is detected and suggestions are known, they must be provided
WCAG 2.2 SC 1.4.1 (Level A) Use of Color Error state cannot be communicated by color alone

aria-invalid="false" is the neutral state — it is equivalent to the attribute being absent. Set it explicitly on injected inputs so that toggling to "true" triggers screen reader announcements reliably. aria-errormessage is the ARIA 1.1 standard for pointing to error text, but AT support remains uneven; always pair it with aria-describedby pointing to the same element as a fallback.


When to Use vs. When Not to Use

Permalink to "When to Use vs. When Not to Use"

Use aria-invalid + aria-describedby when:

  • A native <input> or <select> has been injected into a role="gridcell" for editing.
  • Validation has run and detected a specific, describable error.
  • The error text is rendered in the DOM (even if visually hidden — display:none hides it from AT too).

Do not use aria-invalid="true" when:

  • No validation has run yet — the attribute should not be set preemptively on empty fields the user has not touched.
  • The field is in a read-only display state; the attribute applies to interactive controls only.
  • You are using a native <input type="email"> or <input type="number"> whose browser constraint validation is active — the browser sets the invalid state automatically via the :invalid CSS pseudo-class; adding aria-invalid on top creates duplicate announcements in some AT.

A common misapplication is toggling aria-invalid on the <td> cell element rather than on the <input> inside it. Screen readers track the invalid state on the focusable control, not its container. Placing it on the <td> silently does nothing in NVDA, JAWS, and VoiceOver.


Annotated Code Example

Permalink to "Annotated Code Example"

The minimum viable implementation for one editable email cell:

<!-- SC 3.3.1: gridcell contains the interactive control -->
<div role="gridcell" data-field="email">

  <!-- SC 3.3.1: aria-invalid signals error state to AT -->
  <!-- SC 3.3.1: aria-describedby links input to error text -->
  <!-- ARIA 1.1: aria-errormessage as primary link (fallback: aria-describedby) -->
  <input
    type="text"
    class="cell-input"
    id="cell-input-row3-email"
    aria-label="Email address, row 3"
    aria-invalid="true"
    aria-describedby="cell-err-row3-email"
    aria-errormessage="cell-err-row3-email"
    value="not-an-email"
  />

  <!-- role="alert": announces immediately when text is written in — SC 3.3.1 -->
  <!-- aria-atomic="true": forces the full message to be read, not just the diff -->
  <div
    id="cell-err-row3-email"
    role="alert"
    aria-atomic="true"
    class="cell-error"
  >
    <!-- SC 3.3.3: error suggestion tells the user the correct format -->
    Invalid email address. Enter a complete address, for example name@domain.com.
  </div>

</div>

And the JavaScript that sets and clears those states on blur:

function applyValidationResult(input, errorEl, errorMessage) {
  if (errorMessage) {
    // SC 3.3.1: mark the input invalid so AT announces on focus
    input.setAttribute('aria-invalid', 'true');
    // SC 3.3.1 + role="alert": writing to the container triggers immediate announcement
    errorEl.textContent = errorMessage;
  } else {
    // Clear invalid state when the value passes validation
    input.setAttribute('aria-invalid', 'false');
    errorEl.textContent = '';
  }
}

// Event delegation — one listener on the grid, not per-cell
const grid = document.querySelector('[role="grid"]');

// Synchronous format validation on blur (SC 3.3.1 does not require real-time checks)
grid.addEventListener('blur', (event) => {
  if (!event.target.matches('.cell-input')) return;
  const input = event.target;
  const field = input.closest('[role="gridcell"]').dataset.field;
  const errorEl = input.closest('[role="gridcell"]').querySelector('[role="alert"]');

  const message = validateField(field, input.value); // returns string or null
  applyValidationResult(input, errorEl, message);
}, /* useCapture */ true);

Validation State Machine Diagram

Permalink to "Validation State Machine Diagram"

The diagram below maps the three cell states — read-only, editing, and error — and the transitions between them driven by keyboard events and validation outcomes.

Editable cell validation state machine Three states — READ_ONLY, EDITING, ERROR — connected by arrows. Enter or F2 moves from READ_ONLY to EDITING. Escape returns from EDITING to READ_ONLY. Enter (pass) returns from EDITING to READ_ONLY. Enter (fail) moves from EDITING to ERROR. Escape returns from ERROR to READ_ONLY. Tab or Arrow in ERROR preserves the error state and focuses the next cell. READ_ONLY aria-readonly="true" or static text EDITING aria-invalid="false" input focused ERROR aria-invalid="true" role="alert" announced Enter / F2 Escape (cancel) Enter (pass) Enter (fail) Escape (cancel) Tab / Arrow (error persists) Next cell focused

Keyboard and AT Behaviour

Permalink to "Keyboard and AT Behaviour"
Key / Event Expected AT Announcement NVDA Deviation VoiceOver Deviation
Enter / F2 on read-only cell “Email address, row 3, edit, not invalid” Announces role after field label Announces “text field” type
blur from input with error Silence (error already announced via role="alert") May repeat error from aria-describedby on re-focus Re-reads aria-describedby content on focus
Enter to commit (fail) “[error message text]” via role="alert" immediately Announces alert inline with current speech May queue alert after current utterance
Escape to cancel Focus returns to cell; announces cell coordinates Consistent Consistent
Tab away from ERROR cell Next cell announced; previous error state preserved in DOM Consistent Consistent
Refocus ERROR cell (Shift+Tab) “Email address, row 3, invalid, [error text]” via aria-describedby Announces “invalid” before label Announces “invalid data” after label

Integration Context

Permalink to "Integration Context"

Cell-level validation is one layer inside the wider inline editing and form controls pattern. That parent pattern governs how cells transition into and out of edit mode, how focus returns to the grid after a commit, and how role="gridcell" interacts with role="grid" to support arrow-key navigation. Validation state must not disrupt any of those mechanics.

For the announcement side, the role="alert" approach here uses the same assertive live region concept described in choosing between polite and assertive aria-live regions. Cell-level errors warrant assertive announcement on commit; incremental hints during typing should use a polite region so they do not interrupt navigation announcements.

When focus management in single-page apps is at play — for example, after a row-level save operation re-renders the table — ensure focus returns to the correct cell and that aria-invalid is rehydrated from your application state, not inferred from the DOM.


Gotchas

Permalink to "Gotchas"

1. Writing to role="alert" before it is in the DOM

Permalink to "1. Writing to role="alert" before it is in the DOM"

role="alert" only triggers an announcement when text is inserted into an element that is already in the DOM. A common mistake is injecting the entire <div role="alert"> with its text content in one operation — the browser may not fire the alert event because the element was not present during the previous render frame. Fix: insert the empty container first (on cell activation), then write the error text into it later when validation fails.

// WRONG: inserting the alert element with text simultaneously
cell.innerHTML = `<div role="alert">Invalid email.</div>`;

// CORRECT: container already in DOM (empty); write text when validation fails
const alertEl = cell.querySelector('[role="alert"]');
alertEl.textContent = 'Invalid email address. Enter a complete address.';

2. Losing validation state during virtualized grid scroll

Permalink to "2. Losing validation state during virtualized grid scroll"

Virtualized grids recycle DOM nodes when rows scroll out of the viewport. Any aria-invalid state and error text written to a recycled node disappears. Store validation results in a Map keyed by rowId + columnId, and rehydrate during each render cycle:

// validation store keyed by "rowId:colId"
const validationStore = new Map();

function renderCell(rowId, colId, input, errorEl) {
  const key = `${rowId}:${colId}`;
  const result = validationStore.get(key);
  if (result) {
    // SC 3.3.1: rehydrate aria-invalid on recycled node
    input.setAttribute('aria-invalid', 'true');
    errorEl.textContent = result.message;
  } else {
    input.setAttribute('aria-invalid', 'false');
    errorEl.textContent = '';
  }
}

3. Race conditions in async validation with AbortController

Permalink to "3. Race conditions in async validation with AbortController"

When validation requires a network request (unique username, server-side constraint check), rapid typing can produce stale results from earlier requests overwriting correct results from later ones. Use AbortController to cancel in-flight requests when a new value is submitted:

let controller = null;

async function validateUnique(input, errorEl) {
  if (controller) controller.abort(); // cancel previous request
  controller = new AbortController();

  // aria-busy="true": signals AT that a result is pending — SC 4.1.3
  input.setAttribute('aria-busy', 'true');
  try {
    const res = await fetch(`/api/check?value=${encodeURIComponent(input.value)}`, {
      signal: controller.signal
    });
    const { taken } = await res.json();
    applyValidationResult(input, errorEl, taken ? 'Username is already taken.' : null);
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
    // AbortError means a newer request is in flight — do nothing
  } finally {
    input.removeAttribute('aria-busy');
  }
}

FAQ

Permalink to "FAQ"
Should I use aria-errormessage or aria-describedby for cell-level error messages?

Use both. aria-errormessage is the ARIA 1.1 standard for pointing to error text, but support remains inconsistent across AT versions — NVDA and VoiceOver may not announce it reliably without role="alert" on the target element. aria-describedby pointing to the same element works as a reliable fallback: the error text is read when the input receives focus in every major screen reader. Pair both attributes on the same input until AT support for aria-errormessage matures across the versions your users run.

When should I use role="alert" versus a polite aria-live region for cell errors?

Use role="alert" (which implies aria-live="assertive") only for hard validation failures that block a commit action — format errors, required field violations, or server-side uniqueness conflicts. Use a polite live region for transient hints shown during typing, such as character-count updates or password-strength indicators. Assertive announcements interrupt the screen reader’s current speech queue; triggering them on every keystroke creates announcement spam that disorients AT users navigating a large grid.

How do I prevent validation state from disappearing in a virtualized grid?

Store validation state in a Map keyed by row and column identifiers — not DOM node references. Virtualized grids recycle DOM nodes during scroll, so any ARIA state written to a node is discarded when that node leaves the viewport. During each render cycle, read from the Map to rehydrate aria-invalid and the error message text. Use IntersectionObserver to pause async validation for cells outside the viewport so you do not accumulate stale pending requests.


Permalink to "Related"

← Back to Inline Editing & Form Controls