From 086603677df3e225dfca1e576e9f8c05019cff94 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:35:42 +0000 Subject: [PATCH 1/5] fix(components): a predicate-disabled action:* control says why it is unavailable (objectui#11839) The four action renderers (action:button, action:icon, action:group, action:menu) drew a control greyed out by its declared `disabled` predicate with no reason. They now give the reason objectui#11811 gave the record header, the section bar and DeclaredActionsBar, with the same key (`actions.notAvailableForRecord`): - a button (action:button, action:icon, action:group inline) gets a focusable wrapper span as the tooltip trigger and an sr-only description that the button and the span both point at; - a menu item (action:menu, action:group dropdown) shows the reason as a visible second line that is its description, its name staying the label. Only the declared `disabled` predicate, evaluated true, earns the reason. The host's forwarded `disabled`, an action in flight and the legacy `enabled` leg disable the control without one. One module-private helper, `renderers/action/disabled-reason.tsx`, serves the four renderers; it is not exported from the package entry. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../action-disabled-reason-11839.test.tsx | 355 ++++++++++++++++++ .../src/renderers/action/action-button.tsx | 19 +- .../src/renderers/action/action-group.tsx | 25 +- .../src/renderers/action/action-icon.tsx | 25 +- .../src/renderers/action/action-menu.tsx | 11 +- .../src/renderers/action/disabled-reason.tsx | 159 ++++++++ 6 files changed, 588 insertions(+), 6 deletions(-) create mode 100644 packages/components/src/renderers/action/__tests__/action-disabled-reason-11839.test.tsx create mode 100644 packages/components/src/renderers/action/disabled-reason.tsx diff --git a/packages/components/src/renderers/action/__tests__/action-disabled-reason-11839.test.tsx b/packages/components/src/renderers/action/__tests__/action-disabled-reason-11839.test.tsx new file mode 100644 index 0000000000..17c34cdfc5 --- /dev/null +++ b/packages/components/src/renderers/action/__tests__/action-disabled-reason-11839.test.tsx @@ -0,0 +1,355 @@ +/** + * 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#11839 — an `action:*` control greyed out by its declared `disabled` + * predicate says why, the way objectui#11811 made `page:header`, + * `record:quick_actions` and `DeclaredActionsBar` say it. + * + * Before the fix all four renderers drew the control disabled with no tooltip, + * no `title` and no `aria-describedby`. A natively disabled button (with the + * Button primitive's `disabled:pointer-events-none`) never fires the hover or + * focus a tooltip needs, and a disabled menu item takes no pointer events and is + * skipped by the menu's roving focus. + * + * - A button (`action:button`, `action:icon`, `action:group` inline) gets a + * focusable wrapper span as the tooltip trigger, and a persistent `sr-only` + * description the button and the span both point at. + * - A menu item (`action:menu`, `action:group` dropdown) shows the reason as a + * visible second line that is its description; its name stays the label. + * + * Only the DECLARED `disabled` predicate, evaluated true, earns the reason. The + * controls pin the rest: a predicate that does not hold, an action in flight, + * the host's forwarded `disabled`, and the legacy non-spec `enabled` leg each + * leave the control as it was. + * + * The predicate is the served shape, the `{ dialect: 'cel', source }` envelope + * the spec normalizes an authored CEL string to: the bare string goes to the + * legacy evaluator, where `has()` faults. Nothing is stubbed: the real + * renderers, the real Radix tooltip and menu, and the real zh pack. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import type { ActionResult } from '@object-ui/core'; +import { ActionProvider } from '@object-ui/react'; +import { I18nProvider } from '@object-ui/i18n'; +// Module-scope side-effect imports so the four renderers are registered before +// the first render — the light `dom` project does not load the +// `@object-ui/components` graph. Module scope, not a `beforeAll` +// (object-ui/no-dynamic-import-in-test-hook, objectui#3010). +import '../action-button'; +import '../action-icon'; +import '../action-group'; +import '../action-menu'; + +const REASON_EN = 'Not available for this record'; +const REASON_ZH = '对此记录不可用'; +const DONE_GATE = { dialect: 'cel', source: 'has(record.done) && record.done != true' }; +/** The predicate holds: the task is not done, so Archive is unavailable. */ +const OPEN_TASK = { id: 't-1', done: false }; +/** The predicate does not hold. */ +const DONE_TASK = { id: 't-1', done: true }; + +const ARCHIVE = { name: 'archive', label: 'Archive', icon: 'archive', type: 'script', disabled: DONE_GATE }; +const VIEW = { name: 'view', label: 'View', type: 'script' }; +/** No `disabled`: the legacy non-spec `enabled` leg alone disables it. */ +const LEGACY_DISABLED = { name: 'archive', label: 'Archive', icon: 'archive', type: 'script', enabled: false }; +/** No gate at all: the control a click puts in flight. */ +const UNGATED = { name: 'archive', label: 'Archive', icon: 'archive', type: 'script' }; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function getRenderer(type: string) { + const R = ComponentRegistry.get(type); + if (!R) throw new Error(`${type} is not registered`); + return R; +} + +/** A `script` handler that stays in flight, so a click leaves its control loading. */ +const pendingScript = () => vi.fn(() => new Promise(() => {})); + +function mount( + node: React.ReactElement, + opts: { script?: ReturnType; zh?: boolean } = {}, +) { + const tree = {node}; + return render( + opts.zh ? ( + + {tree} + + ) : ( + tree + ), + ); +} + +/** The text of every element the control's `aria-describedby` names. */ +const description = (el: Element) => + (el.getAttribute('aria-describedby') ?? '') + .split(/\s+/) + .filter(Boolean) + .map((id) => document.getElementById(id)?.textContent ?? `(missing #${id})`) + .join(' | '); + +// --------------------------------------------------------------------------- +// The three BUTTON renderings +// --------------------------------------------------------------------------- + +interface ButtonSite { + id: string; + /** Mounts `action` over `record`; `hostDisabled` is the host's forwarded verdict. */ + node: (action: Record, record: Record, hostDisabled?: boolean) => React.ReactElement; + /** The accessible name of the drawn control. */ + name: string; +} + +const BUTTON_SITES: ButtonSite[] = [ + { + id: 'action:button', + name: 'Archive', + node: (action, record, hostDisabled) => { + const R = getRenderer('action:button'); + return ; + }, + }, + { + // An icon has no visible label: its name is its `aria-label`, which stays. + id: 'action:icon', + name: 'Archive', + node: (action, record, hostDisabled) => { + const R = getRenderer('action:icon'); + return ; + }, + }, + { + // The group's host verdict reaches every member it draws (objectui#11182). + id: 'action:group inline member', + name: 'Archive', + node: (action, record, hostDisabled) => { + const R = getRenderer('action:group'); + return ( + + ); + }, + }, +]; + +const control = (name: string) => screen.getByRole('button', { name }) as HTMLButtonElement; + +/** The control drew no reason: no description, no wrapper, no reason text. */ +function expectNoReason(button: HTMLElement) { + expect(button).not.toHaveAttribute('aria-describedby'); + expect(button.parentElement).not.toHaveAttribute('data-disabled-reason'); + expect(screen.queryByText(REASON_EN)).toBeNull(); +} + +describe.each(BUTTON_SITES)('$id — a predicate-disabled button says why (objectui#11839)', (site) => { + it('the disabled button carries the reason as its accessible description', () => { + mount(site.node(ARCHIVE, OPEN_TASK)); + const button = control(site.name); + expect(button).toBeDisabled(); + expect(description(button)).toBe(REASON_EN); + }); + + it('hovering it opens a tooltip with the reason', async () => { + const user = userEvent.setup(); + mount(site.node(ARCHIVE, OPEN_TASK)); + const trigger = control(site.name).parentElement as HTMLElement; + expect(trigger).toHaveAttribute('data-disabled-reason'); + await user.hover(trigger); + expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON_EN); + }); + + it('a keyboard user reaches it: Tab lands on the trigger, which is described by the reason and opens the tooltip', async () => { + const user = userEvent.setup(); + mount(site.node(ARCHIVE, OPEN_TASK)); + const trigger = control(site.name).parentElement as HTMLElement; + for (let i = 0; i < 6 && document.activeElement !== trigger; i += 1) await user.tab(); + expect(trigger).toHaveFocus(); + expect(description(trigger)).toBe(REASON_EN); + expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON_EN); + }); + + it('control — the predicate does not hold: the button is live and says nothing', () => { + mount(site.node(ARCHIVE, DONE_TASK)); + const button = control(site.name); + expect(button).not.toBeDisabled(); + expectNoReason(button); + }); + + it('control — an action in flight: the loading button gives no reason', async () => { + const user = userEvent.setup(); + const script = pendingScript(); + mount(site.node(UNGATED, OPEN_TASK), { script }); + await user.click(control(site.name)); + await waitFor(() => expect(script).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(control(site.name)).toBeDisabled()); + expect(control(site.name).querySelector('.animate-spin')).not.toBeNull(); + expectNoReason(control(site.name)); + }); + + it("control — the host's forwarded `disabled`: disabled, with no reason", () => { + mount(site.node(UNGATED, OPEN_TASK, true)); + const button = control(site.name); + expect(button).toBeDisabled(); + expectNoReason(button); + }); + + it('control — the legacy `enabled: false` leg: disabled, with no reason', () => { + mount(site.node(LEGACY_DISABLED, OPEN_TASK)); + const button = control(site.name); + expect(button).toBeDisabled(); + expectNoReason(button); + }); +}); + +describe('action:icon — the reason is the description, the label stays the name (objectui#11839)', () => { + it('the tooltip names the action above the reason, since the icon has no visible label', async () => { + const user = userEvent.setup(); + mount(BUTTON_SITES[1].node(ARCHIVE, OPEN_TASK)); + const button = control('Archive'); + expect(button).toHaveAttribute('aria-label', 'Archive'); + await user.hover(button.parentElement as HTMLElement); + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Archive'); + expect(tooltip).toHaveTextContent(REASON_EN); + }); +}); + +describe('action:button — an authored description is kept beside the reason (objectui#11839)', () => { + it('`aria-describedby` from the DOM pass-through and the reason are both on the button', () => { + const R = getRenderer('action:button'); + mount( + <> +

Archiving hides the task

+ + , + ); + expect(description(control('Archive'))).toBe(`Archiving hides the task | ${REASON_EN}`); + }); +}); + +// --------------------------------------------------------------------------- +// The two MENU-ITEM renderings +// --------------------------------------------------------------------------- + +interface MenuSite { + id: string; + node: (action: Record, record: Record) => React.ReactElement; + /** The accessible name of the menu's trigger. */ + trigger: RegExp; +} + +const MENU_SITES: MenuSite[] = [ + { + id: 'action:menu item', + trigger: /More/, + node: (action, record) => { + const R = getRenderer('action:menu'); + return ; + }, + }, + { + id: 'action:group dropdown member', + trigger: /Group menu/, + node: (action, record) => { + const R = getRenderer('action:group'); + return ( + + ); + }, + }, +]; + +async function openItem(user: ReturnType, site: MenuSite) { + await user.click(screen.getByRole('button', { name: site.trigger })); + return screen.findByRole('menuitem', { name: 'Archive' }); +} + +describe.each(MENU_SITES)('$id — a predicate-disabled menu item says why (objectui#11839)', (site) => { + it('the disabled item shows the reason and is described by it; its name stays the label', async () => { + const user = userEvent.setup(); + mount(site.node(ARCHIVE, OPEN_TASK)); + const item = await openItem(user, site); + expect(item).toHaveAttribute('data-disabled'); + expect(item).toHaveAccessibleName('Archive'); + expect(description(item)).toBe(REASON_EN); + expect(document.getElementById(item.getAttribute('aria-describedby') as string)).toBeVisible(); + }); + + it('control — the predicate does not hold: the item is live and says nothing', async () => { + const user = userEvent.setup(); + mount(site.node(ARCHIVE, DONE_TASK)); + const item = await openItem(user, site); + expect(item).not.toHaveAttribute('data-disabled'); + expect(item).not.toHaveAttribute('aria-describedby'); + expect(screen.queryByText(REASON_EN)).toBeNull(); + }); + + it('control — the legacy `enabled: false` leg: disabled, with no reason', async () => { + const user = userEvent.setup(); + mount(site.node(LEGACY_DISABLED, OPEN_TASK)); + const item = await openItem(user, site); + expect(item).toHaveAttribute('data-disabled'); + expect(item).not.toHaveAttribute('aria-describedby'); + expect(screen.queryByText(REASON_EN)).toBeNull(); + }); +}); + +describe("action:menu — the host's forwarded `disabled` on the trigger gives no reason (objectui#11839)", () => { + it('the host-disabled trigger is disabled and undescribed', () => { + const R = getRenderer('action:menu'); + mount(); + const trigger = screen.getByRole('button', { name: 'More' }); + expect(trigger).toBeDisabled(); + expectNoReason(trigger); + }); +}); + +// --------------------------------------------------------------------------- +// The text comes from the language pack +// --------------------------------------------------------------------------- + +describe('the reason comes from the language pack — zh (objectui#11839)', () => { + it('action:button', async () => { + mount(BUTTON_SITES[0].node(ARCHIVE, OPEN_TASK), { zh: true }); + const reason = await screen.findByText(REASON_ZH); + const button = control('Archive'); + expect(button).toBeDisabled(); + expect(document.getElementById(button.getAttribute('aria-describedby') as string)).toBe(reason); + expect(screen.queryByText(REASON_EN)).toBeNull(); + }); + + it('action:menu item', async () => { + const user = userEvent.setup(); + mount(MENU_SITES[0].node(ARCHIVE, OPEN_TASK), { zh: true }); + const item = await openItem(user, MENU_SITES[0]); + await waitFor(() => expect(description(item)).toBe(REASON_ZH)); + expect(screen.queryByText(REASON_EN)).toBeNull(); + }); +}); diff --git a/packages/components/src/renderers/action/action-button.tsx b/packages/components/src/renderers/action/action-button.tsx index 482291ebd9..caacf5ba3b 100644 --- a/packages/components/src/renderers/action/action-button.tsx +++ b/packages/components/src/renderers/action/action-button.tsx @@ -31,6 +31,7 @@ import { resolveIcon } from './resolve-icon'; import { hasDeclaredVisibilityGate } from './visibility-gate'; import { useAutoTriggerOnce } from './auto-trigger'; import { readStaticParamValues } from './static-params'; +import { DisabledReasonTrigger, describedByWithReason, useDisabledReason } from './disabled-reason'; /** * The declared props. `schema` is `UIActionSchema` (objectui#4418): every key @@ -133,6 +134,13 @@ const ActionButtonRenderer = forwardRef< // business here instead of `any`'s. const isDisabled = useCondition(toPredicateInput(schema.disabled), recordData); const isEnabled = useCondition(toPredicateInput(schema.enabled), recordData); + // The reason a greyed-out button gives (objectui#11839): only the DECLARED + // `disabled` predicate, evaluated true, earns it — the verdict that is a + // fact about the record. `hostDisabled`, `loading` and the legacy `enabled` + // leg disable the button without one. See `./disabled-reason`. + const disabledReason = useDisabledReason( + hasDeclaredVisibilityGate(schema.disabled) && isDisabled, + ); // Resolve icon const Icon = resolveIcon(schema.icon); @@ -345,7 +353,8 @@ const ActionButtonRenderer = forwardRef< // this gate is the only one on that path. See `hasDeclaredVisibilityGate`. if (hasDeclaredVisibilityGate(schema.visible) && !isVisible) return null; - return ( + const domProps = toFormControlDomProps(rest); + const button = ( ); + // A predicate-disabled button is wrapped in the trigger that carries its + // reason (objectui#11839); any other button is returned as it was. + return {button}; }, ); diff --git a/packages/components/src/renderers/action/action-group.tsx b/packages/components/src/renderers/action/action-group.tsx index 6aa3c366a5..bd2ed1919b 100644 --- a/packages/components/src/renderers/action/action-group.tsx +++ b/packages/components/src/renderers/action/action-group.tsx @@ -36,6 +36,12 @@ import { Loader2, ChevronDown } from 'lucide-react'; import { resolveIcon } from './resolve-icon'; import { hasDeclaredVisibilityGate } from './visibility-gate'; import { readActionEntryParamValues } from './static-params'; +import { + DisabledReasonMenuLabel, + DisabledReasonTrigger, + menuItemReasonAria, + useDisabledReason, +} from './disabled-reason'; // No group-level `name` (objectui#11168): nothing renders, forwards or keys on // it, and `@objectstack/spec` refuses it on this block, so the registration @@ -118,6 +124,12 @@ const InlineActionButton: React.FC<{ // is now the primary control; `enabled` stays as a deprecated fallback. const isDisabledPred = useCondition(toPredicateInput((action as any).disabled), recordData); const isEnabled = useCondition(toPredicateInput(action.enabled), recordData); + // The reason a greyed-out member gives (objectui#11839): only its DECLARED + // `disabled` predicate, evaluated true. The group's `hostDisabled` and this + // member's `loading` disable it without one. See `./disabled-reason`. + const disabledReason = useDisabledReason( + hasDeclaredVisibilityGate((action as any).disabled) && isDisabledPred, + ); const Icon = resolveIcon(action.icon); const btnVariant = (action.variant as string) === 'primary' ? 'default' : (action.variant || variant || 'outline'); @@ -137,7 +149,7 @@ const InlineActionButton: React.FC<{ // and render it (objectui#3812) — see `hasDeclaredVisibilityGate`. if (hasDeclaredVisibilityGate(action.visible) && !isVisible) return null; - return ( + const button = ( ); + return {button}; }; InlineActionButton.displayName = 'InlineActionButton'; @@ -207,6 +221,12 @@ export const DropdownActionItem: React.FC<{ // InlineActionButton above — objectstack-ai/objectstack#1885 follow-through). const isDisabledPred = useCondition(toPredicateInput((action as any).disabled), recordData); const isEnabled = useCondition(toPredicateInput(action.enabled), recordData); + // The same reason as `InlineActionButton`'s, on the same verdict + // (objectui#11839), drawn the menu-item way: a visible second line that is the + // item's description. See `./disabled-reason`. + const disabledReason = useDisabledReason( + hasDeclaredVisibilityGate((action as any).disabled) && isDisabledPred, + ); // Same declared-gate rule as `InlineActionButton` above — one action cannot be // hidden in one display mode and shown in the other (objectui#3812). if (hasDeclaredVisibilityGate(action.visible) && !isVisible) return null; @@ -227,6 +247,7 @@ export const DropdownActionItem: React.FC<{ {showSeparator && } { e.preventDefault(); if (isDisabled) return; @@ -240,7 +261,7 @@ export const DropdownActionItem: React.FC<{ {/* Dynamic icon resolution from Lucide, not component creation during render */} {/* eslint-disable-next-line react-hooks/static-components */} {Icon && } - {action.label || action.name} + {action.label || action.name} ); diff --git a/packages/components/src/renderers/action/action-icon.tsx b/packages/components/src/renderers/action/action-icon.tsx index 9eece83704..bed6c550b2 100644 --- a/packages/components/src/renderers/action/action-icon.tsx +++ b/packages/components/src/renderers/action/action-icon.tsx @@ -27,6 +27,7 @@ import { resolveIcon } from './resolve-icon'; import { hasDeclaredVisibilityGate } from './visibility-gate'; import { useAutoTriggerOnce } from './auto-trigger'; import { readStaticParamValues } from './static-params'; +import { DisabledReasonTrigger, describedByWithReason, useDisabledReason } from './disabled-reason'; /** * The declared props. `schema` is `UIActionSchema` (objectui#4418) for the same @@ -114,6 +115,12 @@ const ActionIconRenderer = forwardRef< // since objectui#8648 — see `action-button.tsx` for the reading. const isDisabledPred = useCondition(toPredicateInput(schema.disabled), recordData); const isEnabled = useCondition(toPredicateInput(schema.enabled), recordData); + // The reason a greyed-out icon gives (objectui#11839) — the same rule as + // `action:button`: only the DECLARED `disabled` predicate, evaluated true, + // earns it. See `./disabled-reason`. + const disabledReason = useDisabledReason( + hasDeclaredVisibilityGate(schema.disabled) && isDisabledPred, + ); const Icon = resolveIcon(schema.icon); const variant = schema.variant === 'primary' ? 'default' : (schema.variant || 'ghost'); @@ -244,6 +251,7 @@ const ActionIconRenderer = forwardRef< // "no gate". See `hasDeclaredVisibilityGate`. if (hasDeclaredVisibilityGate(schema.visible) && !isVisible) return null; + const domProps = toFormControlDomProps(rest); const button = ( ); + // A predicate-disabled icon (objectui#11839): its label tooltip below can + // never open, since the trigger is the disabled button itself, so the + // wrapper's tooltip carries the label above the reason. + if (disabledReason) { + return ( + + {button} + + ); + } + // Wrap with tooltip if label is provided if (schema.label || schema.description) { return ( diff --git a/packages/components/src/renderers/action/action-menu.tsx b/packages/components/src/renderers/action/action-menu.tsx index 4e09b74707..794c304392 100644 --- a/packages/components/src/renderers/action/action-menu.tsx +++ b/packages/components/src/renderers/action/action-menu.tsx @@ -34,6 +34,7 @@ import { resolveIcon } from './resolve-icon'; import { hasDeclaredVisibilityGate } from './visibility-gate'; import { useAutoTriggerOnce } from './auto-trigger'; import { readActionEntryParamValues } from './static-params'; +import { DisabledReasonMenuLabel, menuItemReasonAria, useDisabledReason } from './disabled-reason'; function useMoreActionsLabel(): string { // useObjectTranslation is provider-safe (never throws); no try/catch, which @@ -107,6 +108,13 @@ export const ActionMenuItem: React.FC<{ // was wired; this renderer ignored a spec-authored `disabled`). const isDisabledPred = useCondition(toPredicateInput((action as any).disabled), recordData); const isEnabled = useCondition(toPredicateInput(action.enabled), recordData); + // The reason a greyed-out item gives (objectui#11839): only its DECLARED + // `disabled` predicate, evaluated true — the rule `action:button` follows — + // drawn the menu-item way: a visible second line that is the item's + // description. See `./disabled-reason`. + const disabledReason = useDisabledReason( + hasDeclaredVisibilityGate((action as any).disabled) && isDisabledPred, + ); const iconElement = useMemo(() => { const Icon = resolveIcon(action.icon); @@ -134,6 +142,7 @@ export const ActionMenuItem: React.FC<{ : hasDeclaredVisibilityGate(action.enabled) ? !isEnabled : false} + {...menuItemReasonAria(disabledReason)} onSelect={(e) => { e.preventDefault(); onExecute(action); @@ -144,7 +153,7 @@ export const ActionMenuItem: React.FC<{ )} > {iconElement} - {action.label || action.name} + {action.label || action.name} ); }; diff --git a/packages/components/src/renderers/action/disabled-reason.tsx b/packages/components/src/renderers/action/disabled-reason.tsx new file mode 100644 index 0000000000..b493cd3b1d --- /dev/null +++ b/packages/components/src/renderers/action/disabled-reason.tsx @@ -0,0 +1,159 @@ +/** + * 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. + */ + +/** + * The reason an action greyed out by its DECLARED `disabled` predicate gives + * (objectui#11839) — one helper for the four `action:*` renderers beside it + * (`action:button`, `action:icon`, `action:group`, `action:menu`). + * + * The same shape objectui#11811 gave the record header (`page:header`), the + * section bar (`record:quick_actions`) and `DeclaredActionsBar`, with the same + * key, `actions.notAvailableForRecord`: + * + * - **A button** gets a focusable wrapper `span` as the tooltip trigger. 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 hit target at + * its centre is its parent. The span 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: the button and the span both + * point `aria-describedby` at an `sr-only` copy, so a screen reader hears it + * with the tooltip closed. + * - **A menu item** shows the reason as a visible second line under its label, + * and that line is the item's description while its name stays the label + * (`aria-labelledby`). A tooltip there cannot open: the menu item primitive + * adds `data-[disabled]:pointer-events-none`, so the hover lands on the menu, + * and the menu's roving focus skips a disabled item, so the keyboard never + * reaches it. + * + * Which `disabled` earns the reason is each caller's to say, and the answer is + * the same at all four: only the DECLARED `disabled` predicate, evaluated true. + * That verdict is a fact about the record. The host's forwarded `disabled` and + * an execution in flight are the host's own state, and the legacy non-spec + * `enabled` leg is not the spec key the objectui#11811 surfaces read, so none of + * them says "not available for this record". + * + * Module-private on purpose: nothing here is re-exported from the package + * entry, so the public surface is unchanged. `containers.tsx`, `app-shell` and + * `plugin-detail` keep their own copies of the wrapper; sharing one across + * packages would need an export. + */ + +import React from 'react'; +import { useSafeTranslate } from '@object-ui/i18n'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui'; + +/** A reason to show, with the ids the control's ARIA attributes point at. */ +export interface DisabledReason { + /** The localized reason text. */ + text: string; + /** Id of the element holding the reason — the control's description. */ + id: string; + /** Id of a menu item's label element — the item's name when it shows a reason. */ + labelId: string; +} + +/** + * The reason for a control, or `undefined` when it gives none. + * + * `disabledByPredicate` is the caller's DECLARED-`disabled` verdict alone, never + * the OR it hands the control's `disabled` prop. A hook, so call it with the + * other hooks, ahead of any early return. + */ +export function useDisabledReason(disabledByPredicate: boolean): DisabledReason | undefined { + const tt = useSafeTranslate(); + const id = React.useId(); + if (!disabledByPredicate) return undefined; + return { + text: tt('actions.notAvailableForRecord', 'Not available for this record'), + id: `${id}-reason`, + labelId: `${id}-label`, + }; +} + +/** + * The control's `aria-describedby`: an id it already carries (an authored + * `ariaDescribedBy` reaches `action:button` and `action:icon` through their DOM + * pass-through) followed by the reason's, or `undefined` when there is neither. + */ +export function describedByWithReason( + existing: unknown, + reason: DisabledReason | undefined, +): string | undefined { + const ids = [typeof existing === 'string' ? existing : '', reason?.id ?? ''] + .map((id) => id.trim()) + .filter((id) => id !== ''); + return ids.length > 0 ? ids.join(' ') : undefined; +} + +/** + * Wraps a disabled button in the tooltip trigger that carries its reason, or + * returns the button alone when there is no reason. `heading` is shown above + * the reason inside the tooltip; `action:icon` passes its label there, since an + * icon-only control has no visible name and its own label tooltip cannot open + * while it is disabled. + */ +export function DisabledReasonTrigger({ + reason, + heading, + children, +}: { + reason: DisabledReason | undefined; + heading?: React.ReactNode; + children: React.ReactElement; +}): React.ReactElement { + if (!reason) return children; + return ( + + + + + {children} + {reason.text} + + + + {heading ?

{heading}

: null} +

{reason.text}

+
+
+
+ ); +} + +/** The ARIA a menu item carries while it shows a reason; none otherwise. */ +export function menuItemReasonAria( + reason: DisabledReason | undefined, +): { 'aria-labelledby'?: string; 'aria-describedby'?: string } { + return reason ? { 'aria-labelledby': reason.labelId, 'aria-describedby': reason.id } : {}; +} + +/** + * A menu item's label, with the reason as a visible second line under it when + * there is one. Pair it with `menuItemReasonAria` on the item. + */ +export function DisabledReasonMenuLabel({ + reason, + children, +}: { + reason: DisabledReason | undefined; + children: React.ReactNode; +}): React.ReactElement { + if (!reason) return {children}; + return ( + + {children} + {reason.text} + + ); +} From 9538569bb95aadc1af1b2d38ca225fab44e4a783 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:36:14 +0000 Subject: [PATCH 2/5] chore(changeset): patch @object-ui/components for the action:* disabled reason (objectui#11839) Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11839-action-disabled-reason.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/11839-action-disabled-reason.md diff --git a/.changeset/11839-action-disabled-reason.md b/.changeset/11839-action-disabled-reason.md new file mode 100644 index 0000000000..ae9371e4d0 --- /dev/null +++ b/.changeset/11839-action-disabled-reason.md @@ -0,0 +1,14 @@ +--- +'@object-ui/components': patch +--- + +An action drawn by `action:button`, `action:icon`, `action:group` or `action:menu` and greyed out by its declared `disabled` predicate now says why (objectui#11839). It shows the reason "Not available for this record", the same generic sentence and the same language-pack key (`actions.notAvailableForRecord`) objectui#11811 gave the record header, the section bar and `DeclaredActionsBar`. The same text is the control's accessible description (`aria-describedby`), so a screen reader announces it too. Before, the control carried no tooltip, no `title` and no description. + +Where it shows: + +- **A button** (`action:button`, `action:icon`, and an `action:group` member in inline mode, including the members `action:bar` draws). Hovering it or focusing it from the keyboard opens a tooltip with the reason. A natively disabled button receives no pointer or focus events, so the tooltip's trigger is a focusable wrapper around it. On `action:icon`, which has no visible label, the tooltip shows the label above the reason, and the icon's accessible name stays its label. +- **A menu item** (an `action:menu` item, including `action:bar`'s overflow menu, and an `action:group` member in dropdown mode). The reason is a second line under the label. A tooltip there could not open: a disabled menu item takes no pointer events, and the menu's keyboard navigation skips it. + +What stays unchanged: a control greyed out while its own action runs, a control the host disables (the `disabled` it forwards, such as a disabled group's members), and an action disabled only through the legacy `enabled` key show no reason. + +**Clause-②: no.** No export, prop, type member or language-pack key is added, removed or changed. From 5d5ebf7055eb3216bf1ed8116a8104281a30d439 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 01:39:52 +0000 Subject: [PATCH 3/5] fix(components): read an authored aria-describedby off the renderer's own props (objectui#11839) The pass-through's return type cannot be indexed by an `aria-*` key, so the two button renderers read the authored description off `rest` instead. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- packages/components/src/renderers/action/action-button.tsx | 5 ++--- packages/components/src/renderers/action/action-icon.tsx | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/components/src/renderers/action/action-button.tsx b/packages/components/src/renderers/action/action-button.tsx index caacf5ba3b..174cc44bb6 100644 --- a/packages/components/src/renderers/action/action-button.tsx +++ b/packages/components/src/renderers/action/action-button.tsx @@ -353,7 +353,6 @@ const ActionButtonRenderer = forwardRef< // this gate is the only one on that path. See `hasDeclaredVisibilityGate`. if (hasDeclaredVisibilityGate(schema.visible) && !isVisible) return null; - const domProps = toFormControlDomProps(rest); const button = (