Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 455c646

Browse files
fix(components,plugin-detail,app-shell,i18n): a record action disabled by its predicate says why it is unavailable (objectui#11811) (#11841)
Fixes #11811 Clause-②: yes A record action greyed out by its declared `disabled` predicate now says why. It shows the generic reason "Not available for this record" as a tooltip, on hover and on keyboard focus, and the same text is its accessible description (`aria-describedby`). Before, the button carried no tooltip, no `title` and no description. ## Where the card's defect actually lives The claim's original Responsibility line named `DeclaredActionsBar` as the code that draws the showcase Task page's *Archive*. It does not: `DeclaredActionsBar` renders only `sys_approval_request` actions (record page approvals bar, approvals inbox). Measured on objectstack `main` (the card's `879bd38c` is its ancestor), the Task page (`showcase_task_detail`, `kind: 'full'`) draws Archive in two other renderers: - **the header**: a `page:header` node whose `actions` are ids, drawn by `PageHeaderRenderer`'s `renderButton` (`@object-ui/components`); - **the section bar**: `record:quick_actions` at `location: 'record_section'`, drawn by `QuickActionButton` (`@object-ui/plugin-detail`). The seat recorded the correction in claim amendment 6049093782 and added the header file to the surface. `DeclaredActionsBar` has the same defect class, so it gets the same fix. ## What changed - `page:header` (`packages/components/src/renderers/layout/containers.tsx`): the inline button gets a tooltip trigger plus an `sr-only` description. In the ⋯ overflow menu the reason is a visible second line under the label and is the item's `aria-describedby`; the item's name stays the label (`aria-labelledby`). A tooltip there would be out of keyboard reach: the menu's roving focus skips a disabled item (`focusable: !disabled`) and the menu traps Tab. **Only an authored action's declared `disabled`** gets the reason. The host-injected Edit and Delete (`RecordContext.headerSystemActions`, a host-computed boolean) and the inline-edit lock are unchanged. Authored and host actions are told apart by key, from the dedupe that lets an authored action win. - `record:quick_actions` (`QuickActionButton`) and `DeclaredActionsBar`: the same tooltip trigger and description on the inline button. - One new key, `actions.notAvailableForRecord`, with real translations in all ten packs. **Why a wrapper span.** A natively `disabled` button fires no pointer or focus events, and the Button primitive adds `disabled:pointer-events-none`, so a tooltip or a native `title` on the button itself never opens. The span is the tooltip trigger, the pattern Radix documents for a disabled trigger. It takes the hover, and `tabIndex={0}` lets a keyboard user focus it. Both the button and the span carry `aria-describedby` pointing at a persistent `sr-only` copy, so a screen reader gets the reason with the tooltip closed. The repo had no disabled-tooltip pattern. Its `title`-on-disabled-Button sites cannot show at all; a Chromium probe found that the hit target at a `pointer-events: none` disabled button's centre is its parent. **Not changed:** a button greyed out only while its own action runs (spinner) shows no reason. No export, prop or type member is added. The author-written reason beside the predicate is a spec key for objectstack, not part of this PR. ## Tests (head `e165c34`) - New pins, three files: `page-header-disabledReason-11811.test.tsx` (7), `record-quick-actions.disabledReason-11811.test.tsx` (7), `DeclaredActionsBar.disabledReason-11811.test.tsx` (7). - **Disabled by the predicate:** the button is disabled and described by the reason; hover opens `role=tooltip`; Tab reaches the trigger, which opens it. - **Controls:** a predicate that does not hold gives no reason; a running or loading button gives no reason; the header's host Edit and Delete with `disabled: true` give no reason. - **Overflow:** the item shows the reason and is described by it. - **Text:** the zh pack and the en pack. - **Fixture:** the predicate is the served CEL envelope. The bare string goes to the legacy evaluator, where `has()` faults and a fail-soft `disabled` greys out both rows, so it would pin the wrong reason. - `vitest run packages/components/` plus the 36 app-shell, console and example files that name `page:header`, `PageHeaderRenderer` or `headerSystemActions`, at `e165c34`: `Test Files 401 passed | 1 skipped (402)`. - `vitest run packages/plugin-detail/ packages/i18n/` plus the 27 app-shell and console files that name `DeclaredActionsBar`, `record-quick-actions` or the key, at `e2a498f`: `Test Files 356 passed | 1 skipped (357)`. `e165c34` changes no file in those packages. - type-check: `@object-ui/components` exit 0 at `e165c34`; `@object-ui/i18n`, `@object-ui/plugin-detail` and `@object-ui/app-shell` exit 0 at `e2a498f`, on dists rebuilt from this branch. - **Ablations**, run after commit through objectstack's `scripts/ablation-replace.mjs`: the anchor `if (!disabledReason) return button;` is replaced by an unconditional `return button;`. - Header: `Tests 4 failed | 3 passed (7)`. Red: description, hover, focus, zh. Green: overflow and both controls. - Quick actions and `DeclaredActionsBar`: `Tests 10 failed | 4 passed (14)`. Red: description, hover, focus, zh, en on each. Green: the controls. - Each restore was proven: blob equals HEAD and `git diff HEAD` is empty. - Gates at `e165c34`, all exit 0: - i18n: `check:i18n-keys`, `check:i18n-drift` (1 key added, no en value changed), `check:i18n-dead-keys` (report; the new key is not a candidate), `check:i18n-designer-parity`; - changeset: `check-changeset-presence` (4 released packages, 1 changeset), `check-changeset-no-major`, `check-changeset-fixed`, `check:changeset-claims`, `check:pending-changeset-literals`; - other: `check:control-bytes`, `check:test-path-roots`, `check:new-line-citations` (0 new); eslint on touched files (0 errors). - **Not run locally:** the full app-shell suite. The named-file sets above cannot see a suite that draws these renderers through a page tree without naming them; CI runs the full farm. ## Changeset `.changeset/11811-disabled-action-reason.md`: `@object-ui/i18n` and `@object-ui/components` are `minor`, `@object-ui/app-shell` and `@object-ui/plugin-detail` are `patch`. The fixed group publishes all of them at `minor`, never `major`. The body names the three surfaces and the one key. ## Acceptance notes - The generic action renderers (`action:button` / `action:icon` / `action:group` / `action:menu`) have the same gap. They are their own family card, objectui#11839, and this PR leaves them alone. - A `disabled` predicate that faults (fail-soft) also greys the action out, and now also shows the generic reason. That is consistent, since the action is unavailable either way, but the wording slightly overstates the case for a broken predicate. - `InlineEditSaveBar`'s Save button sets `title` on a disabled Button, which cannot show for the pointer-events reason above. The approval-lock band already shows that reason on screen. Noted only. - Overlap: #11836 adds keys to the same ten packs. Whoever lands second merges `main`. The session behind this change is `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8`. --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 57dbe34 commit 455c646

17 files changed

Lines changed: 751 additions & 28 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@object-ui/i18n': minor
3+
'@object-ui/components': minor
4+
'@object-ui/app-shell': patch
5+
'@object-ui/plugin-detail': patch
6+
---
7+
8+
A record action greyed out by its declared `disabled` predicate now says why (objectui#11811). It shows the reason "Not available for this record", and the same text is its accessible description (`aria-describedby`), so a screen reader announces it too. Before, the action carried no tooltip, no `title` and no description, so a user could not learn why it was off.
9+
10+
Where it shows:
11+
12+
- **The record page header** (`page:header`, `@object-ui/components`). On an inline action button, hovering it or focusing it from the keyboard opens a tooltip with the reason. On an action in the ⋯ overflow menu, the reason is a second line under the label. A tooltip there could not be reached from the keyboard, because the menu skips a disabled item and traps Tab.
13+
- **The `record:quick_actions` bar** (`@object-ui/plugin-detail`), for example a record page's section bar. Hovering or focusing the button opens the tooltip.
14+
- **The `DeclaredActionsBar`** (`@object-ui/app-shell`), which renders server-declared actions on the approvals surfaces. Hovering or focusing the button opens the tooltip.
15+
16+
A natively disabled button receives no pointer or focus events, so the tooltip's trigger is a focusable wrapper around the button, the pattern Radix documents for a disabled trigger.
17+
18+
What stays unchanged: a button greyed out only while its own action runs shows no reason. So do the header's Edit and Delete that the console injects, whose `disabled` the host computes (for example while the record is locked for approval), and a header action greyed out by a live inline-edit session.
19+
20+
The reason is the same generic sentence for every action. An author-written reason beside the predicate would be a new key on the action spec, which is objectstack's to declare. It is not part of this change.
21+
22+
**Clause-②: yes (widening).** `@object-ui/i18n` gains one language-pack key, `actions.notAvailableForRecord`, translated in all ten packs. No export, prop or type member is added, removed or changed.

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

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,16 @@
3131
*/
3232

3333
import React, { useCallback, useMemo, useState } from 'react';
34-
import { Button, Separator, cn, hasDeclaredVisibilityGate } from '@object-ui/components';
34+
import {
35+
Button,
36+
Separator,
37+
cn,
38+
hasDeclaredVisibilityGate,
39+
Tooltip,
40+
TooltipContent,
41+
TooltipProvider,
42+
TooltipTrigger,
43+
} from '@object-ui/components';
3544
import {
3645
ActionProvider,
3746
useAction,
@@ -178,6 +187,8 @@ const DeclaredActionButton: React.FC<{
178187
// these two lines carried — which existed only because `ActionDef.disabled`
179188
// could not describe the envelope arm — have nothing left to reach around.
180189
const isDisabledPred = useCondition(toPredicateInput(action.disabled), predicateRecord);
190+
// Called up here with the other hooks: the `visible` gate below returns early.
191+
const reasonId = React.useId();
181192

182193
/**
183194
* Is the button this viewer is looking at an ADMIN OVERRIDE (objectui#5178)?
@@ -371,28 +382,42 @@ const DeclaredActionButton: React.FC<{
371382
// dialog body can never come from different bundle reads (objectui#4265).
372383
const label = isOverride ? overrideLabel : declaredLabel;
373384

374-
return (
385+
// Is a `disabled` gate DECLARED? The same question the `visible` gate
386+
// above asks, so it reads the same definition rather than re-spelling it.
387+
// The name is historic — objectui#3492 arrived through `visible` — and the
388+
// predicate is key-neutral: "declared" is `!= null && !== ''`, because an
389+
// empty predicate is nothing to evaluate. Kept under that name
390+
// deliberately (objectui#3842 ruling): one implementation behind two names
391+
// is a dialect, not a clarification.
392+
//
393+
// `!= null` alone was a real defect here, and NOT for the reason it was on
394+
// `visible`: the evaluation entry reads an empty predicate as "no
395+
// condition → true", which on `visible` means SHOW (so an over-broad
396+
// "declared" test cancels out and `''` renders either way), but here means
397+
// DISABLE. A `disabled: ''` on a server-declared approval action rendered
398+
// a permanently greyed-out Approve / Reject — the mirror image of
399+
// objectui#3835 on the same surface, and equally impossible to tell from
400+
// deliberate metadata by looking at it.
401+
//
402+
// Held apart from `loading` (objectui#11811): only the declared predicate is
403+
// a fact about the record, so only it earns the "not available" reason. A
404+
// button greyed out while its own action runs already says why — the spinner.
405+
const disabledByPredicate = hasDeclaredVisibilityGate(action.disabled) ? isDisabledPred : false;
406+
// The generic reason (objectui#11811). An author-written reason beside the
407+
// predicate would be a spec key, which is objectstack's to declare — not one
408+
// this bar may invent — so every predicate-disabled action says the same
409+
// thing for now.
410+
const disabledReason = disabledByPredicate
411+
? String(t('actions.notAvailableForRecord', { defaultValue: 'Not available for this record' }))
412+
: undefined;
413+
414+
const button = (
375415
<Button
376416
type="button"
377417
size="sm"
378418
variant={variant as any}
379-
// Is a `disabled` gate DECLARED? The same question the `visible` gate
380-
// above asks, so it reads the same definition rather than re-spelling it.
381-
// The name is historic — objectui#3492 arrived through `visible` — and the
382-
// predicate is key-neutral: "declared" is `!= null && !== ''`, because an
383-
// empty predicate is nothing to evaluate. Kept under that name
384-
// deliberately (objectui#3842 ruling): one implementation behind two names
385-
// is a dialect, not a clarification.
386-
//
387-
// `!= null` alone was a real defect here, and NOT for the reason it was on
388-
// `visible`: the evaluation entry reads an empty predicate as "no
389-
// condition → true", which on `visible` means SHOW (so an over-broad
390-
// "declared" test cancels out and `''` renders either way), but here means
391-
// DISABLE. A `disabled: ''` on a server-declared approval action rendered
392-
// a permanently greyed-out Approve / Reject — the mirror image of
393-
// objectui#3835 on the same surface, and equally impossible to tell from
394-
// deliberate metadata by looking at it.
395-
disabled={(hasDeclaredVisibilityGate(action.disabled) ? isDisabledPred : false) || loading}
419+
disabled={disabledByPredicate || loading}
420+
aria-describedby={disabledReason ? reasonId : undefined}
396421
onClick={handleClick}
397422
// Amber warning treatment for an override (objectui#5178) — the same
398423
// palette the approvals surfaces already use for "waiting / attention",
@@ -420,6 +445,35 @@ const DeclaredActionButton: React.FC<{
420445
{label}
421446
</Button>
422447
);
448+
if (!disabledReason) return button;
449+
// A natively `disabled` button fires no pointer or focus events, and the
450+
// Button primitive adds `disabled:pointer-events-none` on top — so a tooltip
451+
// (or a native `title`) on the button itself never opens: hovering or
452+
// focusing it showed nothing (objectui#11811). The wrapping span is the
453+
// trigger instead, the idiom Radix documents for a disabled button: it takes
454+
// the hover, and `tabIndex={0}` lets a keyboard user focus it, which opens
455+
// the tooltip too. The reason is ALSO a persistent accessible description
456+
// (`aria-describedby` on both the button and the span, onto an `sr-only`
457+
// copy), so a screen reader reaching either one hears it with the tooltip
458+
// closed. Same shape as `record:quick_actions`' button.
459+
return (
460+
<TooltipProvider delayDuration={200}>
461+
<Tooltip>
462+
<TooltipTrigger asChild>
463+
<span
464+
tabIndex={0}
465+
aria-describedby={reasonId}
466+
data-disabled-reason=""
467+
className="inline-flex rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
468+
>
469+
{button}
470+
<span id={reasonId} className="sr-only">{disabledReason}</span>
471+
</span>
472+
</TooltipTrigger>
473+
<TooltipContent>{disabledReason}</TooltipContent>
474+
</Tooltip>
475+
</TooltipProvider>
476+
);
423477
};
424478

425479
/**
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* DeclaredActionsBar — a declared action greyed out by its `disabled`
5+
* predicate says why (objectui#11811).
6+
*
7+
* The bar evaluates the spec `disabled` predicate itself (`useCondition`), so
8+
* it knows a button is off BY DECLARATION. Before the fix it rendered that
9+
* button with no tooltip, no `title` and no `aria-describedby`, and a natively
10+
* disabled button (with the Button primitive's `disabled:pointer-events-none`)
11+
* never fires the hover or focus a tooltip would need. The generic reason now
12+
* rides a focusable wrapper span — the tooltip trigger — and a persistent
13+
* `sr-only` description both the button and the span point at. A button that
14+
* is disabled only while its own action runs is NOT "not available", and says
15+
* nothing.
16+
*
17+
* Only the action DISPATCH and the console runtime shell are doubled, as in
18+
* the sibling suites. The predicate entry, the Button, the Radix tooltip and
19+
* the language packs are the shipped ones — the zh case reads the real zh pack
20+
* through a real `I18nProvider`.
21+
*/
22+
23+
import { describe, it, expect, vi, beforeEach } from 'vitest';
24+
import { render, screen, fireEvent, act } from '@testing-library/react';
25+
import userEvent from '@testing-library/user-event';
26+
import '@testing-library/jest-dom';
27+
import React from 'react';
28+
29+
const executeSpy = vi.fn().mockResolvedValue({ success: true });
30+
31+
vi.mock('@object-ui/react', async (importOriginal) => {
32+
const actual = await importOriginal<typeof import('@object-ui/react')>();
33+
return {
34+
...actual,
35+
ActionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
36+
useAction: () => ({ execute: executeSpy }),
37+
};
38+
});
39+
vi.mock('../../hooks/useConsoleActionRuntime', () => ({
40+
useConsoleActionRuntime: () => ({ actionProviderProps: {}, dialogs: null }),
41+
}));
42+
vi.mock('../../providers/AdapterProvider', () => ({ useAdapter: () => ({}) }));
43+
vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => null }));
44+
45+
import { I18nProvider } from '@object-ui/i18n';
46+
import { DeclaredActionsBar } from '../DeclaredActionsBar';
47+
48+
const REASON_EN = 'Not available for this record';
49+
const REASON_ZH = '对此记录不可用';
50+
51+
/**
52+
* The `disabled`-predicate specimen the card measured (showcase Archive), in
53+
* the shape the server SERVES it: the authored CEL string compiled into a
54+
* `{ dialect: 'cel', source }` envelope. The bare string would take the legacy
55+
* `${…}` evaluator, where `has()` faults — and a faulting `disabled` greys the
56+
* action out on both rows (fail-soft), so the "disabled" case would pass for
57+
* the wrong reason and the control below would go red. The envelope reaches a
58+
* real verdict both ways.
59+
*/
60+
const ARCHIVE = {
61+
name: 'showcase_archive_task',
62+
type: 'script',
63+
label: 'Archive',
64+
locations: ['record_section'],
65+
disabled: { dialect: 'cel', source: 'has(record.done) && record.done != true' },
66+
};
67+
68+
function mount(
69+
record: Record<string, unknown>,
70+
actions: Record<string, unknown>[] = [ARCHIVE],
71+
wrap: (node: React.ReactElement) => React.ReactElement = (node) => node,
72+
) {
73+
return render(
74+
wrap(
75+
<DeclaredActionsBar
76+
objectName="showcase_task"
77+
record={{ id: 't-1', ...record }}
78+
location="record_section"
79+
actions={actions as any}
80+
/>,
81+
),
82+
);
83+
}
84+
85+
const archive = () => screen.getByTestId('declared-action-showcase_archive_task');
86+
/** The element an `aria-describedby` names, or `null`. */
87+
const describedBy = (el: HTMLElement) => {
88+
const id = el.getAttribute('aria-describedby');
89+
return id ? document.getElementById(id) : null;
90+
};
91+
92+
beforeEach(() => executeSpy.mockReset().mockResolvedValue({ success: true }));
93+
94+
describe('DeclaredActionsBar — a predicate-disabled action says why (objectui#11811)', () => {
95+
it('disabled by its predicate: the button carries the reason as its accessible description', () => {
96+
mount({ done: false });
97+
const button = archive();
98+
expect(button).toBeDisabled();
99+
expect(describedBy(button)).toHaveTextContent(REASON_EN);
100+
// A DESCRIPTION, never folded into the accessible name.
101+
expect(button).toHaveAccessibleName('Archive');
102+
});
103+
104+
it('hovering the disabled action opens a tooltip with the reason', async () => {
105+
const user = userEvent.setup();
106+
mount({ done: false });
107+
const trigger = archive().parentElement as HTMLElement;
108+
expect(trigger).toHaveAttribute('data-disabled-reason');
109+
await user.hover(trigger);
110+
const tooltip = await screen.findByRole('tooltip');
111+
expect(tooltip).toHaveTextContent(REASON_EN);
112+
});
113+
114+
it('a keyboard user reaches the reason: Tab lands on the trigger, which opens the tooltip and is described by it', async () => {
115+
const user = userEvent.setup();
116+
mount({ done: false });
117+
const trigger = archive().parentElement as HTMLElement;
118+
await user.tab();
119+
expect(trigger).toHaveFocus();
120+
expect(describedBy(trigger)).toHaveTextContent(REASON_EN);
121+
const tooltip = await screen.findByRole('tooltip');
122+
expect(tooltip).toHaveTextContent(REASON_EN);
123+
});
124+
125+
it('control — the predicate does not hold: the action is live and says nothing', () => {
126+
mount({ done: true });
127+
const button = archive();
128+
expect(button).not.toBeDisabled();
129+
expect(button).not.toHaveAttribute('aria-describedby');
130+
expect(button.parentElement).not.toHaveAttribute('data-disabled-reason');
131+
expect(screen.queryByText(REASON_EN)).toBeNull();
132+
});
133+
134+
it('control — a button greyed out only while its own action runs gives no "not available" reason', async () => {
135+
let finish: (v: unknown) => void = () => {};
136+
executeSpy.mockReturnValueOnce(new Promise((resolve) => { finish = resolve; }));
137+
const SLOW = { name: 'slow', type: 'script', label: 'Slow', locations: ['record_section'] };
138+
mount({}, [SLOW]);
139+
fireEvent.click(screen.getByTestId('declared-action-slow'));
140+
const slow = screen.getByTestId('declared-action-slow');
141+
expect(slow).toBeDisabled();
142+
expect(slow).not.toHaveAttribute('aria-describedby');
143+
expect(screen.queryByText(REASON_EN)).toBeNull();
144+
await act(async () => { finish({ success: true }); });
145+
expect(screen.getByTestId('declared-action-slow')).not.toBeDisabled();
146+
});
147+
148+
it('the reason comes from the language pack — zh', async () => {
149+
mount({ done: false }, [ARCHIVE], (node) => (
150+
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }} persistLanguage={false}>
151+
{node}
152+
</I18nProvider>
153+
));
154+
const reason = await screen.findByText(REASON_ZH);
155+
expect(describedBy(archive())).toBe(reason);
156+
expect(screen.queryByText(REASON_EN)).toBeNull();
157+
});
158+
159+
it('the reason comes from the language pack — en', async () => {
160+
mount({ done: false }, [ARCHIVE], (node) => (
161+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
162+
{node}
163+
</I18nProvider>
164+
));
165+
const reason = await screen.findByText(REASON_EN);
166+
expect(describedBy(archive())).toBe(reason);
167+
});
168+
});

0 commit comments

Comments
 (0)