Skip to content

fix(fields): 让 TextAreaField 字数统计的可访问名跟随会话语言 (#3406) - #3409

Merged
yinlianghui merged 3 commits into
mainfrom
claude/issue-3406-charcount-aria-i18n
Aug 5, 2026
Merged

fix(fields): 让 TextAreaField 字数统计的可访问名跟随会话语言 (#3406)#3409
yinlianghui merged 3 commits into
mainfrom
claude/issue-3406-charcount-aria-i18n

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3406

问题(已对 origin/main6fe485b 复核,前提成立)

packages/fields/src/widgets/TextAreaField.tsx:84 字面量原样存在:

aria-label={`Character count: ${(value || '').length} of ${maxLength}`}

这块只在字段声明了 maxLength 时渲染,视觉上是纯数字 {n}/{max}(与语言无关),所以屏幕上看不出任何毛病。但该元素带 aria-live="polite" —— 非英文会话下,读屏用户每次输入变化都会听到一句英文。只有读屏用户能感知,这也是它一直活着的原因。

#3404 同族但不重叠:#3404 是「键早已存在、这条路径没消费」;这一条是十个语言包里根本没有任何 character/char count 键。

改动

新键 fields.textarea.characterCount,十包齐,插值 {{count}} / {{max}}

写成一句带插值的整句而不是在代码里拼装,是因为 ja/ko 把上限放在计数前面(ja:「{{max}} 文字中 {{count}} 文字」;ko:「{{max}}자 중 {{count}}자」),任何「标签 + 数字 + 分隔符 + 上限」的代码侧拼接都产不出这个语序。测试里有一条专门钉住这个语序。

widget 走 useFieldTranslation()(createSafeTranslation + 英文兜底),即 #3405 刚验证过的形制,而不是裸 useObjectTranslation()。这不是风格选择,是实测:裸 hook 在无 provider 时返回的是键本身,见下方反向验证。

en 包的值与 FIELD_DEFAULTS 兜底逐字节等同于被替换的字面量,所以 en 会话与无 provider 的嵌入渲染完全没变。

未改行为:aria-live="polite" 与每击键重算原样保留。#3406 正文提出的「这套是否本身该重设计」按 PM 裁定拆成 #3408,含实测(52 次击键 = 52 次整句播报,累计 979 字符);本 PR 里那条属性被测试钉住,免得后来者顺手改掉而无人察觉。

文件面

  • packages/fields/src/widgets/TextAreaField.tsx —— 换键;hook 调用必须放在 readonly 提前返回之上(rules-of-hooks),有测试钉住该分支渲染未变。
  • packages/fields/src/widgets/useFieldTranslation.ts —— 仅新增一条 FIELD_DEFAULTS
  • packages/i18n/src/locales/{ar,de,en,es,fr,ja,ko,pt,ru,zh}.ts —— 新键。
  • 三个测试文件 + 一个 patch changeset。

反向验证(方向是先预测再跑的,两个方向都符合预测)

A. 把英文字面量放回去 —— 预测:provider 用例转红,en 那条不动。实测 6 failed | 8 passed:

FAIL … > resolves the base key under ar as well
Expected: "عدد الأحرف: 5 من 200"
Received: "Character count: 5 of 200"

转红的正是 zh / ja / ru / ar / 逐字重算 / 旧式 max_length 六条;en 那条保持绿,no-provider 文件整个保持绿 —— 这恰恰就是「英文侧是 no-op」这一主张的证据,而不是遗漏。

B. 把 useFieldTranslation() 换成裸 useObjectTranslation() —— 预测:no-provider 文件转红、provider 文件全绿(这正是这两个用例必须分文件的原因)。实测 3 failed | 11 passed,红的三条全在 no-provider 文件:

Expected: "Character count: 5 of 200"
Received: "fields.textarea.characterCount"

裸 hook 连插值都丢了,比被替换的字面量更差。no-provider 用例单独成文件的理由是 #3404 实测出来的:挂载 I18nProvider 会把该实例装成 react-i18next 的全局默认,同文件内的后续用例读到的是泄漏的实例,会「因为错误的原因而绿」。

消费半径清扫

repo 内 Character count 的旧字面量已无任何残留(仅剩本 PR 的 en 包值、兜底与测试断言)。packages/components 的内置分支、FullscreenFieldEditor 的 footer({draft.length}/{maxLength})都没有 aria-label,不在此列(#3406 正文已注明)。

验证

vitest run packages/i18n packages/fields
Test Files 80 passed (80)
Tests 1116 passed (1116)
pnpm --filter @object-ui/fields --filter @object-ui/i18n type-check
packages/i18n type-check: Done
packages/fields type-check: Done
eslint(仅改动文件): 0 errors(1 条 warning 为既有的 `field as any`)
pnpm changeset:check: ✅ fixed group OK / 无 major
node scripts/check-control-bytes.mjs: ✅ OK(另做转义区间自检,无控制字节)

顺带发现


🤖 Generated with Claude Code

https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt


Generated by Claude Code

…me (#3406)
The counter block TextAreaField renders when a field declares `maxLength`
carried the English literal `Character count: ${n} of ${max}` as its
`aria-label`, on an element with `aria-live="polite"`. The visible
`{n}/{max}` is digits and needs no locale, so nothing looked wrong — but a
zh/ja/ar session had that English sentence read out on every keystroke, and
only screen reader users could perceive it.
Unlike #3404 (keys existed, this path did not consume them), no
character-count key existed in any of the ten packs.
`fields.textarea.characterCount` is new in all ten, interpolating
`{{count}}` and `{{max}}` as ONE sentence rather than parts assembled in
code: ja/ko put the cap before the count, an order no concatenation can
produce. The widget reads it through `useFieldTranslation()`
(createSafeTranslation + English defaults), the same shape #3404 verified,
so a provider-less embed keeps rendering a sentence instead of a raw key —
measured: the bare hook returns `fields.textarea.characterCount` verbatim.
The en pack value and the FIELD_DEFAULTS fallback are byte-identical to the
literal they replace, so `en` and no-provider rendering are unchanged.
`aria-live="polite"` and the per-keystroke recompute are deliberately
untouched and tracked in #3408.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
…3406)
The zero-length case asserted `value={undefined}`, which fails `tsc`:
`FieldWidgetComponentProps` declares `value: T`, non-optional. The widget's
`value || ''` guard predates this change and covers a JS host that ignores
the type — pinning that out-of-contract shape here would be the
lenient-consumer move AGENTS.md #0.1 rules out. `''` exercises the same
thing the case was for: `0` is falsy, so an interpolation guarding on
truthiness would speak "Character count: of 40".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@vercel

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 5, 2026 8:50pm

Request Review

…3406)
Three prose spots explaining the ja/ko word order quoted the Japanese
sentence itself, in a code comment, a locale-pack comment, a test header and
the changeset. AGENTS.md #-1 makes the codebase English-only including
comments and docs, so the order is now described rather than transcribed.
The CJK that remains is assertion VALUES in the provider test — expected
translations, which cannot be written any other way and match the existing
`FullscreenFieldEditor.i18n.test.tsx`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@yinlianghui
yinlianghui marked this pull request as ready for review August 5, 2026 20:54
@yinlianghui
yinlianghui added this pull request to the merge queueAug 5, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-Dv9gN-y2.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.47KB3.09KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)18.38KB4.49KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)3.65KB1.42KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.25KB0.53KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)478.45KB105.05KB
core (index.js)2.25KB0.80KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)136.23KB34.75KB
fields (index.js)228.08KB55.90KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)26.14KB6.07KB
i18n (useSafeTranslation.js)3.26KB1.44KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.05KB1.53KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)61.04KB17.31KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)112.01KB28.86KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)231.54KB57.09KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)111.54KB26.97KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)185.08KB49.04KB
plugin-kanban (index.js)47.89KB13.18KB
plugin-list (index.js)105.02KB25.36KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.55KB10.59KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.34KB2.82KB
plugin-view (index.js)83.67KB20.43KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.46KB1.21KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)0.20KB0.18KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Merged via the queue into main with commit 2409e1dAug 5, 2026
17 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3406-charcount-aria-i18n branch August 5, 2026 20:54
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TextAreaField 的字数统计 aria-label 是英文字面量 Character count: {n} of {max},非英文会话的读屏用户听到英文

2 participants

@yinlianghui@claude