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); + }); +});