fix(fields): 字数统计不再每次击键整句重播,改用 describedby + 阈值门控的 debounce 状态区 (#3408) - #3416
Merged
Conversation
…ry 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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
…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
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
…#3408) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
yinlianghui
marked this pull request as ready for review
August 6, 2026 01:48
This was referenced Aug 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3408
前提复核(动手前先复刻探针)
在
origin/main(935ea2f)上按 issue 正文的探针原样跑了一遍 —— zh 会话、maxLength: 500、把Follow up with the customer about the renewal quote.逐字符输入,每次 change 之后采样 live region 的可访问名:{ "keystrokes": 52, "ariaLiveOnRegion": "polite", "regionIsAriaHidden": null, "textareaDescribedBy": null, "announcementsFired": 52, "distinctAnnouncements": 52, "totalCharsSpoken": 979, "first": "已输入 1 个字符,最多 500 个", "last": "已输入 52 个字符,最多 500 个" }与 issue 记录逐字段一致(52/52/979,
aria-hidden与aria-describedby双 null)。前提成立,按 PM 裁定的 A + B + C 的 describedby 半边 实施。改了什么
原来那个计数块同时扮演三个角色:给眼睛看的
{n}/{max}数字、#3406 键化后的整句译文载体、以及 live region 本体。三者同体,于是「重渲染」和「播报」是同一件事。现在拆成 GOV.UK character-count 组件的三节点形制:aria-hidden="true",纯装饰。不再被读屏读成「5 斜杠 500」压在下面那句正经描述上面。fields.textarea.characterCount(十包原样,一个字没动),挂到 textarea 的aria-describedby上。聚焦时读一次「已输入 12 个字符,最多 500 个」,然后闭嘴。这条补掉的正是探针里textareaDescribedBy: null那一项 —— 以前读屏用户只能靠撞上限才知道有上限。aria-live="polite"(带aria-atomic),承载新键fields.textarea.charactersRemaining。两道闸门:remaining只会往下走,「先到」在实现里就是两者取max;500 上限从剩 50 开始警告,100 上限从剩 20 开始(它的 10% 是 10,来得更晚)。aria-describedby是追加不是赋值:FormControl是 Radix Slot,本来就会把字段描述和错误消息的 id 交到控件上(经toDomProps的aria-*透传到达这里)。覆盖它等于拿「上限没播报」换「错误没播报」,是更糟且更隐蔽的 bug。新键
fields.textarea.charactersRemaining十包齐 +FIELD_DEFAULTS英文默认。刻意用冒号/后置形式(Characters remaining: {{count}}/还可输入 {{count}} 个字符)而不是You have {{count}} characters remaining:count会让 i18next 先去查_one/_few复数键,而这批包只声明基础键 —— 语法不随数字变的句子在 count=1 时天然正确,不必给十个包各补一堆复数条目。这也正是characterCount已经依赖的那条基础键回退路径。第二个 commit:静默是算出来的,不是在 effect 里清出来的
第一版在 effect 体里同步
setStatus(''),被react-hooks/set-state-in-effect点名 —— 它说得对:那意味着在「正忙着保持安静」的绝大多数击键上都白挂一次级联渲染。改成:settled 句子只由 debounce 定时器写入,渲染的是settledStatus === pendingStatus ? pendingStatus : ''。退出警告带因此立刻静音,且没有多余渲染。刻意不是
pendingStatus ? settledStatus : '':删到警告带外面再打回来,那种写法会把出走之前的旧计数立刻重播一遍,一秒后才被定时器纠正。播一个错的数字比把对的数字播两遍更糟。这条单独有用例钉住。修后的实测
同一个探针,同样 52 击键:0 次播报。更严苛的变体 —— 每敲一个字符就停满 1s(debounce 完全失效,只剩阈值在挡)—— 同样 0 次。最贴近真实的最坏情况(同样 52 击键,但正好打到 500 上限,每 10 字符停顿一次)是 5 次,全部落在警告带内:
三种都写成了测试,不是一次性脚本。
测试
packages/fields/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx(新,20 例)—— 行为半边:上面三个探针、debounce 的 999ms 临界(证明静默,不是「早晚会说」)、阈值两侧各一例(500 上限的 449/450,100 上限的 79/80 —— 后者钉住max(),写成min()会让警告晚来十个字符,而 500 那组看不出来)、退出警告带立刻静音、出走后回来不播旧值、超长值钳到 0 而不是倒数、live region 挂载即存在且为空({status && ...}会通过上面每一条静默断言,却恰好废掉整套门控要服务的那唯一场景)、describedby 的追加语义与两个实例 id 不撞。debounce 一律假计时器,零真实 sleep。TextAreaField.characterCount.i18n.test.tsx/.no-provider.test.tsx—— #3406 钉住aria-live的那两条 pin 按本单裁定的方向更新(live region 仍在,但已与可见数字分家,数字改带aria-hidden);句子的取值改成顺着aria-describedby解析,和读屏一样,这样「关联真的生效」也进了断言。原有的 ja 词序、ru/ar 基础键回退、英文字面量反向断言全部原样保留 —— 期望字符串一个字没改,因为译文本身没动。两个文件各补了charactersRemaining的对应用例。packages/i18n/src/__tests__/textarea-charactercount-locale-parity.test.ts—— 新键的十包覆盖、只插值{{count}}不重复{{max}}(上限属于聚焦时读的描述,警告里再说一遍只会拉长这唯一允许的打断)、没人偷偷塞复数后缀键、en 与FIELD_DEFAULTS对齐、其余九包没有英文原句回填。命令与结果:
反向验证(方向先声明,再跑)
origin/main的 widget 放回去):预期新用例转红。实测 19 例中 16 红,探针那条直接报出[ '1/500', '2/500', … ]共 52 条,即 issue 记录的那个数字被测试自己复现了出来。留绿的 3 条恰是无上限 / readonly 分支 —— 它们本就不是钉修复的,是防修复越界的。pendingStatus ? settledStatus : '':预期只有新增的「出走后回来不播旧值」那条转红。实测 1 红 19 绿,报expected 'Characters remaining: 40' to be ''。不在本单范围
FullscreenFieldEditor的 footer 计数按裁定未动(它既无aria-label也无 live region,是另一回事)。