diff --git a/.changeset/12625-record-page-names.md b/.changeset/12625-record-page-names.md
new file mode 100644
index 0000000000..963c798710
--- /dev/null
+++ b/.changeset/12625-record-page-names.md
@@ -0,0 +1,5 @@
+---
+'@object-ui/plugin-detail': patch
+---
+
+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.
diff --git a/packages/app-shell/src/layout/__tests__/AppSwitcher.accessibleName-12625.test.tsx b/packages/app-shell/src/layout/__tests__/AppSwitcher.accessibleName-12625.test.tsx
new file mode 100644
index 0000000000..f093a0d1e3
--- /dev/null
+++ b/packages/app-shell/src/layout/__tests__/AppSwitcher.accessibleName-12625.test.tsx
@@ -0,0 +1,32 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectui#12625 — the app pill is a button whose accessible name carries the
+ * app's name, read through the REAL dropdown trigger (no primitive mocks), so
+ * a restyle that moves the label into an `aria-hidden` span, or drops the
+ * `title` the glyph-only phone pill is named by, turns this red.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+vi.mock('../../providers/MetadataProvider', () => ({
+ useMetadata: () => ({ apps: [{ name: 'hotcrm', label: 'HotCRM', icon: 'flame' }] }),
+}));
+
+import { AppSwitcher } from '../AppSwitcher';
+
+describe('the app pill is named by the app (objectui#12625)', () => {
+ it('resolves by role and the app name, and its title names the app too', () => {
+ render(
+
+
+ ,
+ );
+ const pill = screen.getByRole('button', { name: /HotCRM/ });
+ expect(pill).toHaveAttribute('data-testid', 'app-switcher-trigger');
+ expect(pill).toHaveAttribute('title', 'HotCRM');
+ });
+});
diff --git a/packages/app-shell/src/views/RecordDetailView.accessibleNames-12625.test.tsx b/packages/app-shell/src/views/RecordDetailView.accessibleNames-12625.test.tsx
new file mode 100644
index 0000000000..6b20296adc
--- /dev/null
+++ b/packages/app-shell/src/views/RecordDetailView.accessibleNames-12625.test.tsx
@@ -0,0 +1,195 @@
+/**
+ * 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#12625 — the console record page's controls have accessible names,
+ * read on the REAL `RecordDetailView` mount (the synthesized default page:
+ * `page:header`, `page:tabs`, `record:details`), by role and name.
+ *
+ * - The tabs are named by their labels (Details, Related).
+ * - A declared `record_header` action is named by its label (Log a Call).
+ * - A collapsible field group's header is a button named by its label
+ * (Lead Information) that toggles `aria-expanded` — the half the card found
+ * really broken (`DetailSection`'s header was a div with no role).
+ *
+ * The tabs and the header actions were already named on main when this was
+ * written; they are regression pins, so a restyle that moves a label into an
+ * `aria-hidden` span turns this file red.
+ */
+
+import * as React from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, waitFor, screen, cleanup } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom';
+import { MemoryRouter } from 'react-router-dom';
+
+vi.mock('@object-ui/auth', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
+ createAuthenticatedFetch: () => vi.fn(),
+}));
+
+vi.mock('@object-ui/collaboration', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ useRecordPresence: () => [],
+ PresenceAvatars: () => null,
+}));
+
+vi.mock('sonner', () => ({
+ toast: Object.assign(vi.fn(), {
+ success: vi.fn(),
+ error: vi.fn(),
+ info: vi.fn(),
+ warning: vi.fn(),
+ loading: vi.fn(),
+ dismiss: vi.fn(),
+ }),
+}));
+
+// Orthogonal chrome — same posture as RecordDetailView.headerActionLookupPredicate.test.
+vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
+vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
+vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
+vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
+vi.mock('./MetadataInspector', () => ({
+ MetadataPanel: () => null,
+ useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
+}));
+
+import { MetadataCtx } from '@object-ui/react';
+import { RecordDetailView } from './RecordDetailView';
+import { ExpressionProvider } from '../providers/ExpressionProvider';
+
+const headerAction = (name: string, label: string) => ({
+ name,
+ label,
+ type: 'api',
+ target: '/api/v1/noop',
+ locations: ['record_header'],
+});
+
+const LEAD = {
+ name: 'lead',
+ label: 'Lead',
+ managedBy: 'platform',
+ // One declared, ungrouped highlight keeps the heuristic strip from claiming
+ // the grouped fields (see defaultFieldGroupsPage.sectionHeadings.test).
+ highlightFields: ['name'],
+ fieldGroups: [
+ { key: 'info', label: 'Lead Information', collapsible: true },
+ { key: 'addr', label: 'Address' },
+ ],
+ fields: {
+ id: { type: 'text', label: 'Id' },
+ name: { type: 'text', label: 'Name' },
+ company: { type: 'text', label: 'Company', group: 'info' },
+ city: { type: 'text', label: 'City', group: 'addr' },
+ },
+ actions: [
+ headerAction('log_call', 'Log a Call'),
+ headerAction('log_meeting', 'Log a Meeting'),
+ headerAction('schedule_meeting', 'Schedule a Meeting'),
+ ],
+};
+
+/** A child with a lookup to the lead, so the page carries a Related tab. */
+const TASK = {
+ name: 'task',
+ label: 'Task',
+ fields: {
+ id: { type: 'text' },
+ subject: { type: 'text', label: 'Subject' },
+ lead: { type: 'lookup', reference: 'lead', label: 'Lead' },
+ },
+};
+
+async function mountLeadPage() {
+ const objects = [LEAD, TASK];
+ const findOne = vi.fn(async () => ({ id: 'L1', name: 'Ada', company: 'Acme', city: 'Paris' }));
+ const dataSource = {
+ find: vi.fn(async () => ({ data: [] })),
+ findOne,
+ create: vi.fn(async () => ({})),
+ update: vi.fn(async () => ({})),
+ delete: vi.fn(async () => ({})),
+ } as never;
+ const metadata = {
+ objects,
+ pages: [],
+ loading: false,
+ error: null,
+ refresh: async () => {},
+ invalidate: () => {},
+ ensureType: async () => [],
+ getItem: async () => null,
+ getItemsByType: () => [],
+ } as never;
+ render(
+
+
+
+ {}}
+ objectNameOverride="lead"
+ recordIdOverride="L1"
+ />
+
+
+ ,
+ );
+ await waitFor(() => expect(findOne).toHaveBeenCalled());
+ await screen.findByRole('button', { name: 'Lead Information' });
+}
+
+beforeEach(() => {
+ cleanup();
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () =>
+ new Response(JSON.stringify({ data: [] }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ }),
+ ),
+ );
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+});
+
+describe('objectui#12625 — the record page names its tabs, header actions and section headers', () => {
+ it('the tabs are named by their labels', async () => {
+ await mountLeadPage();
+ expect(screen.getByRole('tab', { name: 'Details' })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'Related' })).toBeInTheDocument();
+ });
+
+ it('the header actions are named by their labels', async () => {
+ await mountLeadPage();
+ expect(screen.getByRole('button', { name: 'Log a Call' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Log a Meeting' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Schedule a Meeting' })).toBeInTheDocument();
+ });
+
+ it('the collapsible field group header is a button named by its label that toggles', async () => {
+ const user = userEvent.setup();
+ await mountLeadPage();
+ const header = screen.getByRole('button', { name: 'Lead Information' });
+ expect(header).toHaveAttribute('aria-expanded', 'true');
+ await user.click(header);
+ expect(header).toHaveAttribute('aria-expanded', 'false');
+ // CONTROL: the non-collapsible group shows its heading but is no button.
+ expect(screen.getByText('Address')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Address' })).toBeNull();
+ });
+});
diff --git a/packages/components/src/__tests__/iconOnlyButtonCensus-12143.test.ts b/packages/components/src/__tests__/iconOnlyButtonCensus-12143.test.ts
index d2380f3bcf..c6afc75dc8 100644
--- a/packages/components/src/__tests__/iconOnlyButtonCensus-12143.test.ts
+++ b/packages/components/src/__tests__/iconOnlyButtonCensus-12143.test.ts
@@ -242,7 +242,6 @@ const BACKLOG: Array<[file: string, icons: string[]]> = [
['packages/plugin-ai/src/AIRecommendations.tsx', ['ExternalLink', 'X', 'ThumbsUp', 'ThumbsDown']],
['packages/plugin-chatbot/src/elements/conversation.tsx', ['ArrowDownIcon']],
['packages/plugin-chatbot/src/index.tsx', ['Send']],
- ['packages/plugin-detail/src/DetailSection.tsx', ['Check | Copy']],
['packages/plugin-detail/src/DetailView.tsx', ['ArrowLeft', 'ChevronLeft', 'ChevronRight']],
['packages/plugin-detail/src/RecordComments.tsx', ['Send']],
['packages/plugin-list/src/ListView.tsx', ['X']],
diff --git a/packages/plugin-detail/src/DetailSection.tsx b/packages/plugin-detail/src/DetailSection.tsx
index 856ef124f6..b7a68542dc 100644
--- a/packages/plugin-detail/src/DetailSection.tsx
+++ b/packages/plugin-detail/src/DetailSection.tsx
@@ -669,6 +669,9 @@ export const DetailSection: React.FC = ({
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
+ // objectui#12625 — an icon-only button; the tooltip is
+ // closed at rest, so without this it has no name.
+ aria-label={t('detail.copyToClipboard')}
onClick={(e) => {
e.stopPropagation();
handleCopyField(field.name, value);
@@ -821,29 +824,45 @@ export const DetailSection: React.FC = ({
different behaviour, unmeasured by that card.
*/}
-
-
-
-
- {section.icon && }
- {section.title}
-
-
- {isCollapsed ? (
-
- ) : (
-
- )}
-
-
- {section.description && !isCollapsed && (
-
{section.description}
- )}
-
-
+ {/*
+ objectui#12625 — the disclosure is a NATIVE button inside the
+ header, not the header itself. `CollapsibleTrigger asChild` on the
+ `CardHeader` div used to hand the div `aria-expanded` and a click
+ handler but no role and no tabindex: the section title was not a
+ button in the accessibility tree and a keyboard user could not reach
+ or toggle it. The button takes its name from the section title,
+ carries `aria-expanded`, and Enter / Space toggle it natively. Its
+ `after:` overlay spans the (now `relative`) header, so a click
+ anywhere on the header still toggles, as before.
+ */}
+
+
+
+
+
+
+ {section.description && !isCollapsed && (
+
{section.description}
+ )}
+
{content}
diff --git a/packages/plugin-detail/src/__tests__/DetailSection.accessibleNames-12625.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.accessibleNames-12625.test.tsx
new file mode 100644
index 0000000000..2a23f034ad
--- /dev/null
+++ b/packages/plugin-detail/src/__tests__/DetailSection.accessibleNames-12625.test.tsx
@@ -0,0 +1,93 @@
+/**
+ * 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#12625 — a record section's controls have accessible names.
+ *
+ * - A COLLAPSIBLE section's header is a native button named by the section
+ * title. It carries `aria-expanded`, and a click, Enter and Space each
+ * toggle it. Before the fix `CollapsibleTrigger asChild` sat on the
+ * `CardHeader` div: the div got `aria-expanded` and a click handler but no
+ * role and no tabindex, so no button by that name existed and a keyboard
+ * user could not reach the toggle.
+ * - The per-field copy-value icon button is named by `detail.copyToClipboard`
+ * (its tooltip is closed at rest, so it had no name).
+ *
+ * CONTROL: a titled, NON-collapsible section renders its title and no button
+ * by that name — the header stays a plain heading there.
+ */
+
+import React from 'react';
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { DetailViewSection } from '@object-ui/types';
+
+import { DetailSection } from '../DetailSection';
+
+const RECORD = { company: 'Acme', city: 'Paris' };
+
+const section = (overrides: Record) =>
+ ({
+ title: 'Lead Information',
+ fields: [
+ { name: 'company', label: 'Company' },
+ { name: 'city', label: 'City' },
+ ],
+ ...overrides,
+ }) as unknown as DetailViewSection;
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('objectui#12625 — the collapsible section header is a named button', () => {
+ it('is a button named by the section title, expanded at rest, toggled by a click', async () => {
+ const user = userEvent.setup();
+ render();
+ const header = screen.getByRole('button', { name: 'Lead Information' });
+ expect(header.tagName).toBe('BUTTON');
+ expect(header).toHaveAttribute('type', 'button');
+ expect(header).toHaveAttribute('aria-expanded', 'true');
+ await user.click(header);
+ expect(header).toHaveAttribute('aria-expanded', 'false');
+ await user.click(header);
+ expect(header).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('is reachable by Tab and toggled by Enter and by Space', async () => {
+ const user = userEvent.setup();
+ render();
+ const header = screen.getByRole('button', { name: 'Lead Information' });
+ await user.tab();
+ expect(header).toHaveFocus();
+ await user.keyboard('{Enter}');
+ expect(header).toHaveAttribute('aria-expanded', 'false');
+ await user.keyboard(' ');
+ expect(header).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('honours defaultCollapsed: the button reads collapsed', () => {
+ render();
+ expect(screen.getByRole('button', { name: 'Lead Information' })).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('CONTROL: a non-collapsible titled section draws its title and no header button', () => {
+ render();
+ expect(screen.getByText('Lead Information')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Lead Information' })).toBeNull();
+ expect(document.querySelector('[aria-expanded]')).toBeNull();
+ });
+});
+
+describe('objectui#12625 — the copy-value icon button is named', () => {
+ it('each read-only field offers a button named "Copy to clipboard"', () => {
+ render();
+ expect(screen.getAllByRole('button', { name: 'Copy to clipboard' })).toHaveLength(2);
+ });
+});