Skip to content

fix(vue-disabled-reason): unmount reason layers and add a disabled prop - #320

Merged
dadajam4 merged 6 commits into
mainfrom
feat/fix-disabled-reason
Oct 15, 2026
Merged

dadajam4 merged 6 commits into
mainfrom
feat/fix-disabled-reason

Conversation

@schwarz9791

@schwarz9791 schwarz9791 commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes to defineDisabledReasonComponent in @fastkit/vue-disabled-reason. They apply to vui's VDisabledReason and to any app component built with the factory.

Reason layers were never unmounted

The reason layer (e.g. a tooltip) is rendered into the wrapped element with render(layer, container), and it was never torn down. Every time a disabled-reason component unmounted, the layer's component instances stayed alive. Layers also stayed alive when a container stopped being targeted on update, for example when the container element was replaced or the slot became empty.

The component now tracks which container holds which layer. It unmounts a layer with render(null, container) and removes data-disabled-reason-activated from the container in two cases:

  • the container is no longer targeted;
  • the component unmounts.

On unmount, the teardown is deferred to nextTick. render() flushes the pre/post-flush queues when it is called outside a flush. If it were called from onBeforeUnmount, it would run inside the parent's patch, and the siblings' mounted hooks, post watchers and template refs would run before the remaining siblings are patched.

Each layer has its own host element

Each layer is now rendered into its own <span> with display: contents, placed inside the container, instead of into the container itself. The span has border-radius: inherit so that the overlay's border-radius: inherit still resolves to the container's radius.

This matters when a container's children are text only (e.g. <button :disabled="d">{{ label }}</button>). A text update replaces the container's content, so the layer's nodes are detached too. When a layer's root is a Fragment, as in vui (VTooltip → VMenu → <>{$activator}{$contents}</>), its anchors are detached along with it. render(null, container) then threw in removeFragment. Because the host keeps the layer's nodes together, the layer can always be unmounted.

A container counts as already handled only while its host is inside it. If the host is gone, the stale layer is unmounted and a new one is mounted.

As a result, the layer's elements are no longer direct children of the container. Nothing in this repository or in the known consumer selects them that way.

disabled prop

The disabled state used to be inferred from the DOM only, from the first disableable element's disabled / aria-disabled. A wrapped component that knows its own state had no way to pass it in, so detection could go wrong. For example:

  • A readonly or loading input still carries the disabled attribute.
  • When the first disableable element in a container is an <a> inside a label, that link decides the state.
  • In a group where only some options are disabled, the first option decides the state of the whole group, so the group's overlay can cover options that are still enabled.

The new optional disabled prop takes precedence over DOM detection. When it is omitted, DOM detection works as before.

The prop reports the target's state; it does not disable the target. true is treated exactly like a DOM-detected disabled state, so renderers such as vui's VDisabledReason activate their overlay, and the overlay catches pointer events. Pass true only when the target really is disabled. For a partially disabled group, pass the group's own state so that the group's overlay stays inactive. Per-option reasons need each option to be wrapped.

Behavior change for existing callers

Base props are spread after the component's own props, so a disabled prop defined in the props passed to the factory is replaced by this one. In vui's VDisabledReason, this replaces the disabled inherited from the menu props. That prop previously had no effect, because the wrapper always overrides the tooltip's disabled. Callers that pass it now get the new meaning:

  • false hides the reason even on a disabled target.
  • true activates the tooltip and the overlay on an enabled target.

No such caller exists in this repository. A separate changeset for @fastkit/vui describes this.

Tests

packages/vue-disabled-reason/src/__tests__/disabled-reason.client.spec.ts:

  1. Unmounting via v-if unmounts the layer.
  2. Swapping the DISABLED_REASON_CONTAINER_BIND container unmounts the old layer and removes the activated attribute.
  3. The teardown on unmount does not run a newly mounted sibling's mounted hook before a later sibling is patched.
  4. A layer removed by a text update of a text-only container is rendered again.
  5. Emptying the slot unmounts the layer.
  6. The disabled prop takes precedence over the DOM in both directions.
  7. Without the prop, DOM detection is used.

The test layer's root is a Fragment, the same structure as vui's layer. Each of 1–4 and 6 fails on the code without the corresponding fix. Without the host element, test 4 throws in removeFragment.

I also checked the docs test page by hand, using a temporary block:

  • I toggled a VDisabledReason with v-if. On every cycle, $vstack.controls.length (the number of live tooltips) went back to the same value instead of growing.
  • :disabled="true" around an enabled button showed the tooltip.
  • :disabled="false" around a disabled button did not show it.
  • The existing examples behaved as before.

Release

Changesets:

  • @fastkit/vue-disabled-reason: minor
  • @fastkit/vui: minor, for the change to VDisabledReason's disabled

🤖 Generated with Claude Code

https://claude.ai/code/session_01CbForHXxf1BzugdeQsHpjv

schwarz9791 and others added 2 commits October 15, 2026 17:00
…mounts or a container is no longer targeted

The imperatively rendered reason layers were never torn down, leaking their
component instances. Track the containers that hold a layer and unmount them
on component unmount and whenever a container is no longer targeted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbForHXxf1BzugdeQsHpjv
…over DOM detection

Consumers that know their own state can pass it in instead of relying on
detection from the DOM. Detection remains the fallback when it is not specified.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbForHXxf1BzugdeQsHpjv
@changeset-bot

changeset-bot Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ec70991

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@fastkit/vue-disabled-reason Minor
@fastkit/vui Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@schwarz9791 schwarz9791 self-assigned this Oct 15, 2026

@schwarz9791 schwarz9791 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

コードレビューで見つかった指摘をインラインでまとめました。

  • [要修正] unmount 時の teardown が、親のパッチの途中で post-flush キューを実行してしまう
  • [要確認] vui VDisabledReason で、既存の disabled の意味が変わる
  • [要確認] 有効なコントロールに disabled: true を渡すと、操作できなくなる
  • nit / FYI が 3 件

Comment thread packages/vue-disabled-reason/src/disabled-reason.tsx Outdated
Comment thread packages/vue-disabled-reason/src/disabled-reason.tsx
Comment thread packages/vue-disabled-reason/src/disabled-reason.tsx
Comment thread packages/vue-disabled-reason/src/disabled-reason.tsx
Comment thread packages/vue-disabled-reason/src/disabled-reason.tsx Outdated
schwarz9791 and others added 4 commits October 15, 2026 17:51
…s patch

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbForHXxf1BzugdeQsHpjv
… removed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbForHXxf1BzugdeQsHpjv
… align API types

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbForHXxf1BzugdeQsHpjv
…element

Rendering the layer straight into the target container made it impossible
to unmount once the container's content was replaced (e.g. a text-only
child update), because a Fragment root lost its anchors and teardown threw.
A dedicated display: contents span now holds the layer's nodes, so the
layer can always be unmounted and its presence shows whether it is in place.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CbForHXxf1BzugdeQsHpjv
@schwarz9791
schwarz9791 marked this pull request as ready for review October 15, 2026 09:13
@dadajam4
dadajam4 merged commit 4c2343f into main Oct 15, 2026
1 check passed
@dadajam4
dadajam4 deleted the feat/fix-disabled-reason branch October 15, 2026 09:21
@github-actions github-actions Bot mentioned this pull request Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants