Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/11083-nested-aria-family.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
121 changes: 121 additions & 0 deletions packages/components/src/__tests__/page-root-aria-11083.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Record<string, unknown> {
return {
type: 'page',
pageType: 'app',
label: 'Orders',
children: [{ type: 'element:text', properties: { content: 'body' } }],
...extra,
};
}

function renderIn(language: string, schema: Record<string, unknown>) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<ActionProvider>
<SchemaRenderer schema={schema as never} />
</ActionProvider>
</I18nProvider>,
);
}

/** 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');
});
});
22 changes: 21 additions & 1 deletion packages/components/src/renderers/layout/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -658,6 +677,7 @@ export const PageRenderer: React.FC<{
data-obj-type={dataObjType}
style={style}
{...pageProps}
{...pageAria}
>
<div className={cn(fullBleed ? 'space-y-6' : 'mx-auto space-y-6', maxWidthClass)}>
{/* Implicit page title — the fallback heading for a page that does NOT
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
<RecordContextProvider objectName="crm_account" recordId="rec-1" data={{ id: 'rec-1', stage: 'won' }}>
{node}
</RecordContextProvider>
</I18nProvider>,
);
}

const highlights = (aria?: unknown) => (
<RecordHighlightsRenderer schema={{ fields: ['name'], ...(aria ? { aria } : {}) } as never} />
);

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',
<RecordPathRenderer
schema={{
statusField: 'stage',
stages: [{ value: 'open', label: 'Open' }, { value: 'won', label: 'Won' }],
aria: { ariaLabel: { en: 'Deal stages', 'zh-CN': '交易阶段' } },
} as never}
/>,
);
expect(screen.getAllByRole('list', { name: '交易阶段' }).length).toBeGreaterThan(0);
});
});
59 changes: 31 additions & 28 deletions packages/plugin-detail/src/renderers/recordComponentAria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*
Expand Down Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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,
Expand All @@ -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 } : {}),
Expand Down
Loading
Loading