Skip to content

fix(collaboration,i18n): 给 CommentThread 三颗纯 emoji 按钮加可访问名,并让 7 天以上的日期跟随会话语言 (#3441) - #3481

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3441-commentthread-a11y-time
Aug 6, 2026
Merged

fix(collaboration,i18n): 给 CommentThread 三颗纯 emoji 按钮加可访问名,并让 7 天以上的日期跟随会话语言 (#3441)#3481
yinlianghui merged 1 commit into
mainfrom
claude/issue-3441-commentthread-a11y-time

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3441

objectstack#5506 / #3424 在同一个组件里留下的两处尾巴。CommentThread 是 published exported component,仓内无消费方 —— 两处都只有外部宿主会踩到。

前提已在 origin/main(2a9513d)上逐条核对:三颗按钮仍无名字,toLocaleDateString() 仍是裸调用,而旁边的 + 仍有名字 —— 最后这一点正是"遗漏而非设计"的证据。

一、三颗纯 emoji 按钮的可访问名

每条评论的两颗快捷回应按钮('👍' / '❤️')和回复提示条的关闭按钮('✕')既无 aria-label 也无 title

aria-label,不是旁边 + 用的 titlebutton 来说 accname 先算内容(§2F name from content),title 是最后一档兜底(§2I) —— 所以在一颗内容是字形的按钮上挂 title 只装点了鼠标,名字纹丝不动。屏幕阅读器读出来的是码位本身:"thumbs up"、"red heart",不管会话是什么语言都是英文;而 U+2715 MULTIPLICATION X 则往往什么都不读。

十个包各加三个 key:collaboration.reactThumbsUp / reactHeart / cancelReply

reactThumbsUp 刻意不复用 addThumbsUp,尽管两者今天都派发同一个 onReaction(id, '👍'):addThumbsUp 命名的是回应条上那颗选择器入口(样式名 reactionPicker,是个桩),它的文案将来要跟着选择器走;而在任何已有回应的评论上,两颗控件是同屏并存的,共用一个 key 会让两颗外观不同的按钮顶着同一个名字。同理 cancelReply 而非通用的 common.cancel —— 可访问名必须说清取消的是什么(只丢弃回复目标,输入框里已经打的字不受影响)。

二、7 天以上的时间戳不跟随会话语言

formatTimestamp 最后一档是裸的 date.toLocaleDateString(),取的是运行时的 locale,于是 zh 会话里 6 天前显示"6 天前",8 天前却显示 8/1/2026

改成传入会话 language,但不是直接传 —— 那正是 #3424 点明并绕开的坑。toLocaleDateString(tag) 会先 canonicalize 参数,凡不符合 BCP 47 well-formed 的一律抛 RangeError;而会话语言是原样到达组件的:宿主若配了 defaultLanguage: 'en_US'(POSIX 写法,看着像模像样,实际被拒),Intl 就会拒收。这个 RangeError 会一路落进 formatTimestamp外层 catch,而它的兜底是 return iso —— 日期被换成裸的 2026-08-01T09:30:00.000Z,比原来那个没本地化的日期更糟

所以绝对日期这一档单独包了一层 try/catch,失败时回退到无参调用:畸形 tag 退化成恰好是原来的行为(运行时自己的 locale),跟随会话语言的最坏情况是原状,永远不会是回退。well-formed 但未知的 tag(如 xx-YY)根本不抛 —— Intl 会解析到默认值 —— 所以只有真正畸形的 tag 会走到兜底。不引日期库,locale 包里也不加月份/星期文案:Intl 已经拥有各 locale 的顺序与分隔符。

验证方向(先预测,后运行)

预测写在跑之前,结果与预测完全一致

A. 把 CommentThread.tsx 退回 origin/main、其余全留 —— 预测 9 条红;实测 9 条红:

× names the emoji-only reaction and dismiss buttons in English
× names the two quick-reaction buttons in English
× names the two quick-reaction buttons in the session language
× names them in German too, so the keys are really in the packs
× no longer leaves a button whose accessible name is the bare emoji
× names the reply-banner dismiss button in the session language
× names the reply-banner dismiss button in English
× keeps the reaction-bar picker distinct from the quick thumbs-up
× formats a week-old comment in the session language
Tests  9 failed | 61 passed (70)

注意可访问名这一族en 都是红的 —— 这与本文件里既有的 copy pin 不同:这些名字在 origin/main 上任何语言下都不存在,不存在"英文本来就对"的那一半。模板预设的"en 两侧皆绿"在这里不成立,如实记录。

B. 畸形 tag 那条:两侧皆绿,如实上报。 它的反向验证不是退回旧代码 —— origin/main 从没把 tag 传到任何地方,自然无从被畸形 tag 绊倒。它的反事实是天真修法,也确实是拿它验的:删掉 formatAbsoluteDate 里那层 try/catch(把 language 直接传进去),精确地只有这一条转红:

× falls back to the runtime locale on a malformed session tag, never to raw ISO
AssertionError: expected [ 'Alice Chen', …(1) ] to include '7/29/2026'
Tests  1 failed | 69 passed (70)

即渲染出来的正是外层 catch 吐出的裸 ISO 串。

C. 探针实测(非推演):language 原样透传,toLocaleDateString('en_US') 在本运行时确实抛 RangeError,且 ICU 齐全:

[probe zh] {"lang":"zh","bare":"8/1/2026","tagged":"2026/8/1"}
[probe de] {"lang":"de","bare":"8/1/2026","tagged":"1.8.2026"}
[probe en_US] {"lang":"en_US","bare":"8/1/2026","tagged":"RANGE_ERROR"}

测试

断言的是 getByRole('button', { name }) 算出来的可访问名,而不是某个属性在不在 —— 这个区分正是本次修改的立足点(title 在,名字不在)。并且反向钉住:没有任何按钮的名字还是一个裸 emoji。无 provider 的英文断言留在它自己的文件里(module-global initReactI18next 陷阱),并新增一条属性扫描 —— 可访问名住在属性里,既有那条 container.textContent 的裸 key 扫描根本看不见它。

pnpm exec vitest run packages/collaboration packages/i18n --maxWorkers=2
Test Files  27 passed (27)
Tests  326 passed (326)   # 含 all-locales-key-parity

pnpm --filter @object-ui/collaboration --filter @object-ui/i18n type-check   # Done
pnpm --filter @object-ui/collaboration --filter @object-ui/i18n lint         # 0 errors
node scripts/check-control-bytes.mjs                                         # OK, 3673 files

lint 的告警全部是既有的(CommentThread.tsx:505 那条 setState in effectorigin/main 上就有,不在本次改动行上)。控制字符另做了越过闸门的自查(grep -naP\x00-\x08 等)—— 本 PR 正文与代码注释都提到了 U+2715,属于闸门盲区那一类。

消费半径

CommentThread 与这三个 key 在仓内无其他消费方(grep 过全仓:仅 ROADMAP.md 与两个既有 changeset 提到组件名),所以不存在 PR #5046 那种"改在 A 包、fixture 在 B 包"的漏扫面。

顺带发现(已另行归档,未在本 PR 修)

#3478 —— 旁边那颗 + 选择器的 title 其实从未成为它的可访问名(内容 '+' 优先),#3424 只翻译了 tooltip。证据就是本 PR 里现在就绿的那条断言:getByTitle('Add thumbs up') 找得到,而 queryAllByRole('button', { name: 'Add thumbs up' }) 长度为 0。#3441 正文把这颗按钮当作参照物明确排除在范围外,故单开一条,未在此 PR 内修改。


Generated by Claude Code

…s and localize its >= 7d date (#3441)

Two leftovers from objectstack#5506 / objectui#3424 in the same component.
`CommentThread` is exported and published with no in-repo consumer, so both
only ever bite an external host.

One — each comment's two quick-reaction buttons ('👍' / '❤️') and the reply
banner's dismiss button ('✕') had no aria-label and no title. The '+' picker
beside them has had one since #3424 (collaboration.addThumbsUp), which is what
makes these three an omission rather than a design choice.

aria-label, not the title the '+' uses: a button's accessible name is computed
from CONTENT (accname 2F) before the title tooltip (2I), so a title on a glyph
button decorates the mouse and leaves the name alone. Screen readers read the
codepoint — "thumbs up" / "red heart" in English whatever the session
language, and for U+2715 MULTIPLICATION X often nothing at all.

Three new keys in all ten packs: collaboration.reactThumbsUp / reactHeart /
cancelReply. reactThumbsUp is deliberately NOT a reuse of addThumbsUp even
though both dispatch onReaction(id, thumbs-up) today: addThumbsUp names the
reaction bar's picker entry point, and on a comment that already has reactions
the two controls are on screen together, so one shared key would put two
visibly different buttons under one name. cancelReply rather than the generic
common.cancel because an accessible name has to say what is cancelled (only
the reply target is dropped; composer text survives).

Two — formatTimestamp ended in a bare toLocaleDateString(), the RUNTIME's
locale, so a zh session read "6 天前" at six days and 8/1/2026 at eight. The
session language is now passed, but not straight through: toLocaleDateString
canonicalizes its tag and throws RangeError on anything not well-formed per
BCP 47, and the session language reaches the component verbatim — a host
configuring defaultLanguage: 'en_US' hands Intl a tag it refuses. That
RangeError would land in formatTimestamp's outer catch, whose fallback is
`return iso`, replacing the date with a raw ISO string: worse than the
un-localized date. The absolute-date branch therefore gets its own local
try/catch falling back to the no-argument call, so a malformed tag degrades to
exactly the previous behaviour. A well-formed but unknown tag (xx-YY) does not
throw at all. No date library, no month/weekday copy in the packs.

Tests assert the computed accessible name via getByRole('button', { name }),
not the presence of an attribute — that distinction is what the fix turns on —
and pin that no button answers to a bare emoji. Directions: the name cases and
the zh/de absolute-date cases are red-before/green-after; the malformed-tag
case is green on BOTH sides and is recorded as such, because origin/main never
passed a tag anywhere. Its counterfactual is the naive fix — dropping the
inner catch turns it red with the raw ISO in the DOM.

Fixes #3441

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@vercel

vercel Bot commented Aug 6, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 6, 2026 9:15am

Request Review

@github-actions github-actions Bot added the tests label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-DA2Jkd4n.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.47KB 3.09KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 24.58KB 7.04KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 479.63KB 105.43KB
core (index.js) 2.47KB 0.91KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 136.30KB 34.76KB
fields (index.js) 229.92KB 56.48KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 26.14KB 6.07KB
i18n (useSafeTranslation.js) 3.26KB 1.44KB
layout (index.js) 38.53KB 10.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.05KB 1.53KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.98KB 12.37KB
plugin-charts (index.js) 61.04KB 17.31KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 112.03KB 28.88KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 232.53KB 57.37KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 111.54KB 26.97KB
plugin-gantt (index.js) 162.55KB 39.57KB
plugin-grid (index.js) 185.25KB 49.08KB
plugin-kanban (index.js) 48.03KB 13.22KB
plugin-list (index.js) 105.19KB 25.39KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.46KB 1.21KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 0.20KB 0.18KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@yinlianghui
yinlianghui marked this pull request as ready for review August 6, 2026 13:51
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit 65516ba Aug 6, 2026
19 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3441-commentthread-a11y-time branch August 6, 2026 13:52
Sign up for free to 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.

[finding] CommentThread 遗留两处:纯 emoji 按钮没有无障碍名;7 天以上时间戳不跟随会话语言

2 participants