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
63 changes: 63 additions & 0 deletions .changeset/aria-label-keyed-vocabulary-4581.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
'@object-ui/types': minor
'@object-ui/react': patch
'@object-ui/layout': patch
'@object-ui/app-shell': patch
'@object-ui/components': patch
---

`BaseSchema.ariaLabel` declares the keyed i18n vocabulary the renderer actually
resolves, `.disabled` accepts the predicate string it actually evaluates, and the
keyed shape finally has a name (objectui#4581)

Three slots on one base type had drifted from what the renderer does with them.
PR #4593 fixed `visible` and measured the rest; these are the rest.

`ariaLabel` was `string`, but `SchemaRenderer.tsx:111` resolves it with
`resolveKeyedI18nLabel`, whose input is the KEYED form
`{ key, defaultValue?, params? }` — a reference into a translation bundle. It is
now `string | KeyedI18nLabel`, and `KeyedI18nLabel` is a new exported type in
`@object-ui/types` rather than a fourth inline copy of one object literal: the
three that existed (`@object-ui/react`'s resolver, `@object-ui/layout`'s
`resolveLabel`, `@object-ui/app-shell`'s `t`-taking twin) were verified identical
in their object half first, and two of them now import the name.

The vocabulary matters more than the widening. `#4581` originally asked for
`string | I18nLabel`, and that spelling was withdrawn as measured-wrong: the
spec's `I18nLabel` is the INLINE LOCALE MAP (`string | Record<string, string>`),
a different vocabulary resolved against a BCP-47 locale by a different function
of a confusingly similar name. Under it the shipped keyed fixture type-checked
only vacuously — as a locale map whose "locales" are named `key` and
`defaultValue` — the same label carrying `params` was rejected outright, and a
genuine `{ en: 'Owner' }` compiled while rendering an EMPTY `aria-label`. Naming
the keyed shape is the declaration half of the fix objectui#4167 started on the
naming side; `@object-ui/app-shell`'s copy keeps its inline spelling for now
because an open PR has a pending change to that file, and the comment there says
so.

`disabled` was `boolean` on a key the renderer never reads as one:
`SchemaRenderer.tsx:466` evaluates it through the same `evaluateCondition` as
`visible`, and a `disabledOn?: string` sibling exists for the same reason. It is
now `boolean | string`. The asymmetry with `visible` was accidental rather than
deliberate.

Both are widenings on authored-input-dominant properties: authors gain a
spelling, nothing that type-checked before stops doing so, and readers already
coped with `any` through `BaseSchema`'s index signature. Three test fixtures that
had been casting past these declarations with `as unknown as BaseSchema` state
their values directly now, and the declared unions are pinned invariantly so
neither a missing widening nor an overshoot to `any` can pass unnoticed.

Declaring the vocabulary honestly also surfaced a real one: the `toggle`
renderer writes `aria-label` itself instead of going through SchemaRenderer's
resolver, and it forwarded the raw value. Invoked directly it emitted
`aria-label="[object Object]"` for a keyed label — announced verbatim by a
screen reader. It resolves now. Through `SchemaRenderer` the bug was invisible,
because SchemaRenderer injects its own resolved `aria-label` afterwards; a
downstream type-check sweep found it, not a test.

`BaseSchema.label` and `.description` are deliberately unchanged and pinned that
way. They receive the spec's inline `I18nLabel` from the view bridges, which is a
real defect, but resolving it belongs at the spec-to-schema boundary rather than
in this declaration — and that work is still blocked on a design question about
where the display locale enters, so it is not in this release.
9 changes: 9 additions & 0 deletions packages/app-shell/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ export {
* the `@object-ui/react` twin (`packages/react/src/utils/i18n.ts`), which is the
* same vocabulary without a `t`. Import the spec's as `resolveInlineI18nLabel`
* when you need the map form; the two names now say which is which.
*
* NOTE — the inline object literal below is `KeyedI18nLabel` from
* `@object-ui/types` (#4581), which named this shape and retired the two other
* copies (`packages/react/src/utils/i18n.ts`,
* `packages/layout/src/NavigationRenderer.tsx`). This third one is left spelled
* out ON PURPOSE: PR #4208 — the rc.6 train, still open and blocked on #4165 —
* has a pending change to this file, and a type-spelling swap here would be a
* rebase conflict for the train rather than a cleanup. Swap it to the named
* type once #4208 lands.
*/
export function resolveKeyedI18nLabel(
label: string | { key: string; defaultValue?: string; params?: Record<string, any> } | undefined,
Expand Down
107 changes: 107 additions & 0 deletions packages/components/src/__tests__/toggle-aria-label-keyed.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* 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 `toggle` renderer resolves a KEYED `ariaLabel` itself (objectui#4581).
*
* `BaseSchema.ariaLabel` now declares `string | KeyedI18nLabel` — the keyed
* vocabulary `SchemaRenderer.tsx:111` resolves with `resolveKeyedI18nLabel`.
* This renderer is one of the few that writes `aria-label` ITSELF rather than
* relying on SchemaRenderer's `resolveAriaProps`, and it forwarded the value
* raw: `aria-label={schema.ariaLabel}`. Under the honest declaration that stops
* type-checking, which is how it was found — a downstream `type-check` sweep
* over the consumers of `@object-ui/types`, not a test.
*
* ## The prediction I wrote first was WRONG, and the correction is the point
*
* I predicted the raw forward would render `aria-label="[object Object]"`
* through `SchemaRenderer`. Measured: it does NOT. `SchemaRenderer` injects its
* OWN already-resolved `aria-label` into the component's props
* (`SchemaRenderer.tsx:599` + `:625`, `...ariaProps`), and this renderer spreads
* `{...props}` AFTER its own attribute — so the resolved value always wins and
* the raw expression is shadowed on that path. A test driven through
* `SchemaRenderer` is therefore GREEN IN BOTH DIRECTIONS: vacuous, and it would
* have shipped looking like proof.
*
* So the discriminating case invokes the registered renderer DIRECTLY, which is
* the only path where its own `aria-label` expression is observable. Both paths
* are kept below and labelled for what each can and cannot show.
*
* ## Red-first, measured with the raw forward restored
*
* The direct case reported, verbatim (1 failed | 3 passed):
*
* Expected the element to have attribute:
* aria-label="Close dialog"
* Received:
* aria-label="[object Object]"
*
* and the SchemaRenderer case stayed green, exactly as the correction above
* says it must. Post-fix all four pass.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SchemaRenderer } from '@object-ui/react';
import { ComponentRegistry } from '@object-ui/core';
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`. See
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../renderers';

/** Invoke the registered renderer directly — no SchemaRenderer aria injection. */
function renderToggleDirect(schema: Record<string, unknown>) {
const Toggle = ComponentRegistry.get('toggle') as React.ComponentType<any>;
return render(<Toggle schema={schema} />);
}

describe('toggle renderer — keyed ariaLabel (objectui#4581)', () => {
/* ── The discriminating cases: the renderer's own expression ───────────── */

it('resolves a keyed ariaLabel to its defaultValue (direct invocation)', () => {
renderToggleDirect({
type: 'toggle',
label: 'Mute',
ariaLabel: { key: 'dialog.close', defaultValue: 'Close dialog' },
});
// Pre-fix this read '[object Object]' — the DOM stringifying the keyed
// label object straight into the attribute.
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Close dialog');
});

it('passes a plain-string ariaLabel through unchanged (direct invocation)', () => {
renderToggleDirect({ type: 'toggle', label: 'Mute', ariaLabel: 'Mute notifications' });
// Green in both directions on purpose: this is the passthrough, and a fix
// that resolved too eagerly would break it.
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Mute notifications');
});

it('omits aria-label entirely when the schema declares none (direct invocation)', () => {
renderToggleDirect({ type: 'toggle', label: 'Mute' });
expect(screen.getByRole('button')).not.toHaveAttribute('aria-label');
});

/* ── The end-to-end path: a regression guard, NOT a discriminator ──────── */

it('renders a resolved aria-label end-to-end through SchemaRenderer', () => {
render(
<SchemaRenderer
schema={{
type: 'toggle',
label: 'Mute',
ariaLabel: { key: 'dialog.close', defaultValue: 'Close dialog' },
}}
/>,
);
// NOTE: green with or without the renderer's fix — SchemaRenderer's own
// `...ariaProps` shadows the renderer's attribute. Kept because it pins the
// path an author actually exercises; it cannot prove the fix, and the
// header says so rather than letting a reader assume it does.
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Close dialog');
});
});
17 changes: 12 additions & 5 deletions packages/components/src/renderers/form/toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,24 @@
*/

import { ComponentRegistry } from '@object-ui/core';
import { resolveKeyedI18nLabel } from '@object-ui/react';
import type { ToggleSchema } from '@object-ui/types';
import { Toggle } from '../../ui';
import { renderChildren } from '../../lib/utils';

ComponentRegistry.register('toggle',
ComponentRegistry.register('toggle',
({ schema, ...props }: { schema: ToggleSchema; [key: string]: any }) => (
<Toggle
variant={schema.variant}
size={schema.size}
<Toggle
variant={schema.variant}
size={schema.size}
pressed={schema.pressed}
aria-label={schema.ariaLabel}
// `ariaLabel` is `string | KeyedI18nLabel` (objectui#4581) — the keyed
// form has to be RESOLVED before it reaches the DOM, exactly as
// `SchemaRenderer.tsx:111` does for every other component. Forwarding it
// raw put an object into an `aria-label`, which renders the literal text
// "[object Object]" to a screen reader. This renderer bypasses
// SchemaRenderer's `resolveAriaProps`, so it has to do it itself.
aria-label={resolveKeyedI18nLabel(schema.ariaLabel)}
{...props}
>
{schema.label || renderChildren(schema.children)}
Expand Down
16 changes: 12 additions & 4 deletions packages/layout/src/NavigationRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ import {
cn,
useIsMobile,
} from '@object-ui/components';
import type { NavigationItem } from '@object-ui/types';
import type { NavigationItem, KeyedI18nLabel } from '@object-ui/types';

// ---------------------------------------------------------------------------
// Types
Expand Down Expand Up @@ -254,11 +254,19 @@ export function resolveIcon(name?: string): React.ComponentType<any> {

/**
* Resolve a NavigationItem label to a plain string.
* Handles both plain strings and I18nLabel objects { key, defaultValue }.
* When a `t` function is provided, I18nLabel objects are translated via i18next.
*
* Handles both plain strings and the KEYED i18n form
* `{ key, defaultValue?, params? }` — named `KeyedI18nLabel` in
* `@object-ui/types` since #4581, which is what this signature now states
* instead of a third inline copy of the same object literal. When a `t`
* function is provided the key is translated via i18next.
*
* "Keyed", not the spec's `I18nLabel`: that one is the INLINE LOCALE MAP
* (`{ en: 'Owner' }`) resolved against a BCP-47 locale, and the two answer
* wrongly for each other's input, silently (objectui#4167).
*/
export function resolveLabel(
label: string | { key: string; defaultValue?: string; params?: Record<string, any> },
label: string | KeyedI18nLabel,
t?: (key: string, options?: any) => string,
): string {
if (typeof label === 'string') return label;
Expand Down
38 changes: 32 additions & 6 deletions packages/react/src/__tests__/SchemaRenderer.aria.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { render, screen } from '@testing-library/react';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '../SchemaRenderer';
import type { BaseSchema } from '@object-ui/types';
// No `BaseSchema` import any more: the keyed-`ariaLabel` fixture below was the
// only thing that needed it, for an `as unknown as BaseSchema` cast that
// objectui#4581 made unnecessary by declaring the vocabulary the renderer
// resolves.

// A simple test component that forwards ARIA attributes
const TestWidget: React.FC<any> = (props) => (
Expand Down Expand Up @@ -47,22 +50,45 @@ describe('SchemaRenderer AriaProps injection', () => {
expect(el).toHaveAttribute('aria-label', 'Close dialog');
});

it('should resolve ariaLabel from I18nLabel object', () => {
it('should resolve ariaLabel from a KeyedI18nLabel object', () => {
render(
<SchemaRenderer
schema={{
type: 'test-widget',
// AriaPropsSchema declares `ariaLabel: string | I18nLabel` and the
// renderer resolves the keyed form; `BaseSchema.ariaLabel` is the
// narrower `string` (objectui#4548).
// No cast: `BaseSchema.ariaLabel` declares `string | KeyedI18nLabel`
// (objectui#4581), which is the vocabulary the renderer actually
// resolves — `SchemaRenderer.tsx:111` calls `resolveKeyedI18nLabel`.
// This fixture carried `as unknown as BaseSchema` while the
// declaration said the narrower `string` (objectui#4548).
ariaLabel: { key: 'dialog.close', defaultValue: 'Close dialog' },
} as unknown as BaseSchema}
}}
/>
);
const el = screen.getByTestId('test-widget');
expect(el).toHaveAttribute('aria-label', 'Close dialog');
});

it('should resolve a KeyedI18nLabel carrying params', () => {
render(
<SchemaRenderer
schema={{
type: 'test-widget',
// `params` is the limb the withdrawn `string | I18nLabel` spelling
// REJECTED outright (PR #4593's probe, #4580 ruling Q2-B). Without a
// `t`, this package's resolver falls back to `defaultValue` — the
// point here is that the shape is authorable at all.
ariaLabel: {
key: 'greeting.hello',
defaultValue: 'Hello, Ada',
params: { name: 'Ada' },
},
}}
/>
);
const el = screen.getByTestId('test-widget');
expect(el).toHaveAttribute('aria-label', 'Hello, Ada');
});

it('should inject aria-describedby from ariaDescribedBy', () => {
render(
<SchemaRenderer
Expand Down
23 changes: 12 additions & 11 deletions packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,17 @@ import { render, screen } from '@testing-library/react';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '../SchemaRenderer';
// `BaseSchema.visible` now declares `boolean | string` (objectui#4581), so the
// two visibility cases below state their predicate strings directly — no cast.
// `BaseSchema.visible` AND `.disabled` now both declare `boolean | string`
// (objectui#4581), so every predicate-string case below states its expression
// directly — no casts left in this file.
//
// `.disabled` is the SAME gap and is NOT yet widened: the renderer evaluates it
// through the same `evaluateCondition` (`SchemaRenderer.tsx:466`) and the
// `disabledOn?: string` sibling exists for the same reason, but #4581 named only
// `visible` and `ariaLabel`, so widening `disabled` was left to its own card
// rather than taken unruled. The two `disabled` casts below are what remains of
// the gap — drop them when that lands.
import type { BaseSchema } from '@object-ui/types';
// The header that stood here said the `disabled` casts were "what remains of
// the gap — drop them when that lands". This is that landing: #4580's ruling
// Q3-A widened `disabled` on the same evidence as `visible` (the renderer
// evaluates it through the same `evaluateCondition` at
// `SchemaRenderer.tsx:466`, and the `disabledOn?: string` sibling exists for
// the same reason), and the two casts are gone. The `BaseSchema` import went
// with them — nothing in this file needs the name any more.
import { SchemaRendererContext } from '../context/SchemaRendererContext';

// Simple test component
Expand Down Expand Up @@ -133,7 +134,7 @@ describe('SchemaRenderer Expression Integration', () => {
it('evaluates disabled expression string', () => {
render(
<SchemaRendererContext.Provider value={{ dataSource: { status: 'locked' } }}>
<SchemaRenderer schema={{ type: 'test-component', disabled: '${data.status === "locked"}' } as unknown as BaseSchema} />
<SchemaRenderer schema={{ type: 'test-component', disabled: '${data.status === "locked"}' }} />
</SchemaRendererContext.Provider>
);
expect(screen.getByTestId('test-component')).toHaveAttribute('data-disabled', 'true');
Expand All @@ -142,7 +143,7 @@ describe('SchemaRenderer Expression Integration', () => {
it('does not set disabled when expression is false', () => {
render(
<SchemaRendererContext.Provider value={{ dataSource: { status: 'active' } }}>
<SchemaRenderer schema={{ type: 'test-component', disabled: '${data.status === "locked"}' } as unknown as BaseSchema} />
<SchemaRenderer schema={{ type: 'test-component', disabled: '${data.status === "locked"}' }} />
</SchemaRendererContext.Provider>
);
expect(screen.getByTestId('test-component')).not.toHaveAttribute('data-disabled');
Expand Down
10 changes: 9 additions & 1 deletion packages/react/src/utils/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { KeyedI18nLabel } from '@object-ui/types';

/**
* Resolves objectui's KEYED i18n label to a plain string.
*
Expand Down Expand Up @@ -34,8 +36,14 @@
* convention, which is exactly what objectstack#4115 exists to replace with a
* rule. `Keyed` is the counterpart of that `Inline`: the name now says which
* vocabulary it resolves, at every call site, with no comment required.
*
* The keyed shape itself is now named too — `KeyedI18nLabel` in
* `@object-ui/types` (#4581) — so `BaseSchema.ariaLabel`, this parameter and
* the layout twin all state one type instead of three copies of one object
* literal. The `Inline`/`Keyed` split above is the naming half of #4167; the
* named shape is the declaration half.
*/
export function resolveKeyedI18nLabel(label: string | { key: string; defaultValue?: string; params?: Record<string, any> } | undefined): string | undefined {
export function resolveKeyedI18nLabel(label: string | KeyedI18nLabel | undefined): string | undefined {
if (label === undefined || label === null) return undefined;
if (typeof label === 'string') return label;
return label.defaultValue || label.key;
Expand Down
Loading
Loading