Skip to content

feat(Dialog): add unmountOnHide prop#2662

Open
benjamincanac wants to merge 10 commits into
unovue:v2from
benjamincanac:feat/dialog-unmount-on-hide
Open

feat(Dialog): add unmountOnHide prop#2662
benjamincanac wants to merge 10 commits into
unovue:v2from
benjamincanac:feat/dialog-unmount-on-hide

Conversation

@benjamincanac
Copy link
Copy Markdown
Contributor

@benjamincanac benjamincanac commented May 27, 2026

Resolves #1727

Adds unmountOnHide to DialogRoot. When false, dialog content and overlay stay in the DOM when closed (hidden via v-show) instead of being unmounted. Useful for SEO and avoiding remounts.

Based on #2494 (credit to @MickL), with fixes for aria-hidden leaking while closed, the present prop leaking to the DOM, scroll lock staying on when hidden, focus restoration when the content stays mounted (modal + non-modal), and a DismissableLayer fix so body pointer-events restore when disableOutsidePointerEvents toggles off without unmounting.

Summary by CodeRabbit

  • New Features

    • Added unmountOnHide to Dialog (default: true); dialog components can now stay mounted but hidden, with visibility controlling accessibility and scroll-lock.
  • Documentation

    • Dialog docs updated to document the new unmountOnHide prop and default.
  • Tests

    • Added tests covering DOM presence, visibility, focus restoration, and accessibility when unmountOnHide is false.
  • Bug Fixes

    • Fixed a documentation comment typo.

Review Change Stack

When set to false, Dialog content and overlay stay in the DOM when
closed (hidden via v-show) instead of being unmounted. Useful for SEO
and performance by avoiding remounts on every open.

Closes unovue#1727
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 27, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an unmountOnHide prop (default true) to DialogRoot, provides it via context, and updates Presence-driven rendering across DialogOverlay/DialogContent and modal/non-modal impls so dialogs can be unmounted or hidden; tests and docs updated.

Changes

Dialog unmountOnHide Implementation

Layer / File(s) Summary
Root context and prop definition
packages/core/src/Dialog/DialogRoot.vue
DialogRootProps adds unmountOnHide?: boolean; DialogRootContext includes unmountOnHide: Ref<boolean>; default set to true and provided via context.
Overlay visibility and scroll lock management
packages/core/src/Dialog/DialogOverlay.vue, packages/core/src/Dialog/DialogOverlayImpl.vue
DialogOverlay computes present/force-mount via Presence slot using open and unmountOnHide; DialogOverlayImpl accepts optional present, uses it for v-show and drives body scroll-lock with a watcher.
Content Presence slot and rendering
packages/core/src/Dialog/DialogContent.vue
Refactors Presence to a scoped slot exposing present; modal and non-modal content use v-show controlled by unmountOnHide or slot present and receive present prop.
Modal content accessibility and props
packages/core/src/Dialog/DialogContentModal.vue
Adds present: boolean prop; conditions useHideOthers target on present; strips present before forwarding props; sets :disable-outside-pointer-events from present; watches present to restore focus when it becomes false.
Non-modal content present handling
packages/core/src/Dialog/DialogContentNonModal.vue
Adds present prop; forwards props without it; watches present to restore focus on close and reset outside-interaction tracking.
Implementation minor fix
packages/core/src/Dialog/DialogContentImpl.vue
Fixes a misspelling in the forceMount prop documentation comment.
Unmount-on-hide tests and mocks
packages/core/src/Dialog/Dialog.test.ts
Adds fixtures and test suites for unmountOnHide=false (modal and non-modal), tests DOM persistence when closed, focus restoration, absence of body aria-hidden after close, accessibility checks, and updates mock typings.
Documentation and metadata
docs/content/meta/DialogRoot.md
Adds unmountOnHide prop entry with description and default value; included in the llm-visible props table.

Sequence Diagram

sequenceDiagram
  participant Client
  participant DialogRoot
  participant Presence
  participant DialogContentModal
  participant DialogContentNonModal
  participant DialogOverlayImpl
  Client->>DialogRoot: mount with unmountOnHide (true|false)
  DialogRoot->>Presence: provide :present/:force-mount (computed)
  Presence->>DialogContentModal: present slot value
  Presence->>DialogContentNonModal: present slot value
  Presence->>DialogOverlayImpl: present slot value
  DialogContentModal->>DialogContentModal: use present to set aria-hidden target & pointer events
  DialogContentModal->>DialogContentModal: watch present -> restore focus when false
  DialogOverlayImpl->>DialogOverlayImpl: watch present -> toggle body scroll lock
Loading

🎯 3 (Moderate) | ⏱️ ~20 minutes

🐰 I keep the dialogs in sight,

Hidden with CSS when day turns night,
Unmount or show, the root decides,
Accessibility still abides,
A nimble hop to better tides.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(Dialog): add unmountOnHide prop' is concise and clearly describes the main feature addition—a new prop that controls whether dialog content is unmounted or hidden when closed.
Linked Issues check ✅ Passed The PR fully addresses issue #1727 by implementing the requested unmountOnHide prop to keep dialog content in the DOM using v-show instead of v-if for SEO and performance reasons.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the unmountOnHide feature across Dialog components, context, documentation, and tests—no unrelated modifications detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

Review ran into problems

🔥 Problems

Stopped waiting for pipeline failures after 30000ms. One of your pipelines takes longer than our 30000ms fetch window to run, so review may not consider pipeline-failure results for inline comments if any failures occurred after the fetch window. Increase the timeout if you want to wait longer or run a @coderabbit review after the pipeline has finished.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@benjamincanac benjamincanac marked this pull request as draft May 27, 2026 10:44
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 27, 2026

Open in StackBlitz

npm i https://pkg.pr.new/reka-ui@2662

commit: 9291574

@benjamincanac benjamincanac changed the title feat(Dialog): support unmountOnHide feat(Dialog): add unmountOnHide prop May 27, 2026
@benjamincanac benjamincanac marked this pull request as ready for review May 27, 2026 10:49
Also fixes focus restoration when unmountOnHide=false — since FocusScope
never unmounts, close-auto-focus doesn't fire. Added a watcher on present
to manually restore focus to trigger when the dialog hides.
@MickL
Copy link
Copy Markdown

MickL commented May 27, 2026

You dont believe how happy you make me with this PR! Will test soon, hope it gets merged eventually...

…n-hide

# Conflicts:
#	packages/core/src/Dialog/Dialog.test.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/core/src/Dialog/Dialog.test.ts (1)

121-124: ⚡ Quick win

Strengthen the aria-hidden assertion by exercising the open→close cycle.

This test only checks the initial (never-opened) state. The core value of the present-gated useHideOthers is removing aria-hidden from the rest of the page after the dialog has been opened and then closed (while the content stays mounted). Consider opening, then pressing Escape, then asserting body has no aria-hidden to actually cover that path.

💚 Suggested test enhancement
   it('should not apply aria-hidden to body when closed', async () => {
+    await fireEvent.click(trigger.element)
+    await nextTick()
+    await fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
     await nextTick()
     expect(document.body.getAttribute('aria-hidden')).toBeNull()
   })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/Dialog/Dialog.test.ts` around lines 121 - 124, The test
currently only verifies the initial state; update 'should not apply aria-hidden
to body when closed' to exercise the open→close cycle: render/open the Dialog
(use the component's present/open API or simulate the trigger to set
present=true), await nextTick, simulate closing via Escape (dispatch a
KeyboardEvent or use the library's close method), await nextTick again, then
assert document.body.getAttribute('aria-hidden') is null; this will exercise
useHideOthers' present-gated behavior and confirm aria-hidden is removed after
close while content remains mounted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/core/src/Dialog/Dialog.test.ts`:
- Around line 121-124: The test currently only verifies the initial state;
update 'should not apply aria-hidden to body when closed' to exercise the
open→close cycle: render/open the Dialog (use the component's present/open API
or simulate the trigger to set present=true), await nextTick, simulate closing
via Escape (dispatch a KeyboardEvent or use the library's close method), await
nextTick again, then assert document.body.getAttribute('aria-hidden') is null;
this will exercise useHideOthers' present-gated behavior and confirm aria-hidden
is removed after close while content remains mounted.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 67453a23-6bee-4ca2-9e43-b4cc6aff568c

📥 Commits

Reviewing files that changed from the base of the PR and between 4c52c59 and 84622de.

📒 Files selected for processing (2)
  • packages/core/src/Dialog/Dialog.test.ts
  • packages/core/src/Dialog/DialogOverlayImpl.vue

The focus-restoration watcher only lived in DialogContentModal, so a
non-modal dialog with unmountOnHide=false never returned focus to the
trigger on close (close-auto-focus doesn't fire while mounted). Mirror
the watcher in DialogContentNonModal, respecting interact-outside, and
document why the watcher exists in both.
Assert the rest of the page stays accessible after a full open/close
cycle (content remains mounted) rather than just checking initial state.
…hile mounted

When `disableOutsidePointerEvents` toggles true→false without unmounting
(the unmountOnHide=false case), the watchEffect cleanup read the prop
reactively and saw the new false value, skipping the body pointer-events
restore and leaving the page frozen. Capture the value at run time for
cleanup, and read the layer set size via toRaw to avoid self-retriggering.
With unmountOnHide=false the FocusScope stays mounted across open/close,
so the mount auto-focus (keyed off physical mount) never re-fires on
reopen and focus never enters the content. Watch the trapped false->true
transition and re-run the mount auto-focus. The default unmount-on-close
path mounts already trapped, so this transition never happens there and
behavior is unchanged.
When a force-mounted scope (`unmountOnHide: false`) becomes trapped on
reopen, the `v-show` visibility can apply a frame after `trapped` flips,
so the first focus attempt runs while the container is still
`display: none` and no-ops. Retry the focus move on the next frame
without re-dispatching the auto-focus event.
@benjamincanac benjamincanac marked this pull request as draft May 29, 2026 13:33
@benjamincanac benjamincanac marked this pull request as ready for review May 29, 2026 13:53
…opes

A force-mounted scope (Dialog `unmountOnHide: false`) physically mounts
once while hidden via `v-show`, so keying auto-focus off physical mount
fired `mountAutoFocus`/`openAutoFocus` and stole focus into a closed
dialog, and never re-focused non-modal content on open (it isn't
trapped). Gate the mount-time auto-focus on visibility and re-run it on
the hidden -> visible transition via a MutationObserver, covering first
open and reopen for both modal and non-modal. Replaces the trapped
watcher + rAF retry, which only handled the modal case.
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.

[Feature]: Dont remove Dialog from DOM (use v-show instead of v-if)

2 participants