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: '添加评论…(输入 @ 提及他人)',