fix(fields): 全屏长文本编辑器的字数计数接入无障碍,计数 UI 收敛为一份共享组件 (#3417) - #3436
Merged
Conversation
…diting surfaces (#3417) The fullscreen edit dialog's footer counter was a bare {n}/{max} span: no accessible name, no aria-describedby tying it to the dialog's textarea, nothing aria-live. Browse mode read "5 slash 500" if it happened to sweep the footer; focusing the input said nothing at all. Same field, same cap, same user as the inline surface, which has carried the three-node GOV.UK character-count shape since #3408 -- so the fullscreen branch was at zero. Reachable on any phone form with ObjectFormSchema.mobile.fullscreenLongText on, for every long-text field that declares a limit. Extracted the counting UI into ONE shared CharacterCount component in packages/fields, derived from the inline implementation, and had both surfaces render it instead of keeping two hand-written copies that could only drift. The dialog gets aria-hidden digits plus a visually-hidden description carrying fields.textarea.characterCount, wired to the dialog's textarea through aria-describedby and counting the DRAFT. Description ids are per surface, because the draft and the committed value diverge the moment the user types in the dialog. The dialog deliberately gets NO live region (announceNearLimit={false}): it is a modal opened to write at length, the description already delivers the cap on focus, and the inline surface's aria-live region stays mounted behind the overlay -- a second one would put two in a single document. That absence is pinned rather than left to chance. The inline surface is byte-identical: same DOM, same threshold-gated debounced announcements. FullscreenFieldEditor is untouched -- its footer slot stays a ReactNode callback, since TextAreaField owns both the footer and the editor and is the only place that can mint the id and name it on the textarea. Promoting count to a first-class capability would have meant a fourth children argument that RichTextField (the other host, which passes no footer) would ignore -- a declared prop with one producer, the shape this package keeps deleting. No new i18n keys: fields.textarea.characterCount / fields.textarea.charactersRemaining and their FIELD_DEFAULTS fallbacks are reused as-is. 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
|
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 #3417
前提复核(先做,再动手)
在
origin/main(875c5fa)上逐条核对了单子的前提,成立:packages/fields/src/widgets/TextAreaField.tsx的footer回调仍然只渲染一串裸数字(text-xs text-muted-foreground self-center的 span,内容{draft.length}/{maxLength}):没有可访问名、没有aria-describedby把它和对话框里data-testid="textarea-fullscreen-input"的 textarea 关联起来、没有任何aria-live。aria-hidden数字 + describedby 描述节点 + 阈值门控 debounce 的 polite 状态节点)。ObjectForm在ObjectFormSchema.mobile.fullscreenLongText打开时给每个长文本字段盖mobile_fullscreen,手机端开了这个设置的表单里,每个带上限的长文本字段都命中。顺带核了一件事,结论是不必扩大范围:
packages/components/src/renderers/form/form.tsx的内建分支(form-textarea-fullscreen-*一套 test id)整个就没有字数计数(它只有validation.maxLength校验规则),所以不存在「第三份计数 UI 又漂了」的问题,没有额外要修的分叉。做法(按裁定的 C 形 + B 行为)
新增
packages/fields/src/widgets/CharacterCount.tsx,从 #3416 的内联实现里抽出来,两个编辑面共用这一份,不再有分叉的手抄件。aria-hidden数字 + 视觉隐藏的描述节点,通过aria-describedby挂到对话框的 textarea 上;数字与描述都跟 draft 走(对话框是本地草稿,提交前和已保存值会分叉,所以两个面各自一个描述 id)。announceNearLimit={false}。理由写在代码注释里 —— 全屏是用户主动打开来长写的模态,描述节点已经在聚焦时把上限说清楚了,而内联那份 live region 在遮罩后面仍然挂着,再加一个就是同一份文档里两个 live region。这一条是以「不存在」的形式断言的,不是靠运气。关于
FullscreenFieldEditor的 footer 槽(裁定让我自己判断)保持
footer是 ReactNode 回调,FullscreenFieldEditor.tsx一个字没动。理由是「更小且诚实」:
TextAreaField同时拥有 footer 和 editor 两侧,是唯一能同时铸出 id 并把它写到 textarea 的aria-describedby上的地方,所以这条路只需要 3 行。反过来把 count 提升成一等能力,就要给children加第四个参数(描述 id)来把 id 递回宿主 —— 而另一个宿主RichTextField根本不传 footer,它会拿到一个永远忽略的参数。那正是这个包一直在删的形状(#3232/#3233:声明了没有生产者的 prop)。代价我也写进注释了:全屏 textarea 的aria-describedby是赋值而非追加,因为这个元素是TextAreaField自己从零构造的,domProps根本到不了它,而宿主本会提供的那些 id 指向的是对话框外面的节点 —— 模态打开时被 Radixaria-hidden掉了,追加进来只会指向读屏读不到的东西。内联那份仍然是追加(#3408 的规则原封不动)。i18n
没有新键,
packages/i18n一个字节没碰。复用fields.textarea.characterCount/fields.textarea.charactersRemaining及其FIELD_DEFAULTS兜底;packages/i18n/src/__tests__/textarea-charactercount-locale-parity.test.ts照跑绿。反向验证(方向是跑之前定好的)
用
git checkout origin/main -- packages/fields/src/widgets/TextAreaField.tsx把源文件退回去(测试文件与新组件留着),跑同一组文件 —— 三个方向全部与预测一致:renders no aria-live node inside the dialog一处要如实说明的偏差:
grows no live region as the draft crosses into the near-limit band我原本归在「钉裁定、两侧绿」那一组,实测 before 是 RED —— 因为它除了断言没有 live region,还断言了数字节点的99/100,而 before 根本没有那个节点。它是混合用例,不是纯粹的裁定钉子;纯粹那条是上表第二行。另有两条我在提交前主动加固过,因为它们在 before 会因为「什么都没产出」而假绿(PR #5046 记过的那个坑):
the code-shipped default carries no CJK—— 空串天然满足「不含 CJK」,补了正向断言;gives the inline and fullscreen descriptions distinct ids——null !== 'some-id'天然满足not.toBe,补了「两侧都非 null」。加固后这两条也一并翻红了(见上表第一行的 15 条)。
测试
仓根跑 vitest,
--maxWorkers=2,verbose 确认文件名:packages/fields全量 +plugin-form的全屏消费者:63 files / 957 tests passedplugin-form/src/__tests__/ObjectForm.mobileFullscreen.test.tsx单跑:1 file / 7 tests passedpackages/components/src/renderers/form/__tests__/+packages/i18n的 charactercount parity:31 files / 213 tests passedpnpm --filter @object-ui/fields type-check(tsc --noEmit)通过;lint0 errors(改动文件只剩field as any这条既有 warning);build通过node scripts/check-control-bytes.mjs通过,另按纪律对改动文件做了越过 gate 的自查(grep -naP控制字符范围,无命中);测试里的 CJK 字符类写成u3000一类的转义序列而非裸字符影响面清点
按规则的消费半径扫过,不只按被改的包:
textarea-character-count/CharacterCount/textarea-fullscreen-input的引用只落在packages/fields(本 PR)、packages/components内建分支(独立 test id 命名空间,无计数)、packages/i18n的 parity 测试(未改键)三处,已全部跑过。范围外发现
无。
Generated by Claude Code