From acaf39f9dd36f9c040a2e7181ad3fca998233ef1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:56:40 +0000 Subject: [PATCH 1/2] fix(components): the record header draws one primary action, the rest as outline buttons page:header drew every action without a declared variant with the filled style, so a record header with three actions showed three primaries. The primary is now the first action declaring variant 'primary', else the first by order; the next inline actions are outline buttons; what exceeds maxVisible folds into the overflow menu above the host's Share / Delete. The host's Edit stays a labelled outline button in the row and takes no slot, replacing the icon-only twin it folded behind. Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- .changeset/12617-record-header-actions.md | 14 ++ ...dDetailView.headerMenuIcons-12529.test.tsx | 45 +++-- .../app-shell/src/views/RecordDetailView.tsx | 12 +- .../__tests__/page-header-actions.test.tsx | 19 ++- .../page-header-primary-12617.test.tsx | 160 ++++++++++++++++++ .../src/renderers/layout/containers.tsx | 118 ++++++------- 6 files changed, 266 insertions(+), 102 deletions(-) create mode 100644 .changeset/12617-record-header-actions.md create mode 100644 packages/components/src/__tests__/page-header-primary-12617.test.tsx diff --git a/.changeset/12617-record-header-actions.md b/.changeset/12617-record-header-actions.md new file mode 100644 index 0000000000..174bfd8643 --- /dev/null +++ b/.changeset/12617-record-header-actions.md @@ -0,0 +1,14 @@ +--- +'@object-ui/components': patch +'@object-ui/app-shell': patch +--- + +The record header draws one primary action and the rest as secondary buttons (objectui#12617). + +`@objectstack/spec`'s action contract says that in `record_header` the first visible action becomes the primary button, and that a renderer may prefer a `variant: 'primary'` action. `page:header` drew every action without a declared variant with the filled style, so a header with three actions showed three primary buttons. Now: + +- **One primary.** The primary is the first action declaring `variant: 'primary'`, else the first by `order`. It leads the row and is the only filled button. The other inline actions are outline buttons. A record with no app action shows its Edit as the primary. +- **The rest in the `⋯`.** The page's own actions share the `maxVisible` budget (3 on desktop, 1 on mobile). What does not fit goes to the `⋯` menu, above the host's favorites, Share and Delete. +- **Edit stays a labelled button.** The record page's Edit is the record's base action. It stays a labelled outline button in the row at every width, takes none of the app's slots, and no longer folds into the `⋯` behind an icon-only pencil once the app's actions fill the row. A destructive host action (Delete) always goes to the `⋯`, never inline. + +A declared action whose `visible` predicate holds is still drawn, and one whose predicate fails is still absent. Nothing is added to either package entry: no export, prop, type member or language-pack key. diff --git a/packages/app-shell/src/views/RecordDetailView.headerMenuIcons-12529.test.tsx b/packages/app-shell/src/views/RecordDetailView.headerMenuIcons-12529.test.tsx index c95ced1348..dfd81be99c 100644 --- a/packages/app-shell/src/views/RecordDetailView.headerMenuIcons-12529.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.headerMenuIcons-12529.test.tsx @@ -9,13 +9,15 @@ /** * objectui#12529: the record header's system entries (Edit, favorites, Share, * Delete) declare icons through the same `icon` key the app's actions use, so - * the `⋯` menu draws one kind of row; Delete is last, behind a separator; and - * when the app's own actions fill the header row, so Edit folds into the `⋯`, - * an icon-only Edit button stays beside the `⋯` and opens the same edit - * session the folded `Edit` entry opens. + * the `⋯` menu draws one kind of row; Delete is last, behind a separator. * - * CONTROL: with room in the row, Edit is today's labelled button and no icon - * button is drawn. + * objectui#12617 replaced this file's third rule: Edit no longer folds into + * the `⋯` behind an icon-only twin once the app's actions fill the row. It is + * the record's base action, so it stays a labelled outline button in the row + * at every width and takes none of the app's slots; the first app action is + * the one filled primary button. + * + * CONTROL: with a single app action, Edit is the same labelled button. * * Harness: the `RecordDetailView.favoritesEntry-12427` mount (the real record * page under a real `I18nProvider`, a metadata context and the real @@ -180,12 +182,13 @@ afterEach(() => { vi.clearAllMocks(); }); -describe('record header ⋯ menu: iconed system entries, Delete set apart, an Edit icon button when the row is full (objectui#12529)', () => { - it('each of the four system entries carries its icon', async () => { +describe('record header: iconed system entries, Delete set apart in the ⋯, Edit a labelled button in the row (objectui#12529, objectui#12617)', () => { + it('each system entry carries its icon: Edit in the row, the other three in the ⋯', async () => { await mountRecordPage(FULL_ROW); + const edit = screen.getByRole('button', { name: 'Edit' }); + await waitFor(() => expect(edit.querySelector('svg.lucide-pencil')).not.toBeNull()); const menu = await openOverflow(); const expected: Record = { - Edit: 'lucide-pencil', 'Add to favorites': 'lucide-star', Share: 'lucide-share', Delete: 'lucide-trash', @@ -197,35 +200,29 @@ describe('record header ⋯ menu: iconed system entries, Delete set apart, an Ed } }); - it('Edit leads the system group, and Delete is last, after a separator', async () => { + it('the ⋯ holds the system group with Delete last, after a separator; Edit is not in it', async () => { await mountRecordPage(FULL_ROW); const menu = await openOverflow(); - expect(menuRows(menu)).toEqual(['Edit', 'Add to favorites', 'Share', '---', 'Delete']); + expect(menuRows(menu)).toEqual(['Add to favorites', 'Share', '---', 'Delete']); }); - it('with three app actions in the header, an icon-only Edit button sits beside the ⋯ and opens the session the folded Edit entry opens', async () => { + it('with three app actions in the header, Edit stays a labelled button after them, opens the edit session, and nothing is icon-only', async () => { const { onEdit } = await mountRecordPage(FULL_ROW); const bar = toolbar(); const buttons = Array.from(bar.querySelectorAll(':scope > button, :scope > span > button')); const names = buttons.map((b) => b.getAttribute('aria-label') ?? text(b)); expect(names).toEqual(['Convert Lead', 'Schedule Follow-up', 'Log a Call', 'Edit', 'More actions']); - const editIcon = screen.getByRole('button', { name: 'Edit' }); - // Icon-only: no visible text, the pencil is all it draws. - expect(text(editIcon)).toBe(''); - await waitFor(() => expect(editIcon.querySelector('svg.lucide-pencil')).not.toBeNull()); + const edit = screen.getByRole('button', { name: 'Edit' }); + expect(text(edit)).toBe('Edit'); + expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(1); + expect(bar.querySelector('[data-folded-header-action]')).toBeNull(); - fireEvent.click(editIcon); + fireEvent.click(edit); expect(onEdit).toHaveBeenCalledTimes(1); - const fromIcon = onEdit.mock.calls[0]; - - await openOverflow(); - fireEvent.click(screen.getByRole('menuitem', { name: 'Edit' })); - expect(onEdit).toHaveBeenCalledTimes(2); - expect(onEdit.mock.calls[1]).toEqual(fromIcon); }); - it('CONTROL: with room in the row, Edit is the labelled button it was and no icon button is drawn', async () => { + it('CONTROL: with a single app action, Edit is the same labelled button and no icon button is drawn', async () => { await mountRecordPage([appAction('convert_lead', 'Convert Lead')]); const bar = toolbar(); const edit = screen.getByRole('button', { name: 'Edit' }); diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 8b7a43646c..22b783e445 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -3084,14 +3084,16 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri name: 'sys_edit', label: t('detail.edit', { defaultValue: 'Edit' }), // objectui#12529 — each system entry declares its icon through the - // same `icon` key the app's actions use, so the `⋯` menu draws one - // kind of row. When this CTA folds into the `⋯` because the row is - // full, `page:header` keeps an icon-only twin of it (this pencil) - // beside the `⋯`. + // same `icon` key the app's actions use, so every row draws one kind + // of entry. `page:header` keeps this CTA a labelled button in the row + // at every width: it takes none of the app's `maxVisible` slots and + // never folds into the `⋯` (objectui#12617). It is the filled primary + // button only when the record has no app action ahead of it; next to + // app actions it is an outline button, which `page:header` decides — + // no `variant` is declared here for it to read. icon: 'pencil', type: 'script', locations: ['record_header'], - variant: 'default', order: 100, // objectui#2572 item 4 — while a shared inline-edit draft is live, // the page:header renderer greys this CTA out so the classic form diff --git a/packages/components/src/__tests__/page-header-actions.test.tsx b/packages/components/src/__tests__/page-header-actions.test.tsx index b1c450bfda..9fe6ffc816 100644 --- a/packages/components/src/__tests__/page-header-actions.test.tsx +++ b/packages/components/src/__tests__/page-header-actions.test.tsx @@ -114,7 +114,7 @@ describe('PageHeaderRenderer — actions slot', () => { expect(screen.queryByRole('button', { name: /Empty Placement/i })).toBeNull(); }); - it('still renders host system actions that declare no placement — chrome is placed by the host', () => { + it('still renders host system actions that declare no placement — chrome is placed by the host', async () => { // The host injects Edit/Share/Delete; nobody authored a `locations` for // them, and `action:bar` has always exempted its `systemActions` slot for // exactly that reason. Before #3142 this renderer filtered them, so the @@ -126,7 +126,12 @@ describe('PageHeaderRenderer — actions slot', () => { headerSystemActions: [{ name: 'sys_delete', label: 'Delete Record' }], }, ); - expect(screen.getByRole('button', { name: /Delete Record/i })).toBeTruthy(); + // A destructive host action renders in the `⋯`, never as an inline + // button (objectui#12617) — rendered either way, which is this pin. + expect(screen.queryByRole('button', { name: /Delete Record/i })).toBeNull(); + const trigger = screen.getByRole('button', { name: /More actions/i }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + await waitFor(() => expect(screen.getByRole('menuitem', { name: /Delete Record/i })).toBeTruthy()); }); it('shows action when visible expression matches record', () => { @@ -238,11 +243,11 @@ describe('PageHeaderRenderer — actions slot', () => { ], }, ); - // With 4 actions and the default maxVisible of 3, the first three stay - // inline and the rest collapse into a `⋯` overflow menu. We assert the - // authored action is rendered as a button and the overflow trigger is - // present (full menu contents are exercised by separate dropdown - // interaction tests). + // The host's buttons stay inline beside the authored action and the + // host's destructive Delete folds into the `⋯` (objectui#12617). We + // assert the authored action is rendered as a button and the overflow + // trigger is present (full menu contents are exercised by separate + // dropdown interaction tests). expect(screen.getByRole('button', { name: /Convert Lead/i })).toBeTruthy(); expect(screen.getByRole('button', { name: /More actions/i })).toBeTruthy(); }); diff --git a/packages/components/src/__tests__/page-header-primary-12617.test.tsx b/packages/components/src/__tests__/page-header-primary-12617.test.tsx new file mode 100644 index 0000000000..29dfa9df57 --- /dev/null +++ b/packages/components/src/__tests__/page-header-primary-12617.test.tsx @@ -0,0 +1,160 @@ +/** + * 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#12617: the record header draws ONE primary action and the rest as + * secondary buttons. + * + * `@objectstack/spec`'s action contract says "in `record_header` the first + * visible action becomes the primary button" and lets a renderer prefer a + * `variant: 'primary'` action. `page:header` drew every action without a + * declared variant with the filled style, so a header with three actions + * showed three primaries. Now: + * + * - the primary is the first action declaring `variant: 'primary'`, else the + * first by `order`; it leads the row and is the only filled button; + * - the next two inline actions are outline buttons; + * - a fourth folds into the `⋯`, above the host's Share / Delete; + * - the host's Edit stays a labelled button in the row and takes no slot; + * - a declared action whose `visible` holds is present, and (CONTROL) one + * whose `visible` fails is absent (objectui#12303). + * + * "Filled" and "outline" are read off the Button primitive's own variant + * classes (`bg-primary` for Shadcn's `default`, `border-input` for `outline`). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { ActionProvider, RecordContextProvider } from '@object-ui/react'; +// Registers `page:header` at module scope rather than in a hook +// (object-ui/no-dynamic-import-in-test-hook, objectui#3010). +import '../renderers'; + +function PageHeader({ schema }: { schema: any }) { + const Component = ComponentRegistry.get('page:header'); + if (!Component) throw new Error('page:header not registered'); + // eslint-disable-next-line react-hooks/static-components -- ComponentRegistry.get returns a registered component (stable), not one created during render + return ; +} + +const LEAD = { id: 'lead-1', name: 'Mira Costa', is_converted: false, status: 'contacted' }; + +const HOST_ACTIONS = [ + { name: 'sys_edit', label: 'Edit', icon: 'pencil', order: 100, onClick: vi.fn() }, + { name: 'sys_share', label: 'Share', order: 110, component: 'action:menu', onClick: vi.fn() }, + { name: 'sys_delete', label: 'Delete', order: 120, variant: 'destructive', component: 'action:menu', onClick: vi.fn() }, +]; + +function renderRecordHeader(actions: any[], record: any = LEAD) { + return render( + + + + + , + ); +} + +const act = (name: string, label: string, extra: Record = {}) => ({ + name, + label, + type: 'url', + target: `https://example.com/${name}`, + locations: ['record_header'], + ...extra, +}); + +function toolbarButtons(): HTMLElement[] { + const bar = screen.getByRole('toolbar', { name: /Page header actions/i }); + return Array.from(bar.querySelectorAll('button')); +} + +const nameOf = (b: HTMLElement) => b.getAttribute('aria-label') ?? (b.textContent ?? '').trim(); +const isFilled = (b: HTMLElement) => b.className.split(/\s+/).includes('bg-primary'); +const isOutline = (b: HTMLElement) => b.className.split(/\s+/).includes('border-input'); + +async function overflowRows(): Promise { + const trigger = screen.getByRole('button', { name: 'More actions' }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + await waitFor(() => expect(screen.getByRole('menu')).toBeTruthy()); + return within(screen.getByRole('menu')) + .getAllByRole('menuitem') + .map((el) => (el.textContent ?? '').trim()); +} + +describe('page:header draws one primary action, the rest secondary (objectui#12617)', () => { + it('three record_header actions render as one filled primary + two outline buttons, then a labelled outline Edit', () => { + renderRecordHeader([ + act('convert_lead', 'Convert Lead'), + act('schedule_followup', 'Schedule Follow-up'), + act('log_call', 'Log a Call'), + ]); + const buttons = toolbarButtons().filter((b) => nameOf(b) !== 'More actions'); + expect(buttons.map(nameOf)).toEqual(['Convert Lead', 'Schedule Follow-up', 'Log a Call', 'Edit']); + expect(buttons.map(isFilled)).toEqual([true, false, false, false]); + expect(buttons.slice(1).every(isOutline)).toBe(true); + // Edit is a labelled button, never icon-only. + expect((buttons[3].textContent ?? '').trim()).toBe('Edit'); + }); + + it('a fourth action folds into the ⋯, above the host Share / Delete; Edit stays in the row', async () => { + renderRecordHeader([ + act('convert_lead', 'Convert Lead'), + act('schedule_followup', 'Schedule Follow-up'), + act('log_call', 'Log a Call'), + act('log_meeting', 'Log a Meeting'), + ]); + const names = toolbarButtons().map(nameOf); + expect(names).toEqual(['Convert Lead', 'Schedule Follow-up', 'Log a Call', 'Edit', 'More actions']); + expect(await overflowRows()).toEqual(['Log a Meeting', 'Share', 'Delete']); + }); + + it("`variant: 'primary'` wins over `order`: it leads the row as the only filled button", () => { + renderRecordHeader([ + act('log_call', 'Log a Call', { order: -10 }), + act('log_meeting', 'Log a Meeting'), + act('convert_lead', 'Convert Lead', { variant: 'primary', order: 50 }), + ]); + const buttons = toolbarButtons().filter((b) => nameOf(b) !== 'More actions'); + expect(buttons.map(nameOf)).toEqual(['Convert Lead', 'Log a Call', 'Log a Meeting', 'Edit']); + expect(buttons.map(isFilled)).toEqual([true, false, false, false]); + }); + + it('a declared action whose `visible` holds is present; CONTROL: one whose `visible` fails is absent', () => { + const gate = { + dialect: 'cel', + source: 'has(record.is_converted) && record.is_converted == false && record.status != "converted"', + }; + renderRecordHeader([ + act('convert_lead', 'Convert Lead', { visible: gate }), + act('reopen_lead', 'Reopen Lead', { visible: { dialect: 'cel', source: 'record.status == "converted"' } }), + act('log_call', 'Log a Call'), + ]); + const names = toolbarButtons().map(nameOf); + expect(names).toContain('Convert Lead'); + expect(names).not.toContain('Reopen Lead'); + // The visible gate decides presence, not rank: the surviving first action + // is still the primary. + const convert = toolbarButtons().find((b) => nameOf(b) === 'Convert Lead')!; + expect(isFilled(convert)).toBe(true); + }); + + it('with no app action, the host Edit is the primary button', () => { + renderRecordHeader([]); + const buttons = toolbarButtons().filter((b) => nameOf(b) !== 'More actions'); + expect(buttons.map(nameOf)).toEqual(['Edit']); + expect(isFilled(buttons[0])).toBe(true); + }); +}); diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index d5ef901015..bd72f751c3 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -2015,12 +2015,14 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // 2. `component: 'action:menu'` forces an action into the `⋯` menu // regardless of the count (chrome actions like Share/Delete); // 3. the remainder is pre-sorted by `order` / `variant: 'primary'` - // (see the headerActions memo above), so the first `maxVisible` - // claim the inline slots. + // (see the headerActions memo above); the primary button leads it + // (see `primaryAction` below), and the first `maxVisible` of the + // page's OWN actions claim the inline slots; + // 4. a host button (the record page's Edit) is not one of the page's + // actions: it takes no slot and never folds (objectui#12617). // `maxVisible` / `mobileMaxVisible` are overridable on the page:header - // schema and default to 3 / 1. This still keeps the header from drowning - // in 4–5 buttons on every record page, while letting multi-action objects - // surface several primary buttons at once. + // schema and default to 3 / 1. This keeps the header from drowning in 4–5 + // buttons on every record page. // // `readMax` enforces the SPEC's value domain rather than a laxer renderer // tolerance (objectui#5006). Measured on `ComponentPropsMap['page:header']` @@ -2061,22 +2063,52 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { const buttonable = headerActions.filter( (a: any) => !isMoreOnly(a) && a?.component !== 'action:menu', ); - const inlineActions = buttonable.slice(0, maxVisible); - const foldedButtons = buttonable.slice(maxVisible); - // The overflow reads as one list in three bands (objectui#12529): the - // authored actions first, then the host's chrome (Edit / favorites / - // Share, Edit leading when it folded), then every destructive entry last, - // set apart by a separator. "Host" is the key test the disabled-reason - // branch below uses: a key no authored action holds came from - // `headerSystemActions`. "Destructive" is the menu's own red-row test, so - // the row that is painted red is the row that is set apart. Each band - // keeps the `order` sort the memo above gave it. + // "Host" is the key test the disabled-reason branch below uses: a key no + // authored action holds came from `headerSystemActions`. "Destructive" is + // the menu's own red-row test, so the row that is painted red is the row + // that is set apart in the `⋯`. const isDestructiveAction = (a: any): boolean => a?.variant === 'destructive' || a?.name === 'sys_delete'; const isHostAction = (a: any): boolean => { const key = (a?.name || a?.id || '') as string; return key !== '' && !authoredHeaderActionKeys.has(key); }; + // ONE primary button (objectui#12617). `@objectstack/spec`'s action + // contract says "in `record_header` the first visible action becomes the + // primary button", and lets a renderer prefer a `variant: 'primary'` + // action. So the primary is the first action declaring `variant: + // 'primary'`, else the first of the `order`-sorted list; it leads the row + // and is the only filled button. Every other inline button is an outline + // button. Before this, every action without a declared variant drew the + // filled style, so a header with three actions showed three primaries and + // none of them read as the main one. + // Only an action that can sit in the row competes for it — a destructive + // host action never does (see `isHostButton` below). + const isHostButton = (a: any): boolean => isHostAction(a) && !isDestructiveAction(a); + const rowEligible = buttonable.filter((a: any) => !isHostAction(a) || isHostButton(a)); + const primaryIdx = rowEligible.findIndex((a: any) => a?.variant === 'primary'); + const primaryAction: any = rowEligible[primaryIdx >= 0 ? primaryIdx : 0]; + const ranked = primaryAction + ? [primaryAction, ...buttonable.filter((a: any) => a !== primaryAction)] + : buttonable; + // The page's own actions share the `maxVisible` budget; what does not fit + // folds into the `⋯`. A host button (Edit) is the record's base action: + // it stays a labelled button in the row at every width, so it neither + // takes a slot nor folds — it used to fold behind an icon-only twin once + // the app's actions filled the row (objectui#12529). A DESTRUCTIVE host + // action is the exception and always folds: a host's Delete belongs in + // the `⋯`'s destructive band, never inline as a red button. + const authoredButtons = ranked.filter((a: any) => !isHostAction(a)); + const inlineAuthored = new Set(authoredButtons.slice(0, maxVisible)); + const inlineActions = ranked.filter((a: any) => isHostButton(a) || inlineAuthored.has(a)); + const foldedButtons = [ + ...authoredButtons.slice(maxVisible), + ...ranked.filter((a: any) => isHostAction(a) && !isHostButton(a)), + ]; + // The overflow reads as one list in three bands (objectui#12529): the + // authored actions first, then the host's menu chrome (favorites / + // Share), then every destructive entry last, set apart by a separator. + // Each band keeps the `order` sort the memo above gave it. const overflowPool = [...foldedButtons, ...menuOnly, ...moreOnlyActions]; const overflowActions = [ ...overflowPool.filter((a) => !isDestructiveAction(a) && !isHostAction(a)), @@ -2086,15 +2118,6 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // The first destructive entry gets a separator above it, unless it opens // the menu (a separator under nothing sets nothing apart). const firstDestructiveIdx = overflowActions.findIndex(isDestructiveAction); - // A host action placed as a BUTTON that folded into the ⋯ because the row - // is full keeps an icon-only twin beside the ⋯ (objectui#12529): the host - // asked for a button, so it stays one click from the header at every - // width. Today that is the record page's Edit. It needs a declared icon, - // since an icon is all the twin draws; with room in the row nothing folds - // and nothing extra renders. - const foldedHostButtons = foldedButtons.filter( - (a) => isHostAction(a) && !isDestructiveAction(a) && typeof a?.icon === 'string', - ); const useOverflow = overflowActions.length > 0; // Resolve a `disabled` predicate against the record — a boolean OR an // expression (`'record.status == …'` or the `{ dialect, source }` @@ -2140,9 +2163,10 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { }; const renderButton = (action: any, idx: number) => { const label = resolveLabel(action); - // `variant: 'primary'` is valid ActionSchema but not a Shadcn Button - // variant — map it to `default` (same as action-button.tsx). - const variant = action.variant === 'primary' ? 'default' : (action.variant || 'default'); + // The primary (see `primaryAction` above) is the one filled button — + // Shadcn's `default`; every other inline button is an outline button + // (objectui#12617). + const variant = action === primaryAction ? 'default' : 'outline'; const size = action.size || 'sm'; const disabled = isActionDisabled(action); const icon = typeof action.icon === 'string' ? action.icon : null; @@ -2198,43 +2222,6 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { ); }; - // The folded host button's icon-only twin (objectui#12529). Styled as the - // ⋯ trigger's twin, like the ⟳ below, so `[…][✎][⋯][⟳]` reads as one - // button family. Its accessible name is the action's label, the same text - // its overflow row shows, and it runs the same handler that row runs, so - // both open one session. It honours the same disabled verdict. Host - // actions give no disabled reason (see `disabledReasonFor`), so it needs - // no reason wrapper. - const renderFoldedIconButton = (action: any, idx: number) => { - const label = resolveLabel(action); - const actionKey = `${action.name || action.id || `folded-action-${idx}`}-icon`; - return ( - - - - - - {label} - - - ); - }; return (
= ({ schema, className, ...props }) => { aria-label={tt('detail.pageHeaderActions', 'Page header actions')} > {inlineActions.map(renderButton)} - {foldedHostButtons.map(renderFoldedIconButton)} {useOverflow && ( @@ -2699,7 +2685,7 @@ ComponentRegistry.register('header', PageHeaderRenderer, { // express the domain: `ComponentInput.type` has no integer/min/max slot, so // `description` stays the only authoring-time expression of it, and spec // stays the sole judge of values.) - { name: 'maxVisible', type: 'number', description: 'How many header actions render as inline buttons on desktop before the rest fold into the overflow menu. A positive integer — the contract rejects 0 and fractional values. Two kinds of action are routed to the overflow menu regardless of this budget and never occupy an inline slot: an action whose locations declare record_more without record_header, and any action with component action:menu.' }, + { name: 'maxVisible', type: 'number', description: 'How many header actions render as inline buttons on desktop before the rest fold into the overflow menu. A positive integer — the contract rejects 0 and fractional values. Two kinds of action are routed to the overflow menu regardless of this budget and never occupy an inline slot: an action whose locations declare record_more without record_header, and any action with component action:menu. The host\'s own buttons (the record page\'s Edit) take no slot and never fold. The first inline action, or the one declaring variant primary, is the one filled primary button; the others are outline buttons.' }, { name: 'mobileMaxVisible', type: 'number', description: 'The same inline-button budget on mobile viewports, where horizontal room is scarce. A positive integer, defaulting to 1; the desktop half is Max Inline Actions, and the overflow routing rules stated there apply unchanged.' }, ], }); From 3f10eacc515938861b449e0c1e90e7c9069792b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:18:29 +0000 Subject: [PATCH 2/2] refactor(components): type the header banding helpers without explicit any Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- .../src/renderers/layout/containers.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index bd72f751c3..31ef604c71 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -2084,12 +2084,12 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // none of them read as the main one. // Only an action that can sit in the row competes for it — a destructive // host action never does (see `isHostButton` below). - const isHostButton = (a: any): boolean => isHostAction(a) && !isDestructiveAction(a); - const rowEligible = buttonable.filter((a: any) => !isHostAction(a) || isHostButton(a)); - const primaryIdx = rowEligible.findIndex((a: any) => a?.variant === 'primary'); - const primaryAction: any = rowEligible[primaryIdx >= 0 ? primaryIdx : 0]; + const isHostButton = (a: unknown): boolean => isHostAction(a) && !isDestructiveAction(a); + const rowEligible = buttonable.filter((a) => !isHostAction(a) || isHostButton(a)); + const primaryIdx = rowEligible.findIndex((a) => a?.variant === 'primary'); + const primaryAction: unknown = rowEligible[primaryIdx >= 0 ? primaryIdx : 0]; const ranked = primaryAction - ? [primaryAction, ...buttonable.filter((a: any) => a !== primaryAction)] + ? [primaryAction, ...buttonable.filter((a) => a !== primaryAction)] : buttonable; // The page's own actions share the `maxVisible` budget; what does not fit // folds into the `⋯`. A host button (Edit) is the record's base action: @@ -2098,12 +2098,12 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // the app's actions filled the row (objectui#12529). A DESTRUCTIVE host // action is the exception and always folds: a host's Delete belongs in // the `⋯`'s destructive band, never inline as a red button. - const authoredButtons = ranked.filter((a: any) => !isHostAction(a)); - const inlineAuthored = new Set(authoredButtons.slice(0, maxVisible)); - const inlineActions = ranked.filter((a: any) => isHostButton(a) || inlineAuthored.has(a)); + const authoredButtons = ranked.filter((a) => !isHostAction(a)); + const inlineAuthored = new Set(authoredButtons.slice(0, maxVisible)); + const inlineActions = ranked.filter((a) => isHostButton(a) || inlineAuthored.has(a)); const foldedButtons = [ ...authoredButtons.slice(maxVisible), - ...ranked.filter((a: any) => isHostAction(a) && !isHostButton(a)), + ...ranked.filter((a) => isHostAction(a) && !isHostButton(a)), ]; // The overflow reads as one list in three bands (objectui#12529): the // authored actions first, then the host's menu chrome (favorites /