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
5 changes: 5 additions & 0 deletions .changeset/12625-record-page-names.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<AppSwitcher activeAppName="hotcrm" onAppChange={vi.fn()} />
</MemoryRouter>,
);
const pill = screen.getByRole('button', { name: /HotCRM/ });
expect(pill).toHaveAttribute('data-testid', 'app-switcher-trigger');
expect(pill).toHaveAttribute('title', 'HotCRM');
});
});
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
createAuthenticatedFetch: () => vi.fn(),
}));

vi.mock('@object-ui/collaboration', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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(
<MemoryRouter initialEntries={['/apps/crm/lead/L1']}>
<MetadataCtx.Provider value={metadata}>
<ExpressionProvider user={{ id: 'u1', name: 'Ada' }} app={{ name: 'crm' }} data={{}} features={{}}>
<RecordDetailView
dataSource={dataSource}
objects={objects}
onEdit={() => {}}
objectNameOverride="lead"
recordIdOverride="L1"
/>
</ExpressionProvider>
</MetadataCtx.Provider>
</MemoryRouter>,
);
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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']],
Expand Down
65 changes: 42 additions & 23 deletions packages/plugin-detail/src/DetailSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,9 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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);
Expand Down Expand Up @@ -821,29 +824,45 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
different behaviour, unmeasured by that card.
*/}
<Card className={cn(section.showBorder === false ? 'border-none shadow-none' : '')}>
<CollapsibleTrigger asChild>
<CardHeader className={cn(
"py-3 px-4 sm:py-4 sm:px-6 cursor-pointer hover:bg-muted/50 transition-colors",
headerColorClass(section.headerColor)
)}>
<CardTitle className="flex items-center justify-between text-base font-semibold tracking-tight">
<div className="flex items-center gap-2">
{section.icon && <SectionIcon name={section.icon} />}
<span>{section.title}</span>
</div>
<div className="flex items-center gap-2">
{isCollapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</CardTitle>
{section.description && !isCollapsed && (
<p className="text-xs text-muted-foreground mt-1">{section.description}</p>
)}
</CardHeader>
</CollapsibleTrigger>
{/*
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.
*/}
<CardHeader className={cn(
"relative py-3 px-4 sm:py-4 sm:px-6 cursor-pointer hover:bg-muted/50 transition-colors",
headerColorClass(section.headerColor)
)}>
<CardTitle className="text-base font-semibold tracking-tight">
<CollapsibleTrigger asChild>
<button
type="button"
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"
>
<span className="flex items-center gap-2">
{section.icon && <SectionIcon name={section.icon} />}
<span>{section.title}</span>
</span>
<span className="flex items-center gap-2">
{isCollapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" aria-hidden />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" aria-hidden />
)}
</span>
</button>
</CollapsibleTrigger>
</CardTitle>
{section.description && !isCollapsed && (
<p className="text-xs text-muted-foreground mt-1">{section.description}</p>
)}
</CardHeader>
<CollapsibleContent>
<CardContent className="pt-3 sm:pt-4 px-3 sm:px-6 pb-4 sm:pb-5">
{content}
Expand Down
Loading
Loading