Skip to content

Commit 904eed2

Browse files
committed
fix(components,plugin-list,plugin-detail): the page root reads its nested aria bag, and ListView and useRecordAriaProps map through the shared reader
The page renderer hands its wrapper toDomProps, which drops the `aria` object, so a page's declared accessible name reached no element. The wrapper now spreads resolveInlineAriaProps(schema.aria, locale). ListView's list region and useRecordAriaProps map the three declared keys through the same reader and keep only their own defaults (the region role; defaultRole, defaultLabel and the refused-label report). ListView keeps its aria.live read, the ListViewSchema extension. Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3e71a98 commit 904eed2

6 files changed

Lines changed: 391 additions & 48 deletions

File tree

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)