diff --git a/.changeset/collaboration-presence-avatars-i18n-3440.md b/.changeset/collaboration-presence-avatars-i18n-3440.md new file mode 100644 index 0000000000..fbbba18f7f --- /dev/null +++ b/.changeset/collaboration-presence-avatars-i18n-3440.md @@ -0,0 +1,53 @@ +--- +'@object-ui/collaboration': patch +'@object-ui/i18n': patch +--- + +Localize `PresenceAvatars` — the avatar stack's accessible name and tooltips follow the session language (objectui#3440) + +objectui#3424 wired `@object-ui/collaboration` up to `@object-ui/i18n` but only +converted `CommentThread`. `PresenceAvatars` in the same package kept three +English literals, and it is not a dormant export — the console renders it in +two places: `app-shell/src/layout/AppHeader.tsx` (tenant presence beside the +lifecycle badge) and `app-shell/src/views/RecordDetailView.tsx` (who else is on +this record). A `zh` session got them in English. + +The three sites: + +- the group's `aria-label`, `` `${n} user${n !== 1 ? 's' : ''} present` ``; +- the overflow badge's tooltip, `` `${n} more user${n !== 1 ? 's' : ''}` ``; +- each avatar's tooltip, `` `${name} (${status})` ``. + +The first one is the whole control as far as a screen reader is concerned: the +stack renders images and initials and nothing else, so there was no other +accessible name to fall back on. + +As with the comment count in #3424, the first two carried a second defect on +top of being untranslated — the plural **rule** was compiled into the component. +Both produced correct *English* (each has a real singular branch, so this is +not the "1 items" defect objectui#3423 fixed on the tab badge), but +`n !== 1 ? 's' : ''` is English grammar in a render path and no locale could +apply its own. Both now use the repo's **two-key** plural convention +(`collaboration.presentUserCount`/`presentUserCountOne`, +`collaboration.moreUserCount`/`moreUserCountOne`) rather than an i18next +`_one`/`_other` pair, with the count interpolated as a string so i18next skips +its own plural resolution. German is what witnesses the move: "1 anwesender +Benutzer" vs "2 anwesende Benutzer" inflects the adjective, which the deleted +ternary could not have produced for any pack. + +The avatar tooltip becomes a single `collaboration.userStatusTitle` key +(`{{name}} ({{status}})`) so the parentheses and their spacing belong to the +translation — the CJK packs drop the space English puts before `(`, matching +their existing `edited: '(已编辑)'`. + +Its `status` is a **display-layer** translation +(`collaboration.statusActive` / `statusIdle` / `statusAway`): the +`PresenceUser['status']` enum value stays raw data everywhere it is stored, +compared or passed around — including the `statusColors` lookup — and is +translated only at this render exit. A status outside the declared union +renders as itself, the raw string: presence users arrive from a host-supplied +`PresenceSource` transport, so an unmapped value is reachable at runtime +whatever the type says, and the fallback invents nothing rather than leaving an +empty bracket pair. + +Eight new keys, added to all ten locale packs with real translations. diff --git a/packages/collaboration/src/PresenceAvatars.tsx b/packages/collaboration/src/PresenceAvatars.tsx index 7aa7eb8552..b843421537 100644 --- a/packages/collaboration/src/PresenceAvatars.tsx +++ b/packages/collaboration/src/PresenceAvatars.tsx @@ -8,6 +8,10 @@ import React, { useMemo } from 'react'; import type { PresenceUser } from './usePresence'; +import { + useCollaborationTranslation, + type CollaborationTranslate, +} from './useCollaborationTranslation'; export interface PresenceAvatarsProps { /** Present users */ @@ -34,6 +38,41 @@ const statusColors: Record = { away: '#94a3b8', }; +/** + * Display-layer translation key per presence status (objectui#3440). + * + * The status enum is DATA: `'active' | 'idle' | 'away'` is what + * {@link PresenceUser} carries, what the transport pushes and what + * `statusColors` above keys off. Nothing about that changes — this map exists + * only at the render exit, the one place the value stops being an identifier + * and becomes copy inside a tooltip. + * + * Typed `Record< string, string >` rather than + * `Record< PresenceUser['status'], string >` on purpose. Presence users arrive + * from a host-supplied `PresenceSource` (a WebSocket/SSE transport the package + * does not own — see `PresenceProvider`), so a status outside the union is + * reachable at runtime however strict the type is. An unmapped value renders + * as ITSELF, the raw string: no invented label, and no empty parenthesis where + * a status used to be. + */ +const statusLabelKeys: Record = { + active: 'collaboration.statusActive', + idle: 'collaboration.statusIdle', + away: 'collaboration.statusAway', +}; + +/** + * Resolve a status value to its display copy, falling back to the raw value. + * + * Takes `t` as a parameter (same shape as `CommentThread`'s `formatTimestamp`) + * so this helper cannot drift from whichever half of the union — real i18next + * `t` or the English defaults map — the component is running under. + */ +function statusLabel(status: string, t: CollaborationTranslate): string { + const key = statusLabelKeys[status]; + return key ? t(key) : status; +} + function getInitials(name: string): string { return name .split(' ') @@ -48,6 +87,11 @@ function getInitials(name: string): string { * * Displays user avatars (or initials) in an overlapping stack, * with optional status indicators and a "+N" overflow badge. + * + * Every user-visible string resolves through `useCollaborationTranslation` + * (objectui#3440). The stack is images and initials only, so its `aria-label` + * is the entire control as far as a screen reader is concerned — leaving it in + * English left a `zh` console announcing its avatar group in English. */ export function PresenceAvatars({ users, @@ -56,6 +100,7 @@ export function PresenceAvatars({ showStatus = true, className, }: PresenceAvatarsProps): React.ReactElement { + const { t } = useCollaborationTranslation(); const px = sizeMap[size]; const overlapOffset = Math.round(px * 0.3); const fontSize = Math.round(px * 0.35); @@ -113,13 +158,29 @@ export function PresenceAvatars({ style: containerStyle, className, role: 'group', - 'aria-label': `${users.length} user${users.length !== 1 ? 's' : ''} present`, + // Two keys instead of an English `s` glued on at render time. The old + // `` `${n} user${n !== 1 ? 's' : ''} present` `` produced correct *English* + // — the defect is that the plural RULE was compiled into the component, so + // no locale could apply its own (ru needs three forms, ja needs none, and + // neither could ever be expressed). Same treatment as the comment count in + // objectui#3424. + 'aria-label': t( + users.length === 1 + ? 'collaboration.presentUserCountOne' + : 'collaboration.presentUserCount', + { count: String(users.length) }, + ), }, // Overflow badge (rendered first because of row-reverse) overflowCount > 0 && React.createElement('div', { key: 'overflow', style: overflowStyle, - title: `${overflowCount} more user${overflowCount !== 1 ? 's' : ''}`, + title: t( + overflowCount === 1 + ? 'collaboration.moreUserCountOne' + : 'collaboration.moreUserCount', + { count: String(overflowCount) }, + ), }, `+${overflowCount}`), // Avatars reversedVisible.map((user, idx) => @@ -130,7 +191,14 @@ export function PresenceAvatars({ backgroundColor: user.color, marginLeft: idx > 0 || overflowCount > 0 ? `-${overlapOffset}px` : '0', }, - title: `${user.userName} (${user.status})`, + // The parentheses live in the translation, not in the component, so a + // translator owns the whole shape — spacing included: the CJK packs + // drop the space English puts before `(`, which a component-side + // `` `${name} (${status})` `` could never let them do. + title: t('collaboration.userStatusTitle', { + name: user.userName, + status: statusLabel(user.status, t), + }), }, user.avatar ? React.createElement('img', { diff --git a/packages/collaboration/src/__tests__/presence-avatars-i18n.test.tsx b/packages/collaboration/src/__tests__/presence-avatars-i18n.test.tsx new file mode 100644 index 0000000000..3fc33e2242 --- /dev/null +++ b/packages/collaboration/src/__tests__/presence-avatars-i18n.test.tsx @@ -0,0 +1,259 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `PresenceAvatars` speaks the session language — objectui#3440 (follow-up to + * #3424, which wired the package up but only converted `CommentThread`). + * + * This is not a dormant export: the console renders it in two places under + * whatever language the session is in — `app-shell/src/layout/AppHeader.tsx` + * (tenant presence next to the lifecycle badge) and + * `app-shell/src/views/RecordDetailView.tsx` (who else is on this record). + * The stack itself is images and initials, so its `aria-label` IS the control + * as far as a screen reader is concerned, and it announced "4 users present" + * inside a Chinese console. + * + * ── Directions: predicted BEFORE running ────────────────────────────────── + * Reverting `PresenceAvatars.tsx` to `origin/main` and keeping this file turns + * every `zh` / `de` / `ru` / `ja` case RED and leaves the `en` cases GREEN. + * + * The `en` cases being green on BOTH sides is the invariant, not a gap. The + * English `origin/main` produced was already correct — `` `${n} user${n !== 1 + * ? 's' : ''} present` `` has a real singular branch, so this is NOT the "1 + * items" defect objectui#3423 fixed on the tab badge. What was wrong is that + * the plural RULE was compiled into the component: `n !== 1 ? 's' : ''` is + * English grammar in a render path, and no locale could ever apply its own. + * The `de` cases below are what pin the rule having MOVED — German inflects + * the adjective, so "1 anwesender Benutzer" vs "2 anwesende Benutzer" differ + * in a way the deleted ternary could not express under any locale pack. `ru` + * pins a language that needs three forms and `ja` one that needs none. + * + * The provider-less English fallback is asserted in + * `presence-avatars-no-provider-fallback.test.tsx` and cannot live here: see + * that file's header for the react-i18next module-global instance reason. + */ + +import type { ComponentProps } from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider, en } from '@object-ui/i18n'; +import { PresenceAvatars } from '../PresenceAvatars'; +import { COLLAB_DEFAULT_TRANSLATIONS } from '../useCollaborationTranslation'; +import type { PresenceUser } from '../usePresence'; + +const at = '2026-01-01T00:00:00.000Z'; + +/** Three statuses across the visible slots, so all three enum values render. */ +const users: PresenceUser[] = [ + { userId: 'u_alice', userName: 'Alice Chen', color: '#e74c3c', status: 'active', lastActivity: at }, + { userId: 'u_bob', userName: 'Bob Ito', color: '#3498db', status: 'idle', lastActivity: at }, + { userId: 'u_carol', userName: 'Carol Diaz', color: '#2ecc71', status: 'away', lastActivity: at }, + { userId: 'u_dan', userName: 'Dan Meyer', color: '#f39c12', status: 'active', lastActivity: at }, + { userId: 'u_eve', userName: 'Eve Novak', color: '#9b59b6', status: 'idle', lastActivity: at }, +]; + +function renderStack( + language: string, + overrides: Partial> = {}, +) { + return render( + + + , + ); +} + +/** The avatar group's accessible name — the only name this control has. */ +const groupLabel = () => screen.getByRole('group').getAttribute('aria-label'); + +afterEach(() => cleanup()); + +describe('PresenceAvatars group label (objectui#3440)', () => { + it('announces the present-user count in English under an en session', () => { + renderStack('en'); + + expect(groupLabel()).toBe('5 users present'); + }); + + it('announces the present-user count in Chinese under a zh session', () => { + renderStack('zh'); + + expect(groupLabel()).toBe('5 人在线'); + // The English literal is gone, not merely shadowed. + expect(screen.queryByLabelText('5 users present')).toBeNull(); + }); + + it('announces a one-user stack with a real singular under an en session', () => { + renderStack('en', { users: [users[0]] }); + + expect(groupLabel()).toBe('1 user present'); + }); + + /** + * The plural-rule pin. German inflects the attributive adjective, so the + * singular and plural differ in a way `n !== 1 ? 's' : ''` could not have + * produced for any locale — the rule now lives in the pack, not the render + * path. + */ + it('applies German adjective inflection, which the deleted ternary could not', () => { + renderStack('de', { users: [users[0]] }); + expect(groupLabel()).toBe('1 anwesender Benutzer'); + cleanup(); + + renderStack('de'); + expect(groupLabel()).toBe('5 anwesende Benutzer'); + }); + + /** A language that needs three forms; the pack picks, not the component. */ + it('uses the Russian pack’s own singular / general forms', () => { + renderStack('ru', { users: [users[0]] }); + expect(groupLabel()).toBe('Присутствует 1 пользователь'); + cleanup(); + + renderStack('ru'); + expect(groupLabel()).toBe('Присутствует пользователей: 5'); + }); + + /** A language that needs none — one form for both, and no stray `s`. */ + it('keeps one Japanese form for both counts', () => { + renderStack('ja', { users: [users[0]] }); + expect(groupLabel()).toBe('オンライン 1 人'); + cleanup(); + + renderStack('ja'); + expect(groupLabel()).toBe('オンライン 5 人'); + }); +}); + +describe('PresenceAvatars overflow badge (objectui#3440)', () => { + it('labels the +N badge in the session language, plural and singular', () => { + renderStack('en'); + expect(screen.getByTitle('2 more users')).toBeTruthy(); + cleanup(); + + renderStack('en', { users: users.slice(0, 4) }); + expect(screen.getByTitle('1 more user')).toBeTruthy(); + }); + + it('labels the +N badge in Chinese under a zh session', () => { + renderStack('zh'); + + expect(screen.getByTitle('另有 2 人')).toBeTruthy(); + expect(screen.queryByTitle('2 more users')).toBeNull(); + }); + + it('applies German inflection to the +N badge too', () => { + renderStack('de', { users: users.slice(0, 4) }); + expect(screen.getByTitle('1 weiterer Benutzer')).toBeTruthy(); + cleanup(); + + renderStack('de'); + expect(screen.getByTitle('2 weitere Benutzer')).toBeTruthy(); + }); +}); + +describe('PresenceAvatars status tooltip (objectui#3440)', () => { + it('renders name and status in English under an en session', () => { + renderStack('en'); + + expect(screen.getByTitle('Alice Chen (active)')).toBeTruthy(); + expect(screen.getByTitle('Bob Ito (idle)')).toBeTruthy(); + expect(screen.getByTitle('Carol Diaz (away)')).toBeTruthy(); + }); + + /** + * The status is a display-layer translation: the raw enum value is data and + * stays untouched everywhere except this render exit. The bracket shape is + * part of the translation too, so each pack owns its own spacing — `zh` + * drops the space English puts before `(`, matching that pack's existing + * `edited: '(已编辑)'`, instead of inheriting English-shaped glue. + */ + it('translates the status enum for display under a zh session', () => { + renderStack('zh'); + + expect(screen.getByTitle('Alice Chen(活跃)')).toBeTruthy(); + expect(screen.getByTitle('Bob Ito(空闲)')).toBeTruthy(); + expect(screen.getByTitle('Carol Diaz(离开)')).toBeTruthy(); + // Neither the English enum value nor the English bracket glue survives. + expect(screen.queryByTitle('Alice Chen (active)')).toBeNull(); + expect(screen.queryByTitle('Alice Chen (活跃)')).toBeNull(); + }); + + it('translates the status enum under a de session', () => { + renderStack('de'); + + expect(screen.getByTitle('Alice Chen (aktiv)')).toBeTruthy(); + expect(screen.getByTitle('Bob Ito (inaktiv)')).toBeTruthy(); + expect(screen.getByTitle('Carol Diaz (abwesend)')).toBeTruthy(); + }); + + /** + * Presence users arrive from a host-supplied transport, so a status outside + * the declared union is reachable at runtime whatever the type says. It must + * render as ITSELF — not as an invented label, not as a raw i18n key, and + * not as an empty bracket pair. + */ + it('falls back to the raw value for a status outside the union', () => { + const offUnion = { + ...users[0], + status: 'online' as PresenceUser['status'], + }; + renderStack('zh', { users: [offUnion] }); + + expect(screen.getByTitle('Alice Chen(online)')).toBeTruthy(); + expect(screen.queryByTitle('Alice Chen()')).toBeNull(); + expect(screen.queryByTitle(/collaboration\.status/)).toBeNull(); + }); + + it('keeps the tooltip when status dots are hidden', () => { + // `showStatus` governs the coloured dot, not the accessible copy — the + // tooltip is the only place a status is readable at all. + renderStack('zh', { showStatus: false }); + + expect(screen.getByTitle('Alice Chen(活跃)')).toBeTruthy(); + }); +}); + +describe('the collaboration defaults map mirrors the en pack (objectui#3440)', () => { + /** + * `useCollaborationTranslation`'s docblock states that + * `COLLAB_DEFAULT_TRANSLATIONS` "must stay byte-identical to the `en` locale + * pack's `collaboration` namespace". Nothing enforced it, and this change + * adds eight keys to BOTH places — the exact shape that drifts. A key added + * to only one of them is invisible at runtime: with a provider the pack + * wins, without one the map does, so each path looks fine on its own. + * + * Only the `collaboration.` prefix is compared; the four `common.*` entries + * are deliberately borrowed from the shared namespace. + */ + it('agrees key-for-key and value-for-value with en.collaboration', () => { + const fromMap = Object.fromEntries( + Object.entries(COLLAB_DEFAULT_TRANSLATIONS) + .filter(([k]) => k.startsWith('collaboration.')) + .map(([k, v]) => [k.slice('collaboration.'.length), v]), + ); + + expect(fromMap).toEqual({ ...en.collaboration }); + }); + + it('carries the eight presence keys this change added', () => { + for (const key of [ + 'presentUserCount', + 'presentUserCountOne', + 'moreUserCount', + 'moreUserCountOne', + 'userStatusTitle', + 'statusActive', + 'statusIdle', + 'statusAway', + ]) { + expect(en.collaboration).toHaveProperty(key); + expect(COLLAB_DEFAULT_TRANSLATIONS).toHaveProperty(`collaboration.${key}`); + } + }); +}); diff --git a/packages/collaboration/src/__tests__/presence-avatars-no-provider-fallback.test.tsx b/packages/collaboration/src/__tests__/presence-avatars-no-provider-fallback.test.tsx new file mode 100644 index 0000000000..2abe4112ef --- /dev/null +++ b/packages/collaboration/src/__tests__/presence-avatars-no-provider-fallback.test.tsx @@ -0,0 +1,160 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Every string objectui#3440 moved out of `PresenceAvatars` still resolves to + * ENGLISH when no `I18nProvider` is mounted. + * + * `PresenceAvatars` is an exported component and its host may mount no ObjectUI + * shell at all. Routing a literal through `t()` without a working default is + * exactly how an accessible name turns into a raw dotted key + * (`collaboration.presentUserCount`) in someone else's app — silently, because + * `fallbackLng: 'en'` never fires when there is no i18next instance to fall + * back inside of. `COLLAB_DEFAULT_TRANSLATIONS` is what has to hold here. + * + * ── Directions: predicted BEFORE running ────────────────────────────────── + * **Every** assertion in this file is GREEN on BOTH sides of the change. That + * is the invariant, not a missing test: the English copy is byte-identical + * before and after (`origin/main`'s ternary already produced a correct + * singular), so a flip here would mean the copy moved. What this file catches + * is a break introduced by *this* change — a key wired into the component but + * missing or misspelled in `COLLAB_DEFAULT_TRANSLATIONS` goes red here and + * nowhere else, because the provider-backed file resolves against the locale + * packs instead. + * + * The one genuinely new English string is the status tooltip's fallback for an + * off-union status: `origin/main` interpolated the raw value directly, and it + * still must. + * + * ── Why this is its own FILE, not a describe block ──────────────────────── + * `createI18n` calls `instance.use(initReactI18next)`, which registers that + * instance as react-i18next's **module-global default**. The registration + * survives unmount and `cleanup()`. So the moment any test in a file mounts + * ``, every later "no + * provider" render in that same file silently resolves against the Chinese + * instance — a file that looks green while asserting nothing, or a baffling + * red where a provider-less stack renders 中文. + * + * Vitest's `dom` project runs with `isolate: true`, so a file that never mounts + * a provider gets a genuinely clean global. Keep it that way: **do not import + * or mount `I18nProvider` here.** + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { PresenceAvatars } from '../PresenceAvatars'; +import { COLLAB_DEFAULT_TRANSLATIONS } from '../useCollaborationTranslation'; +import type { PresenceUser } from '../usePresence'; + +const at = '2026-01-01T00:00:00.000Z'; + +const users: PresenceUser[] = [ + { userId: 'u_alice', userName: 'Alice Chen', color: '#e74c3c', status: 'active', lastActivity: at }, + { userId: 'u_bob', userName: 'Bob Ito', color: '#3498db', status: 'idle', lastActivity: at }, + { userId: 'u_carol', userName: 'Carol Diaz', color: '#2ecc71', status: 'away', lastActivity: at }, + { userId: 'u_dan', userName: 'Dan Meyer', color: '#f39c12', status: 'active', lastActivity: at }, + { userId: 'u_eve', userName: 'Eve Novak', color: '#9b59b6', status: 'idle', lastActivity: at }, +]; + +function renderBare(overrides: Record = {}) { + return render(); +} + +const groupLabel = () => screen.getByRole('group').getAttribute('aria-label'); + +afterEach(() => cleanup()); + +describe('PresenceAvatars with no I18nProvider — English fallback (objectui#3440)', () => { + it('names the avatar group in English, plural and singular', () => { + renderBare(); + expect(groupLabel()).toBe('5 users present'); + cleanup(); + + renderBare({ users: [users[0]] }); + expect(groupLabel()).toBe('1 user present'); + expect(screen.queryByLabelText('1 users present')).toBeNull(); + }); + + it('labels the overflow badge in English, plural and singular', () => { + renderBare(); + expect(screen.getByTitle('2 more users')).toBeTruthy(); + cleanup(); + + renderBare({ users: users.slice(0, 4) }); + expect(screen.getByTitle('1 more user')).toBeTruthy(); + expect(screen.queryByTitle('1 more users')).toBeNull(); + }); + + it('renders every status enum value as English display copy', () => { + renderBare(); + + expect(screen.getByTitle('Alice Chen (active)')).toBeTruthy(); + expect(screen.getByTitle('Bob Ito (idle)')).toBeTruthy(); + expect(screen.getByTitle('Carol Diaz (away)')).toBeTruthy(); + }); + + it('falls back to the raw value for a status outside the union', () => { + renderBare({ users: [{ ...users[0], status: 'online' as PresenceUser['status'] }] }); + + expect(screen.getByTitle('Alice Chen (online)')).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 + * — here inside an `aria-label`, where nothing on screen would show it. + */ + it('never renders a raw i18n key or an unsubstituted placeholder', () => { + const { container } = renderBare(); + + const names = [ + groupLabel() ?? '', + ...[...container.querySelectorAll('[title]')].map((el) => el.getAttribute('title') ?? ''), + ].join(' | '); + + expect(names).not.toMatch(/collaboration\.\w+/); + expect(names).not.toMatch(/\{\{\w+\}\}/); + }); +}); + +describe('COLLAB_DEFAULT_TRANSLATIONS covers the presence stack', () => { + /** + * The defaults map is the only English copy left in the package, so an + * absent key would make the assertions above pass for the wrong reason: a + * key that resolves to itself still "renders" something. + */ + it('has real English copy for every presence key, never the key itself', () => { + for (const key of [ + 'collaboration.presentUserCount', + 'collaboration.presentUserCountOne', + 'collaboration.moreUserCount', + 'collaboration.moreUserCountOne', + 'collaboration.userStatusTitle', + 'collaboration.statusActive', + 'collaboration.statusIdle', + 'collaboration.statusAway', + ]) { + const value = COLLAB_DEFAULT_TRANSLATIONS[key]; + expect(value, `${key} has no English copy`).toBeTruthy(); + expect(value, `${key} is just its own key`).not.toBe(key); + } + }); + + it('keeps the count placeholder in both halves of each plural pair', () => { + expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.presentUserCount']).toContain('{{count}}'); + expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.presentUserCountOne']).toContain('{{count}}'); + expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.moreUserCount']).toContain('{{count}}'); + expect(COLLAB_DEFAULT_TRANSLATIONS['collaboration.moreUserCountOne']).toContain('{{count}}'); + }); + + it('gives the tooltip both of its placeholders', () => { + const title = COLLAB_DEFAULT_TRANSLATIONS['collaboration.userStatusTitle']; + expect(title).toContain('{{name}}'); + expect(title).toContain('{{status}}'); + }); +}); diff --git a/packages/collaboration/src/useCollaborationTranslation.ts b/packages/collaboration/src/useCollaborationTranslation.ts index 4201b963be..5afee12e7c 100644 --- a/packages/collaboration/src/useCollaborationTranslation.ts +++ b/packages/collaboration/src/useCollaborationTranslation.ts @@ -75,6 +75,22 @@ export const COLLAB_DEFAULT_TRANSLATIONS: Record = { // Composer 'collaboration.commentPlaceholder': 'Add a comment... (use @ to mention)', 'collaboration.send': 'Send', + // Presence avatar stack (objectui#3440). The group's `aria-label` IS the + // control for a screen reader — the stack itself is images and initials — + // so this pair is not decoration. + 'collaboration.presentUserCount': '{{count}} users present', + 'collaboration.presentUserCountOne': '{{count}} user present', + 'collaboration.moreUserCount': '{{count}} more users', + 'collaboration.moreUserCountOne': '{{count}} more user', + // Avatar tooltip. The parentheses belong to the translation so a translator + // owns the whole shape rather than inheriting English-shaped glue. + 'collaboration.userStatusTitle': '{{name}} ({{status}})', + // Display copy for the `PresenceUser['status']` enum. The enum VALUE stays + // raw data everywhere else — these three exist only for the tooltip above, + // and a status outside the union falls back to the raw string. + 'collaboration.statusActive': 'active', + 'collaboration.statusIdle': 'idle', + 'collaboration.statusAway': 'away', // Shared action words — see the note above. 'common.save': 'Save', 'common.cancel': 'Cancel', diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 7f25c1776a..d729deedd0 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -3073,6 +3073,14 @@ const ar = { replyingToComment: "الرد على التعليق…", commentPlaceholder: "أضف تعليقًا… (استخدم @ للإشارة)", send: "إرسال", + presentUserCount: "{{count}} مستخدمين متواجدين", + presentUserCountOne: "{{count}} مستخدم متواجد", + moreUserCount: "{{count}} مستخدمين آخرين", + moreUserCountOne: "{{count}} مستخدم آخر", + userStatusTitle: "{{name}} ({{status}})", + statusActive: "نشط", + statusIdle: "خامل", + statusAway: "غائب", }, }; diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 45149a2b4b..d68b6799ab 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -3073,6 +3073,14 @@ const de = { replyingToComment: "Antwort auf Kommentar …", commentPlaceholder: "Kommentar hinzufügen … (@ für Erwähnungen)", send: "Senden", + presentUserCount: "{{count}} anwesende Benutzer", + presentUserCountOne: "{{count}} anwesender Benutzer", + moreUserCount: "{{count}} weitere Benutzer", + moreUserCountOne: "{{count}} weiterer Benutzer", + userStatusTitle: "{{name}} ({{status}})", + statusActive: "aktiv", + statusIdle: "inaktiv", + statusAway: "abwesend", }, }; diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index ed13aa6ff5..6b93dee9db 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3209,6 +3209,23 @@ const en = { replyingToComment: 'Replying to comment...', commentPlaceholder: 'Add a comment... (use @ to mention)', send: 'Send', + // Presence avatar stack (objectui#3440). `presentUserCount*` is the avatar + // group's `aria-label` — with only images and initials inside, that label + // IS the control for a screen reader. Two keys, same reason as + // `commentCount` above. + presentUserCount: '{{count}} users present', + presentUserCountOne: '{{count}} user present', + moreUserCount: '{{count}} more users', + moreUserCountOne: '{{count}} more user', + // Avatar tooltip. The parentheses are part of the translation, spacing + // included, so the CJK packs can drop the space English puts before `(`. + userStatusTitle: '{{name}} ({{status}})', + // Display copy for the `PresenceUser['status']` enum. The enum value + // itself stays raw data — it is translated at the render exit only, and a + // status outside the union falls back to the raw string. + statusActive: 'active', + statusIdle: 'idle', + statusAway: 'away', }, } as const; diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 5e8fa8b278..ca6154ac01 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -3078,6 +3078,14 @@ const es = { replyingToComment: "Respondiendo al comentario…", commentPlaceholder: "Agregar un comentario… (usa @ para mencionar)", send: "Enviar", + presentUserCount: "{{count}} usuarios presentes", + presentUserCountOne: "{{count}} usuario presente", + moreUserCount: "{{count}} usuarios más", + moreUserCountOne: "{{count}} usuario más", + userStatusTitle: "{{name}} ({{status}})", + statusActive: "activo", + statusIdle: "inactivo", + statusAway: "ausente", }, }; diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 3f8a8dccc5..b0142252b9 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -3073,6 +3073,14 @@ const fr = { replyingToComment: "Réponse au commentaire…", commentPlaceholder: "Ajouter un commentaire… (utilisez @ pour mentionner)", send: "Envoyer", + presentUserCount: "{{count}} utilisateurs présents", + presentUserCountOne: "{{count}} utilisateur présent", + moreUserCount: "{{count}} autres utilisateurs", + moreUserCountOne: "{{count}} autre utilisateur", + userStatusTitle: "{{name}} ({{status}})", + statusActive: "actif", + statusIdle: "inactif", + statusAway: "absent", }, }; diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index e704091428..d153cab1ae 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -3073,6 +3073,14 @@ const ja = { replyingToComment: "このコメントに返信中…", commentPlaceholder: "コメントを追加…(@ でメンション)", send: "送信", + presentUserCount: "オンライン {{count}} 人", + presentUserCountOne: "オンライン {{count}} 人", + moreUserCount: "他 {{count}} 人", + moreUserCountOne: "他 {{count}} 人", + userStatusTitle: "{{name}}({{status}})", + statusActive: "アクティブ", + statusIdle: "アイドル", + statusAway: "離席中", }, }; diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 3cf5f1f6a2..535a80445d 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -3073,6 +3073,14 @@ const ko = { replyingToComment: "이 댓글에 답글 작성 중…", commentPlaceholder: "댓글 추가…(@로 멘션)", send: "보내기", + presentUserCount: "{{count}}명 접속 중", + presentUserCountOne: "{{count}}명 접속 중", + moreUserCount: "외 {{count}}명", + moreUserCountOne: "외 {{count}}명", + userStatusTitle: "{{name}}({{status}})", + statusActive: "활성", + statusIdle: "유휴", + statusAway: "자리 비움", }, }; diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index cd0cd1b04a..479bf55ab1 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -3073,6 +3073,14 @@ const pt = { replyingToComment: "Respondendo ao comentário…", commentPlaceholder: "Adicionar um comentário… (use @ para mencionar)", send: "Enviar", + presentUserCount: "{{count}} usuários presentes", + presentUserCountOne: "{{count}} usuário presente", + moreUserCount: "mais {{count}} usuários", + moreUserCountOne: "mais {{count}} usuário", + userStatusTitle: "{{name}} ({{status}})", + statusActive: "ativo", + statusIdle: "inativo", + statusAway: "ausente", }, }; diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 5357da2c1d..6741737c13 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -3073,6 +3073,14 @@ const ru = { replyingToComment: "Ответ на комментарий…", commentPlaceholder: "Добавьте комментарий… (@ — упоминание)", send: "Отправить", + presentUserCount: "Присутствует пользователей: {{count}}", + presentUserCountOne: "Присутствует {{count}} пользователь", + moreUserCount: "Ещё пользователей: {{count}}", + moreUserCountOne: "Ещё {{count}} пользователь", + userStatusTitle: "{{name}} ({{status}})", + statusActive: "активен", + statusIdle: "бездействует", + statusAway: "отошёл", }, }; diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 4a5b5c7db2..8f82d7c7f4 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3127,6 +3127,14 @@ const zh = { replyingToComment: '正在回复该评论…', commentPlaceholder: '添加评论…(输入 @ 提及他人)', send: '发送', + presentUserCount: '{{count}} 人在线', + presentUserCountOne: '{{count}} 人在线', + moreUserCount: '另有 {{count}} 人', + moreUserCountOne: '另有 {{count}} 人', + userStatusTitle: '{{name}}({{status}})', + statusActive: '活跃', + statusIdle: '空闲', + statusAway: '离开', }, } as const;