Skip to content

fix(components): a predicate-disabled action:* control says why it is unavailable (objectui#11839) - #11867

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-11839-action-disabled-reason
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-11839-action-disabled-reason

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11839

Clause-②: no

The four action:* renderers in @object-ui/components drew a control greyed out by its declared disabled predicate with no reason. They now give the reason objectui#11811 shipped for the record header, the section bar and DeclaredActionsBar: the generic "Not available for this record", from the same key, actions.notAvailableForRecord. Before, the control carried no tooltip, no title and no aria-describedby.

What changed

  • A button: action:button, action:icon, and an action:group member in inline mode (which also covers the members action:bar draws). The button sits in a focusable wrapper span, which is the tooltip trigger, the same shape as objectui#11811's. The span takes the hover, and tabIndex={0} lets a keyboard user focus it, which opens the tooltip too. The button and the span both point aria-describedby at an sr-only copy of the reason. A natively disabled button fires no pointer or focus events, and the Button primitive adds disabled:pointer-events-none, so a tooltip on the button itself never opens.
  • A menu item: an action:menu item (including action:bar's overflow) and an action:group member in dropdown mode. The reason is a visible second line under the label, and that line is the item's aria-describedby. Its name stays the label, through aria-labelledby. This is objectui#11811's page:header overflow shape.
  • One module-private helper: renderers/action/disabled-reason.tsx, shared by the four renderers. It is not exported from the package entry. Measured on the build of this branch: dist/index.d.ts reaches 101 declaration files through its relative imports and exports, and renderers/action/disabled-reason.d.ts is not one of them. The positive controls visibility-gate.d.ts and resolve-icon.d.ts are reached. The helper's names appear in no other built declaration file. containers.tsx, app-shell and plugin-detail keep their own copies; sharing one across packages would need an export.
  • The changeset: .changeset/11839-action-disabled-reason.md, a patch on @object-ui/components. No export, prop, type member or language-pack key is added.

Which disabled earns the reason

Only the declared disabled predicate, evaluated true. Each renderer computes it beside its existing gate as hasDeclaredVisibilityGate(disabled) && isDisabledPred. Three things still disable a control without a reason:

  • the host's forwarded disabled (hostDisabled, for example a disabled group's members, or a disabled action:menu trigger);
  • an execution in flight (loading), which already shows a spinner;
  • the legacy enabled leg. Measured on the three objectui#11811 surfaces: none of them reads enabled. page:header's resolveDisabled takes action.disabled only, QuickActionButton reads only disabled (its comment says "There is no legacy enabled leg on this surface"), and DeclaredActionButton reads only action.disabled. So there an enabled: false action is not even disabled. Here the four renderers keep enabled as a deprecated, non-spec fallback that still disables. I followed objectui#11811's rule: the reason follows the spec key disabled only, and an enabled: false control stays disabled with no reason. A pin per renderer says so.

action:icon has no visible label. Its name stays its aria-label, and the reason is added as its description. Its own label tooltip cannot open while it is disabled, because the trigger is the disabled button. The wrapper's tooltip therefore shows the label above the reason. An authored aria-describedby on action:button or action:icon (which reaches the DOM through the pass-through) is kept, and the reason's id is appended after it.

Real-browser reading (Chromium, /opt/pw-browsers/chromium)

The harness was a throwaway Vite page in apps/console, never committed. It rendered action:bar (members action:button, action:icon and action:menu), plus action:group inline and dropdown, over a row with done: false, against the gate { dialect: 'cel', source: 'has(record.done) && record.done != true' }.

control before (59ce048) after (0866036; the later commits change no rendered byte for these mounts)
action:button disabled; description none; hit at centre div[role=toolbar]; hover tooltip none; Tab never reaches it disabled; description "Not available for this record"; hit at centre span[data-disabled-reason]; hover tooltip the reason; Tab lands on the span, tooltip the reason
action:icon the same as the button the same as the button; the tooltip reads "Archive icon" above the reason
action:group inline member disabled; description none; hit div; no tooltip; Tab never reaches it the same as action:button after
action:menu item (action:bar overflow) data-disabled; text "Archive later"; description none data-disabled; text "Archive later" with the reason under it; description the reason; name "Archive later"
action:group dropdown member data-disabled; text "Dropdown archive"; description none the same as the menu item after

Why the menu items get no tooltip, also measured in Chromium: the hit target at a disabled item's centre is div[role=menu] (data-[disabled]:pointer-events-none on the item primitive). Four ArrowDown presses never focus the disabled item: the group dropdown's roving focus stays on "View", and the bar overflow whose only other item is disabled stays on the menu. So a tooltip there would be unreachable by pointer and by keyboard. The visible line plus the description is the equivalent. The card's "tooltip on hover and focus" therefore reads as "the reason is visible and announced" on menu items.

The same code is what the showcase's two disabled specimens would exercise, but neither reaches these renderers. Both showcase_archive_task and showcase_zoo_disabled_gate declare locations: ['record_header', 'record_section'], which page:header and record:quick_actions draw (objectui#11811 measured this for the Task page). So the reading above uses the four renderers directly, mounted the way action:bar mounts them.

Tests (head 01d1300)

  • New pins: __tests__/action-disabled-reason-11839.test.tsx, 32 tests, nothing stubbed (the real renderers, Radix tooltip and menu, and the zh pack):
    • for each of action:button, action:icon and action:group inline: the disabled button is described by the reason; hover opens role=tooltip with it; Tab lands on the trigger, which is described by the reason and opens the tooltip. Controls: the predicate does not hold; an action in flight (pending handler, spinner showing); the host's forwarded disabled; the legacy enabled: false;
    • for each of the action:menu item and the action:group dropdown member: the item is data-disabled, named by its label, and described by a visible reason line. Controls: the predicate does not hold; the legacy enabled: false;
    • the host-disabled action:menu trigger gives no reason; the action:icon tooltip carries the label and the reason; an authored aria-describedby is kept beside the reason; the zh pack (对此记录不可用) on action:button and on an action:menu item.
  • Changed pin: action-bar-member-placement-10345.test.tsx. Its openMenuLabels read each item's textContent, and a disabled item's text is now its label plus the reason. It now reads the item's name (the aria-labelledby element when there is one). The "a disabled menu-placed member is a disabled menu item" row also pins the reason as the item's description.
  • vitest run packages/components/ at 01d1300: Test Files 367 passed | 1 skipped (368), Tests 3761 passed | 24 skipped (3785).
  • Ten consumer files that render these renderers or their hosts (app-shell currentUserCan x2, ObjectView create and import predicates, RecordDetailView.userActionPredicates, plugin-detail DetailView x2, console record-block-record-reach, form-control-dom-leak-5632, widget-dom-leak-sweep), run with packages/components/src/renderers/action/ on the tree of c937d9c: Test Files 42 passed (42), Tests 920 passed (920).
  • pnpm --filter @object-ui/components type-check: exit 0 at the 01d1300 tree, on dists of the dependency closure rebuilt from this branch. --listFilesOnly on tsconfig.test.json lists both the new test and the helper.
  • Ablations, run after commit (c937d9c) through objectstack's scripts/ablation-replace.mjs, each with a trap restore. The suite is the new pin file plus the placement file, 53 tests. The tests import the renderers from src, so no build sits between mutation and reading.
    • A1, the hook never gives a reason (if (!disabledByPredicate) return undefined; becomes return undefined;): Tests 16 failed | 37 passed (53). Red: every positive pin (15) and the placement row's description. Green: every control.
    • A2, the trigger returns the bare button (if (!reason) return children; becomes return children;): Tests 12 failed | 41 passed (53). Red: description, hover and Tab on the three button sites, the icon tooltip, the authored description, and zh on action:button. Green: the menu-item pins, zh on the menu item, the placement file, and every control.
    • Each restore was proven: the blob equals HEAD (b493cd3b1d41) and git diff HEAD is empty. 01d1300 changes no byte of the helper.
  • Gates, all exit 0 at 01d1300: check:control-bytes, check:test-path-roots, check:changeset-claims, check:pending-changeset-literals, check:action-forward-parity (it reads the four renderer files), check:i18n-keys (the helper's call site resolves against the en pack), check:new-line-citations (0 new), and check:unreferenced-sources (the helper is reached). The changeset scripts check-changeset-presence (1 released package, 1 changeset), check-changeset-no-major, check-changeset-fixed and check-changeset-overwrite also exit 0.
  • eslint on the seven touched files: 0 errors. Each of the five pre-existing files has the same warning count as on 59ce048. The new helper adds three react-refresh/only-export-components warnings, for its hook and its two attribute helpers (warnings do not gate in this repo).
  • Not run locally: the full app-shell, plugin-detail and console suites. The ten named consumer files cannot see a suite that draws these renderers through a page tree without naming them; CI runs the full farm.

Acceptance notes

  • Showcase reach. Neither showcase disabled specimen reaches the four renderers (see above). The showcase comment that says the Archive specimen exercises "action:button/group/icon/menu" is stale prose in objectstack. These renderers are reached by every action:bar, which draws the list toolbar in ObjectView and InterfaceListPage and the header bar in DetailView, and by authored action:* nodes. Noted only. Carrier: none.
  • Layout of a full-width button. The wrapper is an inline-flex span, as in objectui#11811. An action:button whose author className makes it full width (for example w-full) would shrink to its content while the predicate holds. No such use was found in this repo's sources or in objectstack's examples. Noted only. Carrier: none.
  • The (action as any).disabled casts beside the new uncast reads in action-group.tsx and action-menu.tsx are no longer needed, because UIActionSchema declares disabled. They are left as they were. Polish only.
  • A disabled predicate that faults (fail-soft) also greys the control out and now also shows the generic reason. This is the same note objectui#11811 made for its surfaces.

The session behind this change is https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8.


Generated by Claude Code

claude added 5 commits October 8, 2026 01:35
… 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 <noreply@anthropic.com>
…ed reason (objectui#11839)

Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8
Co-authored-by: Claude <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…jectui#11839)

A menu item greyed out by its declared `disabled` now shows the reason as a
second line and names itself by its label element, so the placement pin
reads each item's name, and pins the reason as the disabled member's
description.

Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8
Co-authored-by: Claude <noreply@anthropic.com>
…s reason (objectui#11839)

`UIActionSchema` declares `disabled`, so the reason's verdict reads it
without the `any` cast the older lines beside it still carry.

Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 336 chunks) 3536.2 KB 3551.8 KB
Main entry chunk (gzip) 160.0 KB 350 KB
Entry file index-BlWhTbGl.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 17.82KB 6.58KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 584.59KB 140.69KB
core (index.js) 10.00KB 3.96KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 235.31KB 65.44KB
fields (index.js) 266.88KB 67.46KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 38.37KB 10.31KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 41.50KB 11.82KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.86KB 5.00KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.52KB 2.26KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 53.39KB 15.52KB
plugin-charts (index.js) 84.71KB 23.25KB
plugin-chatbot (index.js) 199.63KB 47.46KB
plugin-dashboard (index.js) 144.82KB 39.17KB
plugin-designer (index.js) 231.46KB 48.87KB
plugin-detail (index.js) 248.57KB 65.47KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 176.62KB 45.75KB
plugin-gantt (index.js) 179.17KB 45.07KB
plugin-grid (index.js) 239.75KB 65.92KB
plugin-kanban (index.js) 52.17KB 16.37KB
plugin-list (index.js) 117.42KB 29.32KB
plugin-map (index.js) 25.60KB 8.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 39.10KB 11.81KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 91.07KB 22.93KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.25KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 8, 2026 02:55
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 8, 2026 02:55
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit cef0eee Oct 8, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11839-action-disabled-reason branch October 8, 2026 03:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants