From 4aa7ddf97e4fa681494f5882e1f5c08fe3853755 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 09:11:18 +0000 Subject: [PATCH] fix(collaboration,i18n): name CommentThread's three emoji-only buttons and localize its >= 7d date (#3441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- ...omment-thread-a11y-names-and-dates-3441.md | 67 +++++++ packages/collaboration/src/CommentThread.tsx | 87 +++++++-- .../__tests__/comment-thread-i18n.test.tsx | 182 ++++++++++++++++++ ...mment-thread-no-provider-fallback.test.tsx | 87 +++++++++ .../src/useCollaborationTranslation.ts | 12 ++ packages/i18n/src/locales/ar.ts | 3 + packages/i18n/src/locales/de.ts | 3 + packages/i18n/src/locales/en.ts | 14 ++ packages/i18n/src/locales/es.ts | 3 + packages/i18n/src/locales/fr.ts | 3 + packages/i18n/src/locales/ja.ts | 3 + packages/i18n/src/locales/ko.ts | 3 + packages/i18n/src/locales/pt.ts | 3 + packages/i18n/src/locales/ru.ts | 3 + packages/i18n/src/locales/zh.ts | 3 + 15 files changed, 461 insertions(+), 15 deletions(-) create mode 100644 .changeset/collaboration-comment-thread-a11y-names-and-dates-3441.md diff --git a/.changeset/collaboration-comment-thread-a11y-names-and-dates-3441.md b/.changeset/collaboration-comment-thread-a11y-names-and-dates-3441.md new file mode 100644 index 000000000..aa1af89df --- /dev/null +++ b/.changeset/collaboration-comment-thread-a11y-names-and-dates-3441.md @@ -0,0 +1,67 @@ +--- +'@object-ui/collaboration': patch +'@object-ui/i18n': patch +--- + +Name `CommentThread`'s three emoji-only buttons, and follow the session language past the 7-day mark (objectui#3441) + +Two leftovers from objectstack#5506 / objectui#3424, in the same component. It +is an exported, published component with no in-repo consumer, so both only ever +bite an external host. + +**One — three controls with no authored accessible name.** Each comment's two +quick-reaction buttons (`'👍'` and `'❤️'`) and the reply banner's dismiss button +(`'✕'`) carried no `aria-label` and no `title`. The `+` reaction picker right +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 its CONTENT (accname §2F) before the `title` tooltip is ever +consulted (§2I), so on a button whose only child is a glyph a `title` decorates +the mouse and leaves the name alone. What a screen reader read out was the +codepoint — "thumbs up", "red heart", in English whatever the session language, +and for U+2715 MULTIPLICATION X very often nothing at all. + +Three new keys in all ten packs: `collaboration.reactThumbsUp`, +`collaboration.reactHeart`, `collaboration.cancelReply`. + +`reactThumbsUp` is deliberately NOT a reuse of `addThumbsUp`, even though both +dispatch the same `onReaction(id, '👍')` today. `addThumbsUp` names the reaction +bar's picker entry point, whose copy follows the picker if it ever picks; and on +any 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` for the same reason — an +accessible name has to say what is being cancelled (only the reply target is +dropped; anything typed into the composer survives). + +**Two — the >= 7 day timestamp ignored the session language.** `formatTimestamp` +ended in a bare `date.toLocaleDateString()`, i.e. the RUNTIME's locale, so a +`zh` session read "6 天前" for a six-day-old comment and `8/1/2026` for an +eight-day-old one. + +The fix passes the session `language`, but not straight through — that is the +trap #3424 flagged and declined to walk into. `toLocaleDateString(tag)` +canonicalizes its argument and throws `RangeError` on anything not well-formed +per BCP 47, and the session language reaches the component verbatim: a host that +configures `defaultLanguage: 'en_US'` (the POSIX spelling — well-formed-looking, +and rejected) 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 `2026-08-01T09:30:00.000Z` — worse than the un-localized date it +set out to fix. + +So the absolute-date branch gets its own local `try`/`catch` that falls back to +the no-argument call. A malformed tag degrades to exactly the previous +behaviour (the runtime's own locale); the worst case of following the session +language is the status quo, never a regression. A well-formed but unknown tag +such as `xx-YY` does not throw at all — `Intl` resolves it to the default — so +only genuinely malformed tags reach the guard. No date library, and no month or +weekday copy in the locale packs: `Intl` already owns the per-locale ordering +and separators. + +Tests assert the computed accessible name via `getByRole('button', { name })` +rather than the presence of an attribute, which is the distinction the fix turns +on, and pin that no button is left answering to a bare emoji. The malformed-tag +case is recorded honestly as green on both sides of this change — `origin/main` +never passed a tag anywhere, so it could not trip over a bad one; its +counterfactual is the naive fix, and dropping the inner `catch` is what turns it +red with the raw ISO string in the DOM. diff --git a/packages/collaboration/src/CommentThread.tsx b/packages/collaboration/src/CommentThread.tsx index b90b55507..9be2da61b 100644 --- a/packages/collaboration/src/CommentThread.tsx +++ b/packages/collaboration/src/CommentThread.tsx @@ -51,23 +51,57 @@ export interface CommentThreadProps { className?: string; } +/** + * Absolute date for the >= 7d bucket, in the session language (objectui#3441). + * + * Has its OWN try/catch, deliberately not sharing `formatTimestamp`'s. The two + * catches recover from different things and must recover differently: + * + * - `formatTimestamp`'s outer catch is for an input it cannot make sense of, + * and its only honest fallback is to echo the raw `iso` back. + * - a throw from here says nothing about the *date* — it says the LANGUAGE TAG + * is malformed. `Date.prototype.toLocaleDateString(tag)` runs the tag through + * `CanonicalizeLocaleList`, which raises `RangeError` for anything not + * structurally well-formed per BCP 47 (`'en_US'`, `''`, `'zh CN'`). A + * well-formed but unknown tag such as `'xx-YY'` does NOT throw — it resolves + * to the runtime default — so only genuinely malformed tags reach the catch. + * + * Letting the tag's `RangeError` reach the outer catch is why this was left + * undone in objectui#3424: a bad tag would have turned a readable date into the + * raw `2026-08-01T09:30:00.000Z`, i.e. WORSE than the un-localized date it + * replaced. Falling back to the no-argument call restores exactly the previous + * behaviour (the runtime's own locale) for that path, so the worst case of + * following the session language is the status quo, never a regression. + * + * No date library, and no month/weekday copy in the locale packs: `Intl` is + * already in the runtime and owns the per-locale ordering and separators. + */ +function formatAbsoluteDate(date: Date, language: string): string { + try { + return date.toLocaleDateString(language); + } catch { + return date.toLocaleDateString(); + } +} + /** * Relative age of a comment, in the session language. * - * `t` is threaded in as a parameter rather than read from a hook: this runs - * once per rendered comment from inside `renderComment`, and the buckets are - * unchanged — only the words moved into the locale packs. Counts are - * interpolated as STRINGS on purpose, so i18next skips its own plural - * resolution (`needsPluralHandling` is false for a string `count`) and cannot - * silently start looking for `_one`/`_other` variants this repo does not ship. + * `t` and `language` are threaded in as parameters rather than read from a + * hook: this runs once per rendered comment from inside `renderComment`, and + * the buckets are unchanged — only the words moved into the locale packs. + * Counts are interpolated as STRINGS on purpose, so i18next skips its own + * plural resolution (`needsPluralHandling` is false for a string `count`) and + * cannot silently start looking for `_one`/`_other` variants this repo does not + * ship. * - * The >= 7d branch still uses the runtime's own `toLocaleDateString()`. That is - * not a hardcoded English literal — it already follows the environment locale — - * and pinning it to the session language is a separate change with its own - * failure mode (an unrecognised tag throws `RangeError` straight into the - * `catch` below, which would render the raw ISO string). Tracked separately. + * The >= 7d bucket follows the session language too (objectui#3441) — a `zh` + * session used to read "6 天前" for a six-day-old comment and `8/1/2026` for an + * eight-day-old one, because that branch called `toLocaleDateString()` with no + * argument and got the *runtime's* locale. See {@link formatAbsoluteDate} for + * why the tag gets its own guard instead of being handed straight in. */ -function formatTimestamp(iso: string, t: CollaborationTranslate): string { +function formatTimestamp(iso: string, t: CollaborationTranslate, language: string): string { try { const date = new Date(iso); const now = new Date(); @@ -79,7 +113,7 @@ function formatTimestamp(iso: string, t: CollaborationTranslate): string { if (hours < 24) return t('collaboration.hoursAgo', { count: String(hours) }); const days = Math.floor(hours / 24); if (days < 7) return t('collaboration.daysAgo', { count: String(days) }); - return date.toLocaleDateString(); + return formatAbsoluteDate(date, language); } catch { return iso; } @@ -367,7 +401,7 @@ export function CommentThread({ const [mentionIndex, setMentionIndex] = useState(0); const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>('oldest'); const inputRef = useRef(null); - const { t } = useCollaborationTranslation(); + const { t, language } = useCollaborationTranslation(); const filteredMentions = useMemo(() => { if (mentionQuery === null) return []; @@ -511,7 +545,7 @@ export function CommentThread({ // Header React.createElement('div', { style: styles.commentHeader }, React.createElement('span', { style: styles.authorName }, comment.author.name), - React.createElement('span', { style: styles.timestamp }, formatTimestamp(comment.createdAt, t)), + React.createElement('span', { style: styles.timestamp }, formatTimestamp(comment.createdAt, t, language)), comment.updatedAt ? React.createElement('span', { style: styles.timestamp }, t('collaboration.edited')) : null, @@ -569,13 +603,29 @@ export function CommentThread({ style: styles.actionBtn, onClick: () => setReplyTo(comment.id), }, t('collaboration.reply')), + // Quick reactions (objectui#3441). Their only content is the emoji, + // and for a `button` the accessible name comes from CONTENT before it + // ever reaches `title` (accname §2F outranks §2I) — so these two were + // announced as the bare glyph: "thumbs up" / "red heart" at best, + // nothing at all where the SR has no name for the codepoint. Hence + // `aria-label`, which overrides content, rather than the `title` the + // `+` picker above uses. + // + // Their own key pair, NOT a reuse of `collaboration.addThumbsUp`: the + // `+` above happens to fire the same `onReaction(id, '👍')` today, but + // it is the reaction PICKER's entry point (`styles.reactionPicker`) + // whose copy follows the picker if it ever picks. Sharing one key + // would also give a comment that already has reactions two visible + // controls answering to one name. onReaction && React.createElement('button', { style: styles.actionBtn, onClick: () => onReaction(comment.id, '👍'), + 'aria-label': t('collaboration.reactThumbsUp'), }, '👍'), onReaction && React.createElement('button', { style: styles.actionBtn, onClick: () => onReaction(comment.id, '❤️'), + 'aria-label': t('collaboration.reactHeart'), }, '❤️'), isOwner && onEditComment && React.createElement('button', { style: styles.actionBtn, @@ -649,9 +699,16 @@ export function CommentThread({ ? t('collaboration.replyingTo', { name: replyToName }) : t('collaboration.replyingToComment'); })()), + // Dismisses the banner and clears the reply target (objectui#3441). Its + // content is U+2715 MULTIPLICATION X — a math symbol, not an icon with a + // name — so name-from-content gave a screen reader either nothing or + // "multiplication x". `aria-label` overrides it; `cancelReply` rather + // than the generic `common.cancel` because an accessible name has to say + // WHAT is being cancelled (the composer keeps its text either way). React.createElement('button', { style: styles.actionBtn, onClick: () => setReplyTo(null), + 'aria-label': t('collaboration.cancelReply'), }, '✕'), ), // Input area diff --git a/packages/collaboration/src/__tests__/comment-thread-i18n.test.tsx b/packages/collaboration/src/__tests__/comment-thread-i18n.test.tsx index 82908ae6f..1dc596e90 100644 --- a/packages/collaboration/src/__tests__/comment-thread-i18n.test.tsx +++ b/packages/collaboration/src/__tests__/comment-thread-i18n.test.tsx @@ -292,6 +292,188 @@ describe('CommentThread per-comment actions (objectstack#5506)', () => { }); }); +/** + * objectui#3441 — the three emoji-only controls objectstack#5506 left unnamed. + * + * These assert the computed ACCESSIBLE NAME (`getByRole('button', { name })`, + * which runs dom-accessibility-api's accname implementation), not the presence + * of an attribute. That distinction is the whole point of the fix: for a + * `button`, name-from-content (accname §2F) is consulted BEFORE the `title` + * tooltip (§2I), so hanging a `title` on `'👍'` the way the `+` picker does + * would have left the computed name as the glyph. `aria-label` is the only one + * of the three that outranks content. + * + * ── Direction ───────────────────────────────────────────────────────────── + * RED before / GREEN after in EVERY language, `en` included — unlike the + * copy-pin cases above, these names did not exist in any locale on + * `origin/main`, so there is no "English was already right" half here. The + * `queryAllByRole(… { name: '👍' })` assertions are the mirror image: they + * pass ONLY after the fix, because the glyph was the name until `aria-label` + * displaced it. + */ +describe('CommentThread emoji-only control names (objectui#3441)', () => { + it('names the two quick-reaction buttons in English', () => { + renderThread('en'); + + expect(screen.getAllByRole('button', { name: 'React with thumbs up' }).length).toBe(2); + expect(screen.getAllByRole('button', { name: 'React with heart' }).length).toBe(2); + }); + + it('names the two quick-reaction buttons in the session language', () => { + renderThread('zh'); + + expect(screen.getAllByRole('button', { name: '以点赞回应' }).length).toBe(2); + expect(screen.getAllByRole('button', { name: '以爱心回应' }).length).toBe(2); + expect(screen.queryAllByRole('button', { name: 'React with thumbs up' })).toHaveLength(0); + }); + + it('names them in German too, so the keys are really in the packs', () => { + renderThread('de'); + + expect(screen.getAllByRole('button', { name: 'Mit Daumen hoch reagieren' }).length).toBe(2); + expect(screen.getAllByRole('button', { name: 'Mit Herz reagieren' }).length).toBe(2); + }); + + /** + * The bug itself: with no `aria-label`, the button's only content IS its + * name. A screen reader announced "thumbs up button" / "red heart button" — + * the emoji's Unicode name, in English, whatever the session language. + */ + it('no longer leaves a button whose accessible name is the bare emoji', () => { + renderThread('zh'); + + expect(screen.queryAllByRole('button', { name: '👍' })).toHaveLength(0); + expect(screen.queryAllByRole('button', { name: '❤️' })).toHaveLength(0); + }); + + /** + * `cancelReply`, not the generic `common.cancel`: an accessible name has to + * say WHAT is being cancelled. Only the reply target is dropped — anything + * already typed into the composer survives. + */ + it('names the reply-banner dismiss button in the session language', () => { + renderThread('zh'); + + // The banner only exists once a reply target is picked. + fireEvent.click(screen.getAllByText('回复')[0]); + + expect(screen.getByRole('button', { name: '取消回复' })).toBeTruthy(); + // U+2715 MULTIPLICATION X — a math symbol with no reliable spoken name. + expect(screen.queryAllByRole('button', { name: '✕' })).toHaveLength(0); + }); + + it('names the reply-banner dismiss button in English', () => { + renderThread('en'); + + fireEvent.click(screen.getAllByText('Reply')[0]); + + expect(screen.getByRole('button', { name: 'Cancel reply' })).toBeTruthy(); + }); + + /** + * The `+` picker keeps `collaboration.addThumbsUp` (objectstack#5506) and the + * quick 👍 gets its own `reactThumbsUp`, even though both dispatch the same + * `onReaction(id, '👍')` today. This case is what pins the two apart: on a + * comment that already has reactions both controls are on screen at once, so + * sharing one key would put two visibly different controls under one name. + */ + it('keeps the reaction-bar picker distinct from the quick thumbs-up', () => { + renderThread('en'); + + expect(screen.getByTitle('Add thumbs up')).toBeTruthy(); + expect(screen.queryAllByRole('button', { name: 'Add thumbs up' })).toHaveLength(0); + expect(screen.getAllByRole('button', { name: 'React with thumbs up' }).length).toBe(2); + }); +}); + +/** + * objectui#3441, part two — the >= 7d bucket follows the SESSION language. + * + * `formatTimestamp`'s last branch called `toLocaleDateString()` with no + * argument, i.e. the RUNTIME's locale. In a `zh` console a six-day-old comment + * read "6 天前" and a seven-day-old one read `8/1/2026`. + * + * ── Direction ───────────────────────────────────────────────────────────── + * The `zh` and `de` cases are RED before / GREEN after: on `origin/main` every + * session renders the same runtime-default string. The `en` case is green on + * both sides in a runtime whose default locale is already `en-US`, and it is + * kept as a copy pin, not offered as evidence. + */ +describe('CommentThread absolute timestamps (objectui#3441)', () => { + const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); + const oldComment: Comment = { + id: 'old', + author: alice, + content: 'From last week.', + mentions: [], + createdAt: eightDaysAgo.toISOString(), + }; + + const dateCell = () => + Array.from(document.querySelectorAll('[data-comment-id="old"] span')) + .map((n) => n.textContent) + .filter((v): v is string => Boolean(v)); + + it('formats a week-old comment in the session language', () => { + renderThread('zh', { comments: [oldComment] }); + expect(dateCell()).toContain(eightDaysAgo.toLocaleDateString('zh')); + cleanup(); + + renderThread('de', { comments: [oldComment] }); + expect(dateCell()).toContain(eightDaysAgo.toLocaleDateString('de')); + }); + + /** + * Doubles as an ICU-availability assertion: on a runtime built without the + * full locale data every tag collapses to the same output, and the two + * assertions above would pass while proving nothing. + */ + it('produces visibly different strings for zh and de', () => { + expect(eightDaysAgo.toLocaleDateString('zh')).not.toBe( + eightDaysAgo.toLocaleDateString('de'), + ); + }); + + it('keeps the English form under an en session', () => { + renderThread('en', { comments: [oldComment] }); + expect(dateCell()).toContain(eightDaysAgo.toLocaleDateString('en')); + }); + + /** + * The trap the issue was really about, and the reason objectstack#5506 left + * this branch alone. + * + * `toLocaleDateString(tag)` canonicalizes its argument and throws + * `RangeError` on anything not well-formed per BCP 47. `'en_US'` — the POSIX + * spelling, a plausible thing for a host to put in `defaultLanguage` — is + * exactly such a tag, and the session `language` reaches the component + * verbatim. Handed straight to `toLocaleDateString`, that `RangeError` lands + * in `formatTimestamp`'s OUTER catch, whose fallback is `return iso`: the + * comment's date would have been replaced by a raw + * `2026-07-29T…Z`, worse than the un-localized date it set out to fix. + * + * ── Direction, stated honestly ──────────────────────────────────────────── + * This case is GREEN on BOTH sides of the change, and reverting + * `CommentThread.tsx` does NOT turn it red — `origin/main` never passed the + * tag anywhere, so it could not trip over a bad one. Its counterfactual is + * not the old code but the NAIVE fix, and that is what it was reverse-checked + * against: dropping the inner try/catch in `formatAbsoluteDate` (passing + * `language` straight through) turns this case red with the raw ISO string in + * the DOM. Recorded rather than dressed up as a red-before regression pin. + */ + it('falls back to the runtime locale on a malformed session tag, never to raw ISO', () => { + const { container } = renderThread('en_US', { comments: [oldComment] }); + + expect(dateCell()).toContain(eightDaysAgo.toLocaleDateString()); + expect(container.textContent).not.toContain(oldComment.createdAt); + // The shape of the raw value, in case the ISO string is ever reformatted. + expect(container.textContent).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/); + // The rest of the thread still speaks (fallbackLng) English — the guard is + // local to the date, it does not disable the session. + expect(screen.getByText('Send')).toBeTruthy(); + }); +}); + describe('CommentThread composer (objectstack#5506)', () => { it('translates the placeholder and the send button', () => { renderThread('zh'); diff --git a/packages/collaboration/src/__tests__/comment-thread-no-provider-fallback.test.tsx b/packages/collaboration/src/__tests__/comment-thread-no-provider-fallback.test.tsx index 5823e2dc9..2cbf63656 100644 --- a/packages/collaboration/src/__tests__/comment-thread-no-provider-fallback.test.tsx +++ b/packages/collaboration/src/__tests__/comment-thread-no-provider-fallback.test.tsx @@ -159,6 +159,57 @@ describe('CommentThread with no I18nProvider — English fallback (objectstack#5 expect(screen.queryByText('1 comments')).toBeNull(); }); + /** + * objectui#3441 — the three emoji-only controls, named from the same map. + * + * `getByRole(… { name })` computes the accessible name rather than reading an + * attribute: for a `button`, name-from-content outranks `title`, so only an + * `aria-label` can displace the glyph. With no provider that label has to come + * out of `COLLAB_DEFAULT_TRANSLATIONS`, or a standalone host gets a control + * announced as `collaboration.reactThumbsUp`. + * + * Unlike the copy pins in this file these are RED before / GREEN after: the + * names did not exist in any language on `origin/main`. + */ + it('names the emoji-only reaction and dismiss buttons in English', () => { + renderBare(); + + expect(screen.getAllByRole('button', { name: 'React with thumbs up' }).length).toBe(2); + expect(screen.getAllByRole('button', { name: 'React with heart' }).length).toBe(2); + expect(screen.queryAllByRole('button', { name: '👍' })).toHaveLength(0); + + fireEvent.click(screen.getAllByText('Reply')[0]); + expect(screen.getByRole('button', { name: 'Cancel reply' })).toBeTruthy(); + expect(screen.queryAllByRole('button', { name: '✕' })).toHaveLength(0); + }); + + /** + * objectui#3441 — with no provider the session language is whatever + * react-i18next reports (in practice `'en'`), and the >= 7d branch now hands + * that to `toLocaleDateString`. What must hold on this path is narrower than + * under a provider but is the part a standalone host would notice: a real + * formatted date, never the raw ISO string `formatTimestamp`'s outer catch + * would produce if a bad tag reached `Intl`. + */ + it('formats a week-old comment as a date, not a raw ISO string', () => { + const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); + const { container } = renderBare({ + comments: [ + { + id: 'old', + author: alice, + content: 'From last week.', + mentions: [], + createdAt: eightDaysAgo.toISOString(), + }, + ], + }); + + expect(container.textContent).not.toContain(eightDaysAgo.toISOString()); + expect(container.textContent).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/); + expect(screen.getByText(eightDaysAgo.toLocaleDateString('en'))).toBeTruthy(); + }); + /** * The failure mode this whole file exists to catch: a key wired into the * component but absent from the defaults map renders as its own dotted name. @@ -202,4 +253,40 @@ describe('COLLAB_DEFAULT_TRANSLATIONS is the package-wide English source', () => expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.reactionCount']).toBe('{{count}} reactions'); expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.reactionCountOne']).toBe('{{count}} reaction'); }); + + /** + * objectui#3441. `reactThumbsUp` and `addThumbsUp` dispatch the same reaction + * today but name two different controls that render side by side, so their + * copy must not collapse into one string — that is the thing a later + * "de-duplicate these two keys" cleanup would break, and this is where it + * fails. + */ + it('gives the quick thumbs-up its own copy, distinct from the picker', () => { + expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.reactThumbsUp']).toBeTruthy(); + expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.reactHeart']).toBeTruthy(); + expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.cancelReply']).toBeTruthy(); + expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.reactThumbsUp']).not.toBe( + COLLAB_DEFAULT_TRANSLATIONS['collaboration.addThumbsUp'], + ); + }); + + /** + * An accessible name lives in an attribute, so `container.textContent` — what + * the "never renders a raw i18n key" case above scans — cannot see it. A + * missing default would surface as `aria-label="collaboration.reactHeart"` + * and nothing else in this file would notice. + */ + it('never leaves a raw key or placeholder in an accessible name', () => { + const { container } = renderBare(); + fireEvent.click(screen.getAllByText('Reply')[0]); + + const names = Array.from(container.querySelectorAll('[aria-label]')).map((n) => + n.getAttribute('aria-label'), + ); + expect(names.length).toBeGreaterThan(0); + for (const name of names) { + expect(name).not.toMatch(/^(collaboration|common)\.\w+$/); + expect(name).not.toMatch(/\{\{\w+\}\}/); + } + }); }); diff --git a/packages/collaboration/src/useCollaborationTranslation.ts b/packages/collaboration/src/useCollaborationTranslation.ts index 5afee12e7..2d0b0dca5 100644 --- a/packages/collaboration/src/useCollaborationTranslation.ts +++ b/packages/collaboration/src/useCollaborationTranslation.ts @@ -70,6 +70,18 @@ export const COLLAB_DEFAULT_TRANSLATIONS: Record = { 'collaboration.addThumbsUp': 'Add thumbs up', // Per-comment actions and the reply banner 'collaboration.reply': 'Reply', + // Accessible names for the three emoji-only controls (objectui#3441). For a + // `button` the accessible name is computed from CONTENT before `title` is + // consulted, so an emoji button is named by its glyph until an `aria-label` + // overrides it — these three keys ARE those names, not decoration. + // + // `reactThumbsUp` is deliberately distinct from `addThumbsUp` above even + // though both currently dispatch `onReaction(id, '👍')`: `addThumbsUp` names + // the reaction-bar `+` picker, and the two render side by side on any comment + // that already has reactions. + 'collaboration.reactThumbsUp': 'React with thumbs up', + 'collaboration.reactHeart': 'React with heart', + 'collaboration.cancelReply': 'Cancel reply', 'collaboration.replyingTo': 'Replying to {{name}}...', 'collaboration.replyingToComment': 'Replying to comment...', // Composer diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 7c67217cc..b8e16c7af 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -3071,6 +3071,9 @@ const ar = { reactionCountOne: "{{count}} تفاعل", addThumbsUp: "إضافة إعجاب", reply: "رد", + reactThumbsUp: "التفاعل بإعجاب", + reactHeart: "التفاعل بقلب", + cancelReply: "إلغاء الرد", replyingTo: "الرد على {{name}}…", replyingToComment: "الرد على التعليق…", commentPlaceholder: "أضف تعليقًا… (استخدم @ للإشارة)", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index f4964ab7c..2bfb17cba 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -3071,6 +3071,9 @@ const de = { reactionCountOne: "{{count}} Reaktion", addThumbsUp: "Daumen hoch hinzufügen", reply: "Antworten", + reactThumbsUp: "Mit Daumen hoch reagieren", + reactHeart: "Mit Herz reagieren", + cancelReply: "Antwort abbrechen", replyingTo: "Antwort an {{name}} …", replyingToComment: "Antwort auf Kommentar …", commentPlaceholder: "Kommentar hinzufügen … (@ für Erwähnungen)", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 7aaebe2f3..c5e60c847 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3212,6 +3212,20 @@ const en = { reactionCountOne: '{{count}} reaction', addThumbsUp: 'Add thumbs up', reply: 'Reply', + // Accessible names for the three emoji-only controls (objectui#3441). + // A `button`'s accessible name is computed from its CONTENT before `title` + // is ever consulted, so '👍' / '❤️' / '✕' named themselves — these keys are + // wired as `aria-label`, which overrides content. + // + // `reactThumbsUp` stays distinct from `addThumbsUp` above even though both + // dispatch the same reaction today: that one names the reaction-bar `+` + // picker, and the two render side by side on a comment that already has + // reactions. + reactThumbsUp: 'React with thumbs up', + reactHeart: 'React with heart', + // Not `common.cancel`: an accessible name has to say what is cancelled. + // Only the reply TARGET is dropped — the composer keeps whatever was typed. + cancelReply: 'Cancel reply', replyingTo: 'Replying to {{name}}...', // The no-author-found half of the reply banner, as a whole sentence: // languages that inflect around the addressee cannot build it by diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index d313942b3..1547bc6cb 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -3076,6 +3076,9 @@ const es = { reactionCountOne: "{{count}} reacción", addThumbsUp: "Agregar me gusta", reply: "Responder", + reactThumbsUp: "Reaccionar con me gusta", + reactHeart: "Reaccionar con corazón", + cancelReply: "Cancelar respuesta", replyingTo: "Respondiendo a {{name}}…", replyingToComment: "Respondiendo al comentario…", commentPlaceholder: "Agregar un comentario… (usa @ para mencionar)", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 19c2426b0..3cdd7aca6 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -3071,6 +3071,9 @@ const fr = { reactionCountOne: "{{count}} réaction", addThumbsUp: "Ajouter un pouce levé", reply: "Répondre", + reactThumbsUp: "Réagir avec un pouce levé", + reactHeart: "Réagir avec un cœur", + cancelReply: "Annuler la réponse", replyingTo: "Réponse à {{name}}…", replyingToComment: "Réponse au commentaire…", commentPlaceholder: "Ajouter un commentaire… (utilisez @ pour mentionner)", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index ffe85e71e..55d5bcfd5 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -3071,6 +3071,9 @@ const ja = { reactionCountOne: "リアクション {{count}} 件", addThumbsUp: "いいねを追加", reply: "返信", + reactThumbsUp: "いいねでリアクション", + reactHeart: "ハートでリアクション", + cancelReply: "返信をキャンセル", replyingTo: "{{name}} に返信中…", replyingToComment: "このコメントに返信中…", commentPlaceholder: "コメントを追加…(@ でメンション)", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 391444a9a..93bb94a0c 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -3071,6 +3071,9 @@ const ko = { reactionCountOne: "반응 {{count}}개", addThumbsUp: "좋아요 추가", reply: "답글", + reactThumbsUp: "좋아요로 반응", + reactHeart: "하트로 반응", + cancelReply: "답글 취소", replyingTo: "{{name}}님에게 답글 작성 중…", replyingToComment: "이 댓글에 답글 작성 중…", commentPlaceholder: "댓글 추가…(@로 멘션)", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 5bfe4e51c..0f7f2859a 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -3071,6 +3071,9 @@ const pt = { reactionCountOne: "{{count}} reação", addThumbsUp: "Adicionar curtida", reply: "Responder", + reactThumbsUp: "Reagir com curtida", + reactHeart: "Reagir com coração", + cancelReply: "Cancelar resposta", replyingTo: "Respondendo a {{name}}…", replyingToComment: "Respondendo ao comentário…", commentPlaceholder: "Adicionar um comentário… (use @ para mencionar)", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index be5555069..d9d01a217 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -3071,6 +3071,9 @@ const ru = { reactionCountOne: "{{count}} реакция", addThumbsUp: "Поставить лайк", reply: "Ответить", + reactThumbsUp: "Отреагировать лайком", + reactHeart: "Отреагировать сердечком", + cancelReply: "Отменить ответ", replyingTo: "Ответ для {{name}}…", replyingToComment: "Ответ на комментарий…", commentPlaceholder: "Добавьте комментарий… (@ — упоминание)", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 0a1055b64..161d4fceb 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3125,6 +3125,9 @@ const zh = { reactionCountOne: '{{count}} 个回应', addThumbsUp: '点赞', reply: '回复', + reactThumbsUp: '以点赞回应', + reactHeart: '以爱心回应', + cancelReply: '取消回复', replyingTo: '正在回复 {{name}}…', replyingToComment: '正在回复该评论…', commentPlaceholder: '添加评论…(输入 @ 提及他人)',