diff --git a/.changeset/9556-record-component-aria-honoured.md b/.changeset/9556-record-component-aria-honoured.md new file mode 100644 index 0000000000..b8daca04f9 --- /dev/null +++ b/.changeset/9556-record-component-aria-honoured.md @@ -0,0 +1,107 @@ +--- +'@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 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 + 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..ff3b7e4518 --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/recordComponentAria-9556.test.tsx @@ -0,0 +1,431 @@ +/** + * 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. + * + * ## 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 + * 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'; +// 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'; +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 = { + '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(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(([, schema]) => declaresAria(schema)) + .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. + expect(ownKeys(RecordPathProps)).toContain('statusField'); + expect(ownKeys(RecordQuickActionsProps)).toContain('actionNames'); + // …and an absent control, so "declared" is not simply always true. + 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 = RecordQuickActionsProps.safeParse({ + actionNames: ['convert'], + aria: { ariaLabel: 'Account actions' }, + }); + expect(refused.success).toBe(false); + 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) => (i as { keys?: string[] }).keys ?? []), + ).toContain('aria'); + + // The control that makes the refusal mean something: the same document + // without the bag parses. + expect( + RecordQuickActionsProps.safeParse({ actionNames: ['convert'] }).success, + ).toBe(true); + }); + + it('the shared ARIA shape accepts `ariaLabel` (string OR inline map) and refuses `label`', () => { + 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) => (i as { keys?: string[] }).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('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 + * 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,33 @@ 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'), + // ⛔ 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) ? (schema.stages as any) @@ -283,8 +311,7 @@ export const RecordPathRenderer: React.FC = ({ {/* Desktop: forward rail → optional lost-alt group */}
{forwardStages.map((stage, idx) => { @@ -349,8 +376,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..fba8e4de3d 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,36 @@ 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', + // ⛔ The other of the two callers that opt in — objectui#4663 installed this + // fold here deliberately, for documents written before the shape closed. + legacyLabelFold: true, + }); 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 +263,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 ( -
+