Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 4be76e0

Browse files
fix(plugin-detail): a collapsible record section's header is a named button; the copy-value button is named (objectui#12625) (#12661)
Fixes #12625 Clause-②: no ## What the tree said on main Measured on `a5bb7ac2f` (after #12651) before any edit: the real console `RecordDetailView` mounted with a lead object (three `record_header` actions, a child lookup for the Related tab, a collapsible field group 'Lead Information'), names read by role with RTL, and the same rendered DOM loaded into real Chromium and read with CDP `Accessibility.getFullAXTree`. - Already named, no change: the tabs (Details, Related; `page:tabs` renders the label in a plain span, Activity and History use the same trigger), the header actions (Log a Call, Log a Meeting, Schedule a Meeting, Edit, More actions, Refresh), and the app pill (name HotCRM, title HotCRM; `AppMark` is aria-hidden). As in objectui#12510, the card's readings of these came from the tool, not the tree. - Really broken, both in `DetailSection.tsx`: 1. the collapsible section header was `CollapsibleTrigger asChild` on the `CardHeader` div: `aria-expanded` and a click handler, but no role and no tabindex. No button by the section title existed and a keyboard user could not reach or toggle the section. 2. the per-field copy-value icon button had no name (its tooltip is closed at rest): Chromium read `button` with an empty name. ## What changed - `DetailSection.tsx`, collapsible branch: the trigger is now a native `button type="button"` inside the header (full-width, text-left), named by the section title, carrying `aria-expanded`; Enter and Space toggle it natively, Tab reaches it. The header keeps its classes (padding, hover, `headerColor`) and becomes `relative`; the button's `after:` overlay spans it, so a click anywhere on the header still toggles, as before. Chevrons are aria-hidden. The non-collapsible header is unchanged (a plain title, no button). - The copy-value button takes `aria-label` from the existing `detail.copyToClipboard` key (no new string). Every copy button on a record reads the same name, "Copy to clipboard"; a per-field name would need a new interpolated key, left out on purpose (see Acceptance notes). - Changeset: `@object-ui/plugin-detail` patch. ## Pins - `DetailSection.accessibleNames-12625.test.tsx` (plugin-detail): `getByRole('button', { name: 'Lead Information' })` is a native button with `aria-expanded`, toggled by click, by Enter and by Space after Tab; `defaultCollapsed` reads collapsed; copy buttons by name. CONTROL: a non-collapsible titled section shows its title and no header button, no `aria-expanded` anywhere. - `RecordDetailView.accessibleNames-12625.test.tsx` (app-shell, the real record page mount): `getByRole('tab', { name: 'Details' })`, `getByRole('tab', { name: 'Related' })`, `getByRole('button', { name: 'Log a Call' })` and the other two actions, the 'Lead Information' header button toggling; CONTROL: the non-collapsible 'Address' group is no button. - `AppSwitcher.accessibleName-12625.test.tsx` (app-shell): with the real dropdown trigger, `getByRole('button', { name: /HotCRM/ })` resolves and its title is HotCRM. ## Ablation (one-off, both legs recorded) With the fix committed (`294ef9835`), the collapsible header block was put back to the base div shape on disk (marker count for the new block 1 to 0, `CollapsibleTrigger asChild` directly on `CardHeader` 0 to 1) under an EXIT trap; the plugin-detail pin file read `3 failed | 2 passed`: the three header-button rows red, the CONTROL and the copy-button row green. Restored with `git checkout HEAD --`, HEAD blob hash matched, `git diff HEAD` empty. Tests import `src` through the root vitest alias, so no build sat between the mutation and the run. ## Verification (final head `94c0d6a1a`) - `pnpm exec vitest run packages/plugin-detail/`: Test Files 274 passed, 1 skipped; Tests 2660 passed, 14 skipped. - app-shell, narrowed and declared: the full `packages/app-shell/` dir is not run locally (too long for the container's foreground cap; CI runs it). Run instead: every `RecordDetailView*` view test and every `AppSwitcher*` test, 65 files, 413 tests passed, the new pins included. - Type-check: `@object-ui/plugin-detail` `type-check` exit 0; `@object-ui/app-shell` `type-check` exit 0 (both include tests through `tsconfig.test.json`), after building each closure. - ESLint `--no-inline-config` on the four changed files (`--format json`): 4 files, 0 errors; `DetailSection.tsx` has the same 12 warnings as on base, the three test files none. Narrowed lint, not the repo-wide run (CI owns that). - Pre-tsc battery all exit 0: phantom-deps, self-import, unreferenced-sources, doc-example-readers, handler-key-reads, side-effects-array, element-data-source-declaration, spec-symbols, action-forward-parity, designer-field-key-parity, icon-record-names, i18n-keys, i18n-drift, i18n-designer-parity, control-bytes, `check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs`, `check-changeset-presence.mjs`. ## Acceptance notes - Acceptance 2 (a pasted accessibility tree of the live lead record) is answered by the Chromium AX-tree reading of the rendered record page described above, not by a live HotCRM session; no live stack was started. - The copy-value buttons all share one name. A field-specific name ("Copy Company") would need a new interpolated i18n key in all ten packs; not added here per the dispatch order. - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a98a23c commit 4be76e0

6 files changed

Lines changed: 367 additions & 24 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
Record page sections: a collapsible section's header is now a real button named by the section title (objectui#12625). It carries `aria-expanded`, is reachable by Tab, and Enter or Space toggles it; a click anywhere on the header still toggles as before. The per-field copy-value icon button is named "Copy to clipboard". A non-collapsible section's header is unchanged.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#12625 — the app pill is a button whose accessible name carries the
5+
* app's name, read through the REAL dropdown trigger (no primitive mocks), so
6+
* a restyle that moves the label into an `aria-hidden` span, or drops the
7+
* `title` the glyph-only phone pill is named by, turns this red.
8+
*/
9+
10+
import '@testing-library/jest-dom/vitest';
11+
import { describe, expect, it, vi } from 'vitest';
12+
import { render, screen } from '@testing-library/react';
13+
import { MemoryRouter } from 'react-router-dom';
14+
15+
vi.mock('../../providers/MetadataProvider', () => ({
16+
useMetadata: () => ({ apps: [{ name: 'hotcrm', label: 'HotCRM', icon: 'flame' }] }),
17+
}));
18+
19+
import { AppSwitcher } from '../AppSwitcher';
20+
21+
describe('the app pill is named by the app (objectui#12625)', () => {
22+
it('resolves by role and the app name, and its title names the app too', () => {
23+
render(
24+
<MemoryRouter>
25+
<AppSwitcher activeAppName="hotcrm" onAppChange={vi.fn()} />
26+
</MemoryRouter>,
27+
);
28+
const pill = screen.getByRole('button', { name: /HotCRM/ });
29+
expect(pill).toHaveAttribute('data-testid', 'app-switcher-trigger');
30+
expect(pill).toHaveAttribute('title', 'HotCRM');
31+
});
32+
});
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#12625 — the console record page's controls have accessible names,
11+
* read on the REAL `RecordDetailView` mount (the synthesized default page:
12+
* `page:header`, `page:tabs`, `record:details`), by role and name.
13+
*
14+
* - The tabs are named by their labels (Details, Related).
15+
* - A declared `record_header` action is named by its label (Log a Call).
16+
* - A collapsible field group's header is a button named by its label
17+
* (Lead Information) that toggles `aria-expanded` — the half the card found
18+
* really broken (`DetailSection`'s header was a div with no role).
19+
*
20+
* The tabs and the header actions were already named on main when this was
21+
* written; they are regression pins, so a restyle that moves a label into an
22+
* `aria-hidden` span turns this file red.
23+
*/
24+
25+
import * as React from 'react';
26+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
27+
import { render, waitFor, screen, cleanup } from '@testing-library/react';
28+
import userEvent from '@testing-library/user-event';
29+
import '@testing-library/jest-dom';
30+
import { MemoryRouter } from 'react-router-dom';
31+
32+
vi.mock('@object-ui/auth', async (importOriginal) => ({
33+
...(await importOriginal<Record<string, unknown>>()),
34+
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
35+
createAuthenticatedFetch: () => vi.fn(),
36+
}));
37+
38+
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
39+
...(await importOriginal<Record<string, unknown>>()),
40+
useRecordPresence: () => [],
41+
PresenceAvatars: () => null,
42+
}));
43+
44+
vi.mock('sonner', () => ({
45+
toast: Object.assign(vi.fn(), {
46+
success: vi.fn(),
47+
error: vi.fn(),
48+
info: vi.fn(),
49+
warning: vi.fn(),
50+
loading: vi.fn(),
51+
dismiss: vi.fn(),
52+
}),
53+
}));
54+
55+
// Orthogonal chrome — same posture as RecordDetailView.headerActionLookupPredicate.test.
56+
vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
57+
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
58+
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
59+
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
60+
vi.mock('./MetadataInspector', () => ({
61+
MetadataPanel: () => null,
62+
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
63+
}));
64+
65+
import { MetadataCtx } from '@object-ui/react';
66+
import { RecordDetailView } from './RecordDetailView';
67+
import { ExpressionProvider } from '../providers/ExpressionProvider';
68+
69+
const headerAction = (name: string, label: string) => ({
70+
name,
71+
label,
72+
type: 'api',
73+
target: '/api/v1/noop',
74+
locations: ['record_header'],
75+
});
76+
77+
const LEAD = {
78+
name: 'lead',
79+
label: 'Lead',
80+
managedBy: 'platform',
81+
// One declared, ungrouped highlight keeps the heuristic strip from claiming
82+
// the grouped fields (see defaultFieldGroupsPage.sectionHeadings.test).
83+
highlightFields: ['name'],
84+
fieldGroups: [
85+
{ key: 'info', label: 'Lead Information', collapsible: true },
86+
{ key: 'addr', label: 'Address' },
87+
],
88+
fields: {
89+
id: { type: 'text', label: 'Id' },
90+
name: { type: 'text', label: 'Name' },
91+
company: { type: 'text', label: 'Company', group: 'info' },
92+
city: { type: 'text', label: 'City', group: 'addr' },
93+
},
94+
actions: [
95+
headerAction('log_call', 'Log a Call'),
96+
headerAction('log_meeting', 'Log a Meeting'),
97+
headerAction('schedule_meeting', 'Schedule a Meeting'),
98+
],
99+
};
100+
101+
/** A child with a lookup to the lead, so the page carries a Related tab. */
102+
const TASK = {
103+
name: 'task',
104+
label: 'Task',
105+
fields: {
106+
id: { type: 'text' },
107+
subject: { type: 'text', label: 'Subject' },
108+
lead: { type: 'lookup', reference: 'lead', label: 'Lead' },
109+
},
110+
};
111+
112+
async function mountLeadPage() {
113+
const objects = [LEAD, TASK];
114+
const findOne = vi.fn(async () => ({ id: 'L1', name: 'Ada', company: 'Acme', city: 'Paris' }));
115+
const dataSource = {
116+
find: vi.fn(async () => ({ data: [] })),
117+
findOne,
118+
create: vi.fn(async () => ({})),
119+
update: vi.fn(async () => ({})),
120+
delete: vi.fn(async () => ({})),
121+
} as never;
122+
const metadata = {
123+
objects,
124+
pages: [],
125+
loading: false,
126+
error: null,
127+
refresh: async () => {},
128+
invalidate: () => {},
129+
ensureType: async () => [],
130+
getItem: async () => null,
131+
getItemsByType: () => [],
132+
} as never;
133+
render(
134+
<MemoryRouter initialEntries={['/apps/crm/lead/L1']}>
135+
<MetadataCtx.Provider value={metadata}>
136+
<ExpressionProvider user={{ id: 'u1', name: 'Ada' }} app={{ name: 'crm' }} data={{}} features={{}}>
137+
<RecordDetailView
138+
dataSource={dataSource}
139+
objects={objects}
140+
onEdit={() => {}}
141+
objectNameOverride="lead"
142+
recordIdOverride="L1"
143+
/>
144+
</ExpressionProvider>
145+
</MetadataCtx.Provider>
146+
</MemoryRouter>,
147+
);
148+
await waitFor(() => expect(findOne).toHaveBeenCalled());
149+
await screen.findByRole('button', { name: 'Lead Information' });
150+
}
151+
152+
beforeEach(() => {
153+
cleanup();
154+
vi.stubGlobal(
155+
'fetch',
156+
vi.fn(async () =>
157+
new Response(JSON.stringify({ data: [] }), {
158+
status: 200,
159+
headers: { 'content-type': 'application/json' },
160+
}),
161+
),
162+
);
163+
});
164+
165+
afterEach(() => {
166+
vi.unstubAllGlobals();
167+
vi.clearAllMocks();
168+
});
169+
170+
describe('objectui#12625 — the record page names its tabs, header actions and section headers', () => {
171+
it('the tabs are named by their labels', async () => {
172+
await mountLeadPage();
173+
expect(screen.getByRole('tab', { name: 'Details' })).toBeInTheDocument();
174+
expect(screen.getByRole('tab', { name: 'Related' })).toBeInTheDocument();
175+
});
176+
177+
it('the header actions are named by their labels', async () => {
178+
await mountLeadPage();
179+
expect(screen.getByRole('button', { name: 'Log a Call' })).toBeInTheDocument();
180+
expect(screen.getByRole('button', { name: 'Log a Meeting' })).toBeInTheDocument();
181+
expect(screen.getByRole('button', { name: 'Schedule a Meeting' })).toBeInTheDocument();
182+
});
183+
184+
it('the collapsible field group header is a button named by its label that toggles', async () => {
185+
const user = userEvent.setup();
186+
await mountLeadPage();
187+
const header = screen.getByRole('button', { name: 'Lead Information' });
188+
expect(header).toHaveAttribute('aria-expanded', 'true');
189+
await user.click(header);
190+
expect(header).toHaveAttribute('aria-expanded', 'false');
191+
// CONTROL: the non-collapsible group shows its heading but is no button.
192+
expect(screen.getByText('Address')).toBeInTheDocument();
193+
expect(screen.queryByRole('button', { name: 'Address' })).toBeNull();
194+
});
195+
});

‎packages/components/src/__tests__/iconOnlyButtonCensus-12143.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,6 @@ const BACKLOG: Array<[file: string, icons: string[]]> = [
242242
['packages/plugin-ai/src/AIRecommendations.tsx', ['ExternalLink', 'X', 'ThumbsUp', 'ThumbsDown']],
243243
['packages/plugin-chatbot/src/elements/conversation.tsx', ['ArrowDownIcon']],
244244
['packages/plugin-chatbot/src/index.tsx', ['Send']],
245-
['packages/plugin-detail/src/DetailSection.tsx', ['Check | Copy']],
246245
['packages/plugin-detail/src/DetailView.tsx', ['ArrowLeft', 'ChevronLeft', 'ChevronRight']],
247246
['packages/plugin-detail/src/RecordComments.tsx', ['Send']],
248247
['packages/plugin-list/src/ListView.tsx', ['X']],

‎packages/plugin-detail/src/DetailSection.tsx‎

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,9 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
669669
variant="ghost"
670670
size="icon"
671671
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
672+
// objectui#12625 — an icon-only button; the tooltip is
673+
// closed at rest, so without this it has no name.
674+
aria-label={t('detail.copyToClipboard')}
672675
onClick={(e) => {
673676
e.stopPropagation();
674677
handleCopyField(field.name, value);
@@ -821,29 +824,45 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
821824
different behaviour, unmeasured by that card.
822825
*/}
823826
<Card className={cn(section.showBorder === false ? 'border-none shadow-none' : '')}>
824-
<CollapsibleTrigger asChild>
825-
<CardHeader className={cn(
826-
"py-3 px-4 sm:py-4 sm:px-6 cursor-pointer hover:bg-muted/50 transition-colors",
827-
headerColorClass(section.headerColor)
828-
)}>
829-
<CardTitle className="flex items-center justify-between text-base font-semibold tracking-tight">
830-
<div className="flex items-center gap-2">
831-
{section.icon && <SectionIcon name={section.icon} />}
832-
<span>{section.title}</span>
833-
</div>
834-
<div className="flex items-center gap-2">
835-
{isCollapsed ? (
836-
<ChevronRight className="h-4 w-4 text-muted-foreground" />
837-
) : (
838-
<ChevronDown className="h-4 w-4 text-muted-foreground" />
839-
)}
840-
</div>
841-
</CardTitle>
842-
{section.description && !isCollapsed && (
843-
<p className="text-xs text-muted-foreground mt-1">{section.description}</p>
844-
)}
845-
</CardHeader>
846-
</CollapsibleTrigger>
827+
{/*
828+
objectui#12625 — the disclosure is a NATIVE button inside the
829+
header, not the header itself. `CollapsibleTrigger asChild` on the
830+
`CardHeader` div used to hand the div `aria-expanded` and a click
831+
handler but no role and no tabindex: the section title was not a
832+
button in the accessibility tree and a keyboard user could not reach
833+
or toggle it. The button takes its name from the section title,
834+
carries `aria-expanded`, and Enter / Space toggle it natively. Its
835+
`after:` overlay spans the (now `relative`) header, so a click
836+
anywhere on the header still toggles, as before.
837+
*/}
838+
<CardHeader className={cn(
839+
"relative py-3 px-4 sm:py-4 sm:px-6 cursor-pointer hover:bg-muted/50 transition-colors",
840+
headerColorClass(section.headerColor)
841+
)}>
842+
<CardTitle className="text-base font-semibold tracking-tight">
843+
<CollapsibleTrigger asChild>
844+
<button
845+
type="button"
846+
className="flex w-full items-center justify-between text-left rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 after:absolute after:inset-0"
847+
>
848+
<span className="flex items-center gap-2">
849+
{section.icon && <SectionIcon name={section.icon} />}
850+
<span>{section.title}</span>
851+
</span>
852+
<span className="flex items-center gap-2">
853+
{isCollapsed ? (
854+
<ChevronRight className="h-4 w-4 text-muted-foreground" aria-hidden />
855+
) : (
856+
<ChevronDown className="h-4 w-4 text-muted-foreground" aria-hidden />
857+
)}
858+
</span>
859+
</button>
860+
</CollapsibleTrigger>
861+
</CardTitle>
862+
{section.description && !isCollapsed && (
863+
<p className="text-xs text-muted-foreground mt-1">{section.description}</p>
864+
)}
865+
</CardHeader>
847866
<CollapsibleContent>
848867
<CardContent className="pt-3 sm:pt-4 px-3 sm:px-6 pb-4 sm:pb-5">
849868
{content}

0 commit comments

Comments
 (0)