From a8c3bac38547557821ef3df14d886dda8db3c620 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:42:57 +0000 Subject: [PATCH 1/4] fix(app-shell,plugin-detail): a predicate-disabled record action says why it is unavailable A declared action greyed out by its `disabled` predicate now carries the generic reason "Not available for this record" as a tooltip (on hover and on keyboard focus, through a focusable wrapper span, the Radix idiom for a disabled trigger) and as a persistent accessible description (`aria-describedby` onto an `sr-only` copy). A button disabled only while its own action runs stays as it was. Surfaces: `record:quick_actions` (plugin-detail, the record section bar) and `DeclaredActionsBar` (app-shell). One new key, `actions.notAvailableForRecord`, in all ten language packs. Part of objectui#11811. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../src/views/DeclaredActionsBar.tsx | 92 +++++++++++++++---- packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 6 ++ packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + .../src/renderers/record-quick-actions.tsx | 58 +++++++++++- 12 files changed, 143 insertions(+), 22 deletions(-) diff --git a/packages/app-shell/src/views/DeclaredActionsBar.tsx b/packages/app-shell/src/views/DeclaredActionsBar.tsx index 6564ca0a4e..95b4cbbff9 100644 --- a/packages/app-shell/src/views/DeclaredActionsBar.tsx +++ b/packages/app-shell/src/views/DeclaredActionsBar.tsx @@ -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, @@ -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)? @@ -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 = ( ); + 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: the card's "hovering + // or focusing it shows nothing". 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 without the tooltip being open. Same + // shape as `DeclaredActionsBar`'s button in `@object-ui/app-shell`. + return ( + + + + + {button} + {disabledReason} + + + {disabledReason} + + + ); } export default RecordQuickActionsRenderer; From 0a874b3f5eae00e4466ca5bf8b9e61c245ccfd94 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 23:03:01 +0000 Subject: [PATCH 2/4] test(app-shell,plugin-detail): pin the disabled-action reason on hover, focus and description; add the changeset Both surfaces: the predicate-disabled action shows the reason on hover and on keyboard focus and is described by it; a predicate that does not hold and a button greyed out only while it runs show none; the text comes from the en and zh packs. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11811-disabled-action-reason.md | 13 ++ ...edActionsBar.disabledReason-11811.test.tsx | 160 ++++++++++++++++++ ...uick-actions.disabledReason-11811.test.tsx | 144 ++++++++++++++++ 3 files changed, 317 insertions(+) create mode 100644 .changeset/11811-disabled-action-reason.md create mode 100644 packages/app-shell/src/views/__tests__/DeclaredActionsBar.disabledReason-11811.test.tsx create mode 100644 packages/plugin-detail/src/renderers/__tests__/record-quick-actions.disabledReason-11811.test.tsx diff --git a/.changeset/11811-disabled-action-reason.md b/.changeset/11811-disabled-action-reason.md new file mode 100644 index 0000000000..e4ffc812c8 --- /dev/null +++ b/.changeset/11811-disabled-action-reason.md @@ -0,0 +1,13 @@ +--- +'@object-ui/i18n': 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). Hovering it, or focusing it from the keyboard, opens a tooltip that reads "Not available for this record", and the same text is the button's accessible description (`aria-describedby`), so a screen reader announces it with the tooltip closed. Before, the button carried no tooltip, no `title` and no description, so a user could not learn why it was off. + +Where it shows: the `record:quick_actions` bar (`@object-ui/plugin-detail`), and the `DeclaredActionsBar` (`@object-ui/app-shell`) that renders server-declared actions on the approvals surfaces. A natively disabled button receives no pointer or focus events, so the tooltip's trigger is a focusable wrapper around the button, which is the pattern Radix documents for a disabled trigger. A button that is greyed out only while its own action runs is unchanged, and shows no reason. + +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. diff --git a/packages/app-shell/src/views/__tests__/DeclaredActionsBar.disabledReason-11811.test.tsx b/packages/app-shell/src/views/__tests__/DeclaredActionsBar.disabledReason-11811.test.tsx new file mode 100644 index 0000000000..9ce2f659b1 --- /dev/null +++ b/packages/app-shell/src/views/__tests__/DeclaredActionsBar.disabledReason-11811.test.tsx @@ -0,0 +1,160 @@ +// 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(); + 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). */ +const ARCHIVE = { + name: 'showcase_archive_task', + type: 'script', + label: 'Archive', + locations: ['record_section'], + disabled: 'has(record.done) && record.done != true', +}; + +function mount( + record: Record, + actions: Record[] = [ARCHIVE], + wrap: (node: React.ReactElement) => React.ReactElement = (node) => node, +) { + return render( + wrap( + , + ), + ); +} + +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) => ( + + {node} + + )); + 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) => ( + + {node} + + )); + const reason = await screen.findByText(REASON_EN); + expect(describedBy(archive())).toBe(reason); + }); +}); diff --git a/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.disabledReason-11811.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.disabledReason-11811.test.tsx new file mode 100644 index 0000000000..941ea6a6b2 --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.disabledReason-11811.test.tsx @@ -0,0 +1,144 @@ +/** + * 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#11811 — a `record:quick_actions` button greyed out by its declared + * `disabled` predicate says why. + * + * The card's reproduction is the showcase Task page: its section bar is this + * block, and *Archive* declares `disabled: 'has(record.done) && record.done + * != true'`. Before the fix the greyed-out button carried 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 reason now rides a focusable wrapper span — + * the tooltip trigger — and a persistent `sr-only` description both the + * button and the span point at. + * + * Nothing is stubbed: the predicate runs through the real `useCondition`, the + * tooltip is the real Radix one from `@object-ui/components`, and the zh case + * reads the real zh pack through a real `I18nProvider`. + */ + +import * as React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, screen, within, act, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { RecordContextProvider } from '@object-ui/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { RecordQuickActionsRenderer } from '../record-quick-actions'; + +const REASON_EN = 'Not available for this record'; +const REASON_ZH = '对此记录不可用'; + +/** The showcase specimen, as `examples/app-showcase` declares it. */ +const ARCHIVE = { + name: 'showcase_archive_task', + label: 'Archive', + type: 'script', + locations: ['record_section'], + disabled: 'has(record.done) && record.done != true', +}; + +function mount( + action: Record, + record: Record, + wrap: (node: React.ReactElement) => React.ReactElement = (node) => node, +) { + return render( + wrap( + + + , + ), + ); +} + +const archive = () => screen.getByRole('button', { name: 'Archive' }); +/** The element the button's `aria-describedby` names, or `null`. */ +const describedBy = (el: HTMLElement) => { + const id = el.getAttribute('aria-describedby'); + return id ? document.getElementById(id) : null; +}; + +describe('record:quick_actions — a predicate-disabled action says why (objectui#11811)', () => { + it('disabled by its predicate: the button carries the reason as its accessible description', () => { + mount(ARCHIVE, { done: false }); + const button = archive(); + expect(button).toBeDisabled(); + expect(describedBy(button)).toHaveTextContent(REASON_EN); + // The reason is 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(ARCHIVE, { 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(ARCHIVE, { 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(ARCHIVE, { 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: () => void = () => {}; + const pending = new Promise((resolve) => { finish = resolve; }); + // No `disabled` key: the running state is the ONLY reason it greys out. + const SLOW = { name: 'slow', label: 'Slow', type: 'script', locations: ['record_section'], onClick: () => pending }; + mount(SLOW, {}); + const button = screen.getByRole('button', { name: 'Slow' }); + fireEvent.click(button); + expect(screen.getByRole('button', { name: 'Slow' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Slow' })).not.toHaveAttribute('aria-describedby'); + expect(screen.queryByText(REASON_EN)).toBeNull(); + await act(async () => { finish(); await pending; }); + expect(screen.getByRole('button', { name: 'Slow' })).not.toBeDisabled(); + }); + + it('the reason comes from the language pack — zh', async () => { + mount(ARCHIVE, { done: false }, (node) => ( + + {node} + + )); + const reason = await screen.findByText(REASON_ZH); + expect(describedBy(archive())).toBe(reason); + expect(within(archive().parentElement as HTMLElement).queryByText(REASON_EN)).toBeNull(); + }); + + it('the reason comes from the language pack — en', async () => { + mount(ARCHIVE, { done: false }, (node) => ( + + {node} + + )); + const reason = await screen.findByText(REASON_EN); + expect(describedBy(archive())).toBe(reason); + }); +}); From e2a498feb2976ac8308a1a6b0371ed157af13381 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 23:18:42 +0000 Subject: [PATCH 3/4] test(app-shell,plugin-detail): drive the disabled-reason pins with the served CEL envelope The bare predicate string takes the legacy evaluator, where `has()` faults and a fail-soft `disabled` greys the action out on both rows; the pins now use the `{ dialect: 'cel', source }` shape the spec normalizes the authored string to, so the disabled case and its control each reach a real verdict. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../DeclaredActionsBar.disabledReason-11811.test.tsx | 12 ++++++++++-- ...ecord-quick-actions.disabledReason-11811.test.tsx | 11 +++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/app-shell/src/views/__tests__/DeclaredActionsBar.disabledReason-11811.test.tsx b/packages/app-shell/src/views/__tests__/DeclaredActionsBar.disabledReason-11811.test.tsx index 9ce2f659b1..8b22b32c0a 100644 --- a/packages/app-shell/src/views/__tests__/DeclaredActionsBar.disabledReason-11811.test.tsx +++ b/packages/app-shell/src/views/__tests__/DeclaredActionsBar.disabledReason-11811.test.tsx @@ -48,13 +48,21 @@ import { DeclaredActionsBar } from '../DeclaredActionsBar'; const REASON_EN = 'Not available for this record'; const REASON_ZH = '对此记录不可用'; -/** The `disabled`-predicate specimen the card measured (showcase Archive). */ +/** + * 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: 'has(record.done) && record.done != true', + disabled: { dialect: 'cel', source: 'has(record.done) && record.done != true' }, }; function mount( diff --git a/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.disabledReason-11811.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.disabledReason-11811.test.tsx index 941ea6a6b2..0e542187a6 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.disabledReason-11811.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-quick-actions.disabledReason-11811.test.tsx @@ -36,13 +36,20 @@ import { RecordQuickActionsRenderer } from '../record-quick-actions'; const REASON_EN = 'Not available for this record'; const REASON_ZH = '对此记录不可用'; -/** The showcase specimen, as `examples/app-showcase` declares it. */ +/** + * The showcase specimen, 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', label: 'Archive', type: 'script', locations: ['record_section'], - disabled: 'has(record.done) && record.done != true', + disabled: { dialect: 'cel', source: 'has(record.done) && record.done != true' }, }; function mount( From e165c3492feca7f880651fc7dba374e6e47c5f39 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 23:49:05 +0000 Subject: [PATCH 4/4] fix(components): a predicate-disabled page:header action says why it is unavailable The record header's inline action button gets the same reason as the other two surfaces: a tooltip on hover and keyboard focus through a focusable wrapper span, plus a persistent sr-only description. In the overflow menu the reason is a visible second line and the item's description, because the menu skips a disabled item and traps Tab, so a tooltip there is out of keyboard reach. Only an authored action's declared `disabled` earns it; the host's Edit and Delete (a host-computed boolean) and the inline-edit lock are unchanged. The changeset now names @object-ui/components and the three surfaces. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11811-disabled-action-reason.md | 13 +- .../page-header-disabledReason-11811.test.tsx | 175 ++++++++++++++++++ .../src/renderers/layout/containers.tsx | 98 +++++++++- 3 files changed, 278 insertions(+), 8 deletions(-) create mode 100644 packages/components/src/__tests__/page-header-disabledReason-11811.test.tsx diff --git a/.changeset/11811-disabled-action-reason.md b/.changeset/11811-disabled-action-reason.md index e4ffc812c8..63e48b8ffd 100644 --- a/.changeset/11811-disabled-action-reason.md +++ b/.changeset/11811-disabled-action-reason.md @@ -1,12 +1,21 @@ --- '@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). Hovering it, or focusing it from the keyboard, opens a tooltip that reads "Not available for this record", and the same text is the button's accessible description (`aria-describedby`), so a screen reader announces it with the tooltip closed. Before, the button carried no tooltip, no `title` and no description, so a user could not learn why it was off. +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:quick_actions` bar (`@object-ui/plugin-detail`), and the `DeclaredActionsBar` (`@object-ui/app-shell`) that renders server-declared actions on the approvals surfaces. A natively disabled button receives no pointer or focus events, so the tooltip's trigger is a focusable wrapper around the button, which is the pattern Radix documents for a disabled trigger. A button that is greyed out only while its own action runs is unchanged, and shows no reason. +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. diff --git a/packages/components/src/__tests__/page-header-disabledReason-11811.test.tsx b/packages/components/src/__tests__/page-header-disabledReason-11811.test.tsx new file mode 100644 index 0000000000..d596cff108 --- /dev/null +++ b/packages/components/src/__tests__/page-header-disabledReason-11811.test.tsx @@ -0,0 +1,175 @@ +/** + * objectui#11811 — a `page:header` action greyed out by its declared + * `disabled` predicate says why. + * + * The card's reproduction is the showcase Task page, whose header lists + * *Archive* (`disabled: 'has(record.done) && record.done != true'`). Before the + * fix the greyed-out button carried 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 needs. + * + * Inline, the reason rides a focusable wrapper span (the tooltip trigger) and + * a persistent `sr-only` description that the button and the span both point + * at. In the ⋯ overflow it is a visible second line and the item's description, + * because a disabled menu item is skipped by arrow-key focus and the menu traps + * Tab, so a tooltip there would be out of keyboard reach. + * + * Only an AUTHORED action's declared `disabled` earns the reason. The host's + * Edit / Delete (`RecordContext.headerSystemActions`) carry a boolean the host + * computed, and stay as they were; the controls below pin that. + * + * The predicate is the served shape: the spec normalizes the authored CEL + * string to a `{ dialect: 'cel', source }` envelope. Nothing is stubbed — the + * real header, the real Radix tooltip and menu, and the real zh pack. + */ + +import * as React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, screen } 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'; +import { I18nProvider } from '@object-ui/i18n'; +// Registers `page:header` at module scope, NOT inside a `beforeAll` — there the +// cold transform is billed to `hookTimeout` +// (object-ui/no-dynamic-import-in-test-hook, objectui#3010). +import '../renderers'; + +const REASON_EN = 'Not available for this record'; +const REASON_ZH = '对此记录不可用'; +const DONE_GATE = { dialect: 'cel', source: 'has(record.done) && record.done != true' }; + +const ARCHIVE = { + name: 'showcase_archive_task', + label: 'Archive', + type: 'script', + locations: ['record_header'], + disabled: DONE_GATE, +}; +/** A `record_more`-only action: always drawn in the ⋯ overflow. */ +const ARCHIVE_MORE = { ...ARCHIVE, name: 'showcase_archive_more', label: 'Archive later', locations: ['record_more'] }; + +/** The host's chrome, as `RecordDetailView` injects it: a host-computed boolean `disabled`. */ +const HOST_EDIT = { name: 'sys_edit', label: 'Edit', type: 'script', locations: ['record_header'], disabled: true, onClick: () => {} }; +const HOST_DELETE = { + name: 'sys_delete', label: 'Delete', type: 'script', locations: ['record_header'], + component: 'action:menu', disabled: true, onClick: () => {}, +}; + +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, + opts: { host?: Record[]; wrap?: (node: React.ReactElement) => React.ReactElement } = {}, +) { + const node = ( + + + + + + ); + return render(opts.wrap ? opts.wrap(node) : node); +} + +const describedBy = (el: Element) => { + const id = el.getAttribute('aria-describedby'); + return id ? document.getElementById(id) : null; +}; + +describe('page:header — a predicate-disabled authored action says why (objectui#11811)', () => { + it('inline: the disabled button carries the reason as its accessible description', () => { + mount([ARCHIVE], { done: false }); + const button = screen.getByRole('button', { name: 'Archive' }); + expect(button).toBeDisabled(); + expect(describedBy(button)).toHaveTextContent(REASON_EN); + }); + + it('inline: hovering the disabled action opens a tooltip with the reason', async () => { + const user = userEvent.setup(); + mount([ARCHIVE], { done: false }); + const trigger = screen.getByRole('button', { name: 'Archive' }).parentElement as HTMLElement; + expect(trigger).toHaveAttribute('data-disabled-reason'); + await user.hover(trigger); + expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON_EN); + }); + + it('inline: a keyboard user reaches the reason — Tab lands on the trigger, which opens the tooltip', async () => { + const user = userEvent.setup(); + mount([ARCHIVE], { done: false }); + const trigger = screen.getByRole('button', { name: 'Archive' }).parentElement as HTMLElement; + // The header draws other tab stops (the record chip's copy / star); walk + // the real tab order to the trigger rather than assume its position. + for (let i = 0; i < 10 && document.activeElement !== trigger; i += 1) await user.tab(); + expect(trigger).toHaveFocus(); + expect(describedBy(trigger)).toHaveTextContent(REASON_EN); + expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON_EN); + }); + + it('overflow: the disabled item shows the reason and is described by it, its name stays the label', async () => { + const user = userEvent.setup(); + mount([ARCHIVE_MORE], { done: false }); + await user.click(screen.getByRole('button', { name: /More actions/i })); + const item = await screen.findByRole('menuitem', { name: 'Archive later' }); + expect(item).toHaveAttribute('data-disabled'); + expect(describedBy(item)).toHaveTextContent(REASON_EN); + expect(describedBy(item)).toBeVisible(); + }); + + it('control — the predicate does not hold: inline and overflow are live and say nothing', async () => { + const user = userEvent.setup(); + mount([ARCHIVE, ARCHIVE_MORE], { done: true }); + const button = screen.getByRole('button', { name: 'Archive' }); + expect(button).not.toBeDisabled(); + expect(button).not.toHaveAttribute('aria-describedby'); + expect(button.parentElement).not.toHaveAttribute('data-disabled-reason'); + await user.click(screen.getByRole('button', { name: /More actions/i })); + const item = await screen.findByRole('menuitem', { name: 'Archive later' }); + expect(item).not.toHaveAttribute('data-disabled'); + expect(item).not.toHaveAttribute('aria-describedby'); + expect(screen.queryByText(REASON_EN)).toBeNull(); + }); + + it("control — the host's Edit and Delete keep their host-computed `disabled` and give no reason", async () => { + const user = userEvent.setup(); + mount([ARCHIVE], { done: true }, { host: [HOST_EDIT, HOST_DELETE] }); + const edit = screen.getByRole('button', { name: 'Edit' }); + expect(edit).toBeDisabled(); + expect(edit).not.toHaveAttribute('aria-describedby'); + expect(edit.parentElement).not.toHaveAttribute('data-disabled-reason'); + await user.click(screen.getByRole('button', { name: /More actions/i })); + const del = await screen.findByRole('menuitem', { name: 'Delete' }); + expect(del).toHaveAttribute('data-disabled'); + expect(del).not.toHaveAttribute('aria-describedby'); + expect(screen.queryByText(REASON_EN)).toBeNull(); + }); + + it('the reason comes from the language pack — zh', async () => { + mount([ARCHIVE], { done: false }, { + wrap: (node) => ( + + {node} + + ), + }); + const reason = await screen.findByText(REASON_ZH); + const button = screen.getByRole('button', { name: 'Archive' }); + expect(button).toBeDisabled(); + expect(describedBy(button)).toBe(reason); + expect(screen.queryByText(REASON_EN)).toBeNull(); + }); +}); diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index 03038f216d..8a6b07a1dd 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -1510,6 +1510,10 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { // the SAME key `action:menu`'s overflow trigger already reads, so the two // `⋯` buttons a record page can show cannot read differently per locale. const tt = useSafeTranslate(); + // One id base for the disabled-reason descriptions the header's actions + // render (objectui#11811); each action suffixes it, since `renderHeaderActions` + // below draws them in a loop where no hook can be called. + const disabledReasonIdBase = React.useId(); // ── Manual refresh (objectui#3460) ──────────────────────────────────────── // Rendered as page CHROME at the far end of the header row, NOT as a header // action: business/system actions come and go per object and record state, @@ -1766,7 +1770,7 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { [ctx?.data, headerPredicateScope, headerPredicateFields], ); - const headerActionsRaw = React.useMemo(() => { + const headerActionPlan = React.useMemo<{ actions: any[]; authoredKeys: ReadonlySet }>(() => { const recordData: any = ctx?.data; // The record binds three ways inside `evalRowPredicate` — `record.status` // (spec/canonical), bare `status` (the row-action shorthand) and @@ -1877,6 +1881,19 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { if (key) seen.add(key); out.push(a); } + // Which surviving keys are AUTHORED (objectui#11811). Authored entries go + // through the dedupe first, so a host action survives under a key only + // when no authored action holds it — the key alone tells the two apart + // past the localizer, which copies every def. Read by the disabled-reason + // branch below: only an authored action's DECLARED `disabled` is a fact + // about the record; the host's Edit / Delete carry a boolean the host + // computed for reasons of its own (approval lock, `userActions`), which + // the host explains where it computes them. + const authoredKeys = new Set( + authored + .map((a) => (a?.name || a?.id || '') as string) + .filter((key) => key !== ''), + ); // Order the merged list before the inline/overflow split — the same rule // action:bar applies (objectui#2339): // 1. `order` ascending (unset = 0; lower = more prominent) @@ -1887,18 +1904,21 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { const needsOrdering = out.some( (a) => a?.order !== undefined || a?.variant === 'primary', ); - if (!needsOrdering) return out; - return [...out].sort((a, b) => { + if (!needsOrdering) return { actions: out, authoredKeys }; + const ordered = [...out].sort((a, b) => { const byOrder = (a?.order ?? 0) - (b?.order ?? 0); if (byOrder !== 0) return byOrder; const ap = a?.variant === 'primary' ? 0 : 1; const bp = b?.variant === 'primary' ? 0 : 1; return ap - bp; // equal → stable sort preserves registration order }); + return { actions: ordered, authoredKeys }; // `evalHeaderPredicate` closes over `predicateScope` (via // `headerPredicateScope`) and the object's fields, so it replaces the raw // scope in this list rather than adding to it. }, [resolvedHeaderActions, hostSystemActions, ctx?.data, evalHeaderPredicate]); + const headerActionsRaw = headerActionPlan.actions; + const authoredHeaderActionKeys = headerActionPlan.authoredKeys; /** * Localize the surviving actions ONCE, here, so the button text and the @@ -2067,6 +2087,20 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { const isActionDisabled = (action: any): boolean => (inlineEditing && action?.disableDuringInlineEdit === true) || resolveDisabled(action?.disabled, action?.name ?? action?.id); + // The reason a greyed-out action gives (objectui#11811) — or `undefined` + // when it gives none. Only an AUTHORED action's DECLARED `disabled` earns + // it: that verdict is a fact about the record, read off the action spec. + // The inline-edit lock above is the host's own state, and the host's Edit / + // Delete carry a boolean the host computed (see `authoredKeys` in the plan + // memo), so neither says "not available for this record". The reason is + // the generic one; an author-written reason beside the predicate would be a + // spec key, which is objectstack's to declare. + const disabledReasonFor = (action: any): string | undefined => { + const key = (action?.name || action?.id || '') as string; + if (key === '' || !authoredHeaderActionKeys.has(key)) return undefined; + if (!resolveDisabled(action?.disabled, action?.name ?? action?.id)) return undefined; + return tt('actions.notAvailableForRecord', 'Not available for this record'); + }; const renderButton = (action: any, idx: number) => { const label = resolveLabel(action); // `variant: 'primary'` is valid ActionSchema but not a Shadcn Button @@ -2075,12 +2109,16 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { const size = action.size || 'sm'; const disabled = isActionDisabled(action); const icon = typeof action.icon === 'string' ? action.icon : null; - return ( + const actionKey = action.name || action.id || `header-action-${idx}`; + const disabledReason = disabledReasonFor(action); + const reasonId = `${disabledReasonIdBase}-reason-${idx}`; + const 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. 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). Same shape as + // `record:quick_actions`' button and `DeclaredActionsBar`'s. + return ( + + + + + {button} + {disabledReason} + + + {disabledReason} + + + ); }; return (
= ({ schema, className, ...props }) => { const icon = typeof action.icon === 'string' ? action.icon : null; const isDestructive = action.variant === 'destructive' || action.name === 'sys_delete'; + // objectui#11811 — the same reason as the inline button, but + // drawn as a visible second line, not a tooltip. Inside the + // menu a tooltip trigger is unreachable from the keyboard: a + // disabled item is skipped by arrow-key focus (`focusable: + // !disabled` in the menu's roving group), and the menu traps + // Tab. The menu is already an explicit, opened surface, so the + // reason simply shows there, and is the item's description + // while its name stays the label alone. + const disabledReason = disabledReasonFor(action); + const labelId = `${disabledReasonIdBase}-more-label-${idx}`; + const reasonId = `${disabledReasonIdBase}-more-reason-${idx}`; return ( { e.preventDefault(); if (typeof action.onClick === 'function') { @@ -2139,7 +2218,14 @@ const PageHeaderRenderer: React.FC = ({ schema, className, ...props }) => { )} > {icon && } - {label} + {disabledReason ? ( + + {label} + {disabledReason} + + ) : ( + {label} + )} ); })}