Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .changeset/5956-5957-record-path-a11y-residue.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/i18n': patch
---

`record:path` finishes localizing and de-colouring its accessible names — the two residues
objectui#5916 named and deliberately left behind (objectui#5956, objectui#5957).

**The list's own label was English on a localized surface, and the other one named nothing.**
Both the desktop and the mobile `role="list"` row did
`aria-label={schema.aria?.label || 'Record path'}`, so a zh/ja/ar session heard `Record path`
for the list while every stage inside it announced in the session locale — one control
speaking two languages at once. The fallback is now `detail.pathLabel`, translated in all ten
packs; the `schema.aria.label` author override still wins ahead of it.

The lost-terminal alt group was a different defect wearing the same clothes: its
`aria-label="Alternative terminal stages"` sat on a bare `div`, which has the `generic` role,
and browsers expose no accessible name on a generic element. That string reached nobody —
inert, not merely untranslated — so translating it would have shipped copy to ten packs that
no user can hear. It is removed rather than given a role that takes a name, on three
measurements: nothing is lost (it was never announced), it would be redundant (every stage
inside already announces `closed lost` in the session locale after objectui#5916, in the one
place `role="list"` can carry it), and it would fork the two rows (the mobile row renders one
flat list with no alt group, so a named group would make one control expose two structures by
viewport).

**An unreached goal terminus was distinguished by hue alone.** `railClass` paints it
`bg-emerald-500/30` where a plain upcoming stage gets `bg-muted` — the renderer's own note
calls this "a faint emerald so the goal is legible" — while both announced the identical
`{{stage}}, upcoming`. Two stages ahead of the record painted differently and read the same:
the WCAG 2.2 SC 1.4.1 class objectui#5916 closed, on the one distinction it left behind, and
reachable without authors opting in because `classify()` finds `won` through the `WON_TOKENS`
heuristic as well as an explicit `terminal: 'won'`. New key `detail.pathStageWonUpcoming`
(`{{stage}}, goal stage, not reached`), translated in all ten packs.

Scoped to the UNREACHED goal, which is a measurement of the stylesheet rather than a
preference: a reached goal terminus paints `bg-primary` when current and `bg-emerald-500` when
completed, byte-identical to any other current or completed stage. Naming it apart would hand
a screen reader a distinction the screen does not make — the mirror image of the defect — so
it is one new key, not a pair, and a test pins that decision so it cannot drift into a fourth
state unnoticed.

Both new keys also land in `DETAIL_DEFAULT_TRANSLATIONS`, which
`defaults-maps-mirror-en-pack` compares against the `en` pack key by key, so neither can fork
between a provider-mounted console and a provider-less embed. No existing `en` value changes,
so no pack is asked to follow an edit.
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -978,11 +978,13 @@ const ar = {
cancelApprovalTooltipUnlocked: "إلغاء طلب الموافقة المعلق",
cancelApprovalFailed: "فشل إلغاء الموافقة",
cancelApprovalUnavailable: "إلغاء الموافقات غير مدعوم من مصدر البيانات هذا",
pathLabel: 'مسار السجل',
pathStageCompleted: '{{stage}}، مكتملة',
pathStageCurrent: '{{stage}}، المرحلة الحالية',
pathStageUpcoming: '{{stage}}، قادمة',
pathStageLostCurrent: '{{stage}}، خاسرة، المرحلة الحالية',
pathStageLostUpcoming: '{{stage}}، خاسرة، لم يتم الوصول إليها',
pathStageWonUpcoming: '{{stage}}، المرحلة الهدف، لم يتم الوصول إليها',
linkCopied: "تم نسخ الرابط إلى الحافظة",
linkCopyFailed: "فشل نسخ الرابط",
cancel: "إلغاء",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -972,11 +972,13 @@ const de = {
cancelApprovalTooltipUnlocked: "Ausstehende Genehmigungsanfrage zurückziehen",
cancelApprovalFailed: "Genehmigung konnte nicht zurückgezogen werden",
cancelApprovalUnavailable: "Das Zurückziehen von Genehmigungen wird bei dieser Datenquelle nicht unterstützt",
pathLabel: 'Datensatzpfad',
pathStageCompleted: '{{stage}}, abgeschlossen',
pathStageCurrent: '{{stage}}, aktuelle Phase',
pathStageUpcoming: '{{stage}}, ausstehend',
pathStageLostCurrent: '{{stage}}, verloren, aktuelle Phase',
pathStageLostUpcoming: '{{stage}}, verloren, nicht erreicht',
pathStageWonUpcoming: '{{stage}}, Zielphase, nicht erreicht',
linkCopied: "Link in die Zwischenablage kopiert",
linkCopyFailed: "Link konnte nicht kopiert werden",
cancel: "Abbrechen",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -948,11 +948,13 @@ const en = {
// visually-hidden text inside it computes to an EMPTY name; these compose
// the stage's own (already picklist-localized) label with its state into the
// `aria-label`. The ✓/✗ glyphs stay `aria-hidden` decoration.
pathLabel: 'Record path',
pathStageCompleted: '{{stage}}, completed',
pathStageCurrent: '{{stage}}, current stage',
pathStageUpcoming: '{{stage}}, upcoming',
pathStageLostCurrent: '{{stage}}, closed lost, current stage',
pathStageLostUpcoming: '{{stage}}, closed lost, not reached',
pathStageWonUpcoming: '{{stage}}, goal stage, not reached',
linkCopied: 'Link copied to clipboard',
linkCopyFailed: 'Failed to copy link',
cancel: 'Cancel',
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -976,11 +976,13 @@ const es = {
cancelApprovalTooltipUnlocked: "Cancelar la solicitud de aprobación pendiente",
cancelApprovalFailed: "No se pudo cancelar la aprobación",
cancelApprovalUnavailable: "La cancelación de aprobaciones no es compatible con esta fuente de datos",
pathLabel: 'Ruta del registro',
pathStageCompleted: '{{stage}}, completada',
pathStageCurrent: '{{stage}}, etapa actual',
pathStageUpcoming: '{{stage}}, pendiente',
pathStageLostCurrent: '{{stage}}, perdida, etapa actual',
pathStageLostUpcoming: '{{stage}}, perdida, no alcanzada',
pathStageWonUpcoming: '{{stage}}, etapa objetivo, no alcanzada',
linkCopied: "Enlace copiado al portapapeles",
linkCopyFailed: "No se pudo copiar el enlace",
cancel: "Cancelar",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -974,11 +974,13 @@ const fr = {
cancelApprovalTooltipUnlocked: "Annuler la demande d'approbation en attente",
cancelApprovalFailed: "Échec de l'annulation de l'approbation",
cancelApprovalUnavailable: "L'annulation des approbations n'est pas prise en charge par cette source de données",
pathLabel: "Parcours de l'enregistrement",
pathStageCompleted: '{{stage}}, terminée',
pathStageCurrent: '{{stage}}, étape actuelle',
pathStageUpcoming: '{{stage}}, à venir',
pathStageLostCurrent: '{{stage}}, perdue, étape actuelle',
pathStageLostUpcoming: '{{stage}}, perdue, non atteinte',
pathStageWonUpcoming: '{{stage}}, étape objectif, non atteinte',
linkCopied: "Lien copié dans le presse-papiers",
linkCopyFailed: "Impossible de copier le lien",
cancel: "Annuler",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -983,11 +983,13 @@ const ja = {
cancelApprovalTooltipUnlocked: "承認待ちリクエストを取り消す",
cancelApprovalFailed: "承認の取り消しに失敗しました",
cancelApprovalUnavailable: "このデータソースでは承認の取り消しはサポートされていません",
pathLabel: 'レコードパス',
pathStageCompleted: '{{stage}}、完了',
pathStageCurrent: '{{stage}}、現在のステージ',
pathStageUpcoming: '{{stage}}、未着手',
pathStageLostCurrent: '{{stage}}、失注、現在のステージ',
pathStageLostUpcoming: '{{stage}}、失注、未到達',
pathStageWonUpcoming: '{{stage}}、目標ステージ、未到達',
linkCopied: "リンクをクリップボードにコピーしました",
linkCopyFailed: "リンクのコピーに失敗しました",
cancel: "キャンセル",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -972,11 +972,13 @@ const ko = {
cancelApprovalTooltipUnlocked: "대기 중인 승인 요청 취소",
cancelApprovalFailed: "승인 취소 실패",
cancelApprovalUnavailable: "이 데이터 소스에서는 승인 취소가 지원되지 않습니다",
pathLabel: '레코드 경로',
pathStageCompleted: '{{stage}}, 완료됨',
pathStageCurrent: '{{stage}}, 현재 단계',
pathStageUpcoming: '{{stage}}, 예정',
pathStageLostCurrent: '{{stage}}, 실패, 현재 단계',
pathStageLostUpcoming: '{{stage}}, 실패, 도달하지 않음',
pathStageWonUpcoming: '{{stage}}, 목표 단계, 도달하지 않음',
linkCopied: "링크가 클립보드에 복사됨",
linkCopyFailed: "링크 복사 실패",
cancel: "취소",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -973,11 +973,13 @@ const pt = {
cancelApprovalTooltipUnlocked: "Cancelar a solicitação de aprovação pendente",
cancelApprovalFailed: "Falha ao cancelar aprovação",
cancelApprovalUnavailable: "O cancelamento de aprovações não é suportado por esta fonte de dados",
pathLabel: 'Caminho do registro',
pathStageCompleted: '{{stage}}, concluída',
pathStageCurrent: '{{stage}}, etapa atual',
pathStageUpcoming: '{{stage}}, pendente',
pathStageLostCurrent: '{{stage}}, perdida, etapa atual',
pathStageLostUpcoming: '{{stage}}, perdida, não alcançada',
pathStageWonUpcoming: '{{stage}}, etapa objetivo, não alcançada',
linkCopied: "Link copiado para a área de transferência",
linkCopyFailed: "Falha ao copiar o link",
cancel: "Cancelar",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -991,11 +991,13 @@ const ru = {
cancelApprovalTooltipUnlocked: "Отменить ожидающий запрос на согласование",
cancelApprovalFailed: "Не удалось отменить согласование",
cancelApprovalUnavailable: "Отмена согласований не поддерживается этим источником данных",
pathLabel: 'Путь записи',
pathStageCompleted: '{{stage}}, завершён',
pathStageCurrent: '{{stage}}, текущий этап',
pathStageUpcoming: '{{stage}}, предстоит',
pathStageLostCurrent: '{{stage}}, проигран, текущий этап',
pathStageLostUpcoming: '{{stage}}, проигран, не достигнут',
pathStageWonUpcoming: '{{stage}}, целевой этап, не достигнут',
linkCopied: "Ссылка скопирована в буфер обмена",
linkCopyFailed: "Не удалось скопировать ссылку",
cancel: "Отмена",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -879,11 +879,13 @@ const zh = {
cancelApprovalTooltipUnlocked: '撤回当前的待审批请求',
cancelApprovalFailed: '撤回审批失败',
cancelApprovalUnavailable: '当前数据源不支持撤回审批',
pathLabel: '记录路径',
pathStageCompleted: '{{stage}},已完成',
pathStageCurrent: '{{stage}},当前阶段',
pathStageUpcoming: '{{stage}},未开始',
pathStageLostCurrent: '{{stage}},已失败,当前阶段',
pathStageLostUpcoming: '{{stage}},已失败,未到达',
pathStageWonUpcoming: '{{stage}},目标阶段,未到达',
linkCopied: '链接已复制到剪贴板',
linkCopyFailed: '复制链接失败',
cancel: '取消',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
/**
* 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.
*
* ══════════════════════════════════════════════════════════════════════════
* `record:path`'s CONTAINER labels (objectui#5956)
* ══════════════════════════════════════════════════════════════════════════
*
* objectui#5916 localized every STAGE's accessible name and deliberately left
* the two container labels alone. That left one control announcing in two
* languages at once: a zh session heard `Record path` for the list itself while
* every stage inside it announced in Chinese. This file pins the fix — and the
* OTHER container label, which was a different defect entirely.
*
* ── Two defects, two shapes of assertion ──────────────────────────────────
*
* 1. `role="list"` on both rows: a hardcoded English literal became
* `detail.pathLabel`, with `schema.aria.label` still winning ahead of it.
* Assertable as an accessible NAME, because `list` takes a name from the
* author.
*
* 2. The lost-terminal alt group: `aria-label="Alternative terminal stages"` sat
* on a bare `div`. A `div` is `generic`, and browsers expose no accessible
* name on a generic element — so that string reached NOBODY. It was inert,
* not untranslated. The assertion below is therefore an ABSENCE (no element
* carries that name and no wrapper claims a named role), paired with the
* positive fact that makes the absence safe: the stages inside already
* announce `closed lost` themselves, so nothing a user could hear is lost.
* The renderer's own comment records the three measurements behind the pick.
*
* ── Why every case here mounts a provider ─────────────────────────────────
*
* `createI18n` registers its instance as react-i18next's module-global default
* and the registration survives `cleanup()`, so a provider-LESS render in this
* file would silently resolve against whichever locale a previous case mounted
* — the same hazard `record-path.stageStateAccessibleName.i18n.test.tsx` records
* and splits two files over. Rather than re-open that split, every case here is
* provider-mounted. The provider-less path's own invariant — that
* `DETAIL_DEFAULT_TRANSLATIONS` serves the same bytes the `en` pack does, so
* `detail.pathLabel` cannot fork by host — is owned globally and per key by
* `app-shell/src/__tests__/defaults-maps-mirror-en-pack.test.tsx` (objectui#4401),
* which compares the map against the pack key by key and now covers this row.
*/

import * as React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import '@testing-library/jest-dom';
import { render, cleanup, within, type RenderResult } from '@testing-library/react';
import { I18nProvider } from '@object-ui/i18n';
import { RecordContextProvider } from '@object-ui/react';
import { RecordPathRenderer } from '../record-path';

const STAGES = [
{ value: 'draft', label: '草稿' },
{ value: 'in_review', label: '审核中' },
{ value: 'submitted', label: '已提交' },
{ value: 'declined', label: '已拒绝', terminal: 'lost' as const },
];

function mountIn(language: string, schemaExtra: Record<string, unknown> = {}): RenderResult {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<RecordContextProvider objectName="crm_quote" recordId="q1" data={{ id: 'q1', status: 'submitted' }}>
<RecordPathRenderer schema={{ statusField: 'status', stages: STAGES, ...schemaExtra } as never} />
</RecordContextProvider>
</I18nProvider>,
);
}

/** Both rows are rendered on every host; the viewport split is CSS-only. */
const rows = (r: RenderResult): HTMLElement[] =>
Array.from(r.container.querySelectorAll('[role="list"]')) as HTMLElement[];

afterEach(() => cleanup());

describe('record:path container label speaks the session locale (objectui#5956)', () => {
it('en names both rows from the pack', () => {
const [desktop, mobile] = rows(mountIn('en'));
expect(desktop).toHaveAccessibleName('Record path');
expect(mobile).toHaveAccessibleName('Record path');
});

it('zh names both rows in Chinese — no English left on the container', () => {
const [desktop, mobile] = rows(mountIn('zh'));
expect(desktop).toHaveAccessibleName('记录路径');
expect(mobile).toHaveAccessibleName('记录路径');
// The point of the card: the list must not announce in English while the
// stages inside it announce in Chinese.
for (const row of rows(mountIn('zh'))) {
expect(row).not.toHaveAccessibleName(/Record path/);
}
});

it('de and ja name both rows in their own locale', () => {
for (const [lang, name] of [
['de', 'Datensatzpfad'],
['ja', 'レコードパス'],
] as const) {
for (const row of rows(mountIn(lang))) expect(row).toHaveAccessibleName(name);
cleanup();
}
});

it('the three locales do not all render the same string — the key is really consulted', () => {
// Non-vacuity: if `t()` were bypassed (or every pack carried the English),
// these three would coincide and every case above would still pass.
const seen = new Set<string>();
for (const lang of ['en', 'zh', 'de']) {
seen.add(rows(mountIn(lang))[0].getAttribute('aria-label') ?? '');
cleanup();
}
expect(seen.size).toBe(3);
});

it('an author `schema.aria.label` still wins ahead of the pack fallback', () => {
// The override is the whole reason the literal was a FALLBACK; localizing it
// must not promote the pack above what the author asked for.
for (const row of rows(mountIn('zh', { aria: { label: 'Deal stages' } }))) {
expect(row).toHaveAccessibleName('Deal stages');
}
});
});

describe('the lost-terminal alt group carries no inert label (objectui#5956)', () => {
it('no element anywhere claims the old hardcoded group name', () => {
const r = mountIn('en');
// It was never announced — it sat on a `generic` element. Removing it costs
// no user anything, and it must not come back untranslated either.
expect(r.container.querySelector('[aria-label="Alternative terminal stages"]')).toBeNull();
expect(r.container.textContent).not.toContain('Alternative terminal stages');
});

it('the alt-group wrapper claims no named role at all', () => {
const r = mountIn('en');
const desktop = rows(r)[0];
// Every element inside the desktop row is either the list itself, a stage
// `listitem`, a presentational wrapper, or a stage's own decoration — no
// `group`/`region`/`navigation` box was introduced to hold a name.
expect(desktop.querySelectorAll('[role="group"],[role="region"],[role="navigation"]')).toHaveLength(0);
});

it('what the group would have said is already on the stages themselves', () => {
// The positive half: the absence above is only safe because each lost stage
// announces its own terminal state, in the session locale, after #5916.
const zhRow = rows(mountIn('zh'))[0];
const zhStages = within(zhRow).getAllByRole('listitem');
expect(zhStages[3]).toHaveAccessibleName('已拒绝,已失败,未到达');
cleanup();
const enRow = rows(mountIn('en'))[0];
const enStages = within(enRow).getAllByRole('listitem');
expect(enStages[3]).toHaveAccessibleName('已拒绝, closed lost, not reached');
});
});
Loading
Loading