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
31 changes: 31 additions & 0 deletions .changeset/8729-summary-chip-accessible-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@object-ui/plugin-detail': patch
---

The record page's summary chip names its field by its LABEL, like every other band
of the page (objectui#8729).

The chips beside the record H1 carry no visible label, so `<field>: <value>` is the
whole of what a screen-reader user hears — and the name half was the raw stored
column. A record whose `owner_ref` column is authored as "Account Owner" announced
`owner_ref: Ada Lovelace`, so the one band that only a screen-reader user perceives
was also the one band exposing the database spelling.

Every sibling on the same page already resolves a label through one shared helper —
`HeaderHighlight` calls `fieldLabel(objectName, field.name, field.label)` and
`DetailSection` calls it with `field.label || field.name`. This chip now makes the
same call, so the chip and the highlight strip one band below name the same field
the same way, including under a translated session, where the resolver (not the
authored string) decides.

The chip is addressed by NAME (`summaryFields: ['owner_ref']`), so it resolves the
label it hands that helper from this render's own field resolution: the author's
view entry first, then the object schema, then the stored name as the floor — the
same precedence `enrichDetailField` states and the same floor `DetailSection`
spells. A field whose authored label already equals its stored name announces
byte-for-byte as before.

All three of the chip's name sites move together: the string branch's `aria-label`,
the percent branch's `aria-label`, and the renderer-backed branch's visually-hidden
prefix. The `data-summary-chip` attribute keeps the raw stored column — it is a
machine handle for tests and automation, not a name for a reader.
46 changes: 42 additions & 4 deletions packages/plugin-detail/src/DetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
const { t } = useDetailTranslation();
// Tenant default currency (ADR-0053) for summary metrics whose field omits one.
const { currency: tenantCurrency } = useLocalization();
const { fieldOptionLabel } = useSafeFieldLabel();
const { fieldOptionLabel, fieldLabel } = useSafeFieldLabel();

// Field-level permission gate. Filter section.fields and top-level
// fields based on the current user's read permissions BEFORE any
Expand Down Expand Up @@ -1059,6 +1059,44 @@ export const DetailView: React.FC<DetailViewProps> = ({
.find((f) => f.name === fieldName);
const objField = objectSchema?.fields?.[fieldName];
const ftype = sectionField?.type || objField?.type;
// ── The chip's NAME half ──────────────────────────────────
//
// The chip carries no visible label, so what follows is the
// WHOLE of what a screen-reader user hears the field called —
// and it used to be `fieldName`, the raw stored column
// (objectui#8729). Every other band of this page resolves a
// label: `HeaderHighlight` and `DetailSection` both call
// `fieldLabel(objectName, name, authoredLabel)`. This is that
// same call, not a fourth resolution path — a chip and the
// highlight strip one band below must name the same field the
// same way, including when a translation overrides the
// authored label.
//
// The fallback chain is this render's own field resolution,
// not a new one: the summary chip is addressed by NAME only
// (`summaryFields: ['owner_ref']`), so unlike the siblings —
// whose inputs are field objects that already carry a label —
// it has to find one. `sectionField` (the author's explicit
// entry) wins over `objField` (the object schema), exactly as
// `enrichDetailField` states it, and `fieldName` is the floor,
// exactly as `DetailSection` spells it (`field.label ||
// field.name`). `autoSummaryFields` above already merges the
// two sources this way to PICK the chip; this names it from
// the same pair.
//
// ⚠️ NOT `chipField.label` below: `enrichDetailField` copies
// `ENRICHED_FIELD_METADATA_KEYS`, and `label` is deliberately
// not one of them — so that bag carries `sectionField?.label`
// alone and an object-schema label would be dropped.
//
// ⛔ `data-summary-chip` keeps the RAW name on purpose: it is
// a machine handle for tests and automation, not a name for a
// reader, and a stored column is exactly what it should say.
const chipLabel = fieldLabel(
schema.objectName || '',
fieldName,
sectionField?.label || objField?.label || fieldName,
);
let display: string = String(val);
let percentValue: number | null = null;
try {
Expand Down Expand Up @@ -1182,7 +1220,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
it — hiding the very value this branch exists to
show. Same accessible name, `field: value`, composed
from content instead. */}
<span className="sr-only">{`${fieldName}: `}</span>
<span className="sr-only">{`${chipLabel}: `}</span>
<ChipCellRenderer value={val} field={chipField as any} />
</Badge>
);
Expand All @@ -1194,7 +1232,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
key={fieldName}
variant="secondary"
className="text-xs bg-primary/10 text-primary border-transparent hover:bg-primary/15 gap-1.5 pl-2 pr-2"
aria-label={`${fieldName}: ${display}`}
aria-label={`${chipLabel}: ${display}`}
data-summary-chip={fieldName}
>
<span
Expand All @@ -1215,7 +1253,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
key={fieldName}
variant="secondary"
className="text-xs bg-primary/10 text-primary border-transparent hover:bg-primary/15"
aria-label={`${fieldName}: ${display}`}
aria-label={`${chipLabel}: ${display}`}
data-summary-chip={fieldName}
>
{display}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,33 @@
* The replacement navigates by the field LABEL, which no placeholder change can
* move: each chip is `<span>{label}</span>` followed by the value slot, so
* `chipOf(label)` is the chip box and the affordance is read INSIDE it. That
* also strengthens the assertion from "how many" to "which chip", the way the
* summary chips below are already read through the `aria-label="<field>:
* <display>"` each `Badge` carries.
* also strengthens the assertion from "how many" to "which chip".
*
* ⚠️ **The summary chips below were re-derived the same way, and for the same
* reason, by objectui#8729** — declared here rather than done quietly, because
* this instrument belongs to this card and not to that one.
*
* They used to be navigated by `[aria-label^="<storedName>: "]`, which made the
* harness depend on the chip NAMING ITS FIELD BY THE STORED COLUMN — the very
* thing objectui#8729 changed (the chip now resolves a label through
* `fieldLabel`, as every other band of this page already did). That is exactly
* the objectui#8506 shape one paragraph up: the navigation target moved, so
* `chipFor` matched nothing and two cases failed while the emptiness authority
* they exist to guard was untouched.
*
* `chipFor` now navigates by `data-summary-chip="<storedName>"`, the machine
* handle objectui#8464 added. That is strictly better here than either
* spelling of the name: it is a handle rather than a reader-facing string, so
* no display or naming decision can move it, and it also finds the
* renderer-backed chips — which carry no `aria-label` at all and which the old
* selector could never have seen.
*
* ⭐ **This card's subject did not move.** Every emptiness answer below —
* which chip renders, which does not, and that the picker and the render agree
* — is asserted on exactly the same fields with exactly the same data. One
* assertion about the chip's NAME moved (`stage: Won` → `Stage: Won`); its
* stated purpose, "the chip that took the slot reads its value", is preserved
* and now reads the value under the field's resolved label.
*/

import { describe, it, expect, beforeAll, afterEach } from 'vitest';
Expand Down Expand Up @@ -109,9 +133,13 @@ const chipOf = (label: string) => screen.getByText(label).parentElement as HTMLE
const chipsDrawingAffordance = (labels: string[]) =>
labels.filter((l) => chipOf(l).querySelector('[data-slot="empty-value"]') !== null);

/** The summary chip for `field`, read by the `aria-label` the Badge carries. */
/**
* The summary chip for `field`, read by the machine handle the Badge carries —
* never by the name it gives the field, which is objectui#8729's subject and
* not this card's. See the docblock.
*/
const chipFor = (c: HTMLElement, field: string) =>
c.querySelector(`[aria-label^="${field}: "]`);
c.querySelector(`[data-summary-chip="${field}"]`);

const highlightSchema = {
fields: {
Expand Down Expand Up @@ -269,7 +297,11 @@ describe('DetailView — the summary chips beside the H1 trim (#8394)', () => {
expect(
chipFor(container, 'stage')!.getAttribute('aria-label'),
'and the chip that took the slot reads its value',
).toBe('stage: Won');
// The NAME half of this string is objectui#8729's (the chip resolves the
// field's label, `Stage`, where it used to print the stored column,
// `stage`); the VALUE half is this card's and is unmoved. Re-pinned here
// rather than loosened, so this line keeps failing for a render-only fix.
).toBe('Stage: Won');
});
});

Expand Down
Loading
Loading