Skip to content

Commit 7eda096

Browse files
feat(console): a header action shows the reader already pressed it (pressed drawn as aria-pressed) (#28301)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoJQJBhSpgsugbgL93YmdNzA)_ Fixes #28242 Refs #27946 Clause-②: yes (widening: `@object-ui/core` `ActionDef` gains the optional member `pressed`, typed by derivation from the spec's `ActionInput['pressed']`; no export, key or route is added) Metadata-protocol: none ## 维护者速读 - 改了什么:记录页头部按钮和「声明的动作栏」现在会读动作的 `pressed` 条件。条件成立时按钮显示为「已按下」(高亮底色,读屏念出「已按下」),不成立时是普通按钮。例如知识文章的「有帮助 / 没帮助」投票,读者已经投过哪一票,那个按钮就是按下状态。 - 为什么改:`pressed` 已在 #28235 写进 spec,但控制台一直不画,读者看不出自己是否已经投过票。 - 风险与回滚:只改显示层,按下的按钮照样可点、照样执行动作;条件算不出来时画成未按下并在控制台提示一次。没有声明 `pressed` 的动作完全不变。回滚即 revert 本 PR。 - 先看:`ui/packages/components/src/renderers/layout/containers.tsx`(`pressedFor` 与 `renderButton` 里的 `aria-pressed`);`ui/packages/app-shell/src/views/DeclaredActionsBar.tsx`(`useDeclaredActionPressed`);`packages/qa/dogfood/test/expression-conformance.ledger.ts` 的 `cel-action-pressed` 行。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/components/src/__tests__/page-header-pressed-28242.test.tsx packages/app-shell/src/views/__tests__/DeclaredActionsBar.pressed-28242.test.tsx`,预期 2 个文件、12 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes **`ActionDef` derives the key** (`ui/packages/core/src/actions/ActionRunner.ts`): `pressed?: SpecActionInput['pressed']`, so it has the spec's arms (a CEL string or the `{ dialect, source }` envelope, no boolean). `actionKeys.ts` lists it in `ACTION_DEF_KEYS`, and the `SPEC_ACTION_KEYS` comment no longer says it is inventory-only. **The record header** (`page:header`, `PageHeaderActions` in `containers.tsx`). `pressedFor` evaluates a declared `pressed` through the header's one predicate entry, `evalHeaderPredicate` (the entry and bindings `visible` / `disabled` use), with `fallback: false`. An inline button whose action declares the predicate is a toggle button: `aria-pressed="true"` while it holds, `aria-pressed="false"` while it does not. An action that declares none carries no `aria-pressed`. **The declared actions bar** (`DeclaredActionsBar.tsx`). `useDeclaredActionPressed` evaluates it with `useCondition` and `throwOnError`, on the same record binding as the bar's `visible` gate, and draws the same attribute. **The pressed face** on both: Shadcn's toggle-on colours keyed off the attribute (`aria-pressed:bg-accent aria-pressed:text-accent-foreground hover:aria-pressed:bg-accent`, the spelling `PanelToggles` already uses), so the face and the attribute cannot disagree. **Display only.** Nothing gates, greys or routes on the verdict: a pressed action stays enabled and still dispatches. A predicate that cannot be evaluated answers `false` (the unpressed button, never a claim that the reader acted) and is reported once. A boolean `pressed` is refused by the spec at parse, so neither surface gives it a reading. **Re-stated in the same PR** (the card's cross-domain exception): - `packages/spec/liveness/action.json` `pressed`: `planned` → `live`, citing the read points; `authorWarn` / `authorHint` dropped. The generated count shard `packages/spec/liveness/state-counts/action.md` moves with it (`gen:liveness-counts`: live 48 → 49, planned 1 → 0). - `packages/qa/dogfood/test/expression-conformance.ledger.ts` `cel-action-pressed`: `experimental` / `unevaluated` → `enforced` / `fail-soft-log`, naming both evaluators. - `content/docs/protocol/objectui/actions.mdx`: the "not drawn yet" callout is gone; one bullet says which surfaces draw it and that a menu item does not. ## Tests - `ui/packages/components/src/__tests__/page-header-pressed-28242.test.tsx` (6) and `ui/packages/app-shell/src/views/__tests__/DeclaredActionsBar.pressed-28242.test.tsx` (6), the same six cases per surface: the vote that holds is `aria-pressed="true"` with the face and its sibling `"false"`; the envelope arm; a row without the served block draws both unpressed; a pressed action is enabled and dispatches; a faulting predicate draws `"false"` and warns; an action with no predicate has no `aria-pressed` and no face. ## Acceptance notes - A menu item draws no pressed state: an action folded into the header's `⋯` overflow, and a lever in the declared actions bar's override menu. `aria-pressed` is not a menu item attribute; a checkable item (`menuitemcheckbox`) would be a different control. Recorded in the liveness note and the doc bullet. - `aria-pressed="false"` (not an absent attribute) when a declared predicate is false: a declared `pressed` makes the button a toggle, which assistive technology announces as "not pressed". The look is the plain button, as the spec describes. - The bar's fault warning comes from `useCondition`'s shared text, which says "was hidden/disabled"; the label it carries ends in `(pressed)`, and the button is drawn unpressed. The text is outside this card's surface. - The four `action:*` renderers and `record:quick_actions` do not read `pressed`; the card names the header and the bar only. ## Verification Tree: objectstack-ai/objectstack, branch `claude/project-thread-schd4q`, head `7650f47c` (the last commit only regenerates the liveness count shard; every gate it can move was re-run on it). <details><summary>New suites</summary> ``` pnpm --dir ui exec vitest run packages/components/src/__tests__/page-header-pressed-28242.test.tsx Test Files 1 passed (1) Tests 6 passed (6) pnpm --dir ui exec vitest run packages/app-shell/src/views/__tests__/DeclaredActionsBar.pressed-28242.test.tsx Test Files 1 passed (1) Tests 6 passed (6) ``` </details> <details><summary>Ablation: the read removed, the new pins go red</summary> `node scripts/ablation-replace.mjs` replaced `const pressed = pressedFor(action);` (containers.tsx) and `const pressed = useDeclaredActionPressed(action, predicateRecord);` (DeclaredActionsBar.tsx) with `const pressed = undefined`, one leg each (anchor 1 → 0, blob changed), same commands: ``` page header: Tests 4 failed | 2 passed (6) actions bar: Tests 4 failed | 2 passed (6) ``` The four reds per surface are the pressed / envelope / no-block / fault cases; the dispatch and no-predicate controls stay green. Each leg restored by the tool: blob == HEAD and `git diff HEAD` empty. </details> <details><summary>Neighbouring suites and type-check</summary> ``` pnpm --dir ui exec vitest run packages/core/src/actions/ packages/components/src/__tests__/page-header packages/app-shell/src/views/__tests__/DeclaredActionsBar Test Files 81 passed (81) Tests 1038 passed (1038) pnpm --filter @object-ui/core run type-check :: exit 0 pnpm --filter @object-ui/components run type-check :: exit 0 pnpm --filter @object-ui/app-shell run type-check :: exit 0 pnpm --filter @objectstack/dogfood exec vitest run test/expression-conformance.test.ts Test Files 1 passed (1) Tests 8 passed (8) pnpm --dir ui run check:action-forward-parity :: exit 0 pnpm --dir ui run check:new-line-citations :: exit 0 (0 new citations) ``` </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 94 families; all 94 were run with stdin closed and recorded with their exit codes, and `--ran` reconciles: "94 derived famil(ies) accounted for — 94 run, 0 NOT-MEASURED". Two were red on the first pass and are green on the head: `check:liveness` (the count shard, regenerated) and `check-plugin-teardown-shape.mjs --self-test` (its pinned fixture commit was absent from the shallow clone; fetched). `node scripts/check-sdui-manifest.mjs` :: exit 0. Record: `/mnt/project-files/ui-seat-1/r2026-10-30/out/28242-gates-ran-7650f47c.list`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibzrVVJotCXhG4v7ddq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 291bf44 commit 7eda096

11 files changed

Lines changed: 316 additions & 22 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/components': patch
4+
'@object-ui/app-shell': patch
5+
---
6+
7+
The record header and the declared actions bar now draw an action's `pressed` predicate. A button whose action declares `pressed` is a toggle button: `aria-pressed="true"` and Shadcn's toggle-on face while the predicate holds for this reader and record, `aria-pressed="false"` while it does not. A predicate that cannot be evaluated draws the unpressed button and is reported once. Display only: a pressed action stays enabled and still runs. `ActionDef` declares `pressed`, derived from the spec's `ActionSchema.pressed`.
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none

‎content/docs/protocol/objectui/actions.mdx‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -363,13 +363,10 @@ pressed: "has(record.viewer) && record.viewer.vote == 'helpful'"
363363
```
364364

365365
- It is **display only**. A pressed action stays runnable, and the action's own body decides what a second press does (records the vote again, withdraws it, or answers "already").
366+
- The record header and the declared actions bar draw it on their buttons: `aria-pressed="true"` and the pressed face while it is true, `aria-pressed="false"` otherwise. A predicate that cannot be evaluated draws the unpressed button. A menu item (an action folded into the header's overflow menu) has no pressed state, so it is not drawn there.
366367
- A boolean literal is refused: `pressed: true` would draw the button pressed for every reader, which is a style rather than the reader's state.
367368
- A per-reader fact is usually not a column of the row (a vote is a child record per user). Declare it as a block under the object's `attachedOnRead`, have the serving side attach it to each row it returns for the caller (a service, or an `afterFind` hook that shapes the rows), and read it as `record.<block>.<leaf>`, guarded with `has()`. `os build` and `os validate` check the leaf against the declared block.
368369

369-
<Callout type="info">
370-
The spec, the build check and this contract are in place; the console's record header and declared actions bar do not draw the pressed face yet ([#28242](https://github.com/objectstack-ai/objectstack/issues/28242)), so today the button looks the same whatever the predicate answers.
371-
</Callout>
372-
373370
#### Evaluation context
374371

375372
Action predicates evaluate against the **record the action is attached to**. Both `record.<field>` and the bare field name resolve to the current record's value:

‎packages/qa/dogfood/test/expression-conformance.ledger.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -430,14 +430,14 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
430430
note: 'Split from `cel-action-visible` rather than sharing one row with it, for the reason the collision assertion encodes: one classification covering both would have to pick one fail-policy, and the two faces are opposites — a faulting `visible` HIDES the action, a `disabled` the renderer cannot evaluate LEAVES IT RUNNABLE. A single row would have stated one of those and silently spoken for the other. ⛔ NOT MEASURED HERE: the renderer, and the `-log` half of `fail-soft-log` — the reading above establishes the soft DIRECTION from the objectui fix that removed the closed face, not that a line is logged; the same posture, and the same limit, as `cel-row-crud-disabled` on the built-in row buttons.',
431431
},
432432
{
433-
// The key arrived with #27946, its spec half; the console half is a follow-up card.
433+
// The key arrived with #27946 (spec half); the console half that draws it is #28242.
434434
id: 'cel-action-pressed',
435435
summary: 'registered action pressed state (Action.pressed) — the per-reader toggle face of a button, drawn as aria-pressed',
436-
dialect: 'cel', mode: 'interpret', state: 'experimental', failPolicy: 'unevaluated',
436+
dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log',
437437
enforcement:
438-
'PARSE ONLY — `EvaluatedExpressionInputSchema` normalizes a bare string to `{dialect:"cel",source}` and refuses a boolean literal, an `ast`-only envelope and a blank source; NO EVALUATOR FOUND: no action surface reads the key yet. The build-time gate is in place ahead of the reader: lint/validate-expressions.ts `checkAction` checks it against the `record` scope on every declared action, as a served-row site where a block the object declares under `attachedOnRead` resolves',
438+
'Same declaration site and same build-time gate as `cel-action-visible`: `EvaluatedExpressionInputSchema` normalizes a bare string to `{dialect:"cel",source}` and refuses a boolean literal, an `ast`-only envelope and a blank source, and lint/validate-expressions.ts `checkAction` checks it against the `record` scope on every declared action, as a served-row site where a block the object declares under `attachedOnRead` resolves. EVALUATOR: two console (objectui) surfaces, both on @objectstack/formula celEngine (interpret) against the record the client fetched — the record header (`PageHeaderActions` `pressedFor`, through `evalRowPredicate` with `fallback: false` and `warnOnError`) and the declared actions bar (`DeclaredActionButton` `useDeclaredActionPressed`, through `useCondition` with `throwOnError`, the binding its `visible` uses). TRUE draws `aria-pressed="true"` and the pressed face; a predicate that FAULTS answers `false` and is reported once, so the button is drawn unpressed and stays runnable',
439439
covers: ['ui/action.zod.ts:actionObject.pressed'],
440-
note: 'EXPERIMENTAL until the console draws it: declared spec-first, with the liveness row `planned`. Display only by contract — it never gates, greys or routes the action — so when the reader lands its fault face is a plain, unpressed button, and this row is re-stated as `enforced` with that fail policy, naming the surfaces that evaluate it.',
440+
note: 'Display only by contract — it never gates, greys or routes the action — so a fault refuses nothing: the predicate\'s effect (the pressed face) is dropped and the fault is logged, which is `fail-soft-log`, not `fail-closed`. The unpressed face is also the one that never claims the reader acted. Split from `cel-action-visible` / `cel-action-disabled` for the collision rule those two were split on: one row cannot state three fault faces. A menu item (the header overflow, the bar\'s override menu) draws no pressed state at all. Measured here: the ui half is in this checkout and its suites pin the TRUE / FALSE / fault faces on both surfaces.',
441441
},
442442
{
443443
id: 'cel-record-alert-visible',

‎packages/spec/liveness/action.json‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,11 @@
159159
"note": "LIVE since the console half of #26619 (ruling 6442737825, letter A: spec PR #26786 first, this console PR second). Each greyed-action surface shows the declared reason as the hover title and the accessible description (a menu item's second line), only while the DECLARED `disabled` predicate is TRUE, and shows `actions.notAvailableForRecord` (\"Not available for this record\") when the action declares none or a blank one. The record header, the section bar and the declared actions bar run actions through `useActionTextLocalizer`, so the bundle's sentence outranks the authored text; the four `action:*` renderers read the action as authored and honour its inline locale map. `authorWarn` / `authorHint` were dropped in the same edit (a warned `live` row reds this gate, #21127). Display only by contract: never a server admission input, never read as a predicate."
160160
},
161161
"pressed": {
162-
"status": "planned",
163-
"verifiedAt": "2026-10-30",
162+
"status": "live",
163+
"verifiedAt": "2026-10-31",
164164
"evidenceScope": "in-repo",
165-
"authorWarn": true,
166-
"authorHint": "Kept, but no console surface draws `pressed` yet: the button looks the same whatever the predicate answers. The predicate is still checked at build (record scope, declared fields and `attachedOnRead` blocks), so it is ready when the header buttons read it.",
167-
"evidence": "packages/spec/src/ui/action.zod.ts#pressed (the parse-time door: `EvaluatedExpressionInputSchema`, a CEL string normalized to the envelope, a boolean literal and the blank shapes refused); packages/lint/src/validate-expressions.ts#checkAction (the authoring gate at `os build` / `os validate` and the object save door: the predicate is judged in the `record` scope, and as a SERVED_ROW_SITES member a block the object declares under `attachedOnRead` resolves)",
168-
"note": "PLANNED — declared spec-first by #27946 (spec half; the console half is #28242, a domain:ui card). The reader to come is the record page header (`page:header` buttons) and `DeclaredActionsBar`, drawing the button with `aria-pressed` and a pressed face while the predicate is TRUE. Measured in this repo at origin/main 3a2f4c00: no source under ui/ reads `action.pressed` (the one hit is the inventory line in ui/packages/core/src/actions/actionKeys.ts SPEC_ACTION_KEYS, which restates the spec's declared keys and reads nothing), and no other package reads it. So an authored predicate draws nothing today; nothing is gated or stored differently, which is also the contract once it is live (display only). Flip to `live` on the #28242 console PR, citing its read points, and drop `authorWarn` / `authorHint` in the same edit (a warned `live` row reds this gate)."
165+
"evidence": "ui/packages/components/src/renderers/layout/containers.tsx#pressedFor (the record header's inline buttons: a declared predicate makes the button a toggle, `aria-pressed` and the pressed face while it is TRUE, evaluated through the header's `evalHeaderPredicate` with `fallback: false`); ui/packages/app-shell/src/views/DeclaredActionsBar.tsx#useDeclaredActionPressed (the declared actions bar's buttons, through `useCondition` with `throwOnError`, the same record binding as `visible`); ui/packages/core/src/actions/ActionRunner.ts#ActionDef (the key derived from the spec); packages/spec/src/ui/action.zod.ts#pressed (the parse-time door); packages/lint/src/validate-expressions.ts#checkAction (the authoring gate, record scope, `attachedOnRead` blocks resolve)",
166+
"note": "LIVE since #28242 (the console half of #27946). Each declared predicate is evaluated per record for this reader; TRUE draws `aria-pressed=\"true\"` and Shadcn's toggle-on face, FALSE `aria-pressed=\"false\"`, and an action with no predicate carries no `aria-pressed`. A predicate that cannot be evaluated draws the unpressed button and is reported once. Display only by contract: a pressed action stays enabled and still dispatches. Not drawn on a menu item (an action folded into the record header's overflow menu, or a lever in the declared actions bar's override menu): a menu item has no pressed state. `authorWarn` / `authorHint` were dropped in the same edit (a warned `live` row reds this gate)."
169167
},
170168
"ai": {
171169
"status": "live",

‎packages/spec/liveness/state-counts/action.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them.
1212

1313
| Type | live | exp | elsewhere | dead | planned | classified |
1414
|---|---|---|---|---|---|---|
15-
| `action` | 48 | 0 | 0 | 1 | 1 | 50 |
15+
| `action` | 49 | 0 | 0 | 1 | 0 | 50 |

‎ui/packages/app-shell/src/views/DeclaredActionsBar.tsx‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,28 @@ function useDeclaredActionShown(action: ActionDef, predicateRecord: ReturnType<t
215215
return !(hasDeclaredVisibilityGate(action.visible) && !isVisible);
216216
}
217217

218+
/**
219+
* The reader's own state (`ActionSchema.pressed`, objectstack#27946): does the
220+
* action DECLARE a pressed predicate, and does it hold for this record?
221+
*
222+
* `undefined` = no predicate, so the lever is a plain button and carries no
223+
* `aria-pressed`; otherwise the verdict, drawn as `aria-pressed` and the
224+
* pressed face. Same entry and record binding as `visible` above, with
225+
* `throwOnError` so a predicate that cannot be evaluated answers `false` — the
226+
* plain button, never a claim that the reader acted — and is reported once. A
227+
* boolean is refused by the spec at parse (a constant is a style, not a
228+
* reader's state), so it gets no reading here. Display only: the verdict never
229+
* gates, greys or routes the action.
230+
*/
231+
function useDeclaredActionPressed(action: ActionDef, predicateRecord: ReturnType<typeof usePredicateRecordContext>): boolean | undefined {
232+
const declared = typeof action.pressed !== 'boolean' && hasDeclaredVisibilityGate(action.pressed);
233+
const isPressed = useCondition(declared ? toPredicateInput(action.pressed) : undefined, predicateRecord, {
234+
throwOnError: true,
235+
label: `declared action "${action.name ?? action.label ?? 'action'}" (pressed)`,
236+
});
237+
return declared ? isPressed : undefined;
238+
}
239+
218240
/**
219241
* Report whether a declared action is drawn, and withdraw the report when the
220242
* reporter unmounts (objectstack#25877). `onDrawn` is the toolbar's stable
@@ -327,6 +349,7 @@ const DeclaredActionButton: React.FC<{
327349
// these two lines carried — which existed only because `ActionDef.disabled`
328350
// could not describe the envelope arm — have nothing left to reach around.
329351
const isDisabledPred = useCondition(toPredicateInput(action.disabled), predicateRecord);
352+
const pressed = useDeclaredActionPressed(action, predicateRecord);
330353
// Called up here with the other hooks: the `visible` gate below returns early.
331354
const reasonId = React.useId();
332355
const labelId = React.useId();
@@ -651,13 +674,19 @@ const DeclaredActionButton: React.FC<{
651674
variant={variant as any}
652675
disabled={disabledByPredicate || loading}
653676
aria-describedby={disabledReason ? reasonId : undefined}
677+
aria-pressed={pressed}
654678
onClick={handleClick}
655679
// Amber warning treatment for an override (objectui#5178) — the same
656680
// palette the approvals surfaces already use for "waiting / attention",
657681
// via `cn()` over the outline variant rather than a new Button variant
658682
// (`packages/components/src/ui/**` is a no-touch Shadcn zone).
659-
className={cn(isOverride
660-
&& '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')}
683+
// The pressed face is Shadcn's toggle-on colours keyed off the attribute,
684+
// the record header's spelling (`PageHeaderActions`).
685+
className={cn(
686+
isOverride
687+
&& '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',
688+
pressed !== undefined && 'aria-pressed:bg-accent aria-pressed:text-accent-foreground hover:aria-pressed:bg-accent',
689+
)}
661690
data-testid={`declared-action-${action.name}`}
662691
// Pinnable, and readable by an E2E/QA pass without matching on copy.
663692
data-override-decision={isOverride ? 'true' : undefined}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* objectstack#28242 — a `DeclaredActionsBar` action that declares `pressed`
3+
* (the reader's own state, `ActionSchema.pressed`) is a toggle button: drawn
4+
* with `aria-pressed="true"` and the pressed face while the predicate holds
5+
* for this record, `aria-pressed="false"` while it does not. An action that
6+
* declares no predicate carries no `aria-pressed` at all.
7+
*
8+
* Display only: a pressed action stays enabled and still dispatches, and a
9+
* predicate that cannot be evaluated draws the plain button (never a claim
10+
* that the reader acted) and says so once.
11+
*
12+
* Doubles as the sibling suites (`DeclaredActionsBar.disabledReason-26619.test.tsx`)
13+
* do: only the action dispatch and the console runtime shell.
14+
*/
15+
16+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
17+
import { render, screen } from '@testing-library/react';
18+
import userEvent from '@testing-library/user-event';
19+
import '@testing-library/jest-dom';
20+
import React from 'react';
21+
22+
const executeSpy = vi.fn().mockResolvedValue({ success: true });
23+
24+
vi.mock('@object-ui/react', async (importOriginal) => {
25+
const actual = await importOriginal<typeof import('@object-ui/react')>();
26+
return {
27+
...actual,
28+
ActionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
29+
useAction: () => ({ execute: executeSpy }),
30+
};
31+
});
32+
vi.mock('../../hooks/useConsoleActionRuntime', () => ({
33+
useConsoleActionRuntime: () => ({ actionProviderProps: {}, dialogs: null }),
34+
}));
35+
vi.mock('../../providers/AdapterProvider', () => ({ useAdapter: () => ({}) }));
36+
vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => null }));
37+
38+
import { DeclaredActionsBar } from '../DeclaredActionsBar';
39+
40+
const HELPFUL = {
41+
name: 'mark_article_helpful',
42+
type: 'script',
43+
label: 'Helpful',
44+
locations: ['record_section'],
45+
pressed: "has(record.viewer) && record.viewer.vote == 'helpful'",
46+
};
47+
const NOT_HELPFUL = {
48+
name: 'mark_article_not_helpful',
49+
type: 'script',
50+
label: 'Not helpful',
51+
locations: ['record_section'],
52+
pressed: { dialect: 'cel', source: "has(record.viewer) && record.viewer.vote == 'not_helpful'" },
53+
};
54+
const SHARE = { name: 'share_article', type: 'script', label: 'Share', locations: ['record_section'] };
55+
56+
function mount(record: Record<string, unknown>, actions: Record<string, unknown>[] = [HELPFUL, NOT_HELPFUL]) {
57+
return render(
58+
<DeclaredActionsBar
59+
objectName="kb_article"
60+
record={{ id: 'a-1', ...record }}
61+
location="record_section"
62+
actions={actions as any}
63+
/>,
64+
);
65+
}
66+
67+
const lever = (name: string) => screen.getByTestId(`declared-action-${name}`);
68+
69+
beforeEach(() => executeSpy.mockReset().mockResolvedValue({ success: true }));
70+
afterEach(() => vi.restoreAllMocks());
71+
72+
describe('DeclaredActionsBar — `pressed` draws the reader\'s own state (objectstack#28242)', () => {
73+
it('the predicate holds: aria-pressed="true" and the pressed face; the sibling vote is unpressed', () => {
74+
mount({ viewer: { vote: 'helpful' } });
75+
expect(lever('mark_article_helpful')).toHaveAttribute('aria-pressed', 'true');
76+
expect(lever('mark_article_helpful').className).toContain('aria-pressed:bg-accent');
77+
expect(lever('mark_article_not_helpful')).toHaveAttribute('aria-pressed', 'false');
78+
});
79+
80+
it('the envelope arm reads the same: the other vote is the pressed one', () => {
81+
mount({ viewer: { vote: 'not_helpful' } });
82+
expect(lever('mark_article_helpful')).toHaveAttribute('aria-pressed', 'false');
83+
expect(lever('mark_article_not_helpful')).toHaveAttribute('aria-pressed', 'true');
84+
});
85+
86+
it('a row with no served block draws both unpressed', () => {
87+
mount({});
88+
expect(lever('mark_article_helpful')).toHaveAttribute('aria-pressed', 'false');
89+
expect(lever('mark_article_not_helpful')).toHaveAttribute('aria-pressed', 'false');
90+
});
91+
92+
it('display only: a pressed action stays enabled and still dispatches', async () => {
93+
const user = userEvent.setup();
94+
mount({ viewer: { vote: 'helpful' } }, [HELPFUL]);
95+
expect(lever('mark_article_helpful')).not.toBeDisabled();
96+
await user.click(lever('mark_article_helpful'));
97+
expect(executeSpy).toHaveBeenCalledTimes(1);
98+
expect(executeSpy.mock.calls[0][0]).toMatchObject({ name: 'mark_article_helpful' });
99+
});
100+
101+
it('a predicate that cannot be evaluated draws the plain button, and says so', () => {
102+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
103+
mount({}, [{ ...HELPFUL, pressed: "record.viewer.vote == 'helpful'" }]);
104+
expect(lever('mark_article_helpful')).toHaveAttribute('aria-pressed', 'false');
105+
expect(warn.mock.calls.some((c) => String(c[0]).includes('(pressed)'))).toBe(true);
106+
});
107+
108+
it('control — an action that declares no predicate is a plain button: no aria-pressed, no face', () => {
109+
mount({ viewer: { vote: 'helpful' } }, [SHARE]);
110+
expect(lever('share_article')).not.toHaveAttribute('aria-pressed');
111+
expect(lever('share_article').className).not.toContain('aria-pressed:');
112+
});
113+
});

0 commit comments

Comments
 (0)