From 6dc018736d48babbb0385fc2213ba139b8a15662 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:51:15 +0000 Subject: [PATCH 1/4] wip: draw action pressed on the record header and the declared actions bar --- .../src/views/DeclaredActionsBar.tsx | 33 +++++- .../page-header-pressed-28242.test.tsx | 108 ++++++++++++++++++ .../src/renderers/layout/containers.tsx | 26 ++++- ui/packages/core/src/actions/ActionRunner.ts | 13 +++ ui/packages/core/src/actions/actionKeys.ts | 10 +- 5 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 ui/packages/components/src/__tests__/page-header-pressed-28242.test.tsx diff --git a/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx b/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx index 0645e9e63df..ed2ea610e8b 100644 --- a/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx +++ b/ui/packages/app-shell/src/views/DeclaredActionsBar.tsx @@ -215,6 +215,28 @@ function useDeclaredActionShown(action: ActionDef, predicateRecord: ReturnType): boolean | undefined { + const declared = typeof action.pressed !== 'boolean' && hasDeclaredVisibilityGate(action.pressed); + const isPressed = useCondition(declared ? toPredicateInput(action.pressed) : undefined, predicateRecord, { + throwOnError: true, + label: `declared action "${action.name ?? action.label ?? 'action'}" (pressed)`, + }); + return declared ? isPressed : undefined; +} + /** * Report whether a declared action is drawn, and withdraw the report when the * reporter unmounts (objectstack#25877). `onDrawn` is the toolbar's stable @@ -327,6 +349,7 @@ const DeclaredActionButton: React.FC<{ // these two lines carried — which existed only because `ActionDef.disabled` // could not describe the envelope arm — have nothing left to reach around. const isDisabledPred = useCondition(toPredicateInput(action.disabled), predicateRecord); + const pressed = useDeclaredActionPressed(action, predicateRecord); // Called up here with the other hooks: the `visible` gate below returns early. const reasonId = React.useId(); const labelId = React.useId(); @@ -651,13 +674,19 @@ const DeclaredActionButton: React.FC<{ variant={variant as any} disabled={disabledByPredicate || loading} aria-describedby={disabledReason ? reasonId : undefined} + aria-pressed={pressed} onClick={handleClick} // Amber warning treatment for an override (objectui#5178) — the same // palette the approvals surfaces already use for "waiting / attention", // via `cn()` over the outline variant rather than a new Button variant // (`packages/components/src/ui/**` is a no-touch Shadcn zone). - className={cn(isOverride - && 'border-amber-500/60 text-amber-700 hover:bg-amber-50 hover:text-amber-800 dark:text-amber-400 dark:hover:bg-amber-500/10 dark:hover:text-amber-300')} + // The pressed face is Shadcn's toggle-on colours keyed off the attribute, + // the record header's spelling (`PageHeaderActions`). + className={cn( + isOverride + && 'border-amber-500/60 text-amber-700 hover:bg-amber-50 hover:text-amber-800 dark:text-amber-400 dark:hover:bg-amber-500/10 dark:hover:text-amber-300', + pressed !== undefined && 'aria-pressed:bg-accent aria-pressed:text-accent-foreground hover:aria-pressed:bg-accent', + )} data-testid={`declared-action-${action.name}`} // Pinnable, and readable by an E2E/QA pass without matching on copy. data-override-decision={isOverride ? 'true' : undefined} diff --git a/ui/packages/components/src/__tests__/page-header-pressed-28242.test.tsx b/ui/packages/components/src/__tests__/page-header-pressed-28242.test.tsx new file mode 100644 index 00000000000..f060ed8a25a --- /dev/null +++ b/ui/packages/components/src/__tests__/page-header-pressed-28242.test.tsx @@ -0,0 +1,108 @@ +/** + * objectstack#28242 — a `page:header` action that declares `pressed` (the + * reader's own state, `ActionSchema.pressed`) is a toggle button: drawn with + * `aria-pressed="true"` and the pressed face while the predicate holds for + * this record, `aria-pressed="false"` while it does not. An action that + * declares no predicate carries no `aria-pressed` at all. + * + * Display only: a pressed action stays enabled and still dispatches, and a + * predicate that cannot be evaluated draws the plain button (never a claim + * that the reader acted) and says so once. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { ActionProvider, RecordContextProvider } from '@object-ui/react'; +// Registers `page:header` at module scope (object-ui/no-dynamic-import-in-test-hook). +import '../renderers'; + +const HELPFUL = { + name: 'mark_article_helpful', + label: 'Helpful', + type: 'script', + locations: ['record_header'], + pressed: "has(record.viewer) && record.viewer.vote == 'helpful'", +}; +const NOT_HELPFUL = { + name: 'mark_article_not_helpful', + label: 'Not helpful', + type: 'script', + locations: ['record_header'], + pressed: { dialect: 'cel', source: "has(record.viewer) && record.viewer.vote == 'not_helpful'" }, +}; +const SHARE = { name: 'share_article', label: 'Share', type: 'script', locations: ['record_header'] }; + +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 ; +} + +function mount(actions: Record[], record: Record, handler?: (a: any) => any) { + return render( + + + + + , + ); +} + +afterEach(() => vi.restoreAllMocks()); + +describe('page:header — `pressed` draws the reader\'s own state (objectstack#28242)', () => { + it('the predicate holds: aria-pressed="true" and the pressed face; the sibling vote is unpressed', () => { + mount([HELPFUL, NOT_HELPFUL], { viewer: { vote: 'helpful' } }); + const helpful = screen.getByRole('button', { name: 'Helpful' }); + expect(helpful).toHaveAttribute('aria-pressed', 'true'); + expect(helpful.className).toContain('aria-pressed:bg-accent'); + expect(screen.getByRole('button', { name: 'Not helpful' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('the envelope arm reads the same: the other vote is the pressed one', () => { + mount([HELPFUL, NOT_HELPFUL], { viewer: { vote: 'not_helpful' } }); + expect(screen.getByRole('button', { name: 'Helpful' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Not helpful' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('a row with no served block (fetched by another route) draws both unpressed', () => { + mount([HELPFUL, NOT_HELPFUL], {}); + expect(screen.getByRole('button', { name: 'Helpful' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Not helpful' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('display only: a pressed action stays enabled and still dispatches', async () => { + const user = userEvent.setup(); + const handler = vi.fn(async () => ({ success: true })); + mount([{ ...HELPFUL, type: 'vote-run' }], { viewer: { vote: 'helpful' } }, handler); + const helpful = screen.getByRole('button', { name: 'Helpful' }); + expect(helpful).not.toBeDisabled(); + await user.click(helpful); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + expect((handler.mock.calls[0] as unknown[])[0]).toMatchObject({ name: 'mark_article_helpful' }); + }); + + it('a predicate that cannot be evaluated draws the plain button, and says so', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mount([{ ...HELPFUL, pressed: "record.viewer.vote == 'helpful'" }], {}); + expect(screen.getByRole('button', { name: 'Helpful' })).toHaveAttribute('aria-pressed', 'false'); + expect(warn.mock.calls.some((c) => String(c[0]).includes('pressed'))).toBe(true); + }); + + it('control — an action that declares no predicate is a plain button: no aria-pressed, no face', () => { + mount([SHARE], { viewer: { vote: 'helpful' } }); + const share = screen.getByRole('button', { name: 'Share' }); + expect(share).not.toHaveAttribute('aria-pressed'); + expect(share.className).not.toContain('aria-pressed:'); + }); +}); diff --git a/ui/packages/components/src/renderers/layout/containers.tsx b/ui/packages/components/src/renderers/layout/containers.tsx index ce97a34bfdc..59a991efed7 100644 --- a/ui/packages/components/src/renderers/layout/containers.tsx +++ b/ui/packages/components/src/renderers/layout/containers.tsx @@ -2404,6 +2404,22 @@ export const PageHeaderActions: React.FC = ({ if (!src) return false; return evalHeaderPredicate(d, `page:header action "${String(actionName)}" disabled`, true); }; + // The reader's own state (`ActionSchema.pressed`, objectstack#27946): a + // DECLARED predicate makes the button a toggle, drawn pressed while it is + // TRUE. Same entry and bindings as `visible` / `disabled`; its fail + // direction is "not pressed" (`fallback: false`), said once — a fault + // draws the plain button and never claims the reader acted. Display only: + // nothing here gates, greys or routes on it. `undefined` = no predicate + // (not a toggle); a boolean is refused by the spec at parse, so it gets no + // reading here. + const pressedFor = (action: any): boolean | undefined => { + const p = action?.pressed; + const src = typeof p === 'string' + ? p + : (p && typeof p === 'object' && typeof (p as any).source === 'string' ? (p as any).source : undefined); + if (!src || src.trim() === '') return undefined; + return evalHeaderPredicate(p, `page:header action "${String(action?.name ?? action?.id)}" pressed`, false); + }; // A live inline-edit session disables actions the host flagged with // `disableDuringInlineEdit` (objectui#2572 item 4) — see `inlineEditing` // at the top of the renderer. @@ -2442,6 +2458,7 @@ export const PageHeaderActions: React.FC = ({ const actionKey = action.name || action.id || `header-action-${idx}`; const disabledReason = disabledReasonFor(action); const reasonId = `${disabledReasonIdBase}-reason-${idx}`; + const pressed = pressedFor(action); const button = (