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. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 239936d5f9..97b227efed 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 BaseSchema. The page adds no default role. | **Related:** [AppSchema](/docs/core/app-schema), [DivSchema](#divschema), [GridSchema](#gridschema) 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'); + }); +});