diff --git a/.changeset/aria-label-keyed-vocabulary-4581.md b/.changeset/aria-label-keyed-vocabulary-4581.md new file mode 100644 index 000000000..37642f95d --- /dev/null +++ b/.changeset/aria-label-keyed-vocabulary-4581.md @@ -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`), +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. diff --git a/packages/app-shell/src/utils/index.ts b/packages/app-shell/src/utils/index.ts index f6b61e4a9..1095f5958 100644 --- a/packages/app-shell/src/utils/index.ts +++ b/packages/app-shell/src/utils/index.ts @@ -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 } | undefined, diff --git a/packages/components/src/__tests__/toggle-aria-label-keyed.test.tsx b/packages/components/src/__tests__/toggle-aria-label-keyed.test.tsx new file mode 100644 index 000000000..cfd76d255 --- /dev/null +++ b/packages/components/src/__tests__/toggle-aria-label-keyed.test.tsx @@ -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) { + const Toggle = ComponentRegistry.get('toggle') as React.ComponentType; + return render(); +} + +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( + , + ); + // 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'); + }); +}); diff --git a/packages/components/src/renderers/form/toggle.tsx b/packages/components/src/renderers/form/toggle.tsx index f85ea4dd5..5f1d6ea56 100644 --- a/packages/components/src/renderers/form/toggle.tsx +++ b/packages/components/src/renderers/form/toggle.tsx @@ -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 }) => ( - {schema.label || renderChildren(schema.children)} diff --git a/packages/layout/src/NavigationRenderer.tsx b/packages/layout/src/NavigationRenderer.tsx index 35bdd17b9..6b8115c5f 100644 --- a/packages/layout/src/NavigationRenderer.tsx +++ b/packages/layout/src/NavigationRenderer.tsx @@ -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 @@ -254,11 +254,19 @@ export function resolveIcon(name?: string): React.ComponentType { /** * 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 }, + label: string | KeyedI18nLabel, t?: (key: string, options?: any) => string, ): string { if (typeof label === 'string') return label; diff --git a/packages/react/src/__tests__/SchemaRenderer.aria.test.tsx b/packages/react/src/__tests__/SchemaRenderer.aria.test.tsx index 01ea6b802..b1c91b74e 100644 --- a/packages/react/src/__tests__/SchemaRenderer.aria.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.aria.test.tsx @@ -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 = (props) => ( @@ -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( ); const el = screen.getByTestId('test-widget'); expect(el).toHaveAttribute('aria-label', 'Close dialog'); }); + it('should resolve a KeyedI18nLabel carrying params', () => { + render( + + ); + const el = screen.getByTestId('test-widget'); + expect(el).toHaveAttribute('aria-label', 'Hello, Ada'); + }); + it('should inject aria-describedby from ariaDescribedBy', () => { render( { it('evaluates disabled expression string', () => { render( - + ); expect(screen.getByTestId('test-component')).toHaveAttribute('data-disabled', 'true'); @@ -142,7 +143,7 @@ describe('SchemaRenderer Expression Integration', () => { it('does not set disabled when expression is false', () => { render( - + ); expect(screen.getByTestId('test-component')).not.toHaveAttribute('data-disabled'); diff --git a/packages/react/src/utils/i18n.ts b/packages/react/src/utils/i18n.ts index c5ac4ceb4..ac653e0a1 100644 --- a/packages/react/src/utils/i18n.ts +++ b/packages/react/src/utils/i18n.ts @@ -1,3 +1,5 @@ +import type { KeyedI18nLabel } from '@object-ui/types'; + /** * Resolves objectui's KEYED i18n label to a plain string. * @@ -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 } | 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; diff --git a/packages/types/src/__tests__/base-schema-label-vocabulary.test.ts b/packages/types/src/__tests__/base-schema-label-vocabulary.test.ts new file mode 100644 index 000000000..a74930fde --- /dev/null +++ b/packages/types/src/__tests__/base-schema-label-vocabulary.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `BaseSchema` label/state vocabulary, named and declared (objectui#4581). + * + * Sibling of `base-schema-visible-predicate.test.ts`, which pinned `visible` + * in PR #4593. This file pins the three remaining slots that PR measured and + * escalated, under the rulings recorded on objectui#4580 (comment 5284007579). + * + * ## `ariaLabel` declares the KEYED vocabulary (ruling Q2-B) + * + * `packages/react/src/SchemaRenderer.tsx:111` reads: + * + * ```ts + * if (schema.ariaLabel) { + * aria['aria-label'] = resolveKeyedI18nLabel(schema.ariaLabel); + * } + * ``` + * + * `resolveKeyedI18nLabel` (`packages/react/src/utils/i18n.ts`) accepts the + * KEYED form — `{ key, defaultValue?, params? }`, a reference INTO a + * translation bundle. It is NOT the spec's `I18nLabel`, which since + * `@objectstack/spec` 17.0.0-rc.6 is the INLINE LOCALE MAP + * `string | Record` resolved by the spec's own + * `resolveI18nLabel(label, locale)`. + * + * The original #4581 text asked for `string | I18nLabel`. PR #4593 measured + * that spelling and it was wrong in three separate ways, which is why the + * ruling withdrew it: + * + * - the shipped keyed fixture `{ key, defaultValue }` was accepted *for the + * wrong reason* — as a locale map whose "locales" are named `key` and + * `defaultValue`; + * - the same keyed label carrying `params` was REJECTED + * (`Type '{ name: string; }' is not assignable to type 'string'`); + * - a genuine inline map `{ en: 'Owner' }` type-checked while + * `resolveKeyedI18nLabel` returns `undefined` for it at runtime, rendering + * an EMPTY `aria-label`. + * + * So the declared vocabulary here is the keyed one, and it gets a NAME — + * `KeyedI18nLabel` — rather than a fourth inline copy of the same object + * literal. The three that existed before this card + * (`packages/react/src/utils/i18n.ts`, `packages/layout/src/NavigationRenderer.tsx`, + * `packages/app-shell/src/utils/index.ts`) were verified byte-for-byte + * identical in their object half before the name was minted. + * + * ## `disabled` accepts the predicate string (ruling Q3-A) + * + * Exactly the `visible` evidence, one slot over: `SchemaRenderer.tsx:466` + * evaluates it through the same `evaluateCondition` + * (`(condition: string | boolean | undefined, context?) => boolean`), and the + * `disabledOn?: string` sibling exists for the same reason. The asymmetry with + * `visible` was accidental, not deliberate. + * + * ## `label` / `description` STAY `string` (ruling Q1-B) — must-not-change + * + * The two spec bridges hand these slots the spec's INLINE `I18nLabel`, which is + * a real defect (#4593's canary: TS2322 at `list-view.ts:180` and `:224`). The + * ruling resolves it at the BRIDGE, not by widening these declarations. The two + * assertions below are therefore the ruling written down: they are the only + * pins in this file expected GREEN pre-fix, and a future card that "fixes" the + * bridge defect by widening `BaseSchema.label` turns them red on purpose. + * + * ## Predictions, written before the first run (red-first) + * + * Against `origin/main` (`52d878a3b`), `tsc -p packages/types/tsconfig.test.json` + * must report: + * + * 1. the `KeyedI18nLabel` import — TS2305, `Module '"../base"' has no + * exported member 'KeyedI18nLabel'` (the name does not exist yet). + * 2. `keyedAriaLabelIsAuthorable` / `keyedAriaLabelWithParamsIsAuthorable` — + * TS2322, an object is not assignable to `string`. The explicit + * `ariaLabel?: string` member wins over `BaseSchema`'s + * `[key: string]: any` index signature, which is precisely why the shipped + * fixture in `SchemaRenderer.aria.test.tsx` needed + * `as unknown as BaseSchema`. + * 3. `disabledPredicateStringIsAuthorable` — TS2322, `Type 'string' is not + * assignable to type 'boolean | undefined'`. + * 4. `assertionDisabled` — TS2344, `Type 'false' does not satisfy the + * constraint 'true'`. + * 5. `assertionAriaLabel` and `assertionKeyedShape` — expected TS2344 as + * well, but for a DERIVED reason: after the TS2305 above, the unresolved + * import degrades to an error type, so what these two report pre-fix is + * not independent evidence. They are listed for completeness, and the + * load-bearing pre-fix reds are 1-4. + * 6. `assertionLabel` / `assertionDescription` — NO error, pre-fix and + * post-fix. See must-not-change above. + * + * MEASURED (`52d878a3b`, before the fix) — 1, 2, 3, 4 and 6 held exactly: + * + * ``` + * base-schema-label-vocabulary.test.ts(103,27): error TS2305: Module '"../base"' has no exported member 'KeyedI18nLabel'. + * base-schema-label-vocabulary.test.ts(120,3): error TS2344: Type 'false' does not satisfy the constraint 'true'. + * base-schema-label-vocabulary.test.ts(130,3): error TS2344: Type 'false' does not satisfy the constraint 'true'. + * base-schema-label-vocabulary.test.ts(143,3): error TS2322: Type '{ key: string; defaultValue: string; }' is not assignable to type 'string'. + * base-schema-label-vocabulary.test.ts(149,3): error TS2322: Type '{ key: string; defaultValue: string; params: { name: string; }; }' is not assignable to type 'string'. + * base-schema-label-vocabulary.test.ts(161,3): error TS2322: Type 'string' is not assignable to type 'boolean | undefined'. + * ``` + * + * Prediction 5 was half wrong, and it is left standing rather than rewritten to + * match: `assertionKeyedShape` (120) DID report, but `assertionAriaLabel` (126) + * reported NOTHING — TypeScript suppresses the cascade once the import is an + * error type. Which is exactly why 5 was flagged as non-independent evidence in + * advance: a pin whose pre-fix silence is a compiler artifact proves nothing on + * its own. Its value is post-fix, where it pins the union for real. + * + * After the fix, all of it compiles clean (`tsc -p tsconfig.test.json`, exit 0). + * + * Every `Equal` below is INVARIANT for the reason + * `base-schema-visible-predicate.test.ts` spells out and this card inherits: a + * `satisfies`-style or one-way `extends` check is VACUOUS for a widening in + * both directions — the narrow `string` is assignable to the wide + * `string | KeyedI18nLabel`, so a widening that never happened AND a widening + * that overshot to `any` would both stay green. `BaseSchema`'s + * `[key: string]: any` makes the overshoot live rather than hypothetical: + * deleting a declared property altogether leaves it typed `any` and every + * fixture below still compiles. + */ + +import { describe, it, expect } from 'vitest'; +import type { BaseSchema, KeyedI18nLabel } from '../base'; + +/* ── Type-level helpers ──────────────────────────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; + +/* ── The named vocabulary is exactly the shape the resolver accepts ──────── */ + +/** + * The census shape, spelled out literally rather than referenced, so this pin + * fails if the named type ever drifts from what the three retired inline copies + * agreed on and what `resolveKeyedI18nLabel` destructures. + */ +export type assertionKeyedShape = Expect< + Equal< KeyedI18nLabel, { key: string; defaultValue?: string; params?: Record } > +>; + +/* ── The declared slots ──────────────────────────────────────────────────── */ + +export type assertionAriaLabel = Expect< + Equal< BaseSchema['ariaLabel'], string | KeyedI18nLabel | undefined > +>; + +export type assertionDisabled = Expect< + Equal< BaseSchema['disabled'], boolean | string | undefined > +>; + +/* ── must-not-change: the Q1-B ruling, written as a pin ──────────────────── */ + +export type assertionLabel = Expect< Equal< BaseSchema['label'], string | undefined > >; +export type assertionDescription = Expect< Equal< BaseSchema['description'], string | undefined > >; + +/* ── Authorable fixtures ─────────────────────────────────────────────────── */ + +/** The exact shape the shipped aria fixture had to cast past. */ +export const keyedAriaLabelIsAuthorable: BaseSchema = { + type: 'test-widget', + ariaLabel: { key: 'dialog.close', defaultValue: 'Close dialog' }, +}; + +/** `params` — the limb the withdrawn `string | I18nLabel` spelling REJECTED. */ +export const keyedAriaLabelWithParamsIsAuthorable: BaseSchema = { + type: 'test-widget', + ariaLabel: { key: 'greeting.hello', defaultValue: 'Hello, {{name}}', params: { name: 'Ada' } }, +}; + +/** The plain string form is untouched — this is a widening, not a replacement. */ +export const plainAriaLabelIsStillAuthorable: BaseSchema = { + type: 'test-widget', + ariaLabel: 'Close dialog', +}; + +/** The capability `SchemaRenderer.tsx:466` implements, now declared. */ +export const disabledPredicateStringIsAuthorable: BaseSchema = { + type: 'test-component', + disabled: '${data.status === "locked"}', +}; + +/** The boolean form is untouched. */ +export const disabledBooleanIsStillAuthorable: BaseSchema = { + type: 'test-component', + disabled: true, +}; + +/* ── Runtime companion ───────────────────────────────────────────────────── */ + +describe('BaseSchema label vocabulary (objectui#4581)', () => { + it('type-level: ariaLabel is string | KeyedI18nLabel, disabled is boolean | string', () => { + // Erased at runtime; `tsc -p tsconfig.test.json` is the checker, chained + // from this package's `type-check` script. The runtime case exists so a + // green vitest run is not mistaken for the proof. + expect((keyedAriaLabelWithParamsIsAuthorable.ariaLabel as KeyedI18nLabel).params).toEqual({ + name: 'Ada', + }); + expect(plainAriaLabelIsStillAuthorable.ariaLabel).toBe('Close dialog'); + expect(disabledPredicateStringIsAuthorable.disabled).toBe('${data.status === "locked"}'); + expect(disabledBooleanIsStillAuthorable.disabled).toBe(true); + }); +}); diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index 28064c22a..cc59f1103 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -16,6 +16,40 @@ * @packageDocumentation */ +/** + * A KEYED i18n label — a reference INTO a translation bundle (objectui#4581). + * + * This is objectui's own label vocabulary, and it is NOT the spec's + * `I18nLabel`. The two are structurally confusable and answer wrongly for each + * other's input, silently — objectui#4167 is the card that names the hazard, + * and PR #4169 the one that had to alias five imports by hand because neither + * shape had a name that said which it was: + * + * - KEYED (this type): `{ key, defaultValue?, params? }`, resolved against a + * translation bundle by `resolveKeyedI18nLabel` + * (`packages/react/src/utils/i18n.ts`, and the `t`-taking twin in + * `packages/app-shell/src/utils/index.ts`). + * - INLINE (`I18nLabel`, re-exported from `@objectstack/spec/ui`): + * `string | Record` — a locale MAP like + * `{ en: 'Owner' }` — resolved against a BCP-47 locale by the spec's own + * `resolveI18nLabel(label, locale)`. + * + * The shape below is the census of the three inline copies that existed before + * this type was minted — `packages/react/src/utils/i18n.ts`, + * `packages/layout/src/NavigationRenderer.tsx` and + * `packages/app-shell/src/utils/index.ts` — which were verified identical in + * their object half first. It is a NAME for what was already there, not a new + * capability. + */ +export type KeyedI18nLabel = { + /** Translation-bundle key, e.g. `dialog.close`. */ + key: string; + /** Rendered when the key is missing from the bundle, or no `t` is available. */ + defaultValue?: string; + /** Interpolation values for the key's placeholders, e.g. `{{name}}`. */ + params?: Record; +}; + /** * Base schema interface that all component schemas extend. * This is the fundamental building block of the Object UI protocol. @@ -150,9 +184,22 @@ export interface BaseSchema { /** * Controls whether the component is disabled. * Applies to interactive components like buttons and inputs. + * + * Accepts a PREDICATE STRING as well as a boolean (objectui#4581), on exactly + * the `visible` evidence one slot over: the renderer does not read this key + * as a boolean, it evaluates it — `SchemaRenderer.tsx:466` calls + * `evaluator.evaluateCondition(newSchema.disabled)`, and `evaluateCondition` + * is declared + * `(condition: string | boolean | undefined, context?) => boolean`. The + * sibling key `disabledOn` is `string` for the same reason. The asymmetry + * with `visible` was accidental rather than deliberate (#4580 ruling Q3-A); + * the two fixtures exercising it had been casting past the declaration. + * * @default false + * @example false + * @example "${data.status === 'locked'}" */ - disabled?: boolean; + disabled?: boolean | string; /** * Expression for conditional disabling. @@ -169,8 +216,29 @@ export interface BaseSchema { /** * Accessibility label for screen readers. * Rendered as aria-label attribute. + * + * Accepts the KEYED i18n form as well as a plain string (objectui#4581), + * because that is what the renderer resolves: + * `packages/react/src/SchemaRenderer.tsx:111` reads + * `aria['aria-label'] = resolveKeyedI18nLabel(schema.ariaLabel)`, and + * `resolveKeyedI18nLabel` accepts `{ key, defaultValue?, params? }` — the + * shape now named {@link KeyedI18nLabel}. + * + * NOT `I18nLabel`. The original #4581 text asked for `string | I18nLabel`, + * and PR #4593 measured that spelling wrong in three ways before the ruling + * withdrew it (#4580 Q2-B): `I18nLabel` is the spec's INLINE LOCALE MAP + * (`string | Record`), so the shipped keyed fixture was + * accepted only *vacuously* — as a locale map whose "locales" are named `key` + * and `defaultValue`; the same label carrying `params` was REJECTED + * (`Type '{ name: string; }' is not assignable to type 'string'`); and a + * genuine `{ en: 'Owner' }` type-checked while `resolveKeyedI18nLabel` + * returns `undefined` for it, rendering an EMPTY aria-label. The two + * vocabularies are structurally confusable — objectui#4167's exact hazard. + * + * @example "Close dialog" + * @example { key: 'dialog.close', defaultValue: 'Close dialog' } */ - ariaLabel?: string; + ariaLabel?: string | KeyedI18nLabel; /** * Additional properties specific to the component type. diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9bd232db9..210bd55c8 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -83,6 +83,12 @@ export { // ============================================================================ export type { BaseSchema, + // objectui's KEYED i18n label — `{ key, defaultValue?, params? }`, a + // reference INTO a translation bundle. Deliberately NOT the spec's + // `I18nLabel` re-exported further down this file, which is the INLINE LOCALE + // MAP; the two are structurally confusable (objectui#4167) and this name is + // half of what stops them being mixed up (#4581). + KeyedI18nLabel, SchemaNode, ComponentRendererProps, ComponentInput,