From f4cb3fec45202a14b08feec941d089e6e211b0b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 04:35:08 +0000 Subject: [PATCH] fix(plugin-detail,i18n): announce record:path stage state in the accessible name record:path conveyed travelled / upcoming / 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. WCAG 2.2 SC 1.4.1. 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 and mirrored byte-identically into DETAIL_DEFAULT_TRANSLATIONS for the provider-less path. The name is composed into aria-label rather than visually-hidden text because listitem takes its name from the author only: measured on this tree, sr-only text inside a role="listitem" computes to an EMPTY accessible name, so that shape would have satisfied a DOM assertion while delivering nothing to the accessibility tree. The glyphs stay decorative and the #5768 readout contract (role, aria-current, data-stage-* instruments) is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019ZyKZejBWZoCSj1NP35wcp --- ...record-path-stage-state-accessible-name.md | 22 ++ packages/i18n/src/locales/ar.ts | 5 + packages/i18n/src/locales/de.ts | 5 + packages/i18n/src/locales/en.ts | 10 + packages/i18n/src/locales/es.ts | 5 + packages/i18n/src/locales/fr.ts | 5 + packages/i18n/src/locales/ja.ts | 5 + packages/i18n/src/locales/ko.ts | 5 + packages/i18n/src/locales/pt.ts | 5 + packages/i18n/src/locales/ru.ts | 5 + packages/i18n/src/locales/zh.ts | 5 + ...ath.stageStateAccessibleName.i18n.test.tsx | 118 ++++++++ ...ord-path.stageStateAccessibleName.test.tsx | 268 ++++++++++++++++++ .../src/renderers/record-path.tsx | 33 +++ .../plugin-detail/src/useDetailTranslation.ts | 10 + 15 files changed, 506 insertions(+) create mode 100644 .changeset/5916-record-path-stage-state-accessible-name.md create mode 100644 packages/plugin-detail/src/renderers/__tests__/record-path.stageStateAccessibleName.i18n.test.tsx create mode 100644 packages/plugin-detail/src/renderers/__tests__/record-path.stageStateAccessibleName.test.tsx diff --git a/.changeset/5916-record-path-stage-state-accessible-name.md b/.changeset/5916-record-path-stage-state-accessible-name.md new file mode 100644 index 0000000000..7b6bf579e1 --- /dev/null +++ b/.changeset/5916-record-path-stage-state-accessible-name.md @@ -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. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index a1932dd2fe..6a5ab61b94 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -978,6 +978,11 @@ const ar = { cancelApprovalTooltipUnlocked: "إلغاء طلب الموافقة المعلق", cancelApprovalFailed: "فشل إلغاء الموافقة", cancelApprovalUnavailable: "إلغاء الموافقات غير مدعوم من مصدر البيانات هذا", + pathStageCompleted: '{{stage}}، مكتملة', + pathStageCurrent: '{{stage}}، المرحلة الحالية', + pathStageUpcoming: '{{stage}}، قادمة', + pathStageLostCurrent: '{{stage}}، خاسرة، المرحلة الحالية', + pathStageLostUpcoming: '{{stage}}، خاسرة، لم يتم الوصول إليها', linkCopied: "تم نسخ الرابط إلى الحافظة", linkCopyFailed: "فشل نسخ الرابط", cancel: "إلغاء", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index bb38c80442..cf15c1a9b6 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -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", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 8732d7d0da..9fdd43389a 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 47695a1b04..def7e79b33 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -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", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 771e32bf9a..897ab3bef2 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -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", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 58ad470eb9..54327ba121 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -983,6 +983,11 @@ const ja = { cancelApprovalTooltipUnlocked: "承認待ちリクエストを取り消す", cancelApprovalFailed: "承認の取り消しに失敗しました", cancelApprovalUnavailable: "このデータソースでは承認の取り消しはサポートされていません", + pathStageCompleted: '{{stage}}、完了', + pathStageCurrent: '{{stage}}、現在のステージ', + pathStageUpcoming: '{{stage}}、未着手', + pathStageLostCurrent: '{{stage}}、失注、現在のステージ', + pathStageLostUpcoming: '{{stage}}、失注、未到達', linkCopied: "リンクをクリップボードにコピーしました", linkCopyFailed: "リンクのコピーに失敗しました", cancel: "キャンセル", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index fc90ca745d..84132a91f8 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -972,6 +972,11 @@ const ko = { cancelApprovalTooltipUnlocked: "대기 중인 승인 요청 취소", cancelApprovalFailed: "승인 취소 실패", cancelApprovalUnavailable: "이 데이터 소스에서는 승인 취소가 지원되지 않습니다", + pathStageCompleted: '{{stage}}, 완료됨', + pathStageCurrent: '{{stage}}, 현재 단계', + pathStageUpcoming: '{{stage}}, 예정', + pathStageLostCurrent: '{{stage}}, 실패, 현재 단계', + pathStageLostUpcoming: '{{stage}}, 실패, 도달하지 않음', linkCopied: "링크가 클립보드에 복사됨", linkCopyFailed: "링크 복사 실패", cancel: "취소", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 304c506d1b..d34faaf086 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -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", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 20a7249fdb..cf3c8facc3 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -991,6 +991,11 @@ const ru = { cancelApprovalTooltipUnlocked: "Отменить ожидающий запрос на согласование", cancelApprovalFailed: "Не удалось отменить согласование", cancelApprovalUnavailable: "Отмена согласований не поддерживается этим источником данных", + pathStageCompleted: '{{stage}}, завершён', + pathStageCurrent: '{{stage}}, текущий этап', + pathStageUpcoming: '{{stage}}, предстоит', + pathStageLostCurrent: '{{stage}}, проигран, текущий этап', + pathStageLostUpcoming: '{{stage}}, проигран, не достигнут', linkCopied: "Ссылка скопирована в буфер обмена", linkCopyFailed: "Не удалось скопировать ссылку", cancel: "Отмена", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 6afa03c8a5..5d6f2e9062 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -879,6 +879,11 @@ const zh = { cancelApprovalTooltipUnlocked: '撤回当前的待审批请求', cancelApprovalFailed: '撤回审批失败', cancelApprovalUnavailable: '当前数据源不支持撤回审批', + pathStageCompleted: '{{stage}},已完成', + pathStageCurrent: '{{stage}},当前阶段', + pathStageUpcoming: '{{stage}},未开始', + pathStageLostCurrent: '{{stage}},已失败,当前阶段', + pathStageLostUpcoming: '{{stage}},已失败,未到达', linkCopied: '链接已复制到剪贴板', linkCopyFailed: '复制链接失败', cancel: '取消', diff --git a/packages/plugin-detail/src/renderers/__tests__/record-path.stageStateAccessibleName.i18n.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-path.stageStateAccessibleName.i18n.test.tsx new file mode 100644 index 0000000000..5035b6b1fe --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/record-path.stageStateAccessibleName.i18n.test.tsx @@ -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( + + + + + , + ); +} + +/** 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(); + 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); + }); +}); diff --git a/packages/plugin-detail/src/renderers/__tests__/record-path.stageStateAccessibleName.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-path.stageStateAccessibleName.test.tsx new file mode 100644 index 0000000000..507f20ff20 --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/record-path.stageStateAccessibleName.test.tsx @@ -0,0 +1,268 @@ +/** + * 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. + * + * ══════════════════════════════════════════════════════════════════════════ + * A `record:path` stage announces its STATE, not just its label (objectui#5916) + * ══════════════════════════════════════════════════════════════════════════ + * + * The defect: travelled / upcoming / lost reached a sighted user through colour + * (emerald / muted / destructive) and a glyph, and BOTH glyphs are `aria-hidden` + * decoration. `aria-current="step"` marked the current stage and nothing else, so + * a screen-reader user got a list of identically-announced items and could not + * tell 草稿 (behind the record) from 已接受 (ahead of it). A `lost` terminal was + * the sharpest case: 已拒绝 announced exactly like an ordinary unreached stage. + * WCAG 2.2 SC 1.4.1 *Use of Color*. + * + * ── Why `aria-label` and not visually-hidden text ───────────────────────── + * + * The card offered both shapes. The choice is a MEASUREMENT, not a preference: + * `listitem` is not a name-from-contents role, so text placed inside a stage — + * `sr-only` or otherwise — leaves the accessible name EMPTY. Measured on this + * tree with this exact harness before the fix was written: + * + *
completed, 草稿
+ * // → toHaveAccessibleName('') ← the visually-hidden shape + *
+ * // → toHaveAccessibleName('草稿, completed') + * + * So the visually-hidden shape would have satisfied a DOM assertion ("the span + * is there") while delivering nothing to the accessibility tree. + * + * ── Every case here reads the ACCESSIBLE NAME, never the DOM ────────────── + * + * `toHaveAccessibleName` and the `{ name }` option of `getAllByRole` both run + * the accessible-name computation (`dom-accessibility-api`). Nothing below + * asserts a class, a span or an `aria-label` attribute — reading the attribute + * back would only restate the markup, which is precisely the evidence this card + * says is not evidence. + * + * ── Selection is positional, assertion is by name (non-circular) ────────── + * + * Per-stage cases pick the stage out by its INDEX among the row's `listitem`s and + * then read the name off it. Selecting by name would assume the answer: with the + * fix reverted every name is '', so a name-keyed query would report "no such + * stage" rather than "wrong name". + * + * ── Resolution path (ablation validity) ─────────────────────────────────── + * + * The subject is imported RELATIVELY (`../record-path`) and the cross-package + * imports are mapped to each package's own `src` by the root `vitest.config.mts` + * alias table. Nothing here resolves through a `dist/`, so an ablation of + * `record-path.tsx` is visible to this suite WITHOUT a rebuild — the same + * property `record-path.inertReadout.test.tsx` records next door. + * + * ── Direction, predicted before running ─────────────────────────────────── + * + * Deleting the `aria-label` line from `renderStage` turns every case in groups + * B, C and D red (names collapse to ''), and leaves group A — the #5768 readout + * contract: `aria-current`, the decorative glyphs, the labels on screen — GREEN, + * because none of it ever depended on the name. Measured, not assumed; the + * numbers are in the PR body. + * + * ── Provider-less on purpose ────────────────────────────────────────────── + * + * No `I18nProvider` is mounted, so `useDetailTranslation` serves + * `DETAIL_DEFAULT_TRANSLATIONS` and the expected strings are that map's English. + * The pack-backed path is a SEPARATE file + * (`record-path.stageStateAccessibleName.i18n.test.tsx`) because `createI18n` + * registers its instance as react-i18next's module-global default and that + * registration survives `cleanup()` — a provider-less render sharing a file with + * a provider-mounted one would silently resolve against whichever locale ran + * first. Same split, same reason, as plugin-kanban's overlay-title pair. + */ + +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 { RecordContextProvider } from '@object-ui/react'; +import { RecordPathRenderer } from '../record-path'; + +/** The card's own fixture: HotCRM `crm_quote`, six stages, none terminal. */ +const QUOTE_STAGES = [ + { value: 'draft', label: '草稿' }, + { value: 'in_review', label: '审核中' }, + { value: 'submitted', label: '已提交' }, + { value: 'accepted', label: '已接受' }, + { value: 'rejected', label: '已拒绝' }, + { value: 'expired', label: '已过期' }, +]; + +/** A declared `lost` terminal, so the separated alt-group renders too. */ +const WITH_LOST_STAGES = [ + { value: 'draft', label: '草稿' }, + { value: 'in_review', label: '审核中' }, + { value: 'submitted', label: '已提交' }, + { value: 'declined', label: '已拒绝', terminal: 'lost' as const }, +]; + +function mount(status: string, stages: unknown[] = QUOTE_STAGES): RenderResult { + return render( + + + , + ); +} + +/** + * Desktop and mobile are both in the DOM at once (a CSS breakpoint separates + * them and this environment applies none), so every invariant is asserted on + * BOTH — a regression reaching only one viewport would otherwise pass. + */ +const rows = (r: RenderResult): HTMLElement[] => + Array.from(r.container.querySelectorAll('[role="list"]')) as HTMLElement[]; + +/** The row's stages in DOM order. Positional, so per-stage cases stay non-circular. */ +const stagesOf = (row: HTMLElement): HTMLElement[] => within(row).getAllByRole('listitem'); + +/** Stages of `row` whose COMPUTED ACCESSIBLE NAME matches — not a DOM query. */ +const announcedAs = (row: HTMLElement, name: string | RegExp): HTMLElement[] => + within(row).queryAllByRole('listitem', { name }); + +afterEach(() => cleanup()); + +describe('#5916 group A — the #5768 readout contract still holds', () => { + it('both rows render every stage, and the glyphs stay decorative', () => { + const r = mount('submitted'); + expect(rows(r)).toHaveLength(2); + + for (const row of rows(r)) { + expect(stagesOf(row)).toHaveLength(QUOTE_STAGES.length); + // The ✓ is decoration and must REMAIN decoration — the fix adds a text + // equivalent, it does not un-hide the glyph (the card's triage ruling). + for (const span of Array.from(row.querySelectorAll('span'))) { + const text = span.textContent ?? ''; + if (text === '✓' || text === '✗') expect(span).toHaveAttribute('aria-hidden', 'true'); + } + } + }); + + it('`aria-current="step"` still marks exactly the current stage', () => { + const r = mount('submitted'); + for (const row of rows(r)) { + const marked = stagesOf(row).filter((el) => el.getAttribute('aria-current') === 'step'); + expect(marked).toHaveLength(1); + expect(marked[0].textContent).toContain('已提交'); + } + }); +}); + +describe('#5916 group B — travelled / current / upcoming reach the accessible name', () => { + it('no stage is left nameless — non-vacuity for every case below', () => { + // Before the fix every one of these was ''. If the renderer ever stops + // naming stages this fails first, naming the file as vacuous rather than + // letting a later case compare '' against ''. + const r = mount('submitted'); + for (const row of rows(r)) { + for (const stage of stagesOf(row)) expect(stage).toHaveAccessibleName(); + } + }); + + it('a travelled stage announces that it is completed, in both rows', () => { + const r = mount('submitted'); + for (const row of rows(r)) { + expect(stagesOf(row)[0]).toHaveAccessibleName('草稿, completed'); + expect(stagesOf(row)[1]).toHaveAccessibleName('审核中, completed'); + } + }); + + it('the current stage announces that it is current, in both rows', () => { + const r = mount('submitted'); + for (const row of rows(r)) { + expect(stagesOf(row)[2]).toHaveAccessibleName('已提交, current stage'); + } + }); + + it('an upcoming stage announces that it is upcoming, in both rows', () => { + const r = mount('submitted'); + for (const row of rows(r)) { + expect(stagesOf(row)[3]).toHaveAccessibleName('已接受, upcoming'); + expect(stagesOf(row)[5]).toHaveAccessibleName('已过期, upcoming'); + } + }); + + it('THE defect, stated as a partition: six stages, three distinct announcements', () => { + // The card's complaint was "six identically-announced items". Each stage now + // falls into exactly one bucket and the buckets sum to every stage, so no + // stage is announced ambiguously and none was missed. + const r = mount('submitted'); + for (const row of rows(r)) { + expect(announcedAs(row, /, completed$/)).toHaveLength(2); + expect(announcedAs(row, /, current stage$/)).toHaveLength(1); + expect(announcedAs(row, /, upcoming$/)).toHaveLength(3); + expect(stagesOf(row)).toHaveLength(2 + 1 + 3); + } + }); + + it('the state follows the record rather than the position', () => { + // Non-vacuity of a second kind: the names are computed from the record's + // value, not baked into the markup. On stage 1 nothing is completed yet. + const r = mount('draft'); + for (const row of rows(r)) { + expect(stagesOf(row)[0]).toHaveAccessibleName('草稿, current stage'); + expect(stagesOf(row)[1]).toHaveAccessibleName('审核中, upcoming'); + expect(announcedAs(row, /, completed$/)).toHaveLength(0); + } + }); +}); + +describe('#5916 group C — the lost terminal, which used to announce like any other stage', () => { + it('an unreached lost terminal says so, and is NOT announced as an ordinary upcoming stage', () => { + const r = mount('submitted', WITH_LOST_STAGES); + for (const row of rows(r)) { + expect(stagesOf(row)[3]).toHaveAccessibleName('已拒绝, closed lost, not reached'); + // The card verbatim: "已拒绝 announces exactly like an ordinary upcoming + // stage". It must not. In this fixture the lost terminal is the ONLY + // unreached stage, so if it still announced as a plain upcoming one this + // query would find it. + expect(announcedAs(row, /, upcoming$/)).toHaveLength(0); + expect(announcedAs(row, /closed lost/)).toHaveLength(1); + } + }); + + it('a lost terminal the record SITS on announces both facts', () => { + const r = mount('declined', WITH_LOST_STAGES); + for (const row of rows(r)) { + expect(stagesOf(row)[3]).toHaveAccessibleName('已拒绝, closed lost, current stage'); + // …and it is still the current step programmatically. + expect(stagesOf(row)[3]).toHaveAttribute('aria-current', 'step'); + } + }); + + it('a reached lost terminal and an unreached one do not announce alike', () => { + const reached = mount('declined', WITH_LOST_STAGES); + const reachedName = /closed lost, current stage$/; + for (const row of rows(reached)) expect(announcedAs(row, reachedName)).toHaveLength(1); + cleanup(); + + const unreached = mount('submitted', WITH_LOST_STAGES); + for (const row of rows(unreached)) expect(announcedAs(row, reachedName)).toHaveLength(0); + }); + + it('the ✗ stays aria-hidden — the fix adds a text equivalent, it does not expose the glyph', () => { + const r = mount('submitted', WITH_LOST_STAGES); + const crosses = Array.from(r.container.querySelectorAll('span')).filter( + (s) => (s.textContent ?? '') === '✗', + ); + expect(crosses.length).toBeGreaterThan(0); + for (const c of crosses) expect(c).toHaveAttribute('aria-hidden', 'true'); + }); +}); + +describe('#5916 group D — the announced name never drifts from what is on screen', () => { + it("every stage's accessible name contains its own visible label", () => { + // An author-supplied name REPLACES the contents in the name computation, so + // this is the invariant that keeps the two from forking: both are rendered + // from the same already-picklist-localized `stage.label`. + const r = mount('submitted'); + for (const row of rows(r)) { + stagesOf(row).forEach((el, i) => { + expect(announcedAs(row, new RegExp(`^${QUOTE_STAGES[i].label},`))).toContain(el); + }); + } + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-path.tsx b/packages/plugin-detail/src/renderers/record-path.tsx index bea8db9a1f..b6ad7dc027 100644 --- a/packages/plugin-detail/src/renderers/record-path.tsx +++ b/packages/plugin-detail/src/renderers/record-path.tsx @@ -46,12 +46,17 @@ * • `data-stage-rail` — marks the decorative indicator as an element * SEPARATE from the label. A rail has one; a * pill, which is its own label's surface, cannot. + * + * Those are TEST instruments, not accessibility: a `data-*` attribute is not in + * the accessibility tree. The same state reaches a screen reader through each + * stage's composed `aria-label` (objectui#5916) — see `stageAriaLabel` below. */ import React from 'react'; import { useRecordContext, useSafeFieldLabel } from '@object-ui/react'; import type { RecordPathComponentProps } from '@object-ui/types'; import { cn } from '@object-ui/components'; +import { useDetailTranslation } from '../useDetailTranslation'; const splitDesigner = (props: Record) => { const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {}; @@ -73,6 +78,7 @@ export const RecordPathRenderer: React.FC = ({ }) => { const ctx = useRecordContext(); const { translateOptions } = useSafeFieldLabel(); + const { t } = useDetailTranslation(); const { designer } = splitDesigner(props); const rawStages: Array<{ value: any; label: string; terminal?: 'won' | 'lost' }> = Array.isArray(schema.stages) @@ -154,6 +160,32 @@ export const RecordPathRenderer: React.FC = ({ state === 'upcoming' && 'font-normal text-muted-foreground', ); + // ── The stage's STATE, in its ACCESSIBLE NAME (objectui#5916) ────────────── + // + // Travelled / upcoming / lost used to reach a screen reader through colour and + // a glyph, and the glyph is `aria-hidden` decoration, so a rejected stage + // announced exactly like an unreached one — WCAG 2.2 SC 1.4.1. `aria-current` + // marked the current stage and nothing else. + // + // Shape: an `aria-label` on the listitem, NOT visually-hidden text inside it. + // That is a measurement, not a preference — `listitem` takes its name from the + // AUTHOR only (it is not a name-from-contents role), so `sr-only` text placed + // in the item computes to an EMPTY accessible name and would satisfy a DOM + // assertion while delivering nothing to the accessibility tree. The composed + // label re-states `stage.label` — the same already-picklist-localized variable + // the visible text renders — so the name can never drift from what is on + // screen, and the ✓/✗ stay decorative. + const stageAriaLabel = (label: string, state: StageState, terminal?: 'won' | 'lost'): string => { + if (terminal === 'lost') { + return state === 'current' + ? t('detail.pathStageLostCurrent', { stage: label }) + : t('detail.pathStageLostUpcoming', { stage: label }); + } + if (state === 'current') return t('detail.pathStageCurrent', { stage: label }); + if (state === 'completed') return t('detail.pathStageCompleted', { stage: label }); + return t('detail.pathStageUpcoming', { stage: label }); + }; + const renderStage = (o: { key: string; stage: { label: string }; @@ -168,6 +200,7 @@ export const RecordPathRenderer: React.FC = ({ data-stage-state={o.state} data-stage-terminal={o.terminal} aria-current={o.state === 'current' ? 'step' : undefined} + aria-label={stageAriaLabel(o.stage.label, o.state, o.terminal)} className={cn('flex flex-col gap-1.5', o.className)} >