GitHub

Original file line numberDiff line numberDiff line change

@@ -6,6 +6,7 @@ const LazyModalExample = defineAsyncComponent(() => import('../../components/Mod

66

const open = ref(false)

77

const count = ref(0)

88

const overlay = useOverlay()

9+

const toast = useToast()

910
1011

const modal = overlay.create(LazyModalExample, {

1112

props: {

@@ -18,6 +19,15 @@ function openModal() {

1819
1920

modal.open({ count: count.value })

2021

}

22+
23+

function showToast() {

24+

toast.add({

25+

title: 'Toast displayed!',

26+

description: 'This toast was triggered from the modal.',

27+

color: 'success',

28+

icon: 'i-lucide-check-circle'

29+

})

30+

}

2131

</script>

2232
2333

<template>

@@ -92,5 +102,19 @@ function openModal() {

92102

<UButton label="Close with scoped slot close" @click="close" />

93103

</template>

94104

</UModal>

105+
106+

<UModal title="Modal with toast" description="Touch bug repro: tap 'Show Toast' multiple times, modal closes unexpectedly on touch devices.">

107+

<UButton label="Open with toast" color="neutral" variant="subtle" />

108+
109+

<template #body>

110+

<UButton

111+

label="Show Toast"

112+

color="neutral"

113+

variant="outline"

114+

icon="i-lucide-bell"

115+

@click="showToast"

116+

/>

117+

</template>

118+

</UModal>

95119

</div>

96120

</template>

Original file line numberDiff line numberDiff line change

@@ -69,6 +69,7 @@ import { DrawerRoot, DrawerRootNested, DrawerTrigger, DrawerPortal, DrawerOverla

6969

import { reactivePick } from '@vueuse/core'

7070

import { useAppConfig } from '#imports'

7171

import { usePortal } from '../composables/usePortal'

72+

import { pointerDownOutside } from '../utils/overlay'

7273

import { tv } from '../utils/tv'

7374
7475

const props = withDefaults(defineProps<DrawerProps>(), {

@@ -100,7 +101,9 @@ const contentEvents = computed(() => {

100101

}, {} as Record<typeof events[number], (e: Event) => void>)

101102

}

102103
103-

return {}

104+

return {

105+

pointerDownOutside

106+

}

104107

})

105108
106109

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.drawer || {}) })({

Original file line numberDiff line numberDiff line change

@@ -1,5 +1,5 @@

11

<script lang="ts">

2-

import type { DialogRootProps, DialogRootEmits, DialogContentProps, DialogContentEmits } from 'reka-ui'

2+

import type { DialogRootProps, DialogRootEmits, DialogContentProps, DialogContentEmits, PointerDownOutsideEvent } from 'reka-ui'

33

import type { AppConfig } from '@nuxt/schema'

44

import theme from '#build/ui/modal'

55

import type { ButtonProps, IconProps, LinkPropsKeys } from '../types'

@@ -85,6 +85,7 @@ import { reactivePick, createReusableTemplate } from '@vueuse/core'

8585

import { useAppConfig } from '#imports'

8686

import { useLocale } from '../composables/useLocale'

8787

import { usePortal } from '../composables/usePortal'

88+

import { pointerDownOutside } from '../utils/overlay'

8889

import { tv } from '../utils/tv'

8990

import UButton from './Button.vue'

9091

@@ -118,20 +119,9 @@ const contentEvents = computed(() => {

118119

}, {} as Record<typeof events[number], (e: Event) => void>)

119120

}

120121
121-

if (props.scrollable) {

122-

return {

123-

// FIXME: This is a workaround to prevent the modal from closing when clicking on the scrollbar https://reka-ui.com/docs/components/dialog#scrollable-overlay but it's not working on Mac OS.

124-

pointerDownOutside: (e: any) => {

125-

const originalEvent = e.detail.originalEvent

126-

const target = originalEvent.target as HTMLElement

127-

if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {

128-

e.preventDefault()

129-

}

130-

}

131-

}

122+

return {

123+

pointerDownOutside: (e: PointerDownOutsideEvent) => pointerDownOutside(e, { scrollable: props.scrollable })

132124

}

133-
134-

return {}

135125

})

136126
137127

const [DefineContentTemplate, ReuseContentTemplate] = createReusableTemplate()

Original file line numberDiff line numberDiff line change

@@ -65,6 +65,7 @@ import { Popover, HoverCard } from 'reka-ui/namespaced'

6565

import { reactivePick } from '@vueuse/core'

6666

import { useAppConfig } from '#imports'

6767

import { usePortal } from '../composables/usePortal'

68+

import { pointerDownOutside } from '../utils/overlay'

6869

import { tv } from '../utils/tv'

6970
7071

const props = withDefaults(defineProps<PopoverProps<M>>(), {

@@ -96,7 +97,9 @@ const contentEvents = computed(() => {

9697

}, {} as Record<typeof events[number], (e: Event) => void>)

9798

}

9899
99-

return {}

100+

return {

101+

pointerDownOutside

102+

}

100103

})

101104

const arrowProps = toRef(() => props.arrow as PopoverArrowProps)

102105
Original file line numberDiff line numberDiff line change

@@ -85,6 +85,7 @@ import { reactivePick } from '@vueuse/core'

8585

import { useAppConfig } from '#imports'

8686

import { useLocale } from '../composables/useLocale'

8787

import { usePortal } from '../composables/usePortal'

88+

import { pointerDownOutside } from '../utils/overlay'

8889

import { tv } from '../utils/tv'

8990

import UButton from './Button.vue'

9091

@@ -119,7 +120,9 @@ const contentEvents = computed(() => {

119120

}, {} as Record<typeof events[number], (e: Event) => void>)

120121

}

121122
122-

return {}

123+

return {

124+

pointerDownOutside

125+

}

123126

})

124127
125128

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.slideover || {}) })({

Original file line numberDiff line numberDiff line change

@@ -0,0 +1,41 @@

1+

import type { PointerDownOutsideEvent } from 'reka-ui'

2+
3+

export interface PointerDownOutsideOptions {

4+

/**

5+

* Whether the overlay is in scrollable mode.

6+

* When true, prevents closing when clicking on the scrollbar.

7+

*/

8+

scrollable?: boolean

9+

}

10+
11+

/**

12+

* Handles `pointerDownOutside` events to prevent overlays from closing in specific scenarios:

13+

* 1. When the target element is no longer in the DOM (e.g., a toast was dismissed between pointerdown and click on touch devices)

14+

* 2. When clicking on a scrollbar (only in scrollable mode)

15+

*

16+

* Note: Reka UI already handles dismissable layer checks internally via `isLayerExist`,

17+

* so we don't need to check for `[data-dismissable-layer]` here.

18+

*

19+

* @see https://reka-ui.com/docs/components/dialog#disable-close-on-interaction-outside

20+

*/

21+

export function pointerDownOutside(e: PointerDownOutsideEvent, options: PointerDownOutsideOptions = {}) {

22+

const originalEvent = e.detail.originalEvent

23+

const target = originalEvent.target as HTMLElement

24+
25+

// Fix for touch devices: on touch, Reka UI defers the event dispatch to the click event.

26+

// If the target element was removed from DOM between pointerdown and click (e.g., toast dismissed),

27+

// we should prevent the overlay from closing.

28+

// Using `isConnected` instead of `document.body.contains()` to correctly handle Shadow DOM elements.

29+

if (!target?.isConnected) {

30+

e.preventDefault()

31+

return

32+

}

33+
34+

// Scrollable mode: prevent closing when clicking on scrollbar

35+

// FIXME: This is a workaround to prevent the overlay from closing when clicking on the scrollbar https://reka-ui.com/docs/components/dialog#scrollable-overlay but it's not working on Mac OS.

36+

if (options.scrollable) {

37+

if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {

38+

e.preventDefault()

39+

}

40+

}

41+

}

Read the original on github.com ↗