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
324 changes: 323 additions & 1 deletion packages/core/src/DismissableLayer/DismissableLayer.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
import { fireEvent } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { sleep } from '@/test'
import DismissableLayer from './story/_DismissableLayer.vue'
import Dialog from './story/shadowDom/_Dialog.vue'
import ShadowRootContainer from './story/shadowDom/ShadowRootContainer.vue'
import { isLayerExist } from './utils'

const OPEN_LABEL = 'Open'
Expand Down Expand Up @@ -81,3 +84,322 @@ describe('given a default DismissableLayer', () => {
})
})
})

type ShadowRootTestCase = {
description: string
testCase: 'shadowDomOnly' | 'mixedBodyAndShadowDom' | 'bodyOnly'
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

3 different test cases:

Document.body only (for reference)

Image

First dismissable layer (here a dialog) in the document's body, then inside its content, a shadow root (with other dismissable layers teleported into shadow root inside)

Image

All the dismissable layers in the same shadow root

Image

}

describe('shadow DOM dismissable layer tests', () => {
const testSuite: ShadowRootTestCase[] = [
{
description: 'given a Dialog inside a ShadowRoot, with nested dismissable layers also inside the ShadowRoot',
testCase: 'shadowDomOnly',
},
{
description: 'given a Dialog in the document body, with nested dismissable layers inside a ShadowRoot',
testCase: 'mixedBodyAndShadowDom',
},
{
description: 'given a Dialog in the document body, with nested dismissable layers also in the document body',
testCase: 'bodyOnly',
},
]

testSuite.forEach(({ description, testCase }) => {
describe(description, () => {
let wrapper: VueWrapper<InstanceType<typeof ShadowRootContainer>>
let shadowHost: HTMLElement
let shadowRoot: ShadowRoot

globalThis.ResizeObserver = class ResizeObserver {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Needed for Combobox I believe, otherwise the tests would crash.
This is taken from other test files.

observe() {}
unobserve() {}
disconnect() {}
}

function getDialogOverlay(): HTMLElement | null {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Small utility function to get the component from the correct rootNode, because query selectors can't traverse different doms

if (testCase === 'shadowDomOnly') {
return shadowRoot.querySelector('[data-testid="dialog-overlay"]')
}
else {
return document.body.querySelector('[data-testid="dialog-overlay"]')
}
}

function getDialogTrigger(): HTMLElement | null {
if (testCase === 'shadowDomOnly') {
return shadowRoot.querySelector('[data-testid="dialog-trigger"]')
}
else {
return document.body.querySelector('[data-testid="dialog-trigger"]')
}
}

function getDialogContent(): HTMLElement | null {
if (testCase === 'shadowDomOnly') {
return shadowRoot.querySelector('[data-testid="dialog-content"]')
}
else {
return document.body.querySelector('[data-testid="dialog-content"]')
}
}

function getQueryRoot(): Document | ShadowRoot {
if (testCase === 'bodyOnly') {
return document
}
else {
return shadowRoot
}
}

beforeEach(async () => {
document.body.innerHTML = ''
if (testCase === 'shadowDomOnly') {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Here I completely separated the three test cases beforeEach steps as it's more maintainable.
The rest of the tests are the same for each case.

wrapper = mount(ShadowRootContainer, { attachTo: document.body, props: { withDialog: true } })
await nextTick()
shadowHost = wrapper.find('#shadow-root-container').element as HTMLElement
shadowRoot = (shadowHost as unknown as { shadowRoot: ShadowRoot }).shadowRoot!
// Open the dialog
const trigger = getDialogTrigger() as HTMLElement
await fireEvent.click(trigger)
await sleep(1)
const dialogOverlay = getDialogOverlay()
expect(dialogOverlay).toBeTruthy()
const dialogContent = getDialogContent()
expect(dialogContent).toBeTruthy()
}
else if (testCase === 'mixedBodyAndShadowDom') {
wrapper = mount(Dialog, { attachTo: document.body, props: { hasShadowRootInside: true } })
await nextTick()

// Open the dialog
const trigger = getDialogTrigger() as HTMLElement
await fireEvent.click(trigger)
await sleep(1)
const dialogOverlay = getDialogOverlay()
expect(dialogOverlay).toBeTruthy()
const dialogContent = getDialogContent()
expect(dialogContent).toBeTruthy()

shadowHost = dialogContent?.querySelector('#shadow-root-container') as HTMLElement
shadowRoot = (shadowHost as unknown as { shadowRoot: ShadowRoot }).shadowRoot!
}
else {
wrapper = mount(Dialog, { attachTo: document.body })
await nextTick()

// Open the dialog
const trigger = getDialogTrigger() as HTMLElement
await fireEvent.click(trigger)
await sleep(1)
const dialogOverlay = getDialogOverlay()
expect(dialogOverlay).toBeTruthy()
const dialogContent = getDialogContent()
expect(dialogContent).toBeTruthy()
}
})

afterEach(async () => {
await wrapper.unmount()
await nextTick()
})

if (testCase !== 'bodyOnly') {
it('shadowRoot should be defined', () => {
expect(shadowRoot).toBeDefined()
})
}

it('should be able to dismiss Dialog on pressing Escape', async () => {
await fireEvent.keyDown(document.body, { key: 'Escape' })
await sleep(1)
expect(getDialogContent()).toBeFalsy()
})

it('should dismiss Dialog when interacting outside via overlay', async () => {
await fireEvent.pointerDown(getDialogOverlay()!)
await sleep(1)
expect(getDialogContent()).toBeFalsy()
})

describe('nested Combobox (non-modal)', () => {
beforeEach(async () => {
const comboboxTrigger = getQueryRoot().querySelector('[data-testid="combobox-trigger"]') as HTMLElement
await fireEvent.click(comboboxTrigger)
await sleep(1)
const comboboxContent = getQueryRoot().querySelector('[data-testid="combobox-content"]') as HTMLElement
expect(comboboxContent).toBeTruthy()
})

it('interacting inside Combobox content should not close combobox content, nor the dialog', async () => {
const item = getQueryRoot().querySelector('[data-testid="combobox-item"]') as HTMLElement
expect(item).toBeTruthy()
await fireEvent.pointerDown(item)
await sleep(1)
expect(getQueryRoot().querySelector('[data-testid="combobox-content"]')).toBeTruthy()
expect(getDialogContent()).toBeTruthy()
})

it('pressing Escape inside Combobox content should close combobox content, but not the dialog', async () => {
await fireEvent.keyDown(document.body, { key: 'Escape' })
await sleep(1)
expect(getQueryRoot().querySelector('[data-testid="combobox-content"]')).toBeFalsy()
expect(getDialogContent()).toBeTruthy()
})

it('interacting outside Combobox content should close combobox and close dialog', async () => {
await fireEvent.pointerDown(getDialogOverlay()!)
await sleep(1)
expect(getQueryRoot().querySelector('[data-testid="combobox-content"]')).toBeFalsy()
expect(getDialogContent()).toBeFalsy()
})
})

describe('nested Popover', () => {
describe('modal popover', () => {
it('interacting inside popover content should not close the dialog', async () => {
const popoverTrigger = getQueryRoot().querySelector('[data-testid="popover-trigger-modal"]') as HTMLElement
await fireEvent.click(popoverTrigger)
const popoverContent = getQueryRoot().querySelector('[data-testid="popover-content-modal"]') as HTMLElement
expect(popoverContent).toBeTruthy()
const firstInput = getQueryRoot().querySelector('[data-testid="popover-first-input"]') as HTMLElement
await fireEvent.pointerDown(firstInput)
await sleep(1)
expect(popoverContent).toBeTruthy()
expect(getDialogContent()).toBeTruthy()
})

it('modal popover: pressing escape key should close popover, not dialog', async () => {
const popoverTrigger = getQueryRoot().querySelector('[data-testid="popover-trigger-modal"]') as HTMLElement
await fireEvent.click(popoverTrigger)
const popoverContent = getQueryRoot().querySelector('[data-testid="popover-content-modal"]') as HTMLElement
expect(popoverContent).toBeTruthy()
await fireEvent.keyDown(document.body, { key: 'Escape' })
await sleep(1)
expect(getQueryRoot().querySelector('[data-testid="popover-content-modal"]')).toBeFalsy()
expect(getDialogContent()).toBeTruthy()
})

it('modal popover: interacting outside should close popover, not dialog', async () => {
const popoverTrigger = getQueryRoot().querySelector('[data-testid="popover-trigger-modal"]') as HTMLElement
await fireEvent.click(popoverTrigger)
await sleep(1)
const popoverContent = getQueryRoot().querySelector('[data-testid="popover-content-modal"]') as HTMLElement
expect(popoverContent).toBeTruthy()
await fireEvent.pointerDown(getDialogOverlay()!)
await sleep(1)
expect(getQueryRoot().querySelector('[data-testid="popover-content-modal"]')).toBeFalsy()
expect(getDialogContent()).toBeTruthy()
})
})

describe('non-modal popover', () => {
it('interacting inside popover content should not close the dialog', async () => {
const popoverTrigger = getQueryRoot().querySelector('[data-testid="popover-trigger-nonmodal"]') as HTMLElement
await fireEvent.click(popoverTrigger)
const popoverContent = getQueryRoot().querySelector('[data-testid="popover-content-nonmodal"]') as HTMLElement
expect(popoverContent).toBeTruthy()
const firstInput = getQueryRoot().querySelector('[data-testid="popover-first-input"]') as HTMLElement
await fireEvent.pointerDown(firstInput)
await sleep(1)
expect(popoverContent).toBeTruthy()
expect(getDialogContent()).toBeTruthy()
})

it('non-modal popover: pressing escape key should close popover, but not close the dialog', async () => {
const popoverTrigger = getQueryRoot().querySelector('[data-testid="popover-trigger-nonmodal"]') as HTMLElement
await fireEvent.click(popoverTrigger)
await sleep(1)
const popoverContent = getQueryRoot().querySelector('[data-testid="popover-content-nonmodal"]') as HTMLElement
expect(popoverContent).toBeTruthy()
await fireEvent.keyDown(document.body, { key: 'Escape' })
await sleep(1)
expect(getQueryRoot().querySelector('[data-testid="popover-content-nonmodal"]')).toBeFalsy()
expect(getDialogContent()).toBeTruthy()
})

it('non-modal popover: interacting outside should close popover and the dialog', async () => {
const popoverTrigger = getQueryRoot().querySelector('[data-testid="popover-trigger-nonmodal"]') as HTMLElement
await fireEvent.click(popoverTrigger)
await sleep(1)
const popoverContent = getQueryRoot().querySelector('[data-testid="popover-content-nonmodal"]') as HTMLElement
expect(popoverContent).toBeTruthy()
await fireEvent.pointerDown(getDialogOverlay()!)
await sleep(1)
expect(getQueryRoot().querySelector('[data-testid="popover-content-nonmodal"]')).toBeFalsy()
expect(getDialogContent()).toBeFalsy()
})
})
})

describe('nested Dropdown Menu', () => {
describe('modal dropdown', () => {
it('interacting inside dropdown and sub menus should not close the dialog', async () => {
const trigger = getQueryRoot().querySelector('[data-testid="dropdown-trigger-modal"]') as HTMLElement
await fireEvent.click(trigger)
await sleep(1)
const dropdownContent = getQueryRoot().querySelector('[data-testid="dropdown-content-modal"]') as HTMLElement
expect(dropdownContent).toBeTruthy()
const moreTools = getQueryRoot().querySelector('[data-testid="more-tools-subtrigger"]') as HTMLElement
await fireEvent.pointerDown(moreTools)
await sleep(1)
expect(dropdownContent).toBeTruthy()
expect(getDialogContent()).toBeTruthy()
})

it('modal dropdown: pressing escape key should close dropdown, not the dialog', async () => {
const trigger = getQueryRoot().querySelector('[data-testid="dropdown-trigger-modal"]') as HTMLElement
await fireEvent.click(trigger)
await sleep(1)
const dialogContent = getDialogContent() as HTMLElement
expect(dialogContent).toBeTruthy()
await fireEvent.keyDown(document.body, { key: 'Escape' })
await sleep(1)
expect(getQueryRoot().querySelector('[data-testid="dropdown-content-modal"]')).toBeFalsy()
expect(getDialogContent()).toBeTruthy()
})

it('modal dropdown: interacting outside should close dropdown, not the dialog', async () => {
const trigger = getQueryRoot().querySelector('[data-testid="dropdown-trigger-modal"]') as HTMLElement
await fireEvent.click(trigger)
await sleep(1)
const dialogContent = getDialogContent() as HTMLElement
expect(dialogContent).toBeTruthy()
await fireEvent.pointerDown(getDialogOverlay()!)
await sleep(1)
expect(getQueryRoot().querySelector('[data-testid="dropdown-content-modal"]')).toBeFalsy()
expect(getDialogContent()).toBeTruthy()
})
})

describe('non-modal dropdown', () => {
it('non-modal dropdown: pressing escape key should close dropdown, but not close the dialog', async () => {
const trigger = getQueryRoot().querySelector('[data-testid="dropdown-trigger-nonmodal"]') as HTMLElement
await fireEvent.click(trigger)
await sleep(1)
const dialogContent = getDialogContent() as HTMLElement
expect(dialogContent).toBeTruthy()
await fireEvent.keyDown(document.body, { key: 'Escape' })
await sleep(1)
expect(getQueryRoot().querySelector('[data-testid="dropdown-content-nonmodal"]')).toBeFalsy()
expect(getDialogContent()).toBeTruthy()
})

it('non-modal dropdown: interacting outside should close dropdown and close the dialog', async () => {
const trigger = getQueryRoot().querySelector('[data-testid="dropdown-trigger-nonmodal"]') as HTMLElement
await fireEvent.click(trigger)
await sleep(1)
const dialogContent = getDialogContent() as HTMLElement
expect(dialogContent).toBeTruthy()
await fireEvent.pointerDown(getDialogOverlay()!)
await sleep(1)
expect(getQueryRoot().querySelector('[data-testid="dropdown-content-nonmodal"]')).toBeFalsy()
expect(getDialogContent()).toBeFalsy()
})
})
})
})
})
})
14 changes: 14 additions & 0 deletions packages/core/src/DismissableLayer/DismissableLayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ import {
Primitive,
} from '@/Primitive'
import {
getAllDismissableLayers,
getRootNode,
useFocusOutside,
usePointerDownOutside,
} from './utils'
Expand Down Expand Up @@ -130,6 +132,18 @@ const focusOutside = useFocusOutside((event) => {
}, layerElement)

onKeyStroke('Escape', (event) => {
const composedPath = event.composedPath()
Copy link
Copy Markdown
Contributor Author

@ldelhommeau ldelhommeau Jan 14, 2026

Choose a reason for hiding this comment

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

We get the event rootNode via event.composedPath.

Then we just compare the current dismissable layer's layer element (so the current DismissableLayer instance) root node with the event's root node.

If the two elements are not the same and we have some dismissable layers in the DOM of the event (via allDismissableLayers.length > 0), it means we are not in the same DOM, so better to return early.
This way we don't have conflicts between different DOMs.

const eventTarget = (composedPath[0] || event.target) as HTMLElement
const eventTargetRootNode = getRootNode(eventTarget)
const allDismissableLayers = getAllDismissableLayers(eventTarget)

if (eventTargetRootNode instanceof ShadowRoot && allDismissableLayers.length > 0) {
const layerElementRootNode = getRootNode(layerElement.value ?? null)
if (eventTargetRootNode !== layerElementRootNode) {
return
}
}

const isHighestLayer = index.value === layers.value.size - 1
if (!isHighestLayer)
return
Expand Down
Loading
Loading