From 0055081e3c84b9671c0fd42ca19195c13f5ad666 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 01:19:37 +0000 Subject: [PATCH 1/3] fix(fields): stop the textarea character counter re-announcing on every keystroke (#3408) The counter block was three things at once: the visible {n}/{max} digits, the carrier of the translated sentence (#3406) and the aria-live region itself. So every re-render was an announcement. Measured on origin/main (zh, maxLength 500, a 52-character sentence typed one character at a time): 52 keystrokes -> 52 distinct announcements, 979 spoken characters, ~19x the text being written, each one interrupting the screen reader's echo of the letter just pressed. The textarea also carried no aria-describedby, so focusing the field said nothing about the cap. Split into the GOV.UK character-count shape: - the visible digits are aria-hidden and decorative; - fields.textarea.characterCount moved onto the textarea's aria-describedby (appended to the host's, never replacing it) and is read once on focus; - a separate visually-hidden aria-live="polite" region carries a new near-limit warning, fields.textarea.charactersRemaining (ten packs), gated to the last 10% / 20 characters of the cap -- whichever comes first -- and debounced by 1s. The same 52-keystroke probe now announces 0 times; a run typing all the way onto a 500-character cap announces 5. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .../textarea-character-count-live-region.md | 10 + packages/fields/src/widgets/TextAreaField.tsx | 196 +++++++-- ...ield.characterCount.announcements.test.tsx | 397 ++++++++++++++++++ ...TextAreaField.characterCount.i18n.test.tsx | 137 ++++-- ...aField.characterCount.no-provider.test.tsx | 60 ++- .../fields/src/widgets/useFieldTranslation.ts | 14 + ...tarea-charactercount-locale-parity.test.ts | 69 +++ packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 19 +- packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + 17 files changed, 834 insertions(+), 77 deletions(-) create mode 100644 .changeset/textarea-character-count-live-region.md create mode 100644 packages/fields/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx diff --git a/.changeset/textarea-character-count-live-region.md b/.changeset/textarea-character-count-live-region.md new file mode 100644 index 0000000000..2d6d5bedfe --- /dev/null +++ b/.changeset/textarea-character-count-live-region.md @@ -0,0 +1,10 @@ +--- +'@object-ui/fields': patch +'@object-ui/i18n': patch +--- + +`TextAreaField`'s character counter no longer re-announces itself on every keystroke. Measured on `main` in a zh session with `maxLength: 500`, typing a 52-character sentence one character at a time produced 52 distinct screen-reader announcements totalling 979 spoken characters — roughly 19x the text being written, each one cutting off the reader's echo of the letter just typed. The counter element was simultaneously the visible `{n}/{max}` digits, the carrier of the translated sentence and the `aria-live` region itself, so "re-render" and "announce" were the same event; the field also had no `aria-describedby`, so focusing it said nothing about the cap at all (#3408). + +It is now the three-node shape the GOV.UK Design System character-count component uses: the visible digits are `aria-hidden` and purely decorative; the counter sentence (`fields.textarea.characterCount`, unchanged in all ten packs) has moved onto the textarea's `aria-describedby`, so focus reads "Character count: 12 of 500" once and then stays quiet; and a separate visually-hidden `aria-live="polite"` region carries a new near-limit warning, `fields.textarea.charactersRemaining` (new in all ten packs), which stays silent until the value is inside the last 10% or last 20 characters of the cap — whichever the typist reaches first — and updates only after typing pauses for a second. The same 52-keystroke probe now produces zero announcements; a run that types all the way onto a 500-character cap produces five. Any `aria-describedby` the host already supplied (the form renderer's description and error-message ids) is appended to, never replaced. + +No metadata change: the counter still renders exactly when the field declares `maxLength` (or the legacy `max_length`), and a widget rendered with no `I18nProvider` still shows the same English sentences. diff --git a/packages/fields/src/widgets/TextAreaField.tsx b/packages/fields/src/widgets/TextAreaField.tsx index 62f8bce440..17347935af 100644 --- a/packages/fields/src/widgets/TextAreaField.tsx +++ b/packages/fields/src/widgets/TextAreaField.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useId, useState } from 'react'; import { Textarea, EmptyValue } from '@object-ui/components'; import { FullscreenFieldEditor } from './FullscreenFieldEditor'; import { FieldWidgetComponentProps } from './types'; @@ -37,13 +37,116 @@ import { useFieldTranslation } from './useFieldTranslation'; * override is ever genuinely needed, declare ONE key on * `FieldWidgetComponentProps`, stop stripping it, and have a host pass it. */ + +/** + * How long the typist must pause before the counter's status region is allowed + * to change (objectui#3408). The GOV.UK Design System character-count component + * uses the same shape and the same order of magnitude. + * + * The number this replaces was effectively 0: the counter WAS the live region, + * so every keystroke re-rendered it and every re-render was an announcement. + * Measured on `origin/main`, zh session, `maxLength: 500`, typing a 52-character + * sentence one character at a time: 52 keystrokes produced 52 distinct + * announcements totalling 979 spoken characters — ~19x the text the user was + * trying to write, each one interrupting the screen reader's echo of what they + * had just typed. + */ +const COUNTER_STATUS_DEBOUNCE_MS = 1000; + +/** Announce inside the last 10% of the cap … */ +const COUNTER_STATUS_REMAINING_RATIO = 0.1; +/** … or the last 20 characters — whichever the typist reaches FIRST. */ +const COUNTER_STATUS_MIN_REMAINING = 20; + +/** + * The remaining-character count at or below which the status region speaks. + * + * "10% remaining OR 20 characters remaining, whichever comes first" is a + * disjunction, and because `remaining` only ever counts DOWN as the user types, + * the branch that fires first is simply the LARGER of the two — hence `max`. + * A 500-character cap starts warning at 50 remaining; a 100-character cap at 20 + * (10% of it would be 10, which the typist would reach later, not sooner). + * + * A cap smaller than {@link COUNTER_STATUS_MIN_REMAINING} is therefore "near + * the limit" from the first keystroke, which is correct: on a 10-character + * field every character genuinely is one of the last few. The debounce still + * bounds it to one announcement per pause. + */ +function counterStatusThreshold(maxLength: number): number { + return Math.max( + Math.ceil(maxLength * COUNTER_STATUS_REMAINING_RATIO), + COUNTER_STATUS_MIN_REMAINING, + ); +} export function TextAreaField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps) { - // Above the `readonly` early return on purpose: a hook may not sit behind a - // conditional return. The readonly branch renders no counter, so this is a - // no-op there — but moving it down would desync hook order the moment a - // field toggles readonly. + // Everything from here to the `readonly` early return is hook-order + // territory: a hook may not sit behind a conditional return. The readonly + // branch renders no counter, so these are no-ops there — but moving them + // down would desync hook order the moment a field toggles readonly. The + // derivations they read (`maxLength`, `length`) sit up here for the same + // reason, not because the readonly branch wants them. const { t } = useFieldTranslation(); + const textareaField = field as any; + // Spec FieldSchema declares camelCase `maxLength`; `max_length` is the legacy + // objectui spelling. Dual-read (framework#1878 §3 recheck) — without this a + // spec-authored maxLength gave neither the textarea cap nor the counter. + const maxLength = textareaField?.maxLength ?? textareaField?.max_length; + const length = (value || '').length; + + // Two ids off one `useId()`: the description a screen reader reads ONCE on + // focus, and the status region it hears only near the cap. Both derive from + // the widget instance, so two textareas on one form never collide. + const instanceId = useId(); + const descriptionId = `${instanceId}-charcount`; + + /** + * The counter sentence as a DESCRIPTION (objectui#3408). Reached through the + * textarea's `aria-describedby`, so focusing the field says "12 characters, + * 500 max" once and then shuts up. Before this the cap was announced only as + * a side effect of typing — focus told a screen reader user nothing at all + * that a sighted user could read off the corner of the box. + * + * Same key the visible counter's `aria-label` used to carry (objectui#3406, + * ten packs); it moved from a live region onto a description, it was not + * duplicated. + */ + const description = maxLength + ? t('fields.textarea.characterCount', { count: length, max: maxLength }) + : ''; + + /** + * The near-limit warning, gated. Silent for the whole comfortable middle of + * the field — a count nobody is close to is not news — and phrased as what + * is LEFT rather than what has been typed, because that is the number the + * user is about to act on. Over-long values (a cap lowered after the record + * was saved) clamp to 0: the textarea's own `maxLength` blocks further + * input, so "0 left" is the true actionable state, and the description above + * still carries the honest `503 of 500`. + */ + const pendingStatus = + !readonly && maxLength && maxLength - length <= counterStatusThreshold(maxLength) + ? t('fields.textarea.charactersRemaining', { count: Math.max(maxLength - length, 0) }) + : ''; + + const [status, setStatus] = useState(''); + + useEffect(() => { + // Leaving the threshold (or the field going readonly) silences the region + // immediately — emptying a live region announces nothing, so this costs no + // speech. Only ENTERING it is debounced. + if (!pendingStatus) { + setStatus(''); + return; + } + const timer = setTimeout(() => setStatus(pendingStatus), COUNTER_STATUS_DEBOUNCE_MS); + // Every keystroke cancels the previous pending announcement, so a typist + // who never pauses is never interrupted. The dependency is the SENTENCE, + // not the length: re-typing back to the same count produces the same string + // and therefore no DOM change and no second announcement. + return () => clearTimeout(timer); + }, [pendingStatus]); + if (readonly) { return (
@@ -52,12 +155,7 @@ export function TextAreaField({ value, onChange, field, readonly, error, ...prop ); } - const textareaField = field as any; const rows = textareaField?.rows || 4; - // Spec FieldSchema declares camelCase `maxLength`; `max_length` is the legacy - // objectui spelling. Dual-read (framework#1878 §3 recheck) — without this a - // spec-authored maxLength gave neither the textarea cap nor the counter. - const maxLength = textareaField?.maxLength ?? textareaField?.max_length; // Mobile fullscreen opt-in travels on the field metadata and nowhere else. // That metadata has exactly one carrier (`field`, objectui#3233), so this is // a single read — a misspelled flag has no read path to quietly catch it. @@ -71,6 +169,16 @@ export function TextAreaField({ value, onChange, field, readonly, error, ...prop // `isSubmitting`, so that hole was open for the duration of every submit. const disabled = Boolean(domProps.disabled); + // APPENDED, never assigned (objectui#3408). `` is a Radix Slot + // and already hands the control an `aria-describedby` naming the field's + // description and error message; it arrives here through `toDomProps`' + // `aria-*` pass-through. Overwriting it would trade "no cap announced" for + // "no error announced" — a strictly worse bug, and a silent one. + const describedBy = + [domProps['aria-describedby'], maxLength ? descriptionId : undefined] + .filter(Boolean) + .join(' ') || undefined; + return (