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
24 changes: 24 additions & 0 deletions playgrounds/nuxt/app/pages/components/modal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const LazyModalExample = defineAsyncComponent(() => import('../../components/Mod
const open = ref(false)
const count = ref(0)
const overlay = useOverlay()
const toast = useToast()

const modal = overlay.create(LazyModalExample, {
props: {
Expand All @@ -18,6 +19,15 @@ function openModal() {

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

function showToast() {
toast.add({
title: 'Toast displayed!',
description: 'This toast was triggered from the modal.',
color: 'success',
icon: 'i-lucide-check-circle'
})
}
</script>

<template>
Expand Down Expand Up @@ -92,5 +102,19 @@ function openModal() {
<UButton label="Close with scoped slot close" @click="close" />
</template>
</UModal>

<UModal title="Modal with toast" description="Touch bug repro: tap 'Show Toast' multiple times, modal closes unexpectedly on touch devices.">
<UButton label="Open with toast" color="neutral" variant="subtle" />

<template #body>
<UButton
label="Show Toast"
color="neutral"
variant="outline"
icon="i-lucide-bell"
@click="showToast"
/>
</template>
</UModal>
</div>
</template>
5 changes: 4 additions & 1 deletion src/runtime/components/Drawer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { DrawerRoot, DrawerRootNested, DrawerTrigger, DrawerPortal, DrawerOverla
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { usePortal } from '../composables/usePortal'
import { pointerDownOutside } from '../utils/overlay'
import { tv } from '../utils/tv'

const props = withDefaults(defineProps<DrawerProps>(), {
Expand Down Expand Up @@ -100,7 +101,9 @@ const contentEvents = computed(() => {
}, {} as Record<typeof events[number], (e: Event) => void>)
}

return {}
return {
pointerDownOutside
}
})

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.drawer || {}) })({
Expand Down
18 changes: 4 additions & 14 deletions src/runtime/components/Modal.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import type { DialogRootProps, DialogRootEmits, DialogContentProps, DialogContentEmits } from 'reka-ui'
import type { DialogRootProps, DialogRootEmits, DialogContentProps, DialogContentEmits, PointerDownOutsideEvent } from 'reka-ui'
import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/modal'
import type { ButtonProps, IconProps, LinkPropsKeys } from '../types'
Expand Down Expand Up @@ -85,6 +85,7 @@ import { reactivePick, createReusableTemplate } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useLocale } from '../composables/useLocale'
import { usePortal } from '../composables/usePortal'
import { pointerDownOutside } from '../utils/overlay'
import { tv } from '../utils/tv'
import UButton from './Button.vue'
Expand Down Expand Up @@ -118,20 +119,9 @@ const contentEvents = computed(() => {
}, {} as Record<typeof events[number], (e: Event) => void>)
}
if (props.scrollable) {
return {
// 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.
pointerDownOutside: (e: any) => {
const originalEvent = e.detail.originalEvent
const target = originalEvent.target as HTMLElement
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
e.preventDefault()
}
}
}
return {
pointerDownOutside: (e: PointerDownOutsideEvent) => pointerDownOutside(e, { scrollable: props.scrollable })
}
return {}
})
const [DefineContentTemplate, ReuseContentTemplate] = createReusableTemplate()
Expand Down
5 changes: 4 additions & 1 deletion src/runtime/components/Popover.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { Popover, HoverCard } from 'reka-ui/namespaced'
import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { usePortal } from '../composables/usePortal'
import { pointerDownOutside } from '../utils/overlay'
import { tv } from '../utils/tv'
const props = withDefaults(defineProps<PopoverProps<M>>(), {
Expand Down Expand Up @@ -96,7 +97,9 @@ const contentEvents = computed(() => {
}, {} as Record<typeof events[number], (e: Event) => void>)
}
return {}
return {
pointerDownOutside
}
})
const arrowProps = toRef(() => props.arrow as PopoverArrowProps)
Expand Down
5 changes: 4 additions & 1 deletion src/runtime/components/Slideover.vue
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import { reactivePick } from '@vueuse/core'
import { useAppConfig } from '#imports'
import { useLocale } from '../composables/useLocale'
import { usePortal } from '../composables/usePortal'
import { pointerDownOutside } from '../utils/overlay'
import { tv } from '../utils/tv'
import UButton from './Button.vue'

Expand Down Expand Up @@ -119,7 +120,9 @@ const contentEvents = computed(() => {
}, {} as Record<typeof events[number], (e: Event) => void>)
}

return {}
return {
pointerDownOutside
}
})

const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.slideover || {}) })({
Expand Down
41 changes: 41 additions & 0 deletions src/runtime/utils/overlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { PointerDownOutsideEvent } from 'reka-ui'

export interface PointerDownOutsideOptions {
/**
* Whether the overlay is in scrollable mode.
* When true, prevents closing when clicking on the scrollbar.
*/
scrollable?: boolean
}

/**
* Handles `pointerDownOutside` events to prevent overlays from closing in specific scenarios:
* 1. When the target element is no longer in the DOM (e.g., a toast was dismissed between pointerdown and click on touch devices)
* 2. When clicking on a scrollbar (only in scrollable mode)
*
* Note: Reka UI already handles dismissable layer checks internally via `isLayerExist`,
* so we don't need to check for `[data-dismissable-layer]` here.
*
* @see https://reka-ui.com/docs/components/dialog#disable-close-on-interaction-outside
*/
export function pointerDownOutside(e: PointerDownOutsideEvent, options: PointerDownOutsideOptions = {}) {
const originalEvent = e.detail.originalEvent
const target = originalEvent.target as HTMLElement

// Fix for touch devices: on touch, Reka UI defers the event dispatch to the click event.
// If the target element was removed from DOM between pointerdown and click (e.g., toast dismissed),
// we should prevent the overlay from closing.
// Using `isConnected` instead of `document.body.contains()` to correctly handle Shadow DOM elements.
if (!target?.isConnected) {
e.preventDefault()
return
}

// Scrollable mode: prevent closing when clicking on scrollbar
// 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.
if (options.scrollable) {
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
e.preventDefault()
}
}
}
Loading