Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/content/meta/DialogRoot.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@
'description': '<p>The controlled open state of the dialog. Can be binded as <code>v-model:open</code>.</p>\n',
'type': 'boolean',
'required': false
},
{
'name': 'unmountOnHide',
'description': '<p>When set to <code>false</code>, the dialog content will not be unmounted when closed, but instead hidden with CSS. Useful for SEO or when you want to improve performance by not remounting the component on every open.</p>\n',
'type': 'boolean',
'required': false,
'default': 'true'
}
]" />

Expand Down Expand Up @@ -55,6 +62,7 @@
| `defaultOpen` | The open state of the dialog when it is initially rendered. Use when you do not need to control its open state. | `boolean` | No | `false` |
| `modal` | The modality of the dialog When set to true, <br> interaction with outside elements will be disabled and only dialog content will be visible to screen readers. | `boolean` | No | `true` |
| `open` | The controlled open state of the dialog. Can be binded as v-model:open. | `boolean` | No | - |
| `unmountOnHide` | When set to `false`, the dialog content will not be unmounted when closed, but instead hidden with CSS. Useful for SEO or when you want to improve performance by not remounting the component on every open. | `boolean` | No | `true` |

**Events**

Expand Down
266 changes: 262 additions & 4 deletions packages/core/src/Dialog/Dialog.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
import type { Mock, SpyInstance } from 'vitest'
import type { Mock, MockInstance } from 'vitest'
import { createEvent, findByText, fireEvent, render } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'vitest-axe'
import { defineComponent, nextTick } from 'vue'
import { sleep } from '@/test'
import { DialogClose, DialogContent, DialogOverlay, DialogRoot, DialogTitle, DialogTrigger } from '.'

const OPEN_TEXT = 'Open'
Expand Down Expand Up @@ -50,6 +51,29 @@ const DialogTest = defineComponent({
</DialogRoot>`,
})

const UnmountOnHideDialogTest = defineComponent({
components: { DialogRoot, DialogTrigger, DialogOverlay, DialogContent, DialogClose, DialogTitle },
template: `<DialogRoot :unmount-on-hide="false">
<DialogTrigger>${OPEN_TEXT}</DialogTrigger>
<DialogOverlay />
<DialogContent>
<DialogTitle>${TITLE_TEXT}</DialogTitle>
<DialogClose>${CLOSE_TEXT}</DialogClose>
</DialogContent>
</DialogRoot>`,
})

const NonModalUnmountOnHideDialogTest = defineComponent({
components: { DialogRoot, DialogTrigger, DialogOverlay, DialogContent, DialogClose, DialogTitle },
template: `<DialogRoot :modal="false" :unmount-on-hide="false">
<DialogTrigger>${OPEN_TEXT}</DialogTrigger>
<DialogContent>
<DialogTitle>${TITLE_TEXT}</DialogTitle>
<DialogClose>${CLOSE_TEXT}</DialogClose>
</DialogContent>
</DialogRoot>`,
})

// Reproduces https://github.com/unovue/reka-ui/issues/2660 — the content is
// nested *inside* the overlay (a common centering pattern), so pointerdown
// events from controls in the content bubble up to the overlay.
Expand All @@ -67,6 +91,240 @@ const NestedContentDialogTest = defineComponent({
</DialogRoot>`,
})

describe('given a Dialog with unmountOnHide=false', () => {
let wrapper: VueWrapper<InstanceType<typeof UnmountOnHideDialogTest>>
let trigger: DOMWrapper<HTMLElement>

beforeEach(() => {
document.body.innerHTML = ''
wrapper = mount(UnmountOnHideDialogTest, { attachTo: document.body })
trigger = wrapper.find('button')
})

// The content is force-mounted, so unmount explicitly to avoid leaking the
// layer into `DismissableLayer`'s module-level tracking set across tests.
afterEach(() => {
wrapper?.unmount()
})

it('should keep content in DOM when closed after being opened', async () => {
await fireEvent.click(trigger.element)
await nextTick()

await fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
await nextTick()

const contentEl = document.querySelector('[role="dialog"]')
expect(contentEl).not.toBeNull()
expect((contentEl as HTMLElement).style.display).toBe('none')
})

it('should not pull focus into the content while closed on mount', async () => {
// Content is force-mounted but hidden; auto-focus must not fire yet.
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
expect(document.activeElement).toBe(document.body)
})

it('should focus the close button on open', async () => {
await fireEvent.click(trigger.element)
const closeButton = await findByText(document.body, CLOSE_TEXT)
await vi.waitFor(() => expect(closeButton).toBe(document.activeElement))
})

it('should re-focus the content when reopened', async () => {
// The content stays mounted, so focus must be re-applied on each open via
// the `present` false -> true transition (not just on physical mount).
await fireEvent.click(trigger.element)
await nextTick()

await fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
await vi.waitFor(() => expect(document.activeElement).toBe(trigger.element))

await fireEvent.click(trigger.element)
const closeButton = await findByText(document.body, CLOSE_TEXT)
await vi.waitFor(() => expect(closeButton).toBe(document.activeElement))
})

it('should restore focus to trigger on close', async () => {
await fireEvent.click(trigger.element)
await nextTick()

await fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
await vi.waitFor(() => expect(document.activeElement).toBe(trigger.element))
})

it('should not apply aria-hidden to body after open then close', async () => {
await fireEvent.click(trigger.element)
await nextTick()

await fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
await nextTick()

// Content stays mounted, but the rest of the page must stay accessible.
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
expect(document.body.getAttribute('aria-hidden')).toBeNull()
})

it('should pass axe accessibility tests when open', async () => {
await fireEvent.click(trigger.element)
await nextTick()
expect(await axe(document.body)).toHaveNoViolations()
})
})

describe('given a non-modal Dialog with unmountOnHide=false', () => {
let wrapper: VueWrapper<InstanceType<typeof NonModalUnmountOnHideDialogTest>>
let trigger: DOMWrapper<HTMLElement>

beforeEach(() => {
document.body.innerHTML = ''
wrapper = mount(NonModalUnmountOnHideDialogTest, { attachTo: document.body })
trigger = wrapper.find('button')
})

afterEach(() => {
wrapper?.unmount()
})

it('should keep content in DOM when closed after being opened', async () => {
await fireEvent.click(trigger.element)
await nextTick()

await fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
await nextTick()

const contentEl = document.querySelector('[role="dialog"]')
expect(contentEl).not.toBeNull()
expect((contentEl as HTMLElement).style.display).toBe('none')
})

it('should focus the close button on open', async () => {
expect(document.activeElement).toBe(document.body)

await fireEvent.click(trigger.element)
const closeButton = await findByText(document.body, CLOSE_TEXT)
await vi.waitFor(() => expect(closeButton).toBe(document.activeElement))
})

it('should restore focus to trigger on close', async () => {
await fireEvent.click(trigger.element)
await nextTick()

await fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
await vi.waitFor(() => expect(document.activeElement).toBe(trigger.element))
})
})

describe('given a Dialog with unmountOnHide=false, openAutoFocus', () => {
const OpenAutoFocusDialog = defineComponent({
components: { DialogRoot, DialogTrigger, DialogContent, DialogClose, DialogTitle },
props: ['onOpenAutoFocus'],
template: `<DialogRoot :unmount-on-hide="false">
<DialogTrigger>${OPEN_TEXT}</DialogTrigger>
<DialogContent @open-auto-focus="onOpenAutoFocus">
<DialogTitle>${TITLE_TEXT}</DialogTitle>
<DialogClose>${CLOSE_TEXT}</DialogClose>
</DialogContent>
</DialogRoot>`,
})

it('should not emit openAutoFocus while closed and emit once per open', async () => {
document.body.innerHTML = ''
const onOpenAutoFocus = vi.fn()
const wrapper = mount(OpenAutoFocusDialog, { attachTo: document.body, props: { onOpenAutoFocus } })
const trigger = wrapper.find('button')

// Force-mounted but hidden: the auto-focus must not fire on mount.
await nextTick()
expect(onOpenAutoFocus).toHaveBeenCalledTimes(0)

await fireEvent.click(trigger.element)
await vi.waitFor(() => expect(onOpenAutoFocus).toHaveBeenCalledTimes(1))

wrapper.unmount()
})
})

// Two dialogs with `unmountOnHide: false` coexist on the page (e.g. a menu
// drawer and a cart slideover), so both contents are force-mounted from the
// start. Hidden layers/scopes must not participate in the global
// `DismissableLayer` and `FocusScope` stacks: the later-mounted hidden one
// would otherwise be treated as the topmost layer (swallowing Escape meant for
// the open dialog) and would pause the open dialog's focus trap.
describe('given two Dialogs with unmountOnHide=false', () => {
const TwoDialogsTest = defineComponent({
components: { DialogRoot, DialogTrigger, DialogOverlay, DialogContent, DialogClose, DialogTitle },
props: ['onInteractOutside'],
template: `<div>
<DialogRoot :unmount-on-hide="false">
<DialogTrigger data-testid="first-trigger">open first</DialogTrigger>
<DialogOverlay />
<DialogContent data-testid="first-content">
<DialogTitle>first</DialogTitle>
<DialogClose data-testid="first-close">close first</DialogClose>
</DialogContent>
</DialogRoot>
<DialogRoot :modal="false" :unmount-on-hide="false">
<DialogTrigger data-testid="second-trigger">open second</DialogTrigger>
<DialogContent data-testid="second-content" @interact-outside="onInteractOutside">
<DialogTitle>second</DialogTitle>
<DialogClose data-testid="second-close">close second</DialogClose>
</DialogContent>
</DialogRoot>
</div>`,
})

let wrapper: VueWrapper<InstanceType<typeof TwoDialogsTest>>
let onInteractOutside: Mock

beforeEach(() => {
document.body.innerHTML = ''
onInteractOutside = vi.fn()
wrapper = mount(TwoDialogsTest, { attachTo: document.body, props: { onInteractOutside } })
})

afterEach(() => {
wrapper?.unmount()
})

it('should close the open dialog on Escape even though a hidden one mounted after it', async () => {
const trigger = wrapper.find('[data-testid="first-trigger"]')
await fireEvent.click(trigger.element)
await nextTick()

const content = document.querySelector('[data-testid="first-content"]') as HTMLElement
expect(content.style.display).not.toBe('none')

await fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
await nextTick()

expect(content.style.display).toBe('none')
await vi.waitFor(() => expect(document.activeElement).toBe(trigger.element))
})

it('should keep the modal focus trap active despite the hidden later-mounted scope', async () => {
await fireEvent.click(wrapper.find('[data-testid="first-trigger"]').element)
await nextTick()
await vi.waitFor(() => expect(document.activeElement?.getAttribute('data-testid')).toBe('first-close'))

// Move focus outside the open modal dialog: the trap must pull it back.
const outside = document.querySelector('[data-testid="second-trigger"]') as HTMLElement
outside.focus()
await nextTick()

const content = document.querySelector('[data-testid="first-content"]') as HTMLElement
expect(content.contains(document.activeElement)).toBe(true)
})

it('should not emit interactOutside on a closed keep-mounted dialog', async () => {
// Wait out the `setTimeout(0)` before outside-pointerdown listeners attach.
await sleep(1)
await fireEvent.pointerDown(document.body)
await nextTick()
expect(onInteractOutside).not.toHaveBeenCalled()
})
})

// Reproduces https://github.com/unovue/reka-ui/issues/2677 — a modal Dialog
// hardcoded `disableOutsidePointerEvents` to `true`, so the prop passed to
// `DialogContent` was ignored. These are tested without a `DialogOverlay`,
Expand All @@ -86,7 +344,7 @@ function makeModalDialog(contentBinding: string) {
}

describe('given a modal Dialog (#2677)', () => {
let consoleWarnMock: SpyInstance
let consoleWarnMock: MockInstance

beforeEach(() => {
document.body.innerHTML = ''
Expand Down Expand Up @@ -133,7 +391,7 @@ describe('given a default Dialog', () => {
let wrapper: VueWrapper<InstanceType<typeof DialogTest>>
let trigger: DOMWrapper<HTMLElement>
let closeButton: HTMLElement
let consoleWarnMock: SpyInstance
let consoleWarnMock: MockInstance
let consoleWarnMockFunction: Mock

beforeEach(() => {
Expand Down Expand Up @@ -250,7 +508,7 @@ describe('given a default Dialog', () => {
// native `<select>`/`<input>` interactions break.
describe('given a Dialog with content nested inside the overlay', () => {
let wrapper: VueWrapper<InstanceType<typeof NestedContentDialogTest>>
let consoleWarnMock: SpyInstance
let consoleWarnMock: MockInstance

beforeEach(async () => {
document.body.innerHTML = ''
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/Dialog/DialogContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,25 @@ const { forwardRef } = useForwardExpose()
</script>

<template>
<Presence :present="forceMount || rootContext.open.value">
<Presence
v-slot="{ present }"
:present="forceMount || rootContext.open.value"
:force-mount="forceMount || !rootContext.unmountOnHide.value"
>
<DialogContentModal
v-if="rootContext.modal.value"
v-show="rootContext.unmountOnHide.value || present"
:ref="forwardRef"
:present="rootContext.unmountOnHide.value || present"
v-bind="{ ...props, ...emitsAsProps, ...$attrs }"
>
<slot />
</DialogContentModal>
<DialogContentNonModal
v-else
v-show="rootContext.unmountOnHide.value || present"
:ref="forwardRef"
:present="rootContext.unmountOnHide.value || present"
v-bind="{ ...props, ...emitsAsProps, ...$attrs }"
>
<slot />
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/Dialog/DialogContentImpl.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export type DialogContentImplEmits = DismissableLayerEmits & {
export interface DialogContentImplProps extends DismissableLayerProps {
/**
* Used to force mounting when more control is needed. Useful when
* controlling transntion with Vue native transition or other animation libraries.
* controlling transition with Vue native transition or other animation libraries.
*/
forceMount?: boolean
/**
Expand All @@ -41,7 +41,12 @@ import { getOpenState } from '@/Menu/utils'
import { injectDialogRootContext } from './DialogRoot.vue'
import { useWarning } from './utils'

const props = defineProps<DialogContentImplProps>()
const props = defineProps<DialogContentImplProps & {
/** Whether the content is currently visible. Forwarded to `FocusScope` and
* `DismissableLayer` so a force-mounted dialog (`unmountOnHide: false`)
* auto-focuses on show and leaves the layer/scope stacks while hidden. */
present?: boolean
}>()
const emits = defineEmits<DialogContentImplEmits>()

const rootContext = injectDialogRootContext()
Expand Down Expand Up @@ -75,6 +80,7 @@ if (process.env.NODE_ENV !== 'production') {
as-child
loop
:trapped="props.trapFocus"
:present="props.present"
@mount-auto-focus="emits('openAutoFocus', $event)"
@unmount-auto-focus="emits('closeAutoFocus', $event)"
>
Expand All @@ -83,6 +89,7 @@ if (process.env.NODE_ENV !== 'production') {
:ref="forwardRef"
:as="as"
:as-child="asChild"
:present="props.present"
:disable-outside-pointer-events="disableOutsidePointerEvents"
role="dialog"
:aria-describedby="rootContext.descriptionId"
Expand Down
Loading
Loading