Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/11811-disabled-action-reason.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@object-ui/i18n': minor
'@object-ui/components': minor
'@object-ui/app-shell': patch
'@object-ui/plugin-detail': patch
---

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.

Where it shows:

- **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.
- **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.
- **The `DeclaredActionsBar`** (`@object-ui/app-shell`), which renders server-declared actions on the approvals surfaces. Hovering or focusing the button opens the tooltip.

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.

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.

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.

**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.
92 changes: 73 additions & 19 deletions packages/app-shell/src/views/DeclaredActionsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,16 @@
*/

import React, { useCallback, useMemo, useState } from 'react';
import { Button, Separator, cn, hasDeclaredVisibilityGate } from '@object-ui/components';
import {
Button,
Separator,
cn,
hasDeclaredVisibilityGate,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@object-ui/components';
import {
ActionProvider,
useAction,
Expand Down Expand Up @@ -178,6 +187,8 @@ 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);
// Called up here with the other hooks: the `visible` gate below returns early.
const reasonId = React.useId();

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

return (
// Is a `disabled` gate DECLARED? The same question the `visible` gate
// above asks, so it reads the same definition rather than re-spelling it.
// The name is historic — objectui#3492 arrived through `visible` — and the
// predicate is key-neutral: "declared" is `!= null && !== ''`, because an
// empty predicate is nothing to evaluate. Kept under that name
// deliberately (objectui#3842 ruling): one implementation behind two names
// is a dialect, not a clarification.
//
// `!= null` alone was a real defect here, and NOT for the reason it was on
// `visible`: the evaluation entry reads an empty predicate as "no
// condition → true", which on `visible` means SHOW (so an over-broad
// "declared" test cancels out and `''` renders either way), but here means
// DISABLE. A `disabled: ''` on a server-declared approval action rendered
// a permanently greyed-out Approve / Reject — the mirror image of
// objectui#3835 on the same surface, and equally impossible to tell from
// deliberate metadata by looking at it.
//
// Held apart from `loading` (objectui#11811): only the declared predicate is
// a fact about the record, so only it earns the "not available" reason. A
// button greyed out while its own action runs already says why — the spinner.
const disabledByPredicate = hasDeclaredVisibilityGate(action.disabled) ? isDisabledPred : false;
// The generic reason (objectui#11811). An author-written reason beside the
// predicate would be a spec key, which is objectstack's to declare — not one
// this bar may invent — so every predicate-disabled action says the same
// thing for now.
const disabledReason = disabledByPredicate
? String(t('actions.notAvailableForRecord', { defaultValue: 'Not available for this record' }))
: undefined;

const button = (
<Button
type="button"
size="sm"
variant={variant as any}
// Is a `disabled` gate DECLARED? The same question the `visible` gate
// above asks, so it reads the same definition rather than re-spelling it.
// The name is historic — objectui#3492 arrived through `visible` — and the
// predicate is key-neutral: "declared" is `!= null && !== ''`, because an
// empty predicate is nothing to evaluate. Kept under that name
// deliberately (objectui#3842 ruling): one implementation behind two names
// is a dialect, not a clarification.
//
// `!= null` alone was a real defect here, and NOT for the reason it was on
// `visible`: the evaluation entry reads an empty predicate as "no
// condition → true", which on `visible` means SHOW (so an over-broad
// "declared" test cancels out and `''` renders either way), but here means
// DISABLE. A `disabled: ''` on a server-declared approval action rendered
// a permanently greyed-out Approve / Reject — the mirror image of
// objectui#3835 on the same surface, and equally impossible to tell from
// deliberate metadata by looking at it.
disabled={(hasDeclaredVisibilityGate(action.disabled) ? isDisabledPred : false) || loading}
disabled={disabledByPredicate || loading}
aria-describedby={disabledReason ? reasonId : undefined}
onClick={handleClick}
// Amber warning treatment for an override (objectui#5178) — the same
// palette the approvals surfaces already use for "waiting / attention",
Expand Down Expand Up @@ -420,6 +445,35 @@ const DeclaredActionButton: React.FC<{
{label}
</Button>
);
if (!disabledReason) return button;
// A natively `disabled` button fires no pointer or focus events, and the
// Button primitive adds `disabled:pointer-events-none` on top — so a tooltip
// (or a native `title`) on the button itself never opens: hovering or
// focusing it showed nothing (objectui#11811). The wrapping span is the
// trigger instead, the idiom Radix documents for a disabled button: it takes
// the hover, and `tabIndex={0}` lets a keyboard user focus it, which opens
// the tooltip too. The reason is ALSO a persistent accessible description
// (`aria-describedby` on both the button and the span, onto an `sr-only`
// copy), so a screen reader reaching either one hears it with the tooltip
// closed. Same shape as `record:quick_actions`' button.
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
aria-describedby={reasonId}
data-disabled-reason=""
className="inline-flex rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{button}
<span id={reasonId} className="sr-only">{disabledReason}</span>
</span>
</TooltipTrigger>
<TooltipContent>{disabledReason}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* DeclaredActionsBar — a declared action greyed out by its `disabled`
* predicate says why (objectui#11811).
*
* The bar evaluates the spec `disabled` predicate itself (`useCondition`), so
* it knows a button is off BY DECLARATION. Before the fix it rendered that
* button with no tooltip, no `title` and no `aria-describedby`, and a natively
* disabled button (with the Button primitive's `disabled:pointer-events-none`)
* never fires the hover or focus a tooltip would need. The generic reason now
* rides a focusable wrapper span — the tooltip trigger — and a persistent
* `sr-only` description both the button and the span point at. A button that
* is disabled only while its own action runs is NOT "not available", and says
* nothing.
*
* Only the action DISPATCH and the console runtime shell are doubled, as in
* the sibling suites. The predicate entry, the Button, the Radix tooltip and
* the language packs are the shipped ones — the zh case reads the real zh pack
* through a real `I18nProvider`.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import React from 'react';

const executeSpy = vi.fn().mockResolvedValue({ success: true });

vi.mock('@object-ui/react', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/react')>();
return {
...actual,
ActionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
useAction: () => ({ execute: executeSpy }),
};
});
vi.mock('../../hooks/useConsoleActionRuntime', () => ({
useConsoleActionRuntime: () => ({ actionProviderProps: {}, dialogs: null }),
}));
vi.mock('../../providers/AdapterProvider', () => ({ useAdapter: () => ({}) }));
vi.mock('../../utils/getIcon', () => ({ getIcon: () => () => null }));

import { I18nProvider } from '@object-ui/i18n';
import { DeclaredActionsBar } from '../DeclaredActionsBar';

const REASON_EN = 'Not available for this record';
const REASON_ZH = '对此记录不可用';

/**
* The `disabled`-predicate specimen the card measured (showcase Archive), in
* the shape the server SERVES it: the authored CEL string compiled into a
* `{ dialect: 'cel', source }` envelope. The bare string would take the legacy
* `${…}` evaluator, where `has()` faults — and a faulting `disabled` greys the
* action out on both rows (fail-soft), so the "disabled" case would pass for
* the wrong reason and the control below would go red. The envelope reaches a
* real verdict both ways.
*/
const ARCHIVE = {
name: 'showcase_archive_task',
type: 'script',
label: 'Archive',
locations: ['record_section'],
disabled: { dialect: 'cel', source: 'has(record.done) && record.done != true' },
};

function mount(
record: Record<string, unknown>,
actions: Record<string, unknown>[] = [ARCHIVE],
wrap: (node: React.ReactElement) => React.ReactElement = (node) => node,
) {
return render(
wrap(
<DeclaredActionsBar
objectName="showcase_task"
record={{ id: 't-1', ...record }}
location="record_section"
actions={actions as any}
/>,
),
);
}

const archive = () => screen.getByTestId('declared-action-showcase_archive_task');
/** The element an `aria-describedby` names, or `null`. */
const describedBy = (el: HTMLElement) => {
const id = el.getAttribute('aria-describedby');
return id ? document.getElementById(id) : null;
};

beforeEach(() => executeSpy.mockReset().mockResolvedValue({ success: true }));

describe('DeclaredActionsBar — a predicate-disabled action says why (objectui#11811)', () => {
it('disabled by its predicate: the button carries the reason as its accessible description', () => {
mount({ done: false });
const button = archive();
expect(button).toBeDisabled();
expect(describedBy(button)).toHaveTextContent(REASON_EN);
// A DESCRIPTION, never folded into the accessible name.
expect(button).toHaveAccessibleName('Archive');
});

it('hovering the disabled action opens a tooltip with the reason', async () => {
const user = userEvent.setup();
mount({ done: false });
const trigger = archive().parentElement as HTMLElement;
expect(trigger).toHaveAttribute('data-disabled-reason');
await user.hover(trigger);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toHaveTextContent(REASON_EN);
});

it('a keyboard user reaches the reason: Tab lands on the trigger, which opens the tooltip and is described by it', async () => {
const user = userEvent.setup();
mount({ done: false });
const trigger = archive().parentElement as HTMLElement;
await user.tab();
expect(trigger).toHaveFocus();
expect(describedBy(trigger)).toHaveTextContent(REASON_EN);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toHaveTextContent(REASON_EN);
});

it('control — the predicate does not hold: the action is live and says nothing', () => {
mount({ done: true });
const button = archive();
expect(button).not.toBeDisabled();
expect(button).not.toHaveAttribute('aria-describedby');
expect(button.parentElement).not.toHaveAttribute('data-disabled-reason');
expect(screen.queryByText(REASON_EN)).toBeNull();
});

it('control — a button greyed out only while its own action runs gives no "not available" reason', async () => {
let finish: (v: unknown) => void = () => {};
executeSpy.mockReturnValueOnce(new Promise((resolve) => { finish = resolve; }));
const SLOW = { name: 'slow', type: 'script', label: 'Slow', locations: ['record_section'] };
mount({}, [SLOW]);
fireEvent.click(screen.getByTestId('declared-action-slow'));
const slow = screen.getByTestId('declared-action-slow');
expect(slow).toBeDisabled();
expect(slow).not.toHaveAttribute('aria-describedby');
expect(screen.queryByText(REASON_EN)).toBeNull();
await act(async () => { finish({ success: true }); });
expect(screen.getByTestId('declared-action-slow')).not.toBeDisabled();
});

it('the reason comes from the language pack — zh', async () => {
mount({ done: false }, [ARCHIVE], (node) => (
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }} persistLanguage={false}>
{node}
</I18nProvider>
));
const reason = await screen.findByText(REASON_ZH);
expect(describedBy(archive())).toBe(reason);
expect(screen.queryByText(REASON_EN)).toBeNull();
});

it('the reason comes from the language pack — en', async () => {
mount({ done: false }, [ARCHIVE], (node) => (
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
{node}
</I18nProvider>
));
const reason = await screen.findByText(REASON_EN);
expect(describedBy(archive())).toBe(reason);
});
});
Loading
Loading