From 904eed298bc46665f9a16eb017bc514a50ab207c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 12:30:40 +0000 Subject: [PATCH 1/4] 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 --- .../__tests__/page-root-aria-11083.test.tsx | 121 ++++++++++++++++++ .../components/src/renderers/layout/page.tsx | 22 +++- .../recordAriaSharedReader-11083.test.tsx | 95 ++++++++++++++ .../src/renderers/recordComponentAria.ts | 59 +++++---- packages/plugin-list/src/ListView.tsx | 37 +++--- ...View.nestedAriaSharedReader-11083.test.tsx | 105 +++++++++++++++ 6 files changed, 391 insertions(+), 48 deletions(-) create mode 100644 packages/components/src/__tests__/page-root-aria-11083.test.tsx create mode 100644 packages/plugin-detail/src/renderers/__tests__/recordAriaSharedReader-11083.test.tsx create mode 100644 packages/plugin-list/src/__tests__/ListView.nestedAriaSharedReader-11083.test.tsx diff --git a/packages/components/src/__tests__/page-root-aria-11083.test.tsx b/packages/components/src/__tests__/page-root-aria-11083.test.tsx new file mode 100644 index 0000000000..57232f7a5f --- /dev/null +++ b/packages/components/src/__tests__/page-root-aria-11083.test.tsx @@ -0,0 +1,121 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The page root reads its nested `aria` bag (objectui#11083). + * + * The spec's `PageSchema` declares `aria` as `AriaPropsSchema`, and + * `PageNodeSchema` mirrors it. `PageRenderer` hands its wrapper `toDomProps`, + * which keeps DOM attributes only, so the `aria` OBJECT was dropped there and + * `{ type: 'page', aria: { ariaLabel: 'Page name' } }` rendered no + * `aria-label` anywhere. The wrapper now spreads `resolveInlineAriaProps` + * from `@object-ui/react`, the one reader of that bag. + * + * Every case mounts through the real `SchemaRenderer` and registry, inside an + * `I18nProvider` for the language under test. `createI18n` registers its + * instance as react-i18next's module-global default and that survives + * `cleanup()`, so a render without a provider would resolve against whichever + * language an earlier case mounted. + * + * The control is the FLAT `ariaLabel` on the same page, which `SchemaRenderer` + * resolves in the keyed vocabulary and `toDomProps` forwards. It shows the + * harness can see an `aria-label` on the wrapper at all; that channel does not + * change. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { ActionProvider, SchemaRenderer } from '@object-ui/react'; +// Registers every renderer at module scope, not in a hook +// (object-ui/no-dynamic-import-in-test-hook, objectui#3010). +import '../renderers'; + +afterEach(cleanup); + +const LOCALE_MAP = { en: 'Order desk', 'zh-CN': '订单台' }; + +/** A page with one body node, so the wrapper is not the only element. */ +function pageNode(extra: Record): Record { + return { + type: 'page', + pageType: 'app', + label: 'Orders', + children: [{ type: 'element:text', properties: { content: 'body' } }], + ...extra, + }; +} + +function renderIn(language: string, schema: Record) { + return render( + + + + + , + ); +} + +/** The page's own wrapper, the element `data-page-type` marks. */ +function wrapperOf(container: HTMLElement): Element { + const hits = container.querySelectorAll('[data-page-type]'); + expect(hits.length, 'page wrappers').toBe(1); + return hits[0]; +} + +/** The one element carrying `attr`; fails when there is none or more than one. */ +function carrierOf(container: HTMLElement, attr: string): Element { + const hits = container.querySelectorAll(`[${attr}]`); + expect(hits.length, `elements carrying ${attr}`).toBe(1); + return hits[0]; +} + +describe('the page root renders its nested aria bag (objectui#11083)', () => { + for (const language of ['en', 'zh']) { + it(`a plain-string ariaLabel renders aria-label on the page root under ${language}`, () => { + const { container } = renderIn(language, pageNode({ aria: { ariaLabel: 'Page name' } })); + expect(carrierOf(container, 'aria-label')).toBe(wrapperOf(container)); + expect(wrapperOf(container).getAttribute('aria-label')).toBe('Page name'); + }); + } + + it('a locale-map ariaLabel renders the en entry under en', () => { + const { container } = renderIn('en', pageNode({ aria: { ariaLabel: LOCALE_MAP } })); + expect(carrierOf(container, 'aria-label')).toBe(wrapperOf(container)); + expect(wrapperOf(container).getAttribute('aria-label')).toBe('Order desk'); + }); + + it('a locale-map ariaLabel renders the zh entry under zh', () => { + const { container } = renderIn('zh', pageNode({ aria: { ariaLabel: LOCALE_MAP } })); + expect(carrierOf(container, 'aria-label')).toBe(wrapperOf(container)); + expect(wrapperOf(container).getAttribute('aria-label')).toBe('订单台'); + expect(container.innerHTML).not.toContain('[object Object]'); + }); + + it('ariaDescribedBy renders aria-describedby, and role renders role, on the page root', () => { + const { container } = renderIn('en', pageNode({ aria: { ariaDescribedBy: 'hint-1', role: 'main' } })); + const wrapper = wrapperOf(container); + expect(carrierOf(container, 'aria-describedby')).toBe(wrapper); + expect(wrapper.getAttribute('aria-describedby')).toBe('hint-1'); + expect(wrapper.getAttribute('role')).toBe('main'); + }); + + it('a page that authors no aria bag adds no ARIA attribute and no default role', () => { + const wrapper = wrapperOf(renderIn('en', pageNode({})).container); + expect(wrapper.hasAttribute('aria-label')).toBe(false); + expect(wrapper.hasAttribute('aria-describedby')).toBe(false); + expect(wrapper.hasAttribute('role')).toBe(false); + }); + + it('control: the flat keyed ariaLabel still reaches the page root', () => { + const { container } = renderIn('en', pageNode({ ariaLabel: 'Flat name' })); + expect(carrierOf(container, 'aria-label')).toBe(wrapperOf(container)); + expect(wrapperOf(container).getAttribute('aria-label')).toBe('Flat name'); + }); +}); diff --git a/packages/components/src/renderers/layout/page.tsx b/packages/components/src/renderers/layout/page.tsx index 7088672a30..f5185737db 100644 --- a/packages/components/src/renderers/layout/page.tsx +++ b/packages/components/src/renderers/layout/page.tsx @@ -14,8 +14,15 @@ import React, { useMemo } from 'react'; import type { BaseSchema, PageNodeSchema, PageNodeRegion, SchemaNode } from '@object-ui/types'; -import { SchemaRenderer, toRenderableSchema, PageVariablesProvider, PageVariableActionBridge } from '@object-ui/react'; +import { + SchemaRenderer, + toRenderableSchema, + PageVariablesProvider, + PageVariableActionBridge, + resolveInlineAriaProps, +} from '@object-ui/react'; import { ComponentRegistry, toDomProps } from '@object-ui/core'; +import { useDisplayLocale } from '@object-ui/i18n'; import { compile, manifestFromConfigs, type Diagnostic } from '@object-ui/sdui-parser'; import { ReactKindPage } from './react-page'; import { cn } from '../../lib/utils'; @@ -563,6 +570,18 @@ export const PageRenderer: React.FC<{ } = props; const pageProps = toDomProps(props); + // The page's own `aria` bag (objectui#11083). The spec's `PageSchema` + // declares it as `AriaPropsSchema`, and `PageNodeSchema` mirrors it, so it is + // the NESTED vocabulary: `ariaLabel` is a plain string or an inline locale + // map. `toDomProps` above drops the `aria` prop, because it is an object and + // not a DOM attribute, so a declared accessible name used to reach no element. + // It is read through `resolveInlineAriaProps`, the one reader of that bag, and + // this renderer keeps no mapping of its own. The page supplies no default role. + // 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. + const locale = useDisplayLocale(); + const pageAria = resolveInlineAriaProps(schema.aria, locale); + // Select the layout variant based on template or page type const layoutElement = useMemo(() => { // `PageSchema['kind']` now spells the source-authored values too, matching @@ -658,6 +677,7 @@ export const PageRenderer: React.FC<{ data-obj-type={dataObjType} style={style} {...pageProps} + {...pageAria} >
{/* Implicit page title — the fallback heading for a page that does NOT diff --git a/packages/plugin-detail/src/renderers/__tests__/recordAriaSharedReader-11083.test.tsx b/packages/plugin-detail/src/renderers/__tests__/recordAriaSharedReader-11083.test.tsx new file mode 100644 index 0000000000..78310c43fc --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/recordAriaSharedReader-11083.test.tsx @@ -0,0 +1,95 @@ +/** + * 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. + */ + +/** + * `useRecordAriaProps` maps the `record:*` bag through the shared reader, in + * the viewer's language (objectui#11083). + * + * The hook now builds on `resolveInlineAriaProps` from `@object-ui/react`, + * resolved against `useDisplayLocale()`, and keeps only the block family's own + * defaults and the refused-`label` report. `recordComponentAria-9556.test.tsx` + * pins the name, role and description under `en`, and + * `recordAriaLabelRetired-9945.test.tsx` pins the defaults and the report. This + * file adds the `zh` half: a plain string and a locale map, each read off the + * accessibility tree rather than the attribute, for the reason the 9556 file + * gives. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import '@testing-library/jest-dom'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { RecordContextProvider } from '@object-ui/react'; +import { RecordHighlightsRenderer } from '../record-highlights'; +import { RecordPathRenderer } from '../record-path'; + +/** + * One fetch double at module scope, never torn down: a block can issue a read + * after the test body returns, and no case here asserts on its answer (the + * same reasoning `recordComponentAria-9556.test.tsx` records). + */ +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()); + +const LOCALE_MAP = { en: 'Account overview', 'zh-CN': '客户概览' }; + +function mountIn(language: string, node: React.ReactElement) { + return render( + + + {node} + + , + ); +} + +const highlights = (aria?: unknown) => ( + +); + +describe('record:* aria bag under zh (objectui#11083)', () => { + it('a plain-string ariaLabel names the container under zh', () => { + mountIn('zh', highlights({ ariaLabel: 'Account overview' })); + expect(screen.getByRole('region', { name: 'Account overview' })).toBeInTheDocument(); + }); + + it('a locale-map ariaLabel names the container with the zh entry under zh', () => { + const { container } = mountIn('zh', highlights({ ariaLabel: LOCALE_MAP })); + expect(screen.getByRole('region', { name: '客户概览' })).toBeInTheDocument(); + expect(screen.queryByRole('region', { name: 'Account overview' })).toBeNull(); + expect(container.innerHTML).not.toContain('[object Object]'); + }); + + it('a locale-map ariaLabel names the container with the en entry under en', () => { + mountIn('en', highlights({ ariaLabel: LOCALE_MAP })); + expect(screen.getByRole('region', { name: 'Account overview' })).toBeInTheDocument(); + }); + + it("the caller's default role carries the authored locale-map name under zh", () => { + // `record:path` passes `defaultRole: 'list'`; the authored name replaces its + // built-in one and the default role stays. + mountIn( + 'zh', + , + ); + expect(screen.getAllByRole('list', { name: '交易阶段' }).length).toBeGreaterThan(0); + }); +}); diff --git a/packages/plugin-detail/src/renderers/recordComponentAria.ts b/packages/plugin-detail/src/renderers/recordComponentAria.ts index e8a0df0a61..17b045e2de 100644 --- a/packages/plugin-detail/src/renderers/recordComponentAria.ts +++ b/packages/plugin-detail/src/renderers/recordComponentAria.ts @@ -46,15 +46,24 @@ * stay bare `div`s until an author declares something, `record:path` passes * `'list'`, `record:quick_actions` passes `'toolbar'`. The author's own * `aria.role` wins over both, which is the precedence `@object-ui/plugin-list` - * already ships (`ListView`'s `role={schema.aria?.role ?? 'region'}`), and - * `'region'` is the fallback for the same reason it is ListView's. + * already ships (`ListView`'s list region: the authored role, else `region`), + * and `'region'` is the fallback for the same reason it is ListView's. + * + * ## The bag is mapped by the shared reader (objectui#11083) + * + * The three declared keys go through `resolveInlineAriaProps` from + * `@object-ui/react`, the one reader of the spec's nested bag, resolved + * against `useDisplayLocale()`. ⛔ This module keeps no mapping of its own. What + * stays here is what is the block family's own: the caller's `defaultRole` and + * `defaultLabel`, the `'region'` fallback, and the report of a served + * `aria.label`. * * ## An empty name is no name * * `||` for the built-in default, as objectui#4663 chose: a declared - * `ariaLabel: ''` resolves to no name at all, so the block's built-in default - * wins, which matches `ListView`'s own read point (it emits no `aria-label` for - * an empty `ariaLabel`). + * `ariaLabel: ''` resolves to no name at all (the shared reader leaves it + * out), so the block's built-in default wins, which matches `ListView`'s own + * read point (it emits no `aria-label` for an empty `ariaLabel`). * * ## ⛔ The refused `aria.label` spelling is REPORTED, never read (objectui#9945) * @@ -92,7 +101,7 @@ import { useEffect } from 'react'; import { useDisplayLocale } from '@object-ui/i18n'; -import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; +import { resolveInlineAriaProps } from '@object-ui/react'; import type { RecordComponentAriaProps } from '@object-ui/types'; /** @@ -142,29 +151,20 @@ function describeRefusedAriaLabel( } /** - * Resolve a block's authored accessible name from `aria.ariaLabel`, the one - * spelling the contract accepts. - * - * Returns `undefined` when nothing is authored, and the EMPTY STRING when the - * author declared an empty name. The caller's `||` turns either into its - * built-in default. + * Report a served `aria.label` once per mounted block, for a caller that names + * its `block`. The spelling is never read as a name. * - * @param block - the block's registered type (`record:path`), named in the - * report of a served `aria.label`. Omit it and that spelling is still not - * read, just not reported. See this module's header for which blocks pass it. + * From an effect keyed on the message, never from render: the channel the + * row-cap refusal uses (objectui#9925), so a re-render says nothing twice. */ -export function useRecordAriaName( +function useRefusedAriaLabelReport( + block: string | undefined, aria: AuthoredRecordAria | undefined, - { block }: { block?: string } = {}, -): string | undefined { - const locale = useDisplayLocale(); +): void { const refusal = describeRefusedAriaLabel(block, aria); - // From an effect keyed on the message, never from render: the channel the - // row-cap refusal uses (objectui#9925), so a re-render says nothing twice. useEffect(() => { if (refusal) console.warn(refusal); }, [refusal]); - return resolveInlineI18nLabel(aria?.ariaLabel, locale); } /** @@ -177,8 +177,9 @@ export function useRecordAriaName( * because an attribute on a `generic` element carries nothing. * @param defaultLabel - the block's built-in accessible name, used when the * author declared none (or declared an empty one). - * @param block - forwarded to {@link useRecordAriaName}, which names it when - * it reports a served `aria.label`. + * @param block - the block's registered type (`record:path`), named in the + * report of a served `aria.label`. Omit it and that spelling is still not + * read, just not reported. See this module's header for which blocks pass it. */ export function useRecordAriaProps( aria: AuthoredRecordAria | undefined, @@ -188,11 +189,13 @@ export function useRecordAriaProps( block, }: { defaultRole?: string; defaultLabel?: string; block?: string } = {}, ): RecordAriaDomProps { - const name = useRecordAriaName(aria, { block }); + const locale = useDisplayLocale(); + useRefusedAriaLabelReport(block, aria); + const authored = resolveInlineAriaProps(aria, locale); // `||`, not `??`: an authored empty name means "no name", not "this name". - const label = name || defaultLabel; - const describedBy = aria?.ariaDescribedBy || undefined; - const role = aria?.role || defaultRole || (label || describedBy ? 'region' : undefined); + const label = authored['aria-label'] || defaultLabel; + const describedBy = authored['aria-describedby']; + const role = authored.role || defaultRole || (label || describedBy ? 'region' : undefined); return { ...(label ? { 'aria-label': label } : {}), ...(describedBy ? { 'aria-describedby': describedBy } : {}), diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 41776b50e0..a6bab8af4b 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -17,7 +17,7 @@ import { ViewSettingsPopover } from './components/ViewSettingsPopover'; import { UserFilters } from './UserFilters'; import { SchemaRenderer, useNavigationOverlay, classifyLoadError, usePredicateScope, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react'; import type { LoadErrorKind } from '@object-ui/react'; -import { useDensityMode } from '@object-ui/react'; +import { useDensityMode, resolveInlineAriaProps } from '@object-ui/react'; import type { ListViewSchema, ObjectMapConfig } from '@object-ui/types'; import { detectStatusField } from '@object-ui/types'; import { usePullToRefresh } from '@object-ui/mobile'; @@ -4246,31 +4246,31 @@ export const ListView = React.forwardRef(({ }, [schema.columns, tFieldLabel]); /** - * The accessible name for the list region, resolved — not cast. + * The list region's ARIA attributes, read through the ONE reader of the + * nested bag (objectui#11083). * * The NESTED bag is the spec's `AriaPropsSchema`, whose `ariaLabel` is * `I18nLabel`: a plain string **or** an inline locale map - * (`{ en: 'Accounts', 'zh-CN': '客户' }`). This read site used to spread it - * with `as string` — a cast, not a conversion — so a map-valued label - * reached the DOM as `aria-label="[object Object]"` and a screen reader - * announced that as the view's accessible name, in every locale - * (objectui#5134). `as string` is invisible to the compiler by - * construction, which is why the sweep that fixed the compile-visible sites - * (objectui#4163 part 1) could not see this one. + * (`{ en: 'Accounts', 'zh-CN': '客户' }`). `resolveInlineAriaProps` from + * `@object-ui/react` maps its three keys (`ariaLabel`, `ariaDescribedBy`, + * `role`) and resolves the label against the display locale, so this + * component keeps no mapping of its own. A key that resolves to nothing is + * left out: no accessible name beats a garbage one. This read site once spread + * the label with `as string`, so a map reached the DOM as + * `aria-label="[object Object]"` (objectui#5134). * - * A miss resolves to `undefined` and the attribute is omitted, which is what - * an attribute wants — no accessible name beats a garbage one. That is also - * why this uses the spec's resolver rather than objectui's `pickLocalized` - * (`''` on a miss, the spelling a TEXT NODE wants — see `TabBar.tsx`); the - * two agree limb for limb, pinned by `i18nLabel-resolver-parity.test.ts` in - * this package. + * What stays here is only what is this view's own: + * - the default role, `region`, when the author declares none; + * - `aria.live`, the one key objectui's `ListViewSchema` adds to the spec's + * bag (`.extend({ live })`, kept by objectui#2890). The shared reader does + * not read it, because the spec's shape does not declare it. * * ⚠️ The FLAT `schema.ariaLabel` is a different vocabulary — objectui's * keyed `{ key, defaultValue?, params? }` ref, resolved by `SchemaRenderer`'s * `resolveKeyedI18nLabel` — and is deliberately NOT touched here. Neither * resolver accepts the other's shape. */ - const ariaLabel = resolveInlineI18nLabel(schema.aria?.ariaLabel, displayLocale); + const regionAria = resolveInlineAriaProps(schema.aria, displayLocale); /** * The view's description, resolved — not type-tested (objectui#7199). @@ -4301,10 +4301,9 @@ export const ListView = React.forwardRef(({
diff --git a/packages/plugin-list/src/__tests__/ListView.nestedAriaSharedReader-11083.test.tsx b/packages/plugin-list/src/__tests__/ListView.nestedAriaSharedReader-11083.test.tsx new file mode 100644 index 0000000000..f32e0f06c2 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.nestedAriaSharedReader-11083.test.tsx @@ -0,0 +1,105 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The list region maps its nested `aria` bag through the shared reader + * (objectui#11083). + * + * `ListView` used to map `schema.aria` inline, one key at a time. It now + * spreads `resolveInlineAriaProps` from `@object-ui/react` and keeps only what + * is its own: the `region` default role, and the `aria.live` key objectui's + * `ListViewSchema` adds to the spec's bag. The label's string and locale-map + * arms are pinned by `ListView.ariaLabelInlineLocale.test.tsx`, and the legacy + * `label` fold with `live` by `ListView.test.tsx`; this file pins the other two + * keys and the default. + * + * Locale channel: `LocalizationProvider` drives `useDisplayLocale()`'s first + * limb, the same channel the inline-locale file uses. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { LocalizationProvider } from '@object-ui/i18n'; +import { SchemaRendererProvider } from '@object-ui/react'; +import type { DataSource, ListViewSchema } from '@object-ui/types'; +import { ListView } from '../ListView'; + +afterEach(cleanup); + +/** + * A partial stub carrying only the members this path calls, crossing the + * published `DataSource` contract explicitly (objectui#7912). + */ +const mockDataSource = { + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +}; + +function renderListView(aria: unknown, locale = 'en') { + const schema = { + type: 'list-view', + objectName: 'accounts', + viewType: 'grid', + columns: ['name'], + ...(aria === undefined ? {} : { aria }), + } as ListViewSchema; + + return render( + + + + + , + ); +} + +/** The list region is the root node ListView renders. */ +const regionEl = (container: HTMLElement) => container.firstElementChild as HTMLElement; + +describe('ListView nested aria bag through the shared reader (objectui#11083)', () => { + it('ariaDescribedBy renders aria-describedby on the list region', () => { + const { container } = renderListView({ ariaLabel: 'Accounts', ariaDescribedBy: 'hint-1' }); + expect(screen.getByRole('region', { name: 'Accounts' })).toBe(regionEl(container)); + expect(regionEl(container).getAttribute('aria-describedby')).toBe('hint-1'); + }); + + it('an authored role replaces the region default', () => { + const { container } = renderListView({ ariaLabel: 'Accounts', role: 'group' }); + expect(screen.getByRole('group', { name: 'Accounts' })).toBe(regionEl(container)); + expect(screen.queryByRole('region', { name: 'Accounts' })).toBeNull(); + }); + + it('with no aria bag the region default still applies, with no name or description', () => { + const { container } = renderListView(undefined); + expect(regionEl(container).getAttribute('role')).toBe('region'); + expect(regionEl(container).hasAttribute('aria-label')).toBe(false); + expect(regionEl(container).hasAttribute('aria-describedby')).toBe(false); + }); + + it('an empty authored role falls back to the region default instead of an empty attribute', () => { + // The shared reader leaves an empty role out, so the default applies. The + // inline mapping this replaced wrote `role=""`, which names no role. + const { container } = renderListView({ ariaLabel: 'Accounts', role: '' }); + expect(regionEl(container).getAttribute('role')).toBe('region'); + expect(screen.getByRole('region', { name: 'Accounts' })).toBe(regionEl(container)); + }); + + it('a locale-map ariaLabel under zh renders the zh entry', () => { + const { container } = renderListView({ ariaLabel: { en: 'Accounts', 'zh-CN': '客户' } }, 'zh-CN'); + expect(regionEl(container).getAttribute('aria-label')).toBe('客户'); + }); + + it('aria.live, the ListViewSchema extension, still renders aria-live', () => { + const { container } = renderListView({ ariaLabel: 'Accounts', live: 'polite' }); + expect(regionEl(container).getAttribute('aria-live')).toBe('polite'); + }); +}); From 4cd87b09745072d4ee63541be5caf5f41d2dfd26 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 12:54:42 +0000 Subject: [PATCH 2/4] chore(changeset): the page root's nested aria bag, and ListView and useRecordAriaProps on the shared reader Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude --- .changeset/11083-nested-aria-family.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/11083-nested-aria-family.md diff --git a/.changeset/11083-nested-aria-family.md b/.changeset/11083-nested-aria-family.md new file mode 100644 index 0000000000..333c8d6c20 --- /dev/null +++ b/.changeset/11083-nested-aria-family.md @@ -0,0 +1,17 @@ +--- +'@object-ui/components': patch +'@object-ui/plugin-list': patch +'@object-ui/plugin-detail': patch +--- + +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). + +**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: + +- `aria-label` from `ariaLabel`. A locale map (`{ en: 'Orders', 'zh-CN': '订单' }`) gives the entry for the display locale (`useDisplayLocale()`); +- `aria-describedby` from `ariaDescribedBy`; +- `role` from `role`. + +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. + +**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. From f4969f5521c48a440d98a48e7c00f89c435bbd30 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 12:58:43 +0000 Subject: [PATCH 3/4] docs(schema-reference): the page's nested aria bag renders on its root element Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude --- content/docs/api/schema-reference.md | 1 + 1 file changed, 1 insertion(+) diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 639bfc8429..9e1c09c5a5 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -139,6 +139,7 @@ Top-level page container. Defines a full page with optional regions (header, sid | `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. | | `isDefault` | `boolean` | Whether this is the default page for the object. | | `assignedProfiles` | `string[]` | Security profiles that can access this page. | +| `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 DivSchema. The page adds no default role. | **Related:** [AppSchema](/docs/core/app-schema), [DivSchema](#divschema), [GridSchema](#gridschema) From bf5aec064352833c898835cdf8d2ae5d66c11cea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 13:20:10 +0000 Subject: [PATCH 4/4] docs(schema-reference): the page aria row points the keyed flat ariaLabel at BaseSchema, where it is described Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec Co-authored-by: Claude --- content/docs/api/schema-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 5592b215cb..97b227efed 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -139,7 +139,7 @@ Top-level page container. Defines a full page with optional regions (header, sid | `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. | | `isDefault` | `boolean` | Whether this is the default page for the object. | | `assignedProfiles` | `string[]` | Security profiles that can access this page. | -| `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 DivSchema. The page adds no default role. | +| `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. | **Related:** [AppSchema](/docs/core/app-schema), [DivSchema](#divschema), [GridSchema](#gridschema)