Skip to content

Commit 789fe3e

Browse files
yinlianghuiclaude
andauthored
fix(fields): 字数统计不再每次击键整句重播,改用 describedby + 阈值门控的 debounce 状态区 (#3408) (#3416)
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt * refactor(fields): derive the counter status region's silence instead of clearing it in an effect (#3408) `react-hooks/set-state-in-effect` was right: the synchronous `setStatus('')` re-rendered on every keystroke the region spends staying quiet, which is most of them. The settled sentence is now written only by the debounce timer, and what renders is `settledStatus === pendingStatus ? pendingStatus : ''` -- so leaving the warning band is silent immediately, with no cascading render. Not `pendingStatus ? settledStatus : ''`: delete out of the band and type back in, and that spelling re-announces the count from before the excursion a full second before the timer corrects it. A wrong number spoken is worse than a right one spoken twice. Pinned by a new case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt * style(fields): blank line between the threshold helper and the widget (#3408) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 935ea2f commit 789fe3e

17 files changed

Lines changed: 872 additions & 77 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/fields': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
`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).
7+
8+
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.
9+
10+
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.

packages/fields/src/widgets/TextAreaField.tsx

Lines changed: 177 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import React, { useEffect, useId, useState } from 'react';
22
import { Textarea, EmptyValue } from '@object-ui/components';
33
import { FullscreenFieldEditor } from './FullscreenFieldEditor';
44
import { FieldWidgetComponentProps } from './types';
@@ -37,13 +37,134 @@ import { useFieldTranslation } from './useFieldTranslation';
3737
* override is ever genuinely needed, declare ONE key on
3838
* `FieldWidgetComponentProps`, stop stripping it, and have a host pass it.
3939
*/
40+
41+
/**
42+
* How long the typist must pause before the counter's status region is allowed
43+
* to change (objectui#3408). The GOV.UK Design System character-count component
44+
* uses the same shape and the same order of magnitude.
45+
*
46+
* The number this replaces was effectively 0: the counter WAS the live region,
47+
* so every keystroke re-rendered it and every re-render was an announcement.
48+
* Measured on `origin/main`, zh session, `maxLength: 500`, typing a 52-character
49+
* sentence one character at a time: 52 keystrokes produced 52 distinct
50+
* announcements totalling 979 spoken characters — ~19x the text the user was
51+
* trying to write, each one interrupting the screen reader's echo of what they
52+
* had just typed.
53+
*/
54+
const COUNTER_STATUS_DEBOUNCE_MS = 1000;
55+
56+
/** Announce inside the last 10% of the cap … */
57+
const COUNTER_STATUS_REMAINING_RATIO = 0.1;
58+
/** … or the last 20 characters — whichever the typist reaches FIRST. */
59+
const COUNTER_STATUS_MIN_REMAINING = 20;
60+
61+
/**
62+
* The remaining-character count at or below which the status region speaks.
63+
*
64+
* "10% remaining OR 20 characters remaining, whichever comes first" is a
65+
* disjunction, and because `remaining` only ever counts DOWN as the user types,
66+
* the branch that fires first is simply the LARGER of the two — hence `max`.
67+
* A 500-character cap starts warning at 50 remaining; a 100-character cap at 20
68+
* (10% of it would be 10, which the typist would reach later, not sooner).
69+
*
70+
* A cap smaller than {@link COUNTER_STATUS_MIN_REMAINING} is therefore "near
71+
* the limit" from the first keystroke, which is correct: on a 10-character
72+
* field every character genuinely is one of the last few. The debounce still
73+
* bounds it to one announcement per pause.
74+
*/
75+
function counterStatusThreshold(maxLength: number): number {
76+
return Math.max(
77+
Math.ceil(maxLength * COUNTER_STATUS_REMAINING_RATIO),
78+
COUNTER_STATUS_MIN_REMAINING,
79+
);
80+
}
81+
4082
export function TextAreaField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
41-
// Above the `readonly` early return on purpose: a hook may not sit behind a
42-
// conditional return. The readonly branch renders no counter, so this is a
43-
// no-op there — but moving it down would desync hook order the moment a
44-
// field toggles readonly.
83+
// Everything from here to the `readonly` early return is hook-order
84+
// territory: a hook may not sit behind a conditional return. The readonly
85+
// branch renders no counter, so these are no-ops there — but moving them
86+
// down would desync hook order the moment a field toggles readonly. The
87+
// derivations they read (`maxLength`, `length`) sit up here for the same
88+
// reason, not because the readonly branch wants them.
4589
const { t } = useFieldTranslation();
4690

91+
const textareaField = field as any;
92+
// Spec FieldSchema declares camelCase `maxLength`; `max_length` is the legacy
93+
// objectui spelling. Dual-read (framework#1878 §3 recheck) — without this a
94+
// spec-authored maxLength gave neither the textarea cap nor the counter.
95+
const maxLength = textareaField?.maxLength ?? textareaField?.max_length;
96+
const length = (value || '').length;
97+
98+
// Two ids off one `useId()`: the description a screen reader reads ONCE on
99+
// focus, and the status region it hears only near the cap. Both derive from
100+
// the widget instance, so two textareas on one form never collide.
101+
const instanceId = useId();
102+
const descriptionId = `${instanceId}-charcount`;
103+
104+
/**
105+
* The counter sentence as a DESCRIPTION (objectui#3408). Reached through the
106+
* textarea's `aria-describedby`, so focusing the field says "12 characters,
107+
* 500 max" once and then shuts up. Before this the cap was announced only as
108+
* a side effect of typing — focus told a screen reader user nothing at all
109+
* that a sighted user could read off the corner of the box.
110+
*
111+
* Same key the visible counter's `aria-label` used to carry (objectui#3406,
112+
* ten packs); it moved from a live region onto a description, it was not
113+
* duplicated.
114+
*/
115+
const description = maxLength
116+
? t('fields.textarea.characterCount', { count: length, max: maxLength })
117+
: '';
118+
119+
/**
120+
* The near-limit warning, gated. Silent for the whole comfortable middle of
121+
* the field — a count nobody is close to is not news — and phrased as what
122+
* is LEFT rather than what has been typed, because that is the number the
123+
* user is about to act on. Over-long values (a cap lowered after the record
124+
* was saved) clamp to 0: the textarea's own `maxLength` blocks further
125+
* input, so "0 left" is the true actionable state, and the description above
126+
* still carries the honest `503 of 500`.
127+
*/
128+
const pendingStatus =
129+
!readonly && maxLength && maxLength - length <= counterStatusThreshold(maxLength)
130+
? t('fields.textarea.charactersRemaining', { count: Math.max(maxLength - length, 0) })
131+
: '';
132+
133+
/**
134+
* The last sentence a PAUSE settled on. Only ever written by the timer —
135+
* never cleared synchronously — so this effect adds no cascading render on
136+
* the keystrokes it is busy staying quiet through
137+
* (`react-hooks/set-state-in-effect`).
138+
*/
139+
const [settledStatus, setSettledStatus] = useState('');
140+
141+
useEffect(() => {
142+
if (!pendingStatus) return;
143+
const timer = setTimeout(() => setSettledStatus(pendingStatus), COUNTER_STATUS_DEBOUNCE_MS);
144+
// Every keystroke cancels the previous pending announcement, so a typist
145+
// who never pauses is never interrupted. The dependency is the SENTENCE,
146+
// not the length: re-typing back to the same count produces the same string
147+
// and therefore no DOM change and no second announcement.
148+
return () => clearTimeout(timer);
149+
}, [pendingStatus]);
150+
151+
/**
152+
* Speak the settled sentence only while it is still TRUE — i.e. while it is
153+
* the one the current value would produce. Everything else renders empty,
154+
* which costs no speech (emptying a live region announces nothing), and that
155+
* is what makes leaving the warning band silent IMMEDIATELY rather than a
156+
* second later.
157+
*
158+
* The obvious alternative, `pendingStatus ? settledStatus : ''`, is wrong in
159+
* one specific way: delete back out of the band and type into it again, and
160+
* the region re-announces the STALE count from before the excursion a full
161+
* second before the timer corrects it. Announcing a wrong number is worse
162+
* than announcing a right one twice, which is the bounded, sub-second,
163+
* correct-content cost of the comparison below. Pinned by `never
164+
* re-announces the STALE count when the user types back into the band`.
165+
*/
166+
const status = settledStatus === pendingStatus ? pendingStatus : '';
167+
47168
if (readonly) {
48169
return (
49170
<div className="text-sm whitespace-pre-wrap">
@@ -52,12 +173,7 @@ export function TextAreaField({ value, onChange, field, readonly, error, ...prop
52173
);
53174
}
54175

55-
const textareaField = field as any;
56176
const rows = textareaField?.rows || 4;
57-
// Spec FieldSchema declares camelCase `maxLength`; `max_length` is the legacy
58-
// objectui spelling. Dual-read (framework#1878 §3 recheck) — without this a
59-
// spec-authored maxLength gave neither the textarea cap nor the counter.
60-
const maxLength = textareaField?.maxLength ?? textareaField?.max_length;
61177
// Mobile fullscreen opt-in travels on the field metadata and nowhere else.
62178
// That metadata has exactly one carrier (`field`, objectui#3233), so this is
63179
// a single read — a misspelled flag has no read path to quietly catch it.
@@ -71,6 +187,16 @@ export function TextAreaField({ value, onChange, field, readonly, error, ...prop
71187
// `isSubmitting`, so that hole was open for the duration of every submit.
72188
const disabled = Boolean(domProps.disabled);
73189

190+
// APPENDED, never assigned (objectui#3408). `<FormControl>` is a Radix Slot
191+
// and already hands the control an `aria-describedby` naming the field's
192+
// description and error message; it arrives here through `toDomProps`'
193+
// `aria-*` pass-through. Overwriting it would trade "no cap announced" for
194+
// "no error announced" — a strictly worse bug, and a silent one.
195+
const describedBy =
196+
[domProps['aria-describedby'], maxLength ? descriptionId : undefined]
197+
.filter(Boolean)
198+
.join(' ') || undefined;
199+
74200
return (
75201
<div className="relative">
76202
<Textarea
@@ -82,36 +208,50 @@ export function TextAreaField({ value, onChange, field, readonly, error, ...prop
82208
rows={rows}
83209
maxLength={maxLength}
84210
aria-invalid={!!error}
211+
// After the spread so the composed value wins over the raw host one.
212+
aria-describedby={describedBy}
85213
className={domProps.className}
86214
/>
87215
{maxLength && (
88-
<div
89-
className="absolute bottom-2 right-2 text-xs text-gray-400"
90-
aria-live="polite"
91-
// objectui#3406 — this was the English literal
92-
// `Character count: ${n} of ${max}`. The VISIBLE text next to it is
93-
// digits and needs no locale, but the accessible name is a sentence,
94-
// and this element is `aria-live`, so a non-English session heard an
95-
// English sentence read out on every keystroke.
96-
//
97-
// One interpolated key rather than a per-part assembly: ja and ko
98-
// interpolate the CAP BEFORE the count ("of {{max}} characters,
99-
// {{count}}"), an order no code-side concatenation can express.
100-
// The English default in `FIELD_DEFAULTS` is
101-
// byte-identical to the literal it replaces, so a widget rendered
102-
// with no I18nProvider is unchanged.
103-
//
104-
// Deliberately NOT changed here: `aria-live="polite"` plus a name
105-
// recomputed per keystroke. That is a behaviour question (how often
106-
// a screen reader should speak), filed separately — this change is
107-
// key-ing only, byte-for-byte in English.
108-
aria-label={t('fields.textarea.characterCount', {
109-
count: (value || '').length,
110-
max: maxLength,
111-
})}
112-
>
113-
{(value || '').length}/{maxLength}
114-
</div>
216+
<>
217+
{/*
218+
The VISIBLE counter, and now visible ONLY (objectui#3408). It used
219+
to be three things at once: the digits a sighted user glances at,
220+
the carrier of the translated sentence (objectui#3406), and the
221+
live region itself — so the sentence was re-announced on every
222+
single keystroke. Split into the three nodes below, this one is
223+
decorative: `aria-hidden` keeps a screen reader from reading
224+
"5 slash 200" on top of the description that says it properly.
225+
*/}
226+
<div
227+
className="absolute bottom-2 right-2 text-xs text-gray-400"
228+
aria-hidden="true"
229+
data-testid="textarea-character-count"
230+
>
231+
{length}/{maxLength}
232+
</div>
233+
234+
{/*
235+
The DESCRIPTION. Referenced by the textarea's `aria-describedby`,
236+
never announced on its own — a screen reader reads it when focus
237+
lands on the field and not again. Visually hidden because the
238+
digits above already say it to the eye.
239+
*/}
240+
<span id={descriptionId} className="sr-only">
241+
{description}
242+
</span>
243+
244+
{/*
245+
The STATUS region. Rendered unconditionally (whenever there is a
246+
cap) and starting EMPTY on purpose: a live region has to be in the
247+
DOM before its content changes or the first change is not announced
248+
at all. `aria-atomic` so the whole sentence is spoken rather than
249+
the digits that differ from last time.
250+
*/}
251+
<span className="sr-only" aria-live="polite" aria-atomic="true">
252+
{status}
253+
</span>
254+
</>
115255
)}
116256

117257
{showFullscreenButton && (

0 commit comments

Comments
 (0)