Skip to content

Commit 5496cce

Browse files
zernoniaclaude
andauthored
fix(Dialog): honor disableOutsidePointerEvents on modal content (#2677) (#2679)
`DialogContentModal` hardcoded `:disable-outside-pointer-events="true"`, so a `disableOutsidePointerEvents` value passed to `DialogContent` was silently ignored even though the docs list it as a prop. Pass the prop through with `withDefaults(..., { disableOutsidePointerEvents: true })` so modal dialogs stay locked by default but can be overridden. Because `DialogContent` declares the prop as `Boolean`, Vue coerces an absent value to `false`, which would override the child default; declare an explicit `undefined` default on `DialogContent` so the absent case propagates as `undefined` and the child's default applies, while an explicit `false` is still honored. Note: when a `DialogOverlay` is rendered, its `useBodyScrollLock` locks the body's `pointer-events` through a separate mechanism; this change only affects the `DismissableLayer` lock. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bb0c552 commit 5496cce

3 files changed

Lines changed: 73 additions & 3 deletions

File tree

packages/core/src/Dialog/Dialog.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,68 @@ const NestedContentDialogTest = defineComponent({
6767
</DialogRoot>`,
6868
})
6969

70+
// Reproduces https://github.com/unovue/reka-ui/issues/2677 — a modal Dialog
71+
// hardcoded `disableOutsidePointerEvents` to `true`, so the prop passed to
72+
// `DialogContent` was ignored. These are tested without a `DialogOverlay`,
73+
// because the overlay's `useBodyScrollLock` locks `body` pointer-events through
74+
// a separate mechanism unrelated to this prop.
75+
function makeModalDialog(contentBinding: string) {
76+
return defineComponent({
77+
components: { DialogRoot, DialogTrigger, DialogContent, DialogClose, DialogTitle },
78+
template: `<DialogRoot>
79+
<DialogTrigger>${OPEN_TEXT}</DialogTrigger>
80+
<DialogContent ${contentBinding}>
81+
<DialogTitle>${TITLE_TEXT}</DialogTitle>
82+
<DialogClose>${CLOSE_TEXT}</DialogClose>
83+
</DialogContent>
84+
</DialogRoot>`,
85+
})
86+
}
87+
88+
describe('given a modal Dialog (#2677)', () => {
89+
let consoleWarnMock: SpyInstance
90+
91+
beforeEach(() => {
92+
document.body.innerHTML = ''
93+
document.body.style.pointerEvents = ''
94+
consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {})
95+
})
96+
97+
afterEach(() => {
98+
consoleWarnMock.mockRestore()
99+
})
100+
101+
it('should lock body pointer-events by default', async () => {
102+
const wrapper = mount(makeModalDialog(''), { attachTo: document.body })
103+
fireEvent.click(wrapper.find('button').element)
104+
await findByText(document.body, CLOSE_TEXT)
105+
await nextTick()
106+
107+
expect(document.body.style.pointerEvents).toBe('none')
108+
wrapper.unmount()
109+
})
110+
111+
it('should respect disableOutsidePointerEvents=false on the content', async () => {
112+
const wrapper = mount(makeModalDialog(':disable-outside-pointer-events="false"'), { attachTo: document.body })
113+
fireEvent.click(wrapper.find('button').element)
114+
await findByText(document.body, CLOSE_TEXT)
115+
await nextTick()
116+
117+
expect(document.body.style.pointerEvents).not.toBe('none')
118+
wrapper.unmount()
119+
})
120+
121+
it('should still lock body pointer-events when explicitly true', async () => {
122+
const wrapper = mount(makeModalDialog(':disable-outside-pointer-events="true"'), { attachTo: document.body })
123+
fireEvent.click(wrapper.find('button').element)
124+
await findByText(document.body, CLOSE_TEXT)
125+
await nextTick()
126+
127+
expect(document.body.style.pointerEvents).toBe('none')
128+
wrapper.unmount()
129+
})
130+
})
131+
70132
describe('given a default Dialog', () => {
71133
let wrapper: VueWrapper<InstanceType<typeof DialogTest>>
72134
let trigger: DOMWrapper<HTMLElement>

packages/core/src/Dialog/DialogContent.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,13 @@ import DialogContentModal from './DialogContentModal.vue'
2222
import DialogContentNonModal from './DialogContentNonModal.vue'
2323
import { injectDialogRootContext } from './DialogRoot.vue'
2424
25-
const props = defineProps<DialogContentProps>()
25+
const props = withDefaults(defineProps<DialogContentProps>(), {
26+
// Keep `undefined` (instead of Vue's boolean coercion to `false`) so the
27+
// modal/non-modal child can apply its own default. This lets a modal
28+
// `DialogContent` stay locked by default while still honoring an explicit
29+
// `:disable-outside-pointer-events="false"` (#2677).
30+
disableOutsidePointerEvents: undefined,
31+
})
2632
const emits = defineEmits<DialogContentEmits>()
2733
2834
const rootContext = injectDialogRootContext()

packages/core/src/Dialog/DialogContentModal.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { useEmitAsProps, useForwardExpose, useHideOthers } from '@/shared'
44
import DialogContentImpl from './DialogContentImpl.vue'
55
import { injectDialogRootContext } from './DialogRoot.vue'
66
7-
const props = defineProps<DialogContentImplProps>()
7+
const props = withDefaults(defineProps<DialogContentImplProps>(), {
8+
disableOutsidePointerEvents: true,
9+
})
810
const emits = defineEmits<DialogContentImplEmits>()
911
1012
const rootContext = injectDialogRootContext()
@@ -20,7 +22,7 @@ useHideOthers(currentElement)
2022
v-bind="{ ...props, ...emitsAsProps }"
2123
:ref="forwardRef"
2224
:trap-focus="rootContext.open.value"
23-
:disable-outside-pointer-events="true"
25+
:disable-outside-pointer-events="props.disableOutsidePointerEvents"
2426
@close-auto-focus="
2527
(event) => {
2628
if (!event.defaultPrevented) {

0 commit comments

Comments
 (0)