From 84d8b7830a9429ced1e8277b52d931e9adac9037 Mon Sep 17 00:00:00 2001 From: os-sales Date: Fri, 18 Sep 2026 18:23:10 +0000 Subject: [PATCH 1/3] fix(plugin-detail): honour the `aria` bag the protocol declares on record blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec declares `aria` on `record:details`, `record:highlights`, `record:related_list`, `record:activity`, `record:chatter` and `record:path`, so an authored `aria: { ariaLabel }` parses green at publish. None of the six renderers put it anywhere a user could reach: five read nothing, and `record:path` read only `aria.label` — the one spelling the shared ARIA shape refuses — discarding the spec-valid `ariaLabel` it was credited with reading. Closed as an implementation gap, per spec > implementation > docs. One shared read point (`renderers/recordComponentAria.ts`) now serves the whole family, carrying objectui#4663's read order and each block's existing role/name as its defaults, so a document with no `aria` keeps the attributes it has today. `RecordComponentAriaProps.ariaLabel` also stops narrowing the contract's `string | I18nLabel` to `string` — a fourth site of objectui#9092's Group A. Part of objectui#9556 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn --- .../9556-record-component-aria-honoured.md | 102 +++++ .../recordComponentAria-9556.test.tsx | 354 ++++++++++++++++++ .../src/renderers/record-activity.tsx | 15 +- .../src/renderers/record-chatter.tsx | 15 +- .../src/renderers/record-details.tsx | 15 +- .../src/renderers/record-highlights.tsx | 15 +- .../src/renderers/record-path.tsx | 29 +- .../src/renderers/record-quick-actions.tsx | 74 ++-- .../src/renderers/record-related-list.tsx | 15 +- .../src/renderers/recordComponentAria.ts | 133 +++++++ packages/types/src/record-components.ts | 59 ++- 11 files changed, 782 insertions(+), 44 deletions(-) create mode 100644 .changeset/9556-record-component-aria-honoured.md create mode 100644 packages/plugin-detail/src/renderers/__tests__/recordComponentAria-9556.test.tsx create mode 100644 packages/plugin-detail/src/renderers/recordComponentAria.ts diff --git a/.changeset/9556-record-component-aria-honoured.md b/.changeset/9556-record-component-aria-honoured.md new file mode 100644 index 0000000000..c476da26ea --- /dev/null +++ b/.changeset/9556-record-component-aria-honoured.md @@ -0,0 +1,102 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-detail': minor +--- + +`record:*` blocks honour the `aria` bag the protocol declares on them, and +`RecordComponentAriaProps.ariaLabel` states the contract's inline locale +vocabulary instead of narrowing it to `string` (objectui#9556). + +## What an author could write, and what happened to it + +`@objectstack/spec`'s props schema for `record:details`, `record:highlights`, +`record:related_list`, `record:activity`, `record:chatter` and `record:path` +each declares an `aria` bag, so a document carrying +`aria: { ariaLabel: 'Account overview' }` on any of them **parses green at +publish**. None of those six renderers put it anywhere a user could reach: five +read the key not at all, and `record:path` read `(schema.aria as any)?.label` — +the one spelling the shared ARIA shape REFUSES — so it discarded the spec-valid +`ariaLabel` and honoured only a spelling no author can write without the +contract rejecting the document. Zero of the six declared slots was live. That +is the dead-read-point pair objectui#4663 repaired on `record:quick_actions`, +still open on the rest of the family, and it lands on screen-reader users: the +author wrote an accessible name, got a success receipt, and nobody heard it. + +Under the maintainer's standing principle — spec declaration over +implementation over docs — an accepted-then-dropped key is an implementation +gap, so the six renderers gain the read. ⛔ Nothing is narrowed on the consumer +side, and ⛔ no declaration is retired here. + +## The two declaration changes + +- **`@object-ui/types` — `RecordComponentAriaProps.ariaLabel` is now + `string | I18nLabel`.** `AriaPropsSchema.ariaLabel` is + `z.union([z.string(), InlineLocaleMapSchema])`, so the bare `string` was a + narrowing: a map-valued name like `{ en: 'Deal stages', 'zh-CN': '阶段' }` + parsed at publish and `tsc` refused it here. This is a fourth site of + objectui#9092's Group A, missed there because it is reached through a shared + interface rather than restated inline. **For a `.d.ts` consumer:** writing + `aria.ariaLabel` gains the map arm and no existing write breaks; a consumer + that *reads* the member into a `string` position now sees + `string | I18nLabel | undefined` and is named by the compiler — which is the + audit the widening exists to produce, and is why this is `minor` rather than + `patch`. Every in-repo reader of the six interfaces lives in + `@object-ui/plugin-detail`, and both packages type-check clean. +- **`@object-ui/plugin-detail` — no new published symbol.** The shared read + point (`renderers/recordComponentAria.ts`) is internal; it is not added to the + package barrel, so `dist/index.d.ts` gains nothing. What changes is + behaviour. `record:quick_actions`' own props interface keeps its `aria` + member and is retyped from an inline `{ ariaLabel?, label? }` to the family's + bag, which widens `ariaLabel` there the same way. + +## Behaviour, stated exactly + +A block that is authored with no `aria` keeps **the same attributes with the +same values** it has today: the five containers stay bare `div`s and gain +nothing at all, `record:path`'s rails keep `role="list"` and their localized +pack name, and `record:quick_actions` keeps `role="toolbar"` and its built-in +"Quick actions". Every current default is passed to the shared read point as +that block's `defaultRole` / `defaultLabel` rather than removed. (Stated as the +attribute set and not as the bytes: on the two blocks that already emitted ARIA, +the same pair now arrives through a spread, so their serialization ORDER can +differ while the accessibility tree does not.) + +When an author *does* declare the bag, the container gains `aria-label`, +`aria-describedby` and a role — **a role, and not only the attribute, because +`aria-label` on a bare `div` reaches nobody**: a `div` is `generic` and browsers +expose no accessible name on it. This family has already paid for that once, in +`record:path`'s inert `aria-label="Alternative terminal stages"`. So the five +containers fall back to `role="region"` when named, matching +`@object-ui/plugin-list`'s live read point +(`role={schema.aria?.role ?? 'region'}`), and an authored `aria.role` overrides +every default. The new pin asserts through `getByRole(role, { name })` rather +than through the attribute, so a fix a screen reader cannot hear would fail it. + +⚠️ **On `record:activity` and `record:chatter` the authored name does not +replace the panel's own.** Those blocks mount a panel that already renders a +named `
`, so an authored name lands on the block's outer container and +the panel keeps its inner one — two nested named regions. That is measured and +pinned as the shipped behaviour, ⛔ not as the right answer; objectui#9556 asks +whether the authored name should replace the panel's, and that would have to be +plumbed into `RecordActivityTimeline` / `RecordChatterPanel`. + +## What is deliberately NOT done + +- **The legacy `aria.label` alias is neither introduced nor retired.** It stays + a fold behind the canonical spelling, exactly where objectui#4663 and + `normalizeListViewSchema`'s `ARIA_KEY_ALIASES` already put it, and it remains + declared on no authoring face. The contract refuses it, so it cannot mask a + new authoring mistake; whether it should be removed outright is left open on + objectui#9556 rather than settled in passing. +- **No `aria` declaration is added for `record:quick_actions`.** The protocol + declares none on `RecordQuickActionsProps` and refuses the bag there with a + message naming objectui's renderer as the thing that moves first; mirroring it + onto a published `@object-ui/types` face would declare a key the contract + rejects. + +⛔ No count, list or population in the text above is written down as a fact to be +trusted: which blocks the contract declares `aria` on, which spellings the shared +shape accepts, and whether `record:quick_actions` still refuses the bag are all +re-derived from the installed `@objectstack/spec` artifact on every run by +`renderers/__tests__/recordComponentAria-9556.test.tsx`, which goes red the day +the protocol moves either boundary. diff --git a/packages/plugin-detail/src/renderers/__tests__/recordComponentAria-9556.test.tsx b/packages/plugin-detail/src/renderers/__tests__/recordComponentAria-9556.test.tsx new file mode 100644 index 0000000000..d787f1a880 --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/recordComponentAria-9556.test.tsx @@ -0,0 +1,354 @@ +/** + * 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:*` — the `aria` bag is declared by the protocol and honoured here + * (objectui#9556) + * ══════════════════════════════════════════════════════════════════════════ + * + * objectui#9556 measured the bag as declared on six interfaces and read by one + * renderer. Re-measured on this branch's base, the shape is SHARPER than that + * and points the other way on both halves — which is why this file re-derives + * every population instead of restating the card's (AGENTS.md #9): + * + * - the PROTOCOL declares `aria` on all six blocks, so the declarations in + * `@object-ui/types` are faithful and a retirement would have to move + * `@objectstack/spec` first. Under the maintainer's standing principle + * (spec > implementation > docs), an accepted-then-dropped key is an + * IMPLEMENTATION GAP, closed by making the renderer honour it; + * - `record:path` was NOT the healthy row the card's table recorded. It read + * `(schema.aria as any)?.label` — the one spelling the shared ARIA shape + * REFUSES — and therefore discarded the spec-valid `aria.ariaLabel` it was + * credited with reading. Zero of the six spec-declared slots was honoured; + * - `record:quick_actions` was NOT missing a props interface. It declares one + * (`RecordQuickActionsRendererProps`) in its own module, `aria` included. + * What is true is narrower and sharper: the PROTOCOL declares no `aria` on + * `RecordQuickActionsProps`, so that block's read is unreachable for any + * contract-valid document. + * + * ## What each half of this file can and cannot prove + * + * - The CENSUS cases read the INSTALLED `@objectstack/spec` artifact. They are + * the PREMISE of the routing decision, never its evidence — they were green + * before this change and are green after. They earn their place by going RED + * the day the protocol moves either boundary: declaring `aria` on + * `record:quick_actions` (which its own refusal message says it will do once + * objectui's renderer and the contract agree), or retiring it from one of + * the six. + * - The RENDER cases are the only half that discriminates the defect from the + * fix. A source grep cannot: `SchemaRenderer` hands a node's leftover keys + * to the component as React props, so a renderer can consume a key it never + * names (AGENTS.md). These mount the real renderers and read the + * accessibility tree. + * + * ## Why the assertions are accessible NAMES, not attributes + * + * `aria-label` on a bare `div` reaches nobody — a `div` is `generic`, and + * browsers expose no accessible name on a generic element. This block family has + * already paid for that once: `record:path`'s `aria-label="Alternative terminal + * stages"` sat on a `div` and was inert rather than untranslated + * (`record-path.containerLabel.test.tsx`). An assertion on the ATTRIBUTE would + * pass for a fix that no screen-reader user can hear, so every case below goes + * through `getByRole(role, { name })` or `toHaveAccessibleName`. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import '@testing-library/jest-dom'; +import { render, screen, cleanup, type RenderResult } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { RecordContextProvider } from '@object-ui/react'; +import * as specUi from '@objectstack/spec/ui'; +import { RecordDetailsRenderer } from '../record-details'; +import { RecordHighlightsRenderer } from '../record-highlights'; +import { RecordActivityRenderer } from '../record-activity'; +import { RecordChatterRenderer } from '../record-chatter'; +import { RecordRelatedListRenderer } from '../record-related-list'; +import { RecordPathRenderer } from '../record-path'; +import { RecordQuickActionsRenderer } from '../record-quick-actions'; + +/** + * ONE fetch double, at module scope, never torn down (objectui#6640 / + * objectui#7439). + * + * `record:details` probes `POST /api/v1/security/explain` for the row-level + * verdict and `record:related_list` fetches its rows; happy-dom resolves both + * relative URLs to a REAL socket, which the repo's network-escape guard fails + * the file for. Neither answer is what any case here asserts — every assertion + * is about the container's ARIA — so an empty, always-allowed double is the + * whole requirement. It is installed once and left up, rather than restored in + * an `afterEach`, because both blocks can issue a read after the test body + * returns and a restored `fetch` would let that one escape. + */ +vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ allowed: true, value: [], data: [], records: [] }), + text: async () => '{"allowed":true}', +})) as never); + +afterEach(() => cleanup()); + +/** + * The renderer's OWN container — the element every case below is about. + * + * ⛔ Not `screen.getByRole('region')`: `record:activity` and `record:chatter` + * mount a panel that already renders its own named `
` (a `region` in + * its own right), so a document-wide role query answers about the PANEL and + * cannot see whether the block's container gained anything. Measured, not + * assumed — that inner section is what an earlier draft of the absence cases + * below was actually reading. + */ +const rootOf = (r: RenderResult): HTMLElement => r.container.firstElementChild as HTMLElement; + +// --------------------------------------------------------------------------- +// Census — read off the installed contract, never written down here +// --------------------------------------------------------------------------- + +/** Protocol props schemas for the blocks this package renders a container for. */ +const SPEC_BLOCK_SCHEMAS = { + 'record:details': 'RecordDetailsProps', + 'record:highlights': 'RecordHighlightsProps', + 'record:related_list': 'RecordRelatedListProps', + 'record:activity': 'RecordActivityProps', + 'record:chatter': 'RecordChatterProps', + 'record:path': 'RecordPathProps', + 'record:quick_actions': 'RecordQuickActionsProps', +} as const; + +/** Does the installed contract declare an `aria` member on this block? */ +function declaresAria(exportName: string): boolean { + const schema = (specUi as Record)[exportName]; + if (!schema) throw new Error(`@objectstack/spec/ui exports no ${exportName}`); + const shape = typeof schema._def?.shape === 'function' ? schema._def.shape() : schema.shape; + return Object.prototype.hasOwnProperty.call(shape ?? {}, 'aria'); +} + +describe('census: which record blocks the CONTRACT declares `aria` on (objectui#9556)', () => { + it('declares it on every block except `record:quick_actions`', () => { + const declaring = Object.entries(SPEC_BLOCK_SCHEMAS) + .filter(([, exportName]) => declaresAria(exportName)) + .map(([block]) => block); + + // Stated as the two SETS the routing decision turns on, derived above. + expect(declaring).not.toContain('record:quick_actions'); + expect(declaring.length).toBe(Object.keys(SPEC_BLOCK_SCHEMAS).length - 1); + }); + + it('the census instrument fires — a key every one of them declares is found', () => { + // Non-vacuity: `declaresAria` reading a mis-shaped schema would answer + // `false` everywhere and the case above would pass for the wrong reason. + const hasOwnKey = (exportName: string, key: string) => { + const schema = (specUi as Record)[exportName]; + const shape = typeof schema._def?.shape === 'function' ? schema._def.shape() : schema.shape; + return Object.prototype.hasOwnProperty.call(shape ?? {}, key); + }; + expect(hasOwnKey('RecordPathProps', 'statusField')).toBe(true); + expect(hasOwnKey('RecordQuickActionsProps', 'actionNames')).toBe(true); + // …and an absent control, so "declared" is not simply always true. + expect(hasOwnKey('RecordPathProps', 'qqzz_absent_key_9556')).toBe(false); + }); + + it('`record:quick_actions` REFUSES the bag, so its read is unreachable by contract', () => { + const refused = (specUi as Record).RecordQuickActionsProps.safeParse({ + actionNames: ['convert'], + aria: { ariaLabel: 'Account actions' }, + }); + expect(refused.success).toBe(false); + expect(refused.error?.issues.map((i: { code: string }) => i.code)).toContain('unrecognized_keys'); + expect( + refused.error?.issues.flatMap((i: { keys?: string[] }) => i.keys ?? []), + ).toContain('aria'); + + // The control that makes the refusal mean something: the same document + // without the bag parses. + expect( + (specUi as Record).RecordQuickActionsProps.safeParse({ + actionNames: ['convert'], + }).success, + ).toBe(true); + }); + + it('the shared ARIA shape accepts `ariaLabel` (string OR inline map) and refuses `label`', () => { + const { AriaPropsSchema } = specUi as Record; + expect(AriaPropsSchema.safeParse({ ariaLabel: 'Deal stages' }).success).toBe(true); + // The arm the declaration was narrower than until objectui#9556. + expect(AriaPropsSchema.safeParse({ ariaLabel: { en: 'Deal stages', 'zh-CN': '阶段' } }).success) + .toBe(true); + + const alias = AriaPropsSchema.safeParse({ label: 'Deal stages' }); + expect(alias.success).toBe(false); + expect(alias.error?.issues.flatMap((i: { keys?: string[] }) => i.keys ?? [])).toContain('label'); + }); +}); + +// --------------------------------------------------------------------------- +// Render — the accessibility tree, not the source +// --------------------------------------------------------------------------- + +const NAME = 'Account overview'; + +function mount(node: React.ReactElement) { + return render( + + + {node} + + , + ); +} + +/** The five blocks whose container carried no ARIA at all before objectui#9556. */ +const CONTAINER_BLOCKS: Array<[string, (aria?: unknown) => React.ReactElement]> = [ + ['record:details', (aria) => ], + ['record:highlights', (aria) => ], + ['record:activity', (aria) => ], + ['record:chatter', (aria) => ], +]; + +describe('an authored `aria.ariaLabel` reaches the accessibility tree (objectui#9556)', () => { + for (const [block, mk] of CONTAINER_BLOCKS) { + it(`${block} exposes the authored name on a role that can carry one`, () => { + mount(mk({ ariaLabel: NAME })); + // `getByRole` reads the accessibility tree: it fails both when the name is + // absent AND when the name sits on a `generic` element that cannot expose + // one — which an attribute-level assertion would not catch. + expect(screen.getByRole('region', { name: NAME })).toBeInTheDocument(); + }); + + it(`${block} adds nothing to its own container when no aria is authored`, () => { + // The other half of the bargain: a page that never wrote the key renders + // the DOM it always did. A blanket `role="region"` would show up here. + const root = rootOf(mount(mk(undefined))); + expect(root).not.toHaveAttribute('role'); + expect(root).not.toHaveAttribute('aria-label'); + expect(root).not.toHaveAttribute('aria-describedby'); + // …and nothing anywhere answers to the authored name, which is the user- + // visible form of the same claim. + expect(screen.queryByRole('region', { name: NAME })).toBeNull(); + }); + + it(`${block} honours an authored role instead of the fallback`, () => { + mount(mk({ ariaLabel: NAME, role: 'group' })); + expect(screen.getByRole('group', { name: NAME })).toBeInTheDocument(); + expect(screen.queryByRole('region', { name: NAME })).toBeNull(); + }); + + it(`${block} resolves an inline locale map rather than rendering an object`, () => { + // The widened declaration is only worth having if the read side resolves + // it: an unresolved map reaches the DOM as `[object Object]`. + mount(mk({ ariaLabel: { en: NAME, 'zh-CN': '客户概览' } })); + expect(screen.getByRole('region', { name: NAME })).toBeInTheDocument(); + }); + } + + it('record:related_list exposes the authored name too', () => { + mount( + , + ); + expect(screen.getByRole('region', { name: NAME })).toBeInTheDocument(); + }); + + it('an authored `ariaDescribedBy` reaches the same container', () => { + mount(); + expect(screen.getByRole('region', { name: NAME })).toHaveAttribute('aria-describedby', 'desc-1'); + }); +}); + +describe('how the authored name COMPOSES with a panel that already has one', () => { + /** + * objectui#9556 asked this as an open question and called it an a11y decision + * rather than a typing one. This is the measurement the decision needs, and + * ⛔ it is not the decision: `record:activity` and `record:chatter` mount a + * panel that renders its own named `
`, so an authored name lands on + * the block's OUTER container and the panel keeps its inner one — two nested + * named regions, not a replacement. + * + * ⚠️ This is pinned as the SHIPPED behaviour, not as the right answer. + * Whether an authored name should instead REPLACE the panel's own is + * objectui#9556's open question, and it would have to be plumbed into + * `RecordActivityTimeline` / `RecordChatterPanel` rather than decided here. + */ + it('record:activity nests the authored name outside the panel\'s own', () => { + const r = mount(); + const root = rootOf(r); + expect(root).toHaveAttribute('role', 'region'); + expect(root).toHaveAccessibleName(NAME); + // The panel's own section is still there, still named by the panel. + const inner = root.querySelector('section[aria-label]'); + expect(inner).not.toBeNull(); + expect(inner?.getAttribute('aria-label')).not.toBe(NAME); + }); +}); + +describe('record:path honours the CONTRACT spelling, not the refused alias (objectui#9556)', () => { + const STAGES = [ + { value: 'draft', label: 'Draft' }, + { value: 'won', label: 'Won', terminal: 'won' as const }, + ]; + const path = (aria?: unknown) => ( + + ); + + it('the spec-valid `aria.ariaLabel` now names both rails — it used to be discarded', () => { + mount(path({ ariaLabel: 'Deal stages' })); + const rails = screen.getAllByRole('list', { name: 'Deal stages' }); + // Desktop and mobile rails are both rendered; the split is CSS-only. + expect(rails).toHaveLength(2); + }); + + it('the refused `aria.label` alias still folds in behind it, for stored documents', () => { + mount(path({ label: 'Legacy stages' })); + expect(screen.getAllByRole('list', { name: 'Legacy stages' })).toHaveLength(2); + }); + + it('the canonical spelling wins when a stored document carries both', () => { + // The regression this file exists to prevent: `record:path` honoured the + // alias and ONLY the alias, so this case returned the legacy name. + mount(path({ ariaLabel: 'Deal stages', label: 'Legacy stages' })); + expect(screen.getAllByRole('list', { name: 'Deal stages' })).toHaveLength(2); + expect(screen.queryAllByRole('list', { name: 'Legacy stages' })).toHaveLength(0); + }); + + it('with nothing authored the rails keep their localized pack name', () => { + mount(path(undefined)); + expect(screen.getAllByRole('list', { name: 'Record path' })).toHaveLength(2); + }); + + it('the rails stay `list`s, so their stages keep their owner', () => { + // An authored name must not cost the `list`/`listitem` pairing the stage + // labels depend on. + mount(path({ ariaLabel: 'Deal stages' })); + expect(screen.getAllByRole('listitem').length).toBeGreaterThan(0); + }); +}); + +describe('record:quick_actions keeps objectui#4663 behaviour through the shared fold', () => { + const ACT = { name: 'act', label: 'Act', type: 'script', locations: ['record_header'] }; + const bar = (aria?: unknown) => ( + + ); + + it('still reads the canonical spelling', () => { + mount(bar({ ariaLabel: 'Account actions' })); + expect(screen.getByRole('toolbar', { name: 'Account actions' })).toBeInTheDocument(); + }); + + it('still falls back to the built-in name', () => { + mount(bar(undefined)); + expect(screen.getByRole('toolbar', { name: 'Quick actions' })).toBeInTheDocument(); + }); + + it('still treats an authored empty canonical name as no name, shadowing the alias', () => { + mount(bar({ ariaLabel: '', label: 'Legacy' })); + expect(screen.getByRole('toolbar', { name: 'Quick actions' })).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-activity.tsx b/packages/plugin-detail/src/renderers/record-activity.tsx index 9d7d23b07a..4ca6507d04 100644 --- a/packages/plugin-detail/src/renderers/record-activity.tsx +++ b/packages/plugin-detail/src/renderers/record-activity.tsx @@ -70,6 +70,7 @@ import { normalizeLimit, type SysActivityRow, } from './recordActivityFeed'; +import { useRecordAriaProps } from './recordComponentAria'; const splitDesigner = (props: Record) => { const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {}; @@ -96,6 +97,18 @@ export const RecordActivityRenderer: React.FC = ({ ...props }) => { const { designer } = splitDesigner(props); + /** + * The block's authored `aria` bag, honoured through the family's ONE read + * point (objectui#9556). Called here, with the other hooks, because every + * renderer below it has early returns. + * + * ⛔ No `defaultRole`: with nothing authored this container stays the bare + * `div` it has always been, so a page that never wrote `aria` renders + * byte-identical DOM. An author who does write one gets a `region` to carry + * it — see `recordComponentAria.ts` for why the attribute alone would reach + * nobody. + */ + const ariaProps = useRecordAriaProps(schema.aria); const ctx = useRecordContext(); const discussion = useDiscussionContext(); const tt = useSafeTranslate(); @@ -237,7 +250,7 @@ export const RecordActivityRenderer: React.FC = ({ const mentionsEnabled = config.enableMentions !== false; return ( -
+
) => { const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {}; @@ -120,6 +121,18 @@ export const RecordChatterRenderer: React.FC = ({ useRecordContext(); const discussion = useDiscussionContext(); const { designer } = splitDesigner(props); + /** + * The block's authored `aria` bag, honoured through the family's ONE read + * point (objectui#9556). Called here, with the other hooks, because every + * renderer below it has early returns. + * + * ⛔ No `defaultRole`: with nothing authored this container stays the bare + * `div` it has always been, so a page that never wrote `aria` renders + * byte-identical DOM. An author who does write one gets a `region` to carry + * it — see `recordComponentAria.ts` for why the attribute alone would reach + * nobody. + */ + const ariaProps = useRecordAriaProps(schema.aria); // Merge schema-supplied config (position, feed sub-config) with the three // AFFORDANCE defaults a host fallback wants when no author supplied any. @@ -229,7 +242,7 @@ export const RecordChatterRenderer: React.FC = ({ const mentionsEnabled = feed?.enableMentions !== false; return ( -
+
columnIdentity(entry) ?? null; @@ -148,6 +149,18 @@ export const RecordDetailsRenderer: React.FC = ({ // Keep all hooks here; move all conditional returns below them. const ctx = useRecordContext(); const { designer } = splitDesigner(props); + /** + * The block's authored `aria` bag, honoured through the family's ONE read + * point (objectui#9556). Called here, with the other hooks, because every + * renderer below it has early returns. + * + * ⛔ No `defaultRole`: with nothing authored this container stays the bare + * `div` it has always been, so a page that never wrote `aria` renders + * byte-identical DOM. An author who does write one gets a `region` to carry + * it — see `recordComponentAria.ts` for why the attribute alone would reach + * nobody. + */ + const ariaProps = useRecordAriaProps(schema.aria); const objectName = ctx?.objectName || ''; const perms = usePermissions(); @@ -712,7 +725,7 @@ export const RecordDetailsRenderer: React.FC = ({ // consumes that shared context; `inlineEdit` gates the affordance to this // object's lifecycle/permission. return ( -
+
) => { const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {}; @@ -37,6 +38,18 @@ export const RecordHighlightsRenderer: React.FC = }) => { const ctx = useRecordContext(); const { designer } = splitDesigner(props); + /** + * The block's authored `aria` bag, honoured through the family's ONE read + * point (objectui#9556). Called here, with the other hooks, because every + * renderer below it has early returns. + * + * ⛔ No `defaultRole`: with nothing authored this container stays the bare + * `div` it has always been, so a page that never wrote `aria` renders + * byte-identical DOM. An author who does write one gets a `region` to carry + * it — see `recordComponentAria.ts` for why the attribute alone would reach + * nobody. + */ + const ariaProps = useRecordAriaProps(schema.aria); const objectName = ctx?.objectName || ''; const perms = usePermissions(); const { readableFields } = useFieldPermissions(objectName); @@ -124,7 +137,7 @@ export const RecordHighlightsRenderer: React.FC = } return ( -
+
) => { const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {}; @@ -80,6 +81,28 @@ export const RecordPathRenderer: React.FC = ({ const { translateOptions } = useSafeFieldLabel(); const { t } = useDetailTranslation(); const { designer } = splitDesigner(props); + /** + * The two rails' ARIA, through the family's ONE read point (objectui#9556). + * + * This site used to read `(schema.aria as any)?.label` and nothing else — the + * ONE spelling the shared ARIA shape refuses, while `RecordPathProps.aria` + * itself parses green. So a spec-valid `aria: { ariaLabel: 'Deal stages' }` + * was accepted at publish and discarded here, and the spelling that did win + * is one no author can write without the contract rejecting the document: + * the same dead-read-point pair objectui#4663 repaired on + * `record:quick_actions`, still open on this block. The cast is what hid it — + * `schema.aria` is declared, `.label` is not, and `as any` silenced the + * member check that would have said so. + * + * `defaultRole: 'list'` is the role both rails already carry, so the stages' + * `listitem` children keep their owner; an author's `aria.role` overrides it, + * which is the precedence `ListView` ships. `defaultLabel` is the localized + * pack entry this block has fallen back to since objectui#5956. + */ + const railAria = useRecordAriaProps(schema.aria, { + defaultRole: 'list', + defaultLabel: t('detail.pathLabel'), + }); const rawStages: Array<{ value: any; label: string; terminal?: 'won' | 'lost' }> = Array.isArray(schema.stages) ? (schema.stages as any) @@ -283,8 +306,7 @@ export const RecordPathRenderer: React.FC = ({ {/* Desktop: forward rail → optional lost-alt group */}
{forwardStages.map((stage, idx) => { @@ -349,8 +371,7 @@ export const RecordPathRenderer: React.FC = ({ {/* Mobile: horizontally scrollable rail row — same treatment, no chips */}
{stages.map((stage, idx) => { const terminal = stageTerminals[idx]; diff --git a/packages/plugin-detail/src/renderers/record-quick-actions.tsx b/packages/plugin-detail/src/renderers/record-quick-actions.tsx index ea73102b7e..702cb7a916 100644 --- a/packages/plugin-detail/src/renderers/record-quick-actions.tsx +++ b/packages/plugin-detail/src/renderers/record-quick-actions.tsx @@ -23,6 +23,7 @@ import { Loader2 } from 'lucide-react'; import type { ActionDef, ActionLocation } from '@object-ui/core'; import { resolveDeclaredActionIds } from '@object-ui/types'; import type { DeclaredActionsRefusal } from '@object-ui/types'; +import { useRecordAriaProps, type AuthoredRecordAria } from './recordComponentAria'; const splitDesigner = (props: Record) => { const { 'data-obj-id': id, 'data-obj-type': type, style, ...rest } = props || {}; @@ -79,8 +80,19 @@ export interface RecordQuickActionsRendererProps { * `@objectstack/spec`'s `AriaPropsSchema` accepts; `label` is that shape's * alias entry — refused on parse — and is kept here as a back-compat read * for documents written before the contract closed (objectui#4663). + * + * ⚠️ `RecordQuickActionsProps` in `@objectstack/spec` declares NO `aria` + * key at all — measured, not recalled, by + * `__tests__/recordComponentAria-9556.test.tsx`, whose census reads the + * installed artifact. So the contract REFUSES this bag on this block + * (`unrecognized_keys`), and no contract-valid document reaches the read + * below. The read is kept, and typed as the family's bag rather than + * re-spelled, because the protocol's own refusal message names objectui's + * renderer as the thing that has to move first. ⛔ Do not mirror this key + * onto a published `@object-ui/types` face: that would declare, on this + * repo's contract face, a key the protocol refuses. */ - aria?: { ariaLabel?: string; label?: string }; + aria?: AuthoredRecordAria; properties?: Record; [k: string]: any; }; @@ -95,6 +107,33 @@ export const RecordQuickActionsRenderer: React.FC { const ctx = useRecordContext(); const { designer } = splitDesigner(props); + /** + * The toolbar's ARIA, through the family's ONE read point (objectui#9556). + * + * Called up here with the other hooks because two early returns sit below + * (the permission gate and the empty-bar placeholder). + * + * objectui#4663 established the read ORDER this now shares with the rest of + * the `record:*` family — canonical `aria.ariaLabel` first, the refused + * `aria.label` alias behind it, the built-in name when neither resolves. It + * used to be spelled here and only here, which is how five sibling blocks + * came to read nothing and `record:path` came to read only the alias. + * + * `SchemaRenderer`'s generic ARIA channel is no escape hatch on this surface: + * it reads the FLAT `schema.ariaLabel` and injects `aria-label` as a + * component PROP, which `splitDesigner` above drops with every other + * non-designer prop. The nested bag is the only live path for an authored + * name here. + * + * `role="toolbar"` is passed as the DEFAULT rather than written on the + * element, so an author's `aria.role` can override it — the precedence + * `ListView` ships — while a document that declares no `aria` still renders + * the toolbar role this bar has always had. + */ + const toolbarAria = useRecordAriaProps(schema.aria, { + defaultRole: 'toolbar', + defaultLabel: 'Quick actions', + }); const perms = usePermissions(); // The ONE resolver for a declared action's authored strings (objectui#4265). // This bar used to localize the button `label` only, while `executeAction` @@ -221,36 +260,6 @@ export const RecordQuickActionsRenderer: React.FC {visibleActions.map((action, idx) => ( diff --git a/packages/plugin-detail/src/renderers/record-related-list.tsx b/packages/plugin-detail/src/renderers/record-related-list.tsx index 219d09ca28..91572abf93 100644 --- a/packages/plugin-detail/src/renderers/record-related-list.tsx +++ b/packages/plugin-detail/src/renderers/record-related-list.tsx @@ -25,6 +25,7 @@ import { humanizeLabel } from '@object-ui/fields'; import { columnIdentity, elementDataSourceBlock } from '@object-ui/core'; import type { RecordRelatedListComponentProps } from '@object-ui/types'; import { RelatedList } from '../RelatedList'; +import { useRecordAriaProps } from './recordComponentAria'; /** * Normalize a column entry (string | {field} | {name} | {key}) to its name. @@ -88,6 +89,18 @@ const RecordRelatedListBody: React.FC = ({ }) => { const ctx = useRecordContext(); const { designer } = splitDesigner(props); + /** + * The block's authored `aria` bag, honoured through the family's ONE read + * point (objectui#9556). Called here, with the other hooks, because every + * renderer below it has early returns. + * + * ⛔ No `defaultRole`: with nothing authored this container stays the bare + * `div` it has always been, so a page that never wrote `aria` renders + * byte-identical DOM. An author who does write one gets a `region` to carry + * it — see `recordComponentAria.ts` for why the attribute alone would reach + * nobody. + */ + const ariaProps = useRecordAriaProps(schema.aria); const i18n = useSafeFieldLabel(); const { language } = useObjectTranslation(); @@ -234,7 +247,7 @@ const RecordRelatedListBody: React.FC = ({ } return ( -
+
Date: Fri, 18 Sep 2026 18:38:52 +0000 Subject: [PATCH 2/3] fix(types): drop the settled CLAIM_DEBT entry and the namespace spec import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI reds on the previous head, both this branch's. `check:spec-symbols` reported `RecordComponentAriaProps`'s unbacked spec-alignment claim as GONE while the ledger still listed it. It is gone because the interface now references a spec-bound identifier (`I18nLabel`) while citing a symbol the installed spec exports, which is what rule 2's tie test asks for. The ledger block is regenerated by the script's own `--claim-ledger` and applied verbatim — one line removed, no hand edit. ⛔ The ledger is NOT empty afterwards: 17 unbacked claims remain across 5 packages (the gate prints the same number), so objectui#4592 does not close. `eslint` refused `import * as ... from '@objectstack/spec/ui'` in the new pin under `no-restricted-imports` (a namespace import drags in the spec's form-VIEW `FormField`, whose type erases to `any` — objectui#3090). Replaced with named imports, which also gave the census real types and surfaced two issue-union annotations that were only valid through the erased namespace. Part of objectui#9556 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn --- .../recordComponentAria-9556.test.tsx | 75 +++++++++++-------- scripts/check-spec-symbol-derivation.mjs | 1 - 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/packages/plugin-detail/src/renderers/__tests__/recordComponentAria-9556.test.tsx b/packages/plugin-detail/src/renderers/__tests__/recordComponentAria-9556.test.tsx index d787f1a880..32210da07e 100644 --- a/packages/plugin-detail/src/renderers/__tests__/recordComponentAria-9556.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/recordComponentAria-9556.test.tsx @@ -62,7 +62,21 @@ import '@testing-library/jest-dom'; import { render, screen, cleanup, type RenderResult } from '@testing-library/react'; import { I18nProvider } from '@object-ui/i18n'; import { RecordContextProvider } from '@object-ui/react'; -import * as specUi from '@objectstack/spec/ui'; +// Named imports, ⛔ never `import * as` — the repo's `no-restricted-imports` rule +// bans a namespace import of this module because it would drag in the spec's +// form-VIEW `FormField`/`FormFieldSchema`, whose type erases to `any` +// (objectui#3090). Naming each schema also makes a removed export a build +// failure rather than a census that quietly reports one block fewer. +import { + AriaPropsSchema, + RecordDetailsProps, + RecordHighlightsProps, + RecordRelatedListProps, + RecordActivityProps, + RecordChatterProps, + RecordPathProps, + RecordQuickActionsProps, +} from '@objectstack/spec/ui'; import { RecordDetailsRenderer } from '../record-details'; import { RecordHighlightsRenderer } from '../record-highlights'; import { RecordActivityRenderer } from '../record-activity'; @@ -110,28 +124,31 @@ const rootOf = (r: RenderResult): HTMLElement => r.container.firstElementChild a // --------------------------------------------------------------------------- /** Protocol props schemas for the blocks this package renders a container for. */ -const SPEC_BLOCK_SCHEMAS = { - 'record:details': 'RecordDetailsProps', - 'record:highlights': 'RecordHighlightsProps', - 'record:related_list': 'RecordRelatedListProps', - 'record:activity': 'RecordActivityProps', - 'record:chatter': 'RecordChatterProps', - 'record:path': 'RecordPathProps', - 'record:quick_actions': 'RecordQuickActionsProps', -} as const; +const SPEC_BLOCK_SCHEMAS: Record = { + 'record:details': RecordDetailsProps, + 'record:highlights': RecordHighlightsProps, + 'record:related_list': RecordRelatedListProps, + 'record:activity': RecordActivityProps, + 'record:chatter': RecordChatterProps, + 'record:path': RecordPathProps, + 'record:quick_actions': RecordQuickActionsProps, +}; + +/** The schema's own key set, read off the installed artifact. */ +function ownKeys(schema: any): string[] { + const shape = typeof schema?._def?.shape === 'function' ? schema._def.shape() : schema?.shape; + return Object.keys(shape ?? {}); +} /** Does the installed contract declare an `aria` member on this block? */ -function declaresAria(exportName: string): boolean { - const schema = (specUi as Record)[exportName]; - if (!schema) throw new Error(`@objectstack/spec/ui exports no ${exportName}`); - const shape = typeof schema._def?.shape === 'function' ? schema._def.shape() : schema.shape; - return Object.prototype.hasOwnProperty.call(shape ?? {}, 'aria'); +function declaresAria(schema: any): boolean { + return ownKeys(schema).includes('aria'); } describe('census: which record blocks the CONTRACT declares `aria` on (objectui#9556)', () => { it('declares it on every block except `record:quick_actions`', () => { const declaring = Object.entries(SPEC_BLOCK_SCHEMAS) - .filter(([, exportName]) => declaresAria(exportName)) + .filter(([, schema]) => declaresAria(schema)) .map(([block]) => block); // Stated as the two SETS the routing decision turns on, derived above. @@ -142,39 +159,33 @@ describe('census: which record blocks the CONTRACT declares `aria` on (objectui# it('the census instrument fires — a key every one of them declares is found', () => { // Non-vacuity: `declaresAria` reading a mis-shaped schema would answer // `false` everywhere and the case above would pass for the wrong reason. - const hasOwnKey = (exportName: string, key: string) => { - const schema = (specUi as Record)[exportName]; - const shape = typeof schema._def?.shape === 'function' ? schema._def.shape() : schema.shape; - return Object.prototype.hasOwnProperty.call(shape ?? {}, key); - }; - expect(hasOwnKey('RecordPathProps', 'statusField')).toBe(true); - expect(hasOwnKey('RecordQuickActionsProps', 'actionNames')).toBe(true); + expect(ownKeys(RecordPathProps)).toContain('statusField'); + expect(ownKeys(RecordQuickActionsProps)).toContain('actionNames'); // …and an absent control, so "declared" is not simply always true. - expect(hasOwnKey('RecordPathProps', 'qqzz_absent_key_9556')).toBe(false); + expect(ownKeys(RecordPathProps)).not.toContain('qqzz_absent_key_9556'); }); it('`record:quick_actions` REFUSES the bag, so its read is unreachable by contract', () => { - const refused = (specUi as Record).RecordQuickActionsProps.safeParse({ + const refused = RecordQuickActionsProps.safeParse({ actionNames: ['convert'], aria: { ariaLabel: 'Account actions' }, }); expect(refused.success).toBe(false); - expect(refused.error?.issues.map((i: { code: string }) => i.code)).toContain('unrecognized_keys'); + expect(refused.error?.issues.map((i) => i.code)).toContain('unrecognized_keys'); + // `keys` lives on the `unrecognized_keys` variant only, so it is read off the + // issue rather than annotated onto every arm of the union. expect( - refused.error?.issues.flatMap((i: { keys?: string[] }) => i.keys ?? []), + refused.error?.issues.flatMap((i) => (i as { keys?: string[] }).keys ?? []), ).toContain('aria'); // The control that makes the refusal mean something: the same document // without the bag parses. expect( - (specUi as Record).RecordQuickActionsProps.safeParse({ - actionNames: ['convert'], - }).success, + RecordQuickActionsProps.safeParse({ actionNames: ['convert'] }).success, ).toBe(true); }); it('the shared ARIA shape accepts `ariaLabel` (string OR inline map) and refuses `label`', () => { - const { AriaPropsSchema } = specUi as Record; expect(AriaPropsSchema.safeParse({ ariaLabel: 'Deal stages' }).success).toBe(true); // The arm the declaration was narrower than until objectui#9556. expect(AriaPropsSchema.safeParse({ ariaLabel: { en: 'Deal stages', 'zh-CN': '阶段' } }).success) @@ -182,7 +193,7 @@ describe('census: which record blocks the CONTRACT declares `aria` on (objectui# const alias = AriaPropsSchema.safeParse({ label: 'Deal stages' }); expect(alias.success).toBe(false); - expect(alias.error?.issues.flatMap((i: { keys?: string[] }) => i.keys ?? [])).toContain('label'); + expect(alias.error?.issues.flatMap((i) => (i as { keys?: string[] }).keys ?? [])).toContain('label'); }); }); diff --git a/scripts/check-spec-symbol-derivation.mjs b/scripts/check-spec-symbol-derivation.mjs index d414c02d9d..df6181d975 100644 --- a/scripts/check-spec-symbol-derivation.mjs +++ b/scripts/check-spec-symbol-derivation.mjs @@ -1193,7 +1193,6 @@ const CLAIM_DEBT = { "PageRegionWidth", "RecordActivityComponentProps", "RecordChatterComponentProps", - "RecordComponentAriaProps", "RecordDetailsComponentProps", "RecordHighlightsComponentProps", "RecordPathComponentProps", From ec0ed06b5c9f540181d0f6829164e8a289fdf4b0 Mon Sep 17 00:00:00 2001 From: os-sales Date: Fri, 18 Sep 2026 19:21:17 +0000 Subject: [PATCH 3/3] fix(plugin-detail): scope the refused `aria.label` fold to the two blocks that had it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract review findings A and B on e785f52596. B — the shared read point folded the contract-refused `aria.label` for every caller, so details, highlights, related_list, activity and chatter gained a reader of a spelling the contract rejects. No stored document was ever served by the alias on those five, so objectui#4663's back-compat rationale does not reach them and AGENTS.md #0.1 refuses a consumer-side alias with nothing behind it. The fold is now opt-in (`legacyLabelFold`), passed by `record:path` and `record:quick_actions` only — the two that already read it. Retiring it on those two would reverse objectui#4663 and is the maintainer's, so it is left open. The changeset sentence that said the alias was neither introduced nor retired is true again, and the pin now asserts BOTH directions, each absence paired with a control showing the same block honours the canonical spelling. A — the docblock added to `RecordComponentAriaProps` pointed a consumer at `__tests__/record-component-aria-9556.test.ts`, which exists nowhere: wrong name and wrong package. It now names the real pin and the package that holds it, since the instrument is not in the one whose `.d.ts` carries the sentence. Part of objectui#9556 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn --- .../9556-record-component-aria-honoured.md | 15 +++-- .../recordComponentAria-9556.test.tsx | 66 +++++++++++++++++++ .../src/renderers/record-path.tsx | 5 ++ .../src/renderers/record-quick-actions.tsx | 3 + .../src/renderers/recordComponentAria.ts | 52 ++++++++++++--- packages/types/src/record-components.ts | 21 ++++-- 6 files changed, 141 insertions(+), 21 deletions(-) diff --git a/.changeset/9556-record-component-aria-honoured.md b/.changeset/9556-record-component-aria-honoured.md index c476da26ea..b8daca04f9 100644 --- a/.changeset/9556-record-component-aria-honoured.md +++ b/.changeset/9556-record-component-aria-honoured.md @@ -82,11 +82,16 @@ plumbed into `RecordActivityTimeline` / `RecordChatterPanel`. ## What is deliberately NOT done -- **The legacy `aria.label` alias is neither introduced nor retired.** It stays - a fold behind the canonical spelling, exactly where objectui#4663 and - `normalizeListViewSchema`'s `ARIA_KEY_ALIASES` already put it, and it remains - declared on no authoring face. The contract refuses it, so it cannot mask a - new authoring mistake; whether it should be removed outright is left open on +- **The legacy `aria.label` alias gains no reader and loses none.** It is read + on `record:path` and `record:quick_actions` — the two blocks whose renderers + already read it — and ⛔ on no other block. The shared read point takes it as + an OPT-IN (`legacyLabelFold`), so the five container blocks do not read it: + no stored document was ever served by the alias on those five, so the + back-compat rationale does not reach them, and AGENTS.md #0.1 refuses a + consumer-side alias with no such document behind it. It remains declared on + no authoring face, and the contract refuses it, so it cannot mask a new + authoring mistake. Both directions are pinned. Whether to retire it on the + two as well reverses objectui#4663's pinned decision and is left open on objectui#9556 rather than settled in passing. - **No `aria` declaration is added for `record:quick_actions`.** The protocol declares none on `RecordQuickActionsProps` and refuses the bag there with a diff --git a/packages/plugin-detail/src/renderers/__tests__/recordComponentAria-9556.test.tsx b/packages/plugin-detail/src/renderers/__tests__/recordComponentAria-9556.test.tsx index 32210da07e..ff3b7e4518 100644 --- a/packages/plugin-detail/src/renderers/__tests__/recordComponentAria-9556.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/recordComponentAria-9556.test.tsx @@ -45,6 +45,14 @@ * names (AGENTS.md). These mount the real renderers and read the * accessibility tree. * + * ## The alias fold is scoped, and that is asserted here + * + * The contract-refused `aria.label` spelling is read on `record:path` and + * `record:quick_actions` only — the two blocks that already read it, where a + * stored pre-contract document can carry it. ⛔ The other five do not, and the + * cases below assert that in both directions, each absence paired with the + * control that shows the same block honours the canonical spelling. + * * ## Why the assertions are accessible NAMES, not attributes * * `aria-label` on a bare `div` reaches nobody — a `div` is `generic`, and @@ -272,6 +280,64 @@ describe('an authored `aria.ariaLabel` reaches the accessibility tree (objectui# }); }); +describe('the contract-refused `aria.label` alias is read on TWO blocks, not seven', () => { + /** + * The alias is `AriaPropsSchema`'s rename prescription — refused on parse, + * on every bag (the census above measures that). objectui#4663 installed a + * back-compat fold for it on `record:quick_actions`, and `record:path` read + * it and nothing else; those are the two blocks a stored pre-contract + * document can actually reach. + * + * ⚠️ The first draft of the shared read point folded it for EVERY caller, + * which handed a contract-refused spelling five new readers while the + * changeset said nothing had been introduced. These cases are why that + * cannot happen again quietly: the fold is opt-in, and both directions are + * asserted, each with the control that makes the absence mean something. + */ + const ALIAS = 'Legacy name'; + + for (const [block, mk] of CONTAINER_BLOCKS) { + it(`${block} does NOT honour the refused alias`, () => { + mount(mk({ label: ALIAS })); + expect(screen.queryByRole('region', { name: ALIAS })).toBeNull(); + // Nothing anywhere answers to it — not just "not on a region". + expect(screen.queryByText(ALIAS)).toBeNull(); + }); + + it(`${block} control — the SAME block does honour the canonical spelling`, () => { + // Without this the absence above would pass for a block that renders + // nothing at all, which is the way this kind of assertion goes vacuous. + mount(mk({ ariaLabel: ALIAS })); + expect(screen.getByRole('region', { name: ALIAS })).toBeInTheDocument(); + }); + } + + it('record:related_list does not honour it either', () => { + mount( + , + ); + expect(screen.queryByRole('region', { name: ALIAS })).toBeNull(); + }); + + it('record:path and record:quick_actions DO, because a stored document can carry it there', () => { + // The positive half, restated here beside the five absences so the two + // populations are read together rather than a screen apart. + mount( + , + ); + expect(screen.getAllByRole('list', { name: ALIAS })).toHaveLength(2); + cleanup(); + + const ACT = { name: 'act', label: 'Act', type: 'script', locations: ['record_header'] }; + mount(); + expect(screen.getByRole('toolbar', { name: ALIAS })).toBeInTheDocument(); + }); +}); + describe('how the authored name COMPOSES with a panel that already has one', () => { /** * objectui#9556 asked this as an open question and called it an a11y decision diff --git a/packages/plugin-detail/src/renderers/record-path.tsx b/packages/plugin-detail/src/renderers/record-path.tsx index 7318df6d3f..92a825258b 100644 --- a/packages/plugin-detail/src/renderers/record-path.tsx +++ b/packages/plugin-detail/src/renderers/record-path.tsx @@ -102,6 +102,11 @@ export const RecordPathRenderer: React.FC = ({ const railAria = useRecordAriaProps(schema.aria, { defaultRole: 'list', defaultLabel: t('detail.pathLabel'), + // ⛔ One of only TWO callers that opt in: this block read `aria.label` and + // nothing else before objectui#9556, so a stored document can carry it + // here. The five container blocks do NOT pass this — giving a + // contract-refused spelling new readers is the defect, not the fix. + legacyLabelFold: true, }); const rawStages: Array<{ value: any; label: string; terminal?: 'won' | 'lost' }> = Array.isArray(schema.stages) diff --git a/packages/plugin-detail/src/renderers/record-quick-actions.tsx b/packages/plugin-detail/src/renderers/record-quick-actions.tsx index 702cb7a916..fba8e4de3d 100644 --- a/packages/plugin-detail/src/renderers/record-quick-actions.tsx +++ b/packages/plugin-detail/src/renderers/record-quick-actions.tsx @@ -133,6 +133,9 @@ export const RecordQuickActionsRenderer: React.FC