Skip to content

Commit b24f93a

Browse files
fix(components,plugin-list,plugin-detail): the page root reads its nested aria bag, and ListView and useRecordAriaProps map through the shared reader (objectui#11083) (#11102)
Part of #11083 Clause-②: no The page root now renders the `aria` bag the spec declares on it, and the two blocks that kept their own mapping of that bag (`ListView`'s list region, `plugin-detail`'s `useRecordAriaProps`) now go through `resolveInlineAriaProps`, the shared reader PR objectui#11078 added to `@object-ui/react`. There is no second mapping table. **Why `Part of` and not the order's `Fixes`.** One sub-item needs a decision and is reported on the card, not done here: whether `ListView` stops reading `aria.live`. The measurements are in the section on it below. This PR keeps that read exactly as it was. The card's other items are all in this PR. ## What changed 1. **Page root (the defect).** `PageRenderer` gives its wrapper `toDomProps(props)`, which keeps only DOM attributes, so the `aria` object was dropped and `{ type: 'page', aria: { ariaLabel: 'Page name' } }` rendered no `aria-label` anywhere. The wrapper (the element carrying `data-page-type`) now also spreads `resolveInlineAriaProps(schema.aria, useDisplayLocale())`. It is spread after `pageProps`, so when a flat key and the nested bag name the same attribute, the page's own declared slot wins. The page adds no default role, and a page with no `aria` renders the same DOM as before. 2. **ListView (consolidation).** The inline mapping (label, described-by, role) is replaced by `const regionAria = resolveInlineAriaProps(schema.aria, displayLocale)`, spread on the region, plus `role={regionAria.role ?? 'region'}` as ListView's default. Measured: the label had **no** non-aria fallback. It came only from `schema.aria.ariaLabel`, so there was nothing else to keep. One visible difference: an authored `role: ''` used to render an empty `role` attribute, and now falls back to `region` (pinned). The `aria.live` read is kept unchanged (see below). 3. **`useRecordAriaProps` (consolidation).** Built on the shared reader and the display locale. It keeps its signature, the caller's `defaultRole` and `defaultLabel`, the `region` fallback, and the refused-`aria.label` report. The report stays in an effect keyed on the message. The internal `useRecordAriaName` (one caller, never exported from the package) became a report-only hook, so this module no longer resolves the label itself. 4. **Docs.** `content/docs/api/schema-reference.md`, the PageSchema table, gains an `aria` row (AGENTS.md #2). This file is outside the claim's declared surface, and the report says so. ## `aria.live`: measured, the read is kept, and a decision is needed Triage direction 2: "Measure whether any document authors it first. None ⇒ the read goes (objectui#2890's residue). Some ⇒ stop and report it on this card." **Census.** The pattern is the value form `live` followed by `polite`, `assertive` or `off` (any quoting), which also catches a multi-line bag. - objectui at base `3e71a9825`: 5 hits in 4 files, **all test fixtures**: `normalize-list-view.test.ts`, `ListView.test.tsx`, `inlineAria.test.ts` and `p1-spec-alignment.test.ts`. - objectstack at `origin/main` `cd901d7a`: 2 hits, both in `packages/spec/src/ui/i18n.test.ts`. - There are **0** in examples, apps, seeds or docs. - The lit control is the value form of `ariaDescribedBy` over the same trees: 18 lines in objectui and 6 in objectstack. So no document authors it. But the premise that it is "residue" does not hold, because `live` is a **declared** key with a named producer of guidance: - objectui declares it. The zod mirror's `ListViewSchema.aria` is `stripImportedDefaults(SpecAriaPropsSchema).extend({ live })`, described as "objectui-only — promote rather than grow this extension". The `NamedListView.aria` interface also carries `live`. objectui#2890's changeset kept it on purpose: "`aria.live` stays as a documented local extension — it has no spec counterpart, and dropping it would silently disable a shipped capability." - The **published** spec, `@objectstack/spec` 17.4.0 as installed here, refuses `live` on every other surface with this guidance: "On an objectui list view the key is valid as-is; anywhere else, drop it." The spec pins that text in `i18n.test.ts`, together with a reconstruction of objectui's `.extend({ live })` that accepts it. - objectstack#5058, the promotion question, was closed `not_planned` on 2026-09-24 on the maintainer's word. Its rationale names "objectui's own extension carries the rendering". Dropping only the read would leave `live` accepted by objectui's own schema and recommended by the spec's refusal text, then silently dropped at render. That is declared ≠ enforced, the trap this card exists to remove. Retiring it properly also moves a type (`@object-ui/types`, so Clause-② becomes yes) and changes the spec's published guidance text in the other repo. So this PR stops on that half and reports it on the card, with options. ## Tests The runs are from the worktree root with root-relative paths. They were measured at `904eed298`. `git diff --stat 904eed2 f4969f5 -- packages/` is empty, so the `packages/` tree is byte-identical to head `f4969f552` (the later commits add the changeset and the docs row). - New pins: - `page-root-aria-11083.test.tsx`: real `SchemaRenderer` and registry, under `I18nProvider`. A plain string under en and zh, a locale map under en and zh, described-by and role, no bag adds nothing, and a flat-`ariaLabel` control. 7 tests. - `ListView.nestedAriaSharedReader-11083.test.tsx`: described-by, an authored role, the region default, an empty role falling back to region, a zh locale map, and `live` kept. 6 tests. - `recordAriaSharedReader-11083.test.tsx`: a plain string and a locale map under zh, a locale map under en, and `record:path`'s default role carrying a zh name. 4 tests. - Targeted run, new pins plus the existing aria pins (`ListView.ariaLabelInlineLocale`, `recordComponentAria-9556`, `recordAriaLabelRetired-9945`, `record-quick-actions.ariaLabel`): `Test Files 7 passed (7)`, `Tests 82 passed (82)`. - Wider run: `packages/components/src/renderers/layout/`, every `page-*` / `react-page-*` / `html-page-lazy-blocks` / `guide-layout-page-buttons` / `page*ItemMembers` test in `packages/components/src/__tests__/`, `ListView.test.tsx`, `i18nLabel-resolver-parity.test.ts`, and all of `packages/plugin-detail/src/renderers/`. Result: `Test Files 107 passed (107)`, `Tests 1247 passed (1247)`. - Type-check, after building the dependency closure (`--filter` with the `^...` suffix on the three packages, 13 packages): `pnpm --filter @object-ui/components type-check`, `@object-ui/plugin-list` and `@object-ui/plugin-detail`. Each exited 0, and each ran `tsc --noEmit && tsc -p tsconfig.test.json`. Each package's `tsconfig.test.json` includes `src/**/*.test.tsx`, so the new pins are type-checked. - **Ablation (page root).** This used `ablation-replace.mjs` in wrap mode on the committed fix. It deleted the `{...pageAria}` spread (anchor 1 → 0, blob `f5185737dbf2` → `b05ab1b57c9e`) and ran the page pin. The predicted direction was 5 red and 2 green (the no-bag case and the flat control). Measured: `Tests 5 failed | 2 passed (7)`, exactly those five. After the restore, the blob equals HEAD and `git diff HEAD` is empty. The same file on the restored tree: 7 passed. ## Gates - eslint on the 6 changed `.ts`/`.tsx` files: exit 0, and the JSON output lists 6 files with 0 errors. The warnings are all on lines this diff does not touch. The linted population comes from eslint's own config (`files: ['**/*.{ts,tsx}']`). The config has no `parserOptions.project` and no `projectService`, so linting is not type-aware and this diff cannot move the verdict on an untouched file. `pnpm lint` over the whole repo belongs to CI. - Exit 0 on the tree at head `f4969f552`: - `check-changeset-presence`, `check-control-bytes`, `check-new-cross-file-line-citations`; - `check-changeset-claims`, `check-changeset-no-major`, `check-pending-changeset-literals`; - `check-phantom-dependencies`, `check-unused-dependencies`; - `check-handler-key-read-sites`, `check-unreferenced-sources`, `check-test-path-roots`; - `check-doc-component-types`, `check-doc-fence-languages`, `check-doc-links`, `check-doc-example-ids`. - Self-scan for control bytes over the changed files: no hits. ## Acceptance notes These are out of scope and noted only, not filed. - **The page root has no default role.** An authored `aria.ariaLabel` with no `aria.role` lands on the wrapper `div`, which is `generic`, and browsers expose no name on it. This is the same question triage kept off this card for `element:text` / `element:number` (its own card, once there is an assistive-technology measurement). The page follows that ruling and adds no default. Carrier: that future card. No other carrier. - **A pending changeset quotes the old ListView source.** `.changeset/9556-record-component-aria-honoured.md` quotes ListView's old `role=` expression for the region's precedence. The precedence (the authored role, else `region`) still holds, but the quoted expression is no longer literal source. This PR does not edit another PR's pending changeset. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2fb0f9a commit b24f93a

8 files changed

Lines changed: 409 additions & 48 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/components': patch
3+
'@object-ui/plugin-list': patch
4+
'@object-ui/plugin-detail': patch
5+
---
6+
7+
A page now renders the accessible name an author declares in its `aria` bag, and the list view and the `record:*` blocks read the same bag through the one shared reader (objectui#11083).
8+
9+
**The page root (a fix).** The spec's `PageSchema` declares `aria` as `AriaPropsSchema`, and `PageNodeSchema` mirrors it. The page renderer passed its wrapper only DOM attributes, so the `aria` object was dropped and `{ type: 'page', aria: { ariaLabel: 'Orders' } }` rendered no `aria-label` anywhere. The page's root element now carries:
10+
11+
- `aria-label` from `ariaLabel`. A locale map (`{ en: 'Orders', 'zh-CN': '订单' }`) gives the entry for the display locale (`useDisplayLocale()`);
12+
- `aria-describedby` from `ariaDescribedBy`;
13+
- `role` from `role`.
14+
15+
The page adds no default role, and a page that declares no `aria` renders the same DOM as before. The flat `ariaLabel` on the page node still reaches the root as before; when both spellings name the same attribute, the nested `aria` bag wins.
16+
17+
**The list view and the `record:*` blocks (no visible change).** `ListView`'s list region and the `record:*` blocks' `useRecordAriaProps` used to map the nested bag themselves. Both now go through `resolveInlineAriaProps` from `@object-ui/react`, the reader the `element:*` blocks use, and keep only their own defaults: the list region's `region` role, and each block's default role, default name and the report of the refused `aria.label` spelling. One difference: an authored empty `role: ''` on a list view used to render an empty `role` attribute, and now falls back to `region`. The list view still reads `aria.live`, which objectui's `ListViewSchema` declares on top of the spec's bag.

‎content/docs/api/schema-reference.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ Top-level page container. Defines a full page with optional regions (header, sid
139139
| `children` | `SchemaNode \| SchemaNode[]` | Main page content when the page declares no regions — one node, or a list of them. Spelled `body` until objectui#6771 retired that spelling. |
140140
| `isDefault` | `boolean` | Whether this is the default page for the object. |
141141
| `assignedProfiles` | `string[]` | Security profiles that can access this page. |
142+
| `aria` | `AriaProps` | ARIA attributes for the page's root element: `ariaLabel` (a plain string, or an inline locale map such as `{ "en": "Orders", "fr": "Commandes" }`, resolved for the display locale) renders `aria-label`, `ariaDescribedBy` renders `aria-describedby`, and `role` renders `role`. This is the spec's inline vocabulary, not the keyed flat `ariaLabel` described under BaseSchema. The page adds no default role. |
142143

143144
**Related:** [AppSchema](/docs/core/app-schema), [DivSchema](#divschema), [GridSchema](#gridschema)
144145

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The page root reads its nested `aria` bag (objectui#11083).
11+
*
12+
* The spec's `PageSchema` declares `aria` as `AriaPropsSchema`, and
13+
* `PageNodeSchema` mirrors it. `PageRenderer` hands its wrapper `toDomProps`,
14+
* which keeps DOM attributes only, so the `aria` OBJECT was dropped there and
15+
* `{ type: 'page', aria: { ariaLabel: 'Page name' } }` rendered no
16+
* `aria-label` anywhere. The wrapper now spreads `resolveInlineAriaProps`
17+
* from `@object-ui/react`, the one reader of that bag.
18+
*
19+
* Every case mounts through the real `SchemaRenderer` and registry, inside an
20+
* `I18nProvider` for the language under test. `createI18n` registers its
21+
* instance as react-i18next's module-global default and that survives
22+
* `cleanup()`, so a render without a provider would resolve against whichever
23+
* language an earlier case mounted.
24+
*
25+
* The control is the FLAT `ariaLabel` on the same page, which `SchemaRenderer`
26+
* resolves in the keyed vocabulary and `toDomProps` forwards. It shows the
27+
* harness can see an `aria-label` on the wrapper at all; that channel does not
28+
* change.
29+
*/
30+
31+
import * as React from 'react';
32+
import { describe, it, expect, afterEach } from 'vitest';
33+
import { render, cleanup } from '@testing-library/react';
34+
import { I18nProvider } from '@object-ui/i18n';
35+
import { ActionProvider, SchemaRenderer } from '@object-ui/react';
36+
// Registers every renderer at module scope, not in a hook
37+
// (object-ui/no-dynamic-import-in-test-hook, objectui#3010).
38+
import '../renderers';
39+
40+
afterEach(cleanup);
41+
42+
const LOCALE_MAP = { en: 'Order desk', 'zh-CN': '订单台' };
43+
44+
/** A page with one body node, so the wrapper is not the only element. */
45+
function pageNode(extra: Record<string, unknown>): Record<string, unknown> {
46+
return {
47+
type: 'page',
48+
pageType: 'app',
49+
label: 'Orders',
50+
children: [{ type: 'element:text', properties: { content: 'body' } }],
51+
...extra,
52+
};
53+
}
54+
55+
function renderIn(language: string, schema: Record<string, unknown>) {
56+
return render(
57+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
58+
<ActionProvider>
59+
<SchemaRenderer schema={schema as never} />
60+
</ActionProvider>
61+
</I18nProvider>,
62+
);
63+
}
64+
65+
/** The page's own wrapper, the element `data-page-type` marks. */
66+
function wrapperOf(container: HTMLElement): Element {
67+
const hits = container.querySelectorAll('[data-page-type]');
68+
expect(hits.length, 'page wrappers').toBe(1);
69+
return hits[0];
70+
}
71+
72+
/** The one element carrying `attr`; fails when there is none or more than one. */
73+
function carrierOf(container: HTMLElement, attr: string): Element {
74+
const hits = container.querySelectorAll(`[${attr}]`);
75+
expect(hits.length, `elements carrying ${attr}`).toBe(1);
76+
return hits[0];
77+
}
78+
79+
describe('the page root renders its nested aria bag (objectui#11083)', () => {
80+
for (const language of ['en', 'zh']) {
81+
it(`a plain-string ariaLabel renders aria-label on the page root under ${language}`, () => {
82+
const { container } = renderIn(language, pageNode({ aria: { ariaLabel: 'Page name' } }));
83+
expect(carrierOf(container, 'aria-label')).toBe(wrapperOf(container));
84+
expect(wrapperOf(container).getAttribute('aria-label')).toBe('Page name');
85+
});
86+
}
87+
88+
it('a locale-map ariaLabel renders the en entry under en', () => {
89+
const { container } = renderIn('en', pageNode({ aria: { ariaLabel: LOCALE_MAP } }));
90+
expect(carrierOf(container, 'aria-label')).toBe(wrapperOf(container));
91+
expect(wrapperOf(container).getAttribute('aria-label')).toBe('Order desk');
92+
});
93+
94+
it('a locale-map ariaLabel renders the zh entry under zh', () => {
95+
const { container } = renderIn('zh', pageNode({ aria: { ariaLabel: LOCALE_MAP } }));
96+
expect(carrierOf(container, 'aria-label')).toBe(wrapperOf(container));
97+
expect(wrapperOf(container).getAttribute('aria-label')).toBe('订单台');
98+
expect(container.innerHTML).not.toContain('[object Object]');
99+
});
100+
101+
it('ariaDescribedBy renders aria-describedby, and role renders role, on the page root', () => {
102+
const { container } = renderIn('en', pageNode({ aria: { ariaDescribedBy: 'hint-1', role: 'main' } }));
103+
const wrapper = wrapperOf(container);
104+
expect(carrierOf(container, 'aria-describedby')).toBe(wrapper);
105+
expect(wrapper.getAttribute('aria-describedby')).toBe('hint-1');
106+
expect(wrapper.getAttribute('role')).toBe('main');
107+
});
108+
109+
it('a page that authors no aria bag adds no ARIA attribute and no default role', () => {
110+
const wrapper = wrapperOf(renderIn('en', pageNode({})).container);
111+
expect(wrapper.hasAttribute('aria-label')).toBe(false);
112+
expect(wrapper.hasAttribute('aria-describedby')).toBe(false);
113+
expect(wrapper.hasAttribute('role')).toBe(false);
114+
});
115+
116+
it('control: the flat keyed ariaLabel still reaches the page root', () => {
117+
const { container } = renderIn('en', pageNode({ ariaLabel: 'Flat name' }));
118+
expect(carrierOf(container, 'aria-label')).toBe(wrapperOf(container));
119+
expect(wrapperOf(container).getAttribute('aria-label')).toBe('Flat name');
120+
});
121+
});

‎packages/components/src/renderers/layout/page.tsx‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,15 @@
1414

1515
import React, { useMemo } from 'react';
1616
import type { BaseSchema, PageNodeSchema, PageNodeRegion, SchemaNode } from '@object-ui/types';
17-
import { SchemaRenderer, toRenderableSchema, PageVariablesProvider, PageVariableActionBridge } from '@object-ui/react';
17+
import {
18+
SchemaRenderer,
19+
toRenderableSchema,
20+
PageVariablesProvider,
21+
PageVariableActionBridge,
22+
resolveInlineAriaProps,
23+
} from '@object-ui/react';
1824
import { ComponentRegistry, toDomProps } from '@object-ui/core';
25+
import { useDisplayLocale } from '@object-ui/i18n';
1926
import { compile, manifestFromConfigs, type Diagnostic } from '@object-ui/sdui-parser';
2027
import { ReactKindPage } from './react-page';
2128
import { cn } from '../../lib/utils';
@@ -563,6 +570,18 @@ export const PageRenderer: React.FC<{
563570
} = props;
564571
const pageProps = toDomProps(props);
565572

573+
// The page's own `aria` bag (objectui#11083). The spec's `PageSchema`
574+
// declares it as `AriaPropsSchema`, and `PageNodeSchema` mirrors it, so it is
575+
// the NESTED vocabulary: `ariaLabel` is a plain string or an inline locale
576+
// map. `toDomProps` above drops the `aria` prop, because it is an object and
577+
// not a DOM attribute, so a declared accessible name used to reach no element.
578+
// It is read through `resolveInlineAriaProps`, the one reader of that bag, and
579+
// this renderer keeps no mapping of its own. The page supplies no default role.
580+
// It is spread AFTER `pageProps`, so when a flat key and the nested bag name
581+
// the same attribute, the page's own declared slot wins.
582+
const locale = useDisplayLocale();
583+
const pageAria = resolveInlineAriaProps(schema.aria, locale);
584+
566585
// Select the layout variant based on template or page type
567586
const layoutElement = useMemo(() => {
568587
// `PageSchema['kind']` now spells the source-authored values too, matching
@@ -658,6 +677,7 @@ export const PageRenderer: React.FC<{
658677
data-obj-type={dataObjType}
659678
style={style}
660679
{...pageProps}
680+
{...pageAria}
661681
>
662682
<div className={cn(fullBleed ? 'space-y-6' : 'mx-auto space-y-6', maxWidthClass)}>
663683
{/* Implicit page title — the fallback heading for a page that does NOT
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* `useRecordAriaProps` maps the `record:*` bag through the shared reader, in
11+
* the viewer's language (objectui#11083).
12+
*
13+
* The hook now builds on `resolveInlineAriaProps` from `@object-ui/react`,
14+
* resolved against `useDisplayLocale()`, and keeps only the block family's own
15+
* defaults and the refused-`label` report. `recordComponentAria-9556.test.tsx`
16+
* pins the name, role and description under `en`, and
17+
* `recordAriaLabelRetired-9945.test.tsx` pins the defaults and the report. This
18+
* file adds the `zh` half: a plain string and a locale map, each read off the
19+
* accessibility tree rather than the attribute, for the reason the 9556 file
20+
* gives.
21+
*/
22+
23+
import * as React from 'react';
24+
import { describe, it, expect, afterEach, vi } from 'vitest';
25+
import '@testing-library/jest-dom';
26+
import { render, screen, cleanup } from '@testing-library/react';
27+
import { I18nProvider } from '@object-ui/i18n';
28+
import { RecordContextProvider } from '@object-ui/react';
29+
import { RecordHighlightsRenderer } from '../record-highlights';
30+
import { RecordPathRenderer } from '../record-path';
31+
32+
/**
33+
* One fetch double at module scope, never torn down: a block can issue a read
34+
* after the test body returns, and no case here asserts on its answer (the
35+
* same reasoning `recordComponentAria-9556.test.tsx` records).
36+
*/
37+
vi.stubGlobal('fetch', vi.fn(async () => ({
38+
ok: true,
39+
status: 200,
40+
json: async () => ({ allowed: true, value: [], data: [], records: [] }),
41+
text: async () => '{"allowed":true}',
42+
})) as never);
43+
44+
afterEach(() => cleanup());
45+
46+
const LOCALE_MAP = { en: 'Account overview', 'zh-CN': '客户概览' };
47+
48+
function mountIn(language: string, node: React.ReactElement) {
49+
return render(
50+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
51+
<RecordContextProvider objectName="crm_account" recordId="rec-1" data={{ id: 'rec-1', stage: 'won' }}>
52+
{node}
53+
</RecordContextProvider>
54+
</I18nProvider>,
55+
);
56+
}
57+
58+
const highlights = (aria?: unknown) => (
59+
<RecordHighlightsRenderer schema={{ fields: ['name'], ...(aria ? { aria } : {}) } as never} />
60+
);
61+
62+
describe('record:* aria bag under zh (objectui#11083)', () => {
63+
it('a plain-string ariaLabel names the container under zh', () => {
64+
mountIn('zh', highlights({ ariaLabel: 'Account overview' }));
65+
expect(screen.getByRole('region', { name: 'Account overview' })).toBeInTheDocument();
66+
});
67+
68+
it('a locale-map ariaLabel names the container with the zh entry under zh', () => {
69+
const { container } = mountIn('zh', highlights({ ariaLabel: LOCALE_MAP }));
70+
expect(screen.getByRole('region', { name: '客户概览' })).toBeInTheDocument();
71+
expect(screen.queryByRole('region', { name: 'Account overview' })).toBeNull();
72+
expect(container.innerHTML).not.toContain('[object Object]');
73+
});
74+
75+
it('a locale-map ariaLabel names the container with the en entry under en', () => {
76+
mountIn('en', highlights({ ariaLabel: LOCALE_MAP }));
77+
expect(screen.getByRole('region', { name: 'Account overview' })).toBeInTheDocument();
78+
});
79+
80+
it("the caller's default role carries the authored locale-map name under zh", () => {
81+
// `record:path` passes `defaultRole: 'list'`; the authored name replaces its
82+
// built-in one and the default role stays.
83+
mountIn(
84+
'zh',
85+
<RecordPathRenderer
86+
schema={{
87+
statusField: 'stage',
88+
stages: [{ value: 'open', label: 'Open' }, { value: 'won', label: 'Won' }],
89+
aria: { ariaLabel: { en: 'Deal stages', 'zh-CN': '交易阶段' } },
90+
} as never}
91+
/>,
92+
);
93+
expect(screen.getAllByRole('list', { name: '交易阶段' }).length).toBeGreaterThan(0);
94+
});
95+
});

‎packages/plugin-detail/src/renderers/recordComponentAria.ts‎

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,24 @@
4646
* stay bare `div`s until an author declares something, `record:path` passes
4747
* `'list'`, `record:quick_actions` passes `'toolbar'`. The author's own
4848
* `aria.role` wins over both, which is the precedence `@object-ui/plugin-list`
49-
* already ships (`ListView`'s `role={schema.aria?.role ?? 'region'}`), and
50-
* `'region'` is the fallback for the same reason it is ListView's.
49+
* already ships (`ListView`'s list region: the authored role, else `region`),
50+
* and `'region'` is the fallback for the same reason it is ListView's.
51+
*
52+
* ## The bag is mapped by the shared reader (objectui#11083)
53+
*
54+
* The three declared keys go through `resolveInlineAriaProps` from
55+
* `@object-ui/react`, the one reader of the spec's nested bag, resolved
56+
* against `useDisplayLocale()`. ⛔ This module keeps no mapping of its own. What
57+
* stays here is what is the block family's own: the caller's `defaultRole` and
58+
* `defaultLabel`, the `'region'` fallback, and the report of a served
59+
* `aria.label`.
5160
*
5261
* ## An empty name is no name
5362
*
5463
* `||` for the built-in default, as objectui#4663 chose: a declared
55-
* `ariaLabel: ''` resolves to no name at all, so the block's built-in default
56-
* wins, which matches `ListView`'s own read point (it emits no `aria-label` for
57-
* an empty `ariaLabel`).
64+
* `ariaLabel: ''` resolves to no name at all (the shared reader leaves it
65+
* out), so the block's built-in default wins, which matches `ListView`'s own
66+
* read point (it emits no `aria-label` for an empty `ariaLabel`).
5867
*
5968
* ## ⛔ The refused `aria.label` spelling is REPORTED, never read (objectui#9945)
6069
*
@@ -92,7 +101,7 @@
92101

93102
import { useEffect } from 'react';
94103
import { useDisplayLocale } from '@object-ui/i18n';
95-
import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui';
104+
import { resolveInlineAriaProps } from '@object-ui/react';
96105
import type { RecordComponentAriaProps } from '@object-ui/types';
97106

98107
/**
@@ -142,29 +151,20 @@ function describeRefusedAriaLabel(
142151
}
143152

144153
/**
145-
* Resolve a block's authored accessible name from `aria.ariaLabel`, the one
146-
* spelling the contract accepts.
147-
*
148-
* Returns `undefined` when nothing is authored, and the EMPTY STRING when the
149-
* author declared an empty name. The caller's `||` turns either into its
150-
* built-in default.
154+
* Report a served `aria.label` once per mounted block, for a caller that names
155+
* its `block`. The spelling is never read as a name.
151156
*
152-
* @param block - the block's registered type (`record:path`), named in the
153-
* report of a served `aria.label`. Omit it and that spelling is still not
154-
* read, just not reported. See this module's header for which blocks pass it.
157+
* From an effect keyed on the message, never from render: the channel the
158+
* row-cap refusal uses (objectui#9925), so a re-render says nothing twice.
155159
*/
156-
export function useRecordAriaName(
160+
function useRefusedAriaLabelReport(
161+
block: string | undefined,
157162
aria: AuthoredRecordAria | undefined,
158-
{ block }: { block?: string } = {},
159-
): string | undefined {
160-
const locale = useDisplayLocale();
163+
): void {
161164
const refusal = describeRefusedAriaLabel(block, aria);
162-
// From an effect keyed on the message, never from render: the channel the
163-
// row-cap refusal uses (objectui#9925), so a re-render says nothing twice.
164165
useEffect(() => {
165166
if (refusal) console.warn(refusal);
166167
}, [refusal]);
167-
return resolveInlineI18nLabel(aria?.ariaLabel, locale);
168168
}
169169

170170
/**
@@ -177,8 +177,9 @@ export function useRecordAriaName(
177177
* because an attribute on a `generic` element carries nothing.
178178
* @param defaultLabel - the block's built-in accessible name, used when the
179179
* author declared none (or declared an empty one).
180-
* @param block - forwarded to {@link useRecordAriaName}, which names it when
181-
* it reports a served `aria.label`.
180+
* @param block - the block's registered type (`record:path`), named in the
181+
* report of a served `aria.label`. Omit it and that spelling is still not
182+
* read, just not reported. See this module's header for which blocks pass it.
182183
*/
183184
export function useRecordAriaProps(
184185
aria: AuthoredRecordAria | undefined,
@@ -188,11 +189,13 @@ export function useRecordAriaProps(
188189
block,
189190
}: { defaultRole?: string; defaultLabel?: string; block?: string } = {},
190191
): RecordAriaDomProps {
191-
const name = useRecordAriaName(aria, { block });
192+
const locale = useDisplayLocale();
193+
useRefusedAriaLabelReport(block, aria);
194+
const authored = resolveInlineAriaProps(aria, locale);
192195
// `||`, not `??`: an authored empty name means "no name", not "this name".
193-
const label = name || defaultLabel;
194-
const describedBy = aria?.ariaDescribedBy || undefined;
195-
const role = aria?.role || defaultRole || (label || describedBy ? 'region' : undefined);
196+
const label = authored['aria-label'] || defaultLabel;
197+
const describedBy = authored['aria-describedby'];
198+
const role = authored.role || defaultRole || (label || describedBy ? 'region' : undefined);
196199
return {
197200
...(label ? { 'aria-label': label } : {}),
198201
...(describedBy ? { 'aria-describedby': describedBy } : {}),

0 commit comments

Comments
 (0)