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
22 changes: 22 additions & 0 deletions .changeset/5916-record-path-stage-state-accessible-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/i18n': patch
---

`record:path` now announces each stage's state, not just its label (WCAG 2.2 SC 1.4.1)

The lifecycle path distinguished travelled, upcoming and lost-terminal stages with
colour plus a `✓`/`✗` glyph, and both glyphs are `aria-hidden` decoration.
`aria-current="step"` marked the current stage and nothing else, so a screen-reader
user heard a run of identically-announced items — and a rejected stage announced
exactly like an ordinary stage the record had not reached yet.

Each stage now carries an accessible name composing its (already picklist-localized)
label with its state, from five new `detail.pathStage*` keys translated in all ten
locale packs. The glyphs stay decorative and the readout's `role="listitem"` /
`aria-current` semantics are unchanged.

The name is composed into `aria-label` rather than visually-hidden text because
`listitem` takes its name from the author only: text placed inside a stage computes
to an empty accessible name, so the visually-hidden shape would have looked right in
the markup and delivered nothing to the accessibility tree.
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,11 @@ const ar = {
cancelApprovalTooltipUnlocked: "إلغاء طلب الموافقة المعلق",
cancelApprovalFailed: "فشل إلغاء الموافقة",
cancelApprovalUnavailable: "إلغاء الموافقات غير مدعوم من مصدر البيانات هذا",
pathStageCompleted: '{{stage}}، مكتملة',
pathStageCurrent: '{{stage}}، المرحلة الحالية',
pathStageUpcoming: '{{stage}}، قادمة',
pathStageLostCurrent: '{{stage}}، خاسرة، المرحلة الحالية',
pathStageLostUpcoming: '{{stage}}، خاسرة، لم يتم الوصول إليها',
linkCopied: "تم نسخ الرابط إلى الحافظة",
linkCopyFailed: "فشل نسخ الرابط",
cancel: "إلغاء",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,11 @@ 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",
pathStageCompleted: '{{stage}}, abgeschlossen',
pathStageCurrent: '{{stage}}, aktuelle Phase',
pathStageUpcoming: '{{stage}}, ausstehend',
pathStageLostCurrent: '{{stage}}, verloren, aktuelle Phase',
pathStageLostUpcoming: '{{stage}}, verloren, nicht erreicht',
linkCopied: "Link in die Zwischenablage kopiert",
linkCopyFailed: "Link konnte nicht kopiert werden",
cancel: "Abbrechen",
Expand Down
10 changes: 10 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,16 @@ const en = {
cancelApprovalTooltipUnlocked: 'Recall the pending approval request',
cancelApprovalFailed: 'Failed to recall approval',
cancelApprovalUnavailable: 'Recalling approvals is not supported on this data source',
// objectui#5916 — `record:path` stage state in the ACCESSIBLE NAME.
// Each stage is a `role="listitem"`, which is name-from-AUTHOR only, so
// 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.
pathStageCompleted: '{{stage}}, completed',
pathStageCurrent: '{{stage}}, current stage',
pathStageUpcoming: '{{stage}}, upcoming',
pathStageLostCurrent: '{{stage}}, closed lost, current stage',
pathStageLostUpcoming: '{{stage}}, closed lost, not reached',
linkCopied: 'Link copied to clipboard',
linkCopyFailed: 'Failed to copy link',
cancel: 'Cancel',
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,11 @@ 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",
pathStageCompleted: '{{stage}}, completada',
pathStageCurrent: '{{stage}}, etapa actual',
pathStageUpcoming: '{{stage}}, pendiente',
pathStageLostCurrent: '{{stage}}, perdida, etapa actual',
pathStageLostUpcoming: '{{stage}}, perdida, no alcanzada',
linkCopied: "Enlace copiado al portapapeles",
linkCopyFailed: "No se pudo copiar el enlace",
cancel: "Cancelar",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,11 @@ 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",
pathStageCompleted: '{{stage}}, terminée',
pathStageCurrent: '{{stage}}, étape actuelle',
pathStageUpcoming: '{{stage}}, à venir',
pathStageLostCurrent: '{{stage}}, perdue, étape actuelle',
pathStageLostUpcoming: '{{stage}}, perdue, non atteinte',
linkCopied: "Lien copié dans le presse-papiers",
linkCopyFailed: "Impossible de copier le lien",
cancel: "Annuler",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,11 @@ const ja = {
cancelApprovalTooltipUnlocked: "承認待ちリクエストを取り消す",
cancelApprovalFailed: "承認の取り消しに失敗しました",
cancelApprovalUnavailable: "このデータソースでは承認の取り消しはサポートされていません",
pathStageCompleted: '{{stage}}、完了',
pathStageCurrent: '{{stage}}、現在のステージ',
pathStageUpcoming: '{{stage}}、未着手',
pathStageLostCurrent: '{{stage}}、失注、現在のステージ',
pathStageLostUpcoming: '{{stage}}、失注、未到達',
linkCopied: "リンクをクリップボードにコピーしました",
linkCopyFailed: "リンクのコピーに失敗しました",
cancel: "キャンセル",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,11 @@ const ko = {
cancelApprovalTooltipUnlocked: "대기 중인 승인 요청 취소",
cancelApprovalFailed: "승인 취소 실패",
cancelApprovalUnavailable: "이 데이터 소스에서는 승인 취소가 지원되지 않습니다",
pathStageCompleted: '{{stage}}, 완료됨',
pathStageCurrent: '{{stage}}, 현재 단계',
pathStageUpcoming: '{{stage}}, 예정',
pathStageLostCurrent: '{{stage}}, 실패, 현재 단계',
pathStageLostUpcoming: '{{stage}}, 실패, 도달하지 않음',
linkCopied: "링크가 클립보드에 복사됨",
linkCopyFailed: "링크 복사 실패",
cancel: "취소",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,11 @@ 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",
pathStageCompleted: '{{stage}}, concluída',
pathStageCurrent: '{{stage}}, etapa atual',
pathStageUpcoming: '{{stage}}, pendente',
pathStageLostCurrent: '{{stage}}, perdida, etapa atual',
pathStageLostUpcoming: '{{stage}}, perdida, não alcançada',
linkCopied: "Link copiado para a área de transferência",
linkCopyFailed: "Falha ao copiar o link",
cancel: "Cancelar",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,11 @@ const ru = {
cancelApprovalTooltipUnlocked: "Отменить ожидающий запрос на согласование",
cancelApprovalFailed: "Не удалось отменить согласование",
cancelApprovalUnavailable: "Отмена согласований не поддерживается этим источником данных",
pathStageCompleted: '{{stage}}, завершён',
pathStageCurrent: '{{stage}}, текущий этап',
pathStageUpcoming: '{{stage}}, предстоит',
pathStageLostCurrent: '{{stage}}, проигран, текущий этап',
pathStageLostUpcoming: '{{stage}}, проигран, не достигнут',
linkCopied: "Ссылка скопирована в буфер обмена",
linkCopyFailed: "Не удалось скопировать ссылку",
cancel: "Отмена",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,11 @@ const zh = {
cancelApprovalTooltipUnlocked: '撤回当前的待审批请求',
cancelApprovalFailed: '撤回审批失败',
cancelApprovalUnavailable: '当前数据源不支持撤回审批',
pathStageCompleted: '{{stage}},已完成',
pathStageCurrent: '{{stage}},当前阶段',
pathStageUpcoming: '{{stage}},未开始',
pathStageLostCurrent: '{{stage}},已失败,当前阶段',
pathStageLostUpcoming: '{{stage}},已失败,未到达',
linkCopied: '链接已复制到剪贴板',
linkCopyFailed: '复制链接失败',
cancel: '取消',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* 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.
*
* ══════════════════════════════════════════════════════════════════════════
* The stage-state announcement comes from the LOCALE PACKS (objectui#5916)
* ══════════════════════════════════════════════════════════════════════════
*
* This is the half of the card that made it its own card rather than a one-line
* addition: the state a stage announces is user-facing copy, so it has to be a
* translated key — NOT the hardcoded English literal the component still uses
* for its two container labels (`'Record path'`, `'Alternative terminal
* stages'`, both untouched here and filed separately).
*
* `record-path.stageStateAccessibleName.test.tsx` covers the SAME assertions on
* the provider-less path, where `useDetailTranslation` serves
* `DETAIL_DEFAULT_TRANSLATIONS`. The two paths are split across two files, not
* two describes: `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. plugin-kanban's overlay-title pair splits for
* exactly this reason and records it.
*
* Both paths must agree byte for byte on `en` — a map row that disagrees with
* the pack labels one control two ways depending on whether a provider is
* mounted (objectui#4401). `defaults-maps-mirror-en-pack.test.tsx` owns that
* invariant globally; the `en` case below is this component's local instance of
* it, and the two would fail together.
*
* ── What is asserted, and what is deliberately not ────────────────────────
*
* The STATE half of each name. The stage LABEL half is picklist data localized
* upstream by `translateOptions`, and with no object metadata registered it
* falls back to the schema's own labels — so the fixture's labels are asserted
* as-is and this file makes no claim about picklist translation, which is not
* its subject.
*/

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, status: string): RenderResult {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<RecordContextProvider objectName="crm_quote" recordId="q1" data={{ id: 'q1', status }}>
<RecordPathRenderer schema={{ statusField: 'status', stages: STAGES } as never} />
</RecordContextProvider>
</I18nProvider>,
);
}

/** The desktop row; both rows carry identical names, and the split is covered next door. */
const firstRow = (r: RenderResult): HTMLElement =>
(Array.from(r.container.querySelectorAll('[role="list"]')) as HTMLElement[])[0];

const stagesOf = (row: HTMLElement): HTMLElement[] => within(row).getAllByRole('listitem');

afterEach(() => cleanup());

describe('record:path stage state speaks the session locale (objectui#5916)', () => {
it('en announces the same bytes the provider-less defaults map does', () => {
// The #4401 invariant, locally: pack and map must not fork.
const row = firstRow(mountIn('en', 'submitted'));
expect(stagesOf(row)[0]).toHaveAccessibleName('草稿, completed');
expect(stagesOf(row)[2]).toHaveAccessibleName('已提交, current stage');
expect(stagesOf(row)[3]).toHaveAccessibleName('已拒绝, closed lost, not reached');
});

it('zh announces the state in Chinese, with no English left in the name', () => {
const row = firstRow(mountIn('zh', 'submitted'));
expect(stagesOf(row)[0]).toHaveAccessibleName('草稿,已完成');
expect(stagesOf(row)[2]).toHaveAccessibleName('已提交,当前阶段');
expect(stagesOf(row)[3]).toHaveAccessibleName('已拒绝,已失败,未到达');
// The point of the card: a zh session must not hear English state words.
for (const stage of stagesOf(row)) {
expect(stage).not.toHaveAccessibleName(/completed|current stage|upcoming|closed lost/);
}
});

it('de announces the state in German', () => {
const row = firstRow(mountIn('de', 'submitted'));
expect(stagesOf(row)[0]).toHaveAccessibleName('草稿, abgeschlossen');
expect(stagesOf(row)[2]).toHaveAccessibleName('已提交, aktuelle Phase');
expect(stagesOf(row)[3]).toHaveAccessibleName('已拒绝, verloren, nicht erreicht');
});

it('the record on a lost terminal announces it in the session locale too', () => {
const row = firstRow(mountIn('zh', 'declined'));
expect(stagesOf(row)[3]).toHaveAccessibleName('已拒绝,已失败,当前阶段');
});

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']) {
const row = firstRow(mountIn(lang, 'submitted'));
seen.add(stagesOf(row)[2].getAttribute('aria-label') ?? '');
cleanup();
}
expect(seen.size).toBe(3);
});
});
Loading
Loading