Skip to content
Open
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
190 changes: 187 additions & 3 deletions packages/core/src/Dialog/Dialog.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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'
Expand Down Expand Up @@ -50,6 +50,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,11 +90,172 @@ 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')
})

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)
await nextTick()
await new Promise(resolve => setTimeout(resolve, 10))
const closeButton = await findByText(document.body, CLOSE_TEXT)
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 `trapped` false -> true transition (not just on physical mount).
await fireEvent.click(trigger.element)
await nextTick()

await fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
await nextTick()
await new Promise(resolve => setTimeout(resolve, 10))
expect(document.activeElement).toBe(trigger.element)

await fireEvent.click(trigger.element)
await nextTick()
await new Promise(resolve => setTimeout(resolve, 10))

const closeButton = await findByText(document.body, CLOSE_TEXT)
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 nextTick()
await new Promise(resolve => setTimeout(resolve, 10))

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()
await new Promise(resolve => setTimeout(resolve, 10))

// 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')
})

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)
await nextTick()
await new Promise(resolve => setTimeout(resolve, 10))

const closeButton = await findByText(document.body, CLOSE_TEXT)
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 nextTick()
await new Promise(resolve => setTimeout(resolve, 10))

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 nextTick()
await new Promise(resolve => setTimeout(resolve, 10))
expect(onOpenAutoFocus).toHaveBeenCalledTimes(1)
})
})

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 @@ -188,7 +372,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 @@ -32,17 +32,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
2 changes: 1 addition & 1 deletion 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 Down
24 changes: 20 additions & 4 deletions packages/core/src/Dialog/DialogContentModal.vue
Original file line number Diff line number Diff line change
@@ -1,26 +1,42 @@
<script setup lang="ts">
import type { DialogContentImplEmits, DialogContentImplProps } from './DialogContentImpl.vue'
import { computed, watch } from 'vue'
import { useEmitAsProps, useForwardExpose, useHideOthers } from '@/shared'
import DialogContentImpl from './DialogContentImpl.vue'
import { injectDialogRootContext } from './DialogRoot.vue'

const props = defineProps<DialogContentImplProps>()
const props = defineProps<DialogContentImplProps & { present: boolean }>()
const emits = defineEmits<DialogContentImplEmits>()

const rootContext = injectDialogRootContext()

const emitsAsProps = useEmitAsProps(emits)

const { forwardRef, currentElement } = useForwardExpose()
useHideOthers(currentElement)

const ariaHiddenTarget = computed(() => props.present ? currentElement.value : undefined)
useHideOthers(ariaHiddenTarget)

const forwardedProps = computed(() => {
const { present: _, ...rest } = props
return rest
})

// When `unmountOnHide` is `false` the content stays mounted on close, so
// `FocusScope` never unmounts and `close-auto-focus` never fires. Restore
// focus to the trigger manually once the content is no longer present.
watch(() => props.present, (isPresent, wasPresent) => {
if (!isPresent && wasPresent)
rootContext.triggerElement.value?.focus()
})
</script>

<template>
<DialogContentImpl
v-bind="{ ...props, ...emitsAsProps }"
v-bind="{ ...forwardedProps, ...emitsAsProps }"
:ref="forwardRef"
:trap-focus="rootContext.open.value"
:disable-outside-pointer-events="true"
:disable-outside-pointer-events="present"
@close-auto-focus="
(event) => {
if (!event.defaultPrevented) {
Expand Down
23 changes: 20 additions & 3 deletions packages/core/src/Dialog/DialogContentNonModal.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
<script setup lang="ts">
import type { DialogContentImplEmits, DialogContentImplProps } from './DialogContentImpl.vue'
import { ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useEmitAsProps, useForwardExpose } from '@/shared'
import DialogContentImpl from './DialogContentImpl.vue'
import { injectDialogRootContext } from './DialogRoot.vue'

const props = defineProps<DialogContentImplProps>()
const props = defineProps<DialogContentImplProps & { present: boolean }>()
const emits = defineEmits<DialogContentImplEmits>()

const emitsAsProps = useEmitAsProps(emits)
Expand All @@ -14,11 +14,28 @@ useForwardExpose()
const rootContext = injectDialogRootContext()
const hasInteractedOutsideRef = ref(false)
const hasPointerDownOutsideRef = ref(false)

const forwardedProps = computed(() => {
const { present: _, ...rest } = props
return rest
})

// When `unmountOnHide` is `false` the content stays mounted on close, so
// `close-auto-focus` never fires. Restore focus to the trigger manually once
// the content is no longer present, unless the user interacted outside.
watch(() => props.present, (isPresent, wasPresent) => {
if (!isPresent && wasPresent) {
if (!hasInteractedOutsideRef.value)
rootContext.triggerElement.value?.focus()
hasInteractedOutsideRef.value = false
hasPointerDownOutsideRef.value = false
}
})
</script>

<template>
<DialogContentImpl
v-bind="{ ...props, ...emitsAsProps }"
v-bind="{ ...forwardedProps, ...emitsAsProps }"
:trap-focus="false"
:disable-outside-pointer-events="false"
@close-auto-focus="
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/Dialog/DialogOverlay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,17 @@ const { forwardRef } = useForwardExpose()
<template>
<Presence
v-if="rootContext?.modal.value"
v-slot="{ present }"
:present="forceMount || rootContext.open.value"
:force-mount="forceMount || !rootContext.unmountOnHide.value"
>
<DialogOverlayImpl
v-show="rootContext.unmountOnHide.value || present"
v-bind="$attrs"
:ref="forwardRef"
:as="as"
:as-child="asChild"
:present="rootContext.unmountOnHide.value || present"
>
<slot />
</DialogOverlayImpl>
Expand Down
Loading
Loading