diff --git a/.changeset/11697-composite-record-display.md b/.changeset/11697-composite-record-display.md
new file mode 100644
index 0000000000..5453cd03a6
--- /dev/null
+++ b/.changeset/11697-composite-record-display.md
@@ -0,0 +1,12 @@
+---
+'@object-ui/fields': patch
+---
+
+A `composite` or `record` field value reads as labelled sub-values, not as its stored JSON (objectui#11697). On the record page, and in every grid cell that draws from the same cell-renderer table, a composite value such as `{"width":10,"height":20}` showed as raw JSON in a monospace face. It now reads `Width 10 · Height 20`. A record value reads one labelled group per entry name, so `{ primary: { name: "A", score: 9 }, backup: { name: "B", score: 7 } }` reads `Primary (Name A · Score 9) · Backup (Name B · Score 7)`. A record entry that is not a sub-object reads as a pair.
+
+- **Labels** are the humanized key. The field metadata has no sub-field declaration to read one from: `@objectstack/spec`'s field schema declares none for these types and refuses `fields` / `subFields` as unrecognized keys.
+- **Sub-values.** A number is formatted as the number cell formats a field with no declared `scale`, and a boolean reads as Yes / No in the reader's language. A string reads as itself and an unset sub-value as the shared "No value" dash. An object or array nested inside a sub-value stays compact JSON.
+- **One line.** The value is one truncated line in the grid, on the record page and in the summary chip, with the full text in its `title`. It is a description list, so a screen reader reads term and value pairs.
+- **Unchanged.** An empty value, `[]`, `{}` and a non-object value read exactly as the JSON cell reads them, and a string holding JSON is never parsed. `json` and `object` keep the JSON cell. The record page's copy button still copies the stored JSON.
+
+Nothing is added to the package entry: no export, prop, type member or language-pack key. The new renderer is module-local and is reached through `getCellRenderer('composite')` and `getCellRenderer('record')`.
diff --git a/content/docs/fields/object.mdx b/content/docs/fields/object.mdx
index 4998365f60..2b1886b905 100644
--- a/content/docs/fields/object.mdx
+++ b/content/docs/fields/object.mdx
@@ -131,8 +131,7 @@ The object field stores data as parsed JSON:
## Cell Renderer
In tables/grids, an `object` value is rendered by the JSON cell renderer —
-`getCellRenderer('object')` resolves to this same component, shared with `json`,
-`composite` and `record`:
+`getCellRenderer('object')` resolves to this same component, shared with `json`:
```ts
import { JsonCellRenderer } from '@object-ui/fields';
@@ -142,6 +141,24 @@ import { JsonCellRenderer } from '@object-ui/fields';
// A null or empty value renders an em-dash instead.
```
+`composite` and `record` values are not drawn as JSON. Their cell — in a grid and
+on the record page alike — is one line of labelled sub-values, with the full text
+in the `title`:
+
+```plaintext
+// composite { width: 10, height: 20 }
+Width 10 · Height 20
+
+// record { primary: { name: "A", score: 9 }, backup: { name: "B", score: 7 } }
+Primary (Name A · Score 9) · Backup (Name B · Score 7)
+```
+
+A label is the humanized key: the field metadata declares no sub-fields to take a
+label from. A number sub-value is formatted as the number cell formats it, a boolean
+reads as Yes / No in the reader's language, a string reads as itself, and an object or
+an array nested inside a sub-value stays compact JSON.
+An empty value, `[]`, `{}` and a non-object value read as the JSON cell reads them.
+
## Schema Validation
For typed object fields, you can define a schema in two formats:
diff --git a/packages/fields/src/__tests__/AddressCellRenderer.test.tsx b/packages/fields/src/__tests__/AddressCellRenderer.test.tsx
index 9ab639966d..aa73b4c4f6 100644
--- a/packages/fields/src/__tests__/AddressCellRenderer.test.tsx
+++ b/packages/fields/src/__tests__/AddressCellRenderer.test.tsx
@@ -158,7 +158,9 @@ describe('address display renderer — controls (unchanged surfaces)', () => {
it('leaves genuinely structural types stringifying as JSON', () => {
// `address` moved out of the JSON bucket; `json` / `object` stay in it.
- for (const type of ['json', 'object', 'composite', 'record']) {
+ // `composite` / `record` moved out too (objectui#11697): they draw labelled
+ // sub-values, pinned in `compositeRecordCell-11697.test.tsx`.
+ for (const type of ['json', 'object']) {
const { container } = renderThroughDisplayRegistry(type, { a: 1 });
expect(container.textContent).toBe('{"a":1}');
}
diff --git a/packages/fields/src/__tests__/compositeRecordCell-11697.test.tsx b/packages/fields/src/__tests__/compositeRecordCell-11697.test.tsx
new file mode 100644
index 0000000000..e7130e7373
--- /dev/null
+++ b/packages/fields/src/__tests__/compositeRecordCell-11697.test.tsx
@@ -0,0 +1,212 @@
+/**
+ * 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.
+ */
+
+/**
+ * objectui#11697: a `composite` or `record` value read as its stored JSON on
+ * the record page — `{"width":10,"height":20}` in a monospace face — because
+ * the cell-renderer table mapped both types to `JsonCellRenderer`. They now
+ * draw labelled sub-values on one line.
+ *
+ * Every case resolves its renderer through `getCellRenderer`, the resolver the
+ * record page and the grid both call, so a regressed table entry goes red here
+ * and not only a regressed renderer. The record-page end of the path is pinned
+ * in `@object-ui/plugin-detail`'s `DetailSection.compositeRecord-11697`.
+ *
+ * Values are the platform's own producers where one exists: the showcase Field
+ * Zoo seeds `f_composite` / `f_record`, and the SQL driver's fidelity test and
+ * the dogfood field-zoo matrix write a `record` whose entries are scalars.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import React from 'react';
+import { render, cleanup } from '@testing-library/react';
+import type { FieldMetadata } from '@object-ui/types';
+import { getCellRenderer, resolveCellRendererType } from '../index';
+
+afterEach(() => cleanup());
+
+/** Resolve + render exactly the way `DetailSection` builds a read-mode value. */
+function renderCell(type: string, value: unknown) {
+ const Renderer = getCellRenderer(resolveCellRendererType({ type }) || type);
+ const field = { type, name: `f_${type}` } as unknown as FieldMetadata;
+ return render();
+}
+
+/** What assistive technology reads in an element: its text minus `aria-hidden` nodes. */
+function spokenText(element: Element): string {
+ const clone = element.cloneNode(true) as Element;
+ clone.querySelectorAll('[aria-hidden="true"]').forEach((node) => node.remove());
+ return (clone.textContent ?? '').trim();
+}
+
+/** The `dt` / `dd` pairs directly under one `
`, as spoken. */
+function pairsOf(list: Element): Array<[term: string, definition: string]> {
+ return Array.from(list.querySelectorAll(':scope > div')).map((group) => [
+ spokenText(group.querySelector(':scope > dt')!),
+ spokenText(group.querySelector(':scope > dd')!),
+ ]);
+}
+
+/** The face's outer ``; fails loudly when the cell drew something else. */
+function outerList(container: HTMLElement): HTMLElement {
+ const outer = container.firstElementChild as HTMLElement | null;
+ expect(outer?.tagName, 'the face is a description list').toBe('DL');
+ return outer!;
+}
+
+describe('objectui#11697 — composite and record values render as labelled sub-values', () => {
+ it('a composite value renders each sub-field as a labelled pair, not as its JSON', () => {
+ // The Field Zoo seed for `f_composite`, the value the card reported.
+ const { container } = renderCell('composite', { width: 10, height: 20 });
+ const outer = outerList(container);
+
+ expect(pairsOf(outer)).toEqual([
+ ['Width', '10'],
+ ['Height', '20'],
+ ]);
+ expect(container.textContent).toBe('Width 10 · Height 20');
+ expect(container.textContent, 'no stored-JSON signature').not.toMatch(/[{}"]/);
+ });
+
+ it('a record value renders one labelled group per entry name, holding that entry’s pairs', () => {
+ // The Field Zoo seed for `f_record`.
+ const { container } = renderCell('record', {
+ primary: { name: 'A', score: 9 },
+ backup: { name: 'B', score: 7 },
+ });
+ const outer = outerList(container);
+
+ const entries = Array.from(outer.querySelectorAll(':scope > div'));
+ expect(entries.map((entry) => spokenText(entry.querySelector(':scope > dt')!))).toEqual([
+ 'Primary',
+ 'Backup',
+ ]);
+ const groups = entries.map((entry) => entry.querySelector(':scope > dd > dl')!);
+ expect(pairsOf(groups[0])).toEqual([
+ ['Name', 'A'],
+ ['Score', '9'],
+ ]);
+ expect(pairsOf(groups[1])).toEqual([
+ ['Name', 'B'],
+ ['Score', '7'],
+ ]);
+ expect(container.textContent).toBe('Primary (Name A · Score 9) · Backup (Name B · Score 7)');
+ expect(container.textContent, 'no stored-JSON signature').not.toMatch(/[{}"]/);
+ });
+
+ it('a record entry that is not a sub-object is drawn as a pair, with no group', () => {
+ // The shape the SQL driver's fidelity test and the dogfood matrix write.
+ const { container } = renderCell('record', { home: '+1', work: '+2' });
+ const outer = outerList(container);
+
+ expect(pairsOf(outer)).toEqual([
+ ['Home', '+1'],
+ ['Work', '+2'],
+ ]);
+ expect(outer.querySelector('dd dl'), 'a scalar entry opens no group').toBeNull();
+ });
+
+ it('a nested object or array inside a sub-value stays compact JSON', () => {
+ const composite = renderCell('composite', { size: { w: 1 }, tags: ['a', 'b'] });
+ expect(pairsOf(outerList(composite.container))).toEqual([
+ ['Size', '{"w":1}'],
+ ['Tags', '["a","b"]'],
+ ]);
+ expect(
+ composite.container.querySelector('dd dl'),
+ 'a composite never groups: its sub-field holds an object, it is not a map entry',
+ ).toBeNull();
+ cleanup();
+
+ const record = renderCell('record', { primary: { dims: { w: 1 } } });
+ expect(record.container.textContent).toBe('Primary (Dims {"w":1})');
+ });
+
+ it('each scalar sub-value reads through this package’s face for its type', () => {
+ const { container } = renderCell('composite', {
+ total: 1234.5,
+ active: true,
+ archived: false,
+ note: null,
+ code: 'x-1',
+ });
+ const pairs = pairsOf(outerList(container));
+
+ // The number is the `number` cell's own text for a field with no `scale`:
+ // compared against that cell, not against a literal, so the two agree by
+ // construction rather than by a copy.
+ const numberCell = render(
+ React.createElement(getCellRenderer('number'), {
+ value: 1234.5,
+ field: { type: 'number', name: 'n' } as FieldMetadata,
+ }),
+ );
+ expect(pairs[0]).toEqual(['Total', numberCell.container.textContent]);
+ expect(pairs[0][1], 'grouped, natural precision').toBe('1,234.5');
+ // A boolean is the locale's word, the boolean-as-text face.
+ expect(pairs[1]).toEqual(['Active', 'Yes']);
+ expect(pairs[2]).toEqual(['Archived', 'No']);
+ // A floor member is the shared affordance, with its accessible name.
+ const noteValue = outerList(container).querySelectorAll(':scope > div > dd')[3];
+ expect(
+ noteValue.querySelector('[data-slot="empty-value"]')?.getAttribute('aria-label'),
+ 'an unset sub-value is the shared "No value" affordance',
+ ).toBe('No value');
+ expect(pairs[4]).toEqual(['Code', 'x-1']);
+ });
+
+ it('the face is one truncated line whose title carries the full text', () => {
+ const { container } = renderCell('record', {
+ primary: { name: 'A', score: 9 },
+ backup: { name: 'B', score: 7 },
+ });
+ const outer = outerList(container);
+ for (const token of ['block', 'max-w-full', 'truncate']) {
+ expect(outer.classList.contains(token), `the line carries \`${token}\``).toBe(true);
+ }
+ expect(outer.getAttribute('title'), 'the hover text is the line itself').toBe(container.textContent);
+ });
+});
+
+describe('objectui#11697 — what does NOT move', () => {
+ for (const type of ['composite', 'record'] as const) {
+ it(`\`${type}\`: the floor and the unrecognized shapes answer exactly as the JSON cell does`, () => {
+ for (const empty of [null, undefined, '']) {
+ const { container } = renderCell(type, empty);
+ expect(
+ container.querySelector('[data-slot="empty-value"]'),
+ `${type} holding ${JSON.stringify(empty)}: the shared affordance`,
+ ).not.toBeNull();
+ expect(container.querySelector('dl')).toBeNull();
+ cleanup();
+ }
+ // The literals objectui#8474 and objectui#8481's fence pinned, and a
+ // string that happens to hold JSON: drawn as it is, never parsed.
+ for (const [input, text] of [
+ [[], '[]'],
+ [{}, '{}'],
+ ['{"width":10}', '{"width":10}'],
+ ] as const) {
+ const { container } = renderCell(type, input);
+ expect(container.textContent, `${type} holding ${JSON.stringify(input)}`).toBe(text);
+ expect(container.querySelector('dl')).toBeNull();
+ cleanup();
+ }
+ });
+ }
+
+ it('`json` and `object` keep the compact JSON face', () => {
+ for (const type of ['json', 'object']) {
+ const { container } = renderCell(type, { width: 10, height: 20 });
+ expect(container.textContent, `${type}: a free-form JSON value reads as JSON`).toBe(
+ '{"width":10,"height":20}',
+ );
+ cleanup();
+ }
+ });
+});
diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx
index 883f72b7dd..6e801a91c2 100644
--- a/packages/fields/src/index.tsx
+++ b/packages/fields/src/index.tsx
@@ -31,6 +31,7 @@ import { formatAddress, type AddressValue } from './widgets/address-format.js';
// (`CurrencyField`) and percent (`PercentField`) (objectui#11444).
import { formatNumberFieldValue, formatCurrency, formatPercentPoints, percentCellScale } from './widgets/number-format.js';
import { useFieldTranslation } from './widgets/useFieldTranslation.js';
+import { useBooleanValueLabel } from './widgets/booleanValueLabel.js';
// Module-level cache so multiple renderers fetching the same lookup ID
// only trigger one network call. Keyed by `${objectName}:${id}`. It holds the
@@ -406,8 +407,9 @@ export { coerceToSafeValue };
* empty, and + every unparsable one, objectui#8581);
* - **the floor with a member DECLINED, out loud** — `JsonCellRenderer` draws
* the two-character literal for `[]` on purpose (objectui#8474 measured and
- * kept it), `LocationCellRenderer` and `AddressCellRenderer` inherit that
- * through their JSON fallback, and `FileCellRenderer` states "0 files".
+ * kept it), `LocationCellRenderer`, `AddressCellRenderer` and the
+ * `composite` / `record` face (`StructuredValueCell`) inherit that through
+ * their JSON fallback, and `FileCellRenderer` states "0 files".
*
* ⛔ Those disagreements are MEASURED, not drift: do not "finish the job" by
* making every renderer answer the floor. The pins that go red if one is
@@ -3064,9 +3066,28 @@ export function resolveCellRendererType(fieldOrType: string | { type?: string; f
}
/**
- * Renders structured/embedded values (json, object, composite, record,
- * address, geolocation) as compact, readable JSON. Objects and arrays are
- * stringified; primitives fall through to their string form.
+ * The compact JSON text of a value: objects and arrays are stringified,
+ * primitives fall through to their string form. A structure `JSON.stringify`
+ * cannot represent (a cycle) keeps its `String` form rather than throwing out
+ * of a render.
+ *
+ * ONE spelling, two readers: {@link JsonCellRenderer} draws a whole value with
+ * it, and the `composite` / `record` face below draws the nested values it
+ * still shows as JSON with it, so one stored value cannot read two ways.
+ */
+function compactJsonText(value: unknown): string {
+ if (typeof value !== 'object') return String(value);
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+}
+
+/**
+ * Renders a free-form JSON value (`json`, `object`) as compact, readable JSON,
+ * and is the fallback face for a shape a structured renderer cannot recognize
+ * (`location` / `geolocation`, `address`, `composite` / `record`).
*/
export function JsonCellRenderer({ value }: CellRendererProps): React.ReactElement {
// THE FLOOR WITH ONE MEMBER DECLINED, and the declension is the point
@@ -3076,22 +3097,191 @@ export function JsonCellRenderer({ value }: CellRendererProps): React.ReactEleme
// objectui#8474 measured that and pinned it. ⛔ Do not simplify this to
// `isEmptyValue(value)`: that flattens a decision already on the record.
if (isEmptyValue(value) && !Array.isArray(value)) return ;
- let text: string;
- if (typeof value === 'object') {
- try {
- text = JSON.stringify(value);
- } catch {
- text = String(value);
- }
- } else {
- text = String(value);
- }
+ const text = compactJsonText(value);
// The original site of the block-level+max-w-full+title pattern
// (objectui#2578) — now shared with every single-line value renderer via
// TruncatedText (objectui#3466).
return ;
}
+/**
+ * The `number` field a sub-value is formatted as: no `scale` is declared, so a
+ * sub-value keeps its natural precision and the default grouping, exactly what
+ * `NumberCellRenderer` draws for a `number` field that declares none.
+ */
+const UNDECLARED_NUMBER_SUB_FIELD: FieldMetadata = { name: '', type: 'number' };
+
+/** One drawn sub-value: the node the line shows, and its text for the `title`. */
+interface SubValueFace {
+ node: React.ReactNode;
+ text: string;
+}
+
+/** One labelled sub-value: a key and its face. */
+interface StructuredPair {
+ key: string;
+ label: string;
+ face: SubValueFace;
+}
+
+/** One entry of the face: a pair, or (a `record` entry) a labelled group of pairs. */
+type StructuredEntry = StructuredPair | { key: string; label: string; group: StructuredPair[] };
+
+const STRUCTURED_SEPARATOR = ' · ';
+
+/** The plain text of a list of entries — the line as the `title` spells it. */
+function structuredEntriesText(entries: readonly StructuredEntry[]): string {
+ return entries
+ .map((entry) =>
+ 'group' in entry
+ ? `${entry.label} (${structuredEntriesText(entry.group)})`
+ : `${entry.label} ${entry.face.text}`,
+ )
+ .join(STRUCTURED_SEPARATOR);
+}
+
+/**
+ * The `dt` / `dd` pairs of a list of entries. The separator and the brackets
+ * are `aria-hidden` (the `` already says where a pair starts) and sit
+ * INSIDE a `dt` / `dd`, because a `` may hold nothing else.
+ */
+function structuredEntriesNodes(entries: readonly StructuredEntry[]): React.ReactNode {
+ return entries.map((entry, index) => (
+
+
-
+ {index > 0 && (
+
+ {STRUCTURED_SEPARATOR}
+
+ )}
+ {entry.label}
+
{' '}
+
-
+ {'group' in entry ? (
+ <>
+ (
+
{structuredEntriesNodes(entry.group)}
+ )
+ >
+ ) : (
+ entry.face.node
+ )}
+
+
+ ));
+}
+
+/**
+ * `composite` / `record` cell renderers: an embedded value read as labelled
+ * sub-values, not as its stored JSON (objectui#11697).
+ *
+ * `@objectstack/spec` declares `composite` as one embedded sub-object and
+ * `record` as a name-keyed map of embedded sub-objects whose insertion order is
+ * the display order. Both were registered to {@link JsonCellRenderer}, so the
+ * record page, which reaches every value through this table (the one the grid
+ * reads too), drew `{"width":10,"height":20}` in a monospace face. `address`
+ * made the same move in objectui#4037.
+ *
+ * ## The face
+ *
+ * - `composite`: one labelled pair per key, `Width 10 · Height 20`.
+ * - `record`: one labelled group per entry name holding that entry's pairs,
+ * `Primary (Name A · Score 9) · Backup (Name B · Score 7)`. An entry that
+ * is not a populated sub-object is drawn as a pair, as a composite key is.
+ * - A scalar sub-value is drawn with this package's face for its JS type: a
+ * number through `formatNumberFieldValue` (the call `NumberCellRenderer`
+ * makes, here with no declared `scale`), a boolean as the locale's word
+ * (`useBooleanValueLabel`, the boolean-as-text face of the read-only
+ * surfaces), a string as itself, and a floor member as {@link EmptyValue}.
+ * ⛔ Not the cell components themselves: the text cell and the boolean
+ * checkbox are block-level, and this face is one line.
+ * - A nested object or array stays compact JSON ({@link compactJsonText}).
+ *
+ * It is ONE truncated line in every host, as {@link TruncatedText} is, because
+ * the grid cell, the record page row and the summary chip all draw it; the
+ * full text is the `title` on the value element. The structure is a ``, so
+ * assistive technology reads term / definition pairs.
+ *
+ * ## Labels
+ *
+ * The humanized key (`humanizeLabel`, the key fallback this package's boolean
+ * cell already uses for its "Off" badge). There is no declared sub-field label
+ * to prefer: the spec's `FieldSchema` has no sub-field member for either type
+ * and refuses `fields` / `subFields` as unrecognized keys, so no metadata a
+ * producer can publish carries one, and a label read from the field here would
+ * be a key the contract refuses (AGENTS.md #0.1).
+ *
+ * ## Unchanged
+ *
+ * The floor, and every shape this face does not recognize, answer exactly as
+ * {@link JsonCellRenderer} answers: `null` / `undefined` / `''` draw
+ * {@link EmptyValue}; `[]` and `{}` keep their literal (objectui#8474, and
+ * objectui#8481's json-literal fence); a value that is not an object, a JSON
+ * string included, is drawn as it is and never parsed (AGENTS.md #0.1).
+ *
+ * ⛔ Not exported, for the reason {@link RepeaterCellRenderer} gives: the table
+ * entries are reachable the way every call site reaches them,
+ * `getCellRenderer('composite')` / `getCellRenderer('record')`.
+ */
+function StructuredValueCell({
+ value,
+ field,
+ groupEntries,
+}: CellRendererProps & { groupEntries: boolean }): React.ReactElement {
+ // Hooks before the early returns, so the hook count does not change with the
+ // value (the rule `NumberCellRenderer` states).
+ const locale = useDisplayLocale();
+ const booleanLabel = useBooleanValueLabel();
+ if (isEmptyValue(value) && !Array.isArray(value)) return ;
+ if (!isPlainObjectValue(value) || Object.keys(value).length === 0) {
+ return ;
+ }
+
+ const faceOf = (sub: unknown): SubValueFace => {
+ if (isEmptyValue(sub) && !Array.isArray(sub)) {
+ return { node: , text: '—' };
+ }
+ if (typeof sub === 'boolean') {
+ const word = booleanLabel(sub);
+ return { node: word, text: word };
+ }
+ if (typeof sub === 'number' && Number.isFinite(sub)) {
+ const formatted = formatNumberFieldValue(sub, UNDECLARED_NUMBER_SUB_FIELD, locale);
+ return { node: {formatted}, text: formatted };
+ }
+ if (Array.isArray(sub) || isPlainObjectValue(sub)) {
+ const json = compactJsonText(sub);
+ return { node: {json}, text: json };
+ }
+ const text = String(coerceToSafeValue(sub));
+ return { node: text, text };
+ };
+ const pairsOf = (object: Record): StructuredPair[] =>
+ Object.entries(object).map(([key, sub]) => ({ key, label: humanizeLabel(key), face: faceOf(sub) }));
+
+ const entries: StructuredEntry[] = Object.entries(value).map(([key, sub]) =>
+ groupEntries && isPlainObjectValue(sub) && Object.keys(sub).length > 0
+ ? { key, label: humanizeLabel(key), group: pairsOf(sub) }
+ : { key, label: humanizeLabel(key), face: faceOf(sub) },
+ );
+
+ return (
+
+ {structuredEntriesNodes(entries)}
+
+ );
+}
+
+/** `composite`: one embedded sub-object, drawn as labelled pairs. See {@link StructuredValueCell}. */
+function CompositeCellRenderer(props: CellRendererProps): React.ReactElement {
+ return ;
+}
+
+/** `record`: a name-keyed map of sub-objects, one labelled group per entry. See {@link StructuredValueCell}. */
+function RecordMapCellRenderer(props: CellRendererProps): React.ReactElement {
+ return ;
+}
+
/**
* Renders a `color` value as a swatch alongside its hex/string value.
*/
@@ -3499,8 +3689,9 @@ function buildStandardCellRendererMap(): Record {
+ // The desktop row renders above the mobile breakpoint.
+ beforeAll(() => {
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
+ });
+
+ // The Field Zoo's two fields and its seed values.
+ const objectSchema = {
+ fields: {
+ f_composite: { type: 'composite', label: 'Composite (embedded object)' },
+ f_record: { type: 'record', label: 'Record (name-keyed map)' },
+ },
+ };
+ const data = {
+ f_composite: { width: 10, height: 20 },
+ f_record: { primary: { name: 'A', score: 9 }, backup: { name: 'B', score: 7 } },
+ };
+
+ /** The value `` drawn in the row labelled `label`. */
+ function valueListFor(container: HTMLElement, label: string): HTMLElement {
+ const row = Array.from(container.querySelectorAll('div')).find(
+ (div) => div.firstElementChild?.textContent === label,
+ );
+ expect(row, `a row labelled "${label}"`).toBeDefined();
+ const list = row!.querySelector('dl');
+ expect(list, `the "${label}" row draws a description list`).not.toBeNull();
+ return list as HTMLElement;
+ }
+
+ /** The terms directly under one ``, minus the `aria-hidden` separators. */
+ function termsOf(list: Element): string[] {
+ return Array.from(list.querySelectorAll(':scope > div > dt')).map((term) => {
+ const clone = term.cloneNode(true) as Element;
+ clone.querySelectorAll('[aria-hidden="true"]').forEach((node) => node.remove());
+ return (clone.textContent ?? '').trim();
+ });
+ }
+
+ it('a composite field renders labelled sub-values on the record page', () => {
+ const { container } = render(
+ ,
+ );
+ const list = valueListFor(container, 'Composite (embedded object)');
+ expect(termsOf(list)).toEqual(['Width', 'Height']);
+ expect(list.textContent).toBe('Width 10 · Height 20');
+ expect(container.textContent, 'no stored-JSON signature').not.toContain('{"width"');
+ });
+
+ it('a record field renders one labelled group per entry on the record page', () => {
+ const { container } = render(
+ ,
+ );
+ const list = valueListFor(container, 'Record (name-keyed map)');
+ expect(termsOf(list)).toEqual(['Primary', 'Backup']);
+ expect(list.textContent).toBe('Primary (Name A · Score 9) · Backup (Name B · Score 7)');
+ expect(container.textContent, 'no stored-JSON signature').not.toContain('{"primary"');
+ });
+});
diff --git a/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx b/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx
index 99da7760a6..ee24bde01f 100644
--- a/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx
+++ b/packages/plugin-detail/src/__tests__/summaryChip.badgeFitCensus-8464.test.tsx
@@ -28,7 +28,7 @@
*
* | class | types | what the pill received |
* |--------------------------|-------------------------------------------------------------|-----------------------------------------------------|
- * | ✅ plain inline text | the other 35 | `Acme Corp`, or the JSON literal for the seven behind objectui#8481's declared json-literal fence, or a value-independent face |
+ * | ✅ plain inline text | the other 35 | `Acme Corp`, or the JSON literal for the seven behind objectui#8481's declared json-literal fence (five since objectui#11697: `composite` / `record` now draw labelled sub-values), or a value-independent face |
* | ⛔ a pill inside a pill | `select` `status` `multiselect` `radio` `checkboxes` `tags` | `SelectCellRenderer`'s own `Badge` — one `rounded-full` node nested in the chip's own |
* | ⛔ an avatar composite | `user` | TWO `rounded-full` nodes and the initials glued on: `ACAcme Corp` |
* | ⛔ an image and no text | `image` `avatar` `signature` | an `
`, `textContent === ''` |
@@ -143,8 +143,10 @@ const CENSUS: ReadonlyArray` of inline `dt` / `dd` text: no pill, no image, no control.
+ ['composite', 'Id acct-1 · Name Acme Corp', 'fit'],
+ ['record', 'Id acct-1 · Name Acme Corp', 'fit'],
// ── plain inline text: value-independent faces ─────────────────────────
['password', '••••••', 'fit'],
['secret', '••••••', 'fit'],