Skip to content
Draft
2 changes: 1 addition & 1 deletion docs/app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ provide('navigation', mappedNavigation)
</script>

<template>
<UApp :toaster="appConfig.toaster">
<UApp :toaster="appConfig.toaster" :overlay="{ stacked: true }">
<NuxtLoadingIndicator color="var(--ui-primary)" :height="2" />
<Analytics />

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import { LazyStackingModal } from '#components'

const overlay = useOverlay()

function onClick() {
overlay
.create(LazyStackingModal, {
destroyOnClose: true
})
.open()
}
</script>

<template>
<UButton label="Open" variant="subtle" color="neutral" @click="onClick" />
</template>
23 changes: 23 additions & 0 deletions docs/app/components/content/examples/modal/StackingModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import StackingModal from './StackingModal.vue'

const overlay = useOverlay()
function onClick() {
overlay
.create(StackingModal, {
destroyOnClose: true
})
.open()
}
</script>

<template>
<UModal
title="A Modal"
:ui="{ footer: 'justify-end' }"
>
<template #footer>
<UButton label="Another Modal" variant="subtle" color="neutral" @click="onClick" />
</template>
</UModal>
</template>
14 changes: 14 additions & 0 deletions docs/content/3.components/modal.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,20 @@ name: 'modal-nested-example'
---
::

### Stacking modals

You can nicely stack modals on top of each other thanks to two CSS variables: `--overlay-count` and `--overlay-index`.

::component-example
---
name: 'modal-stacking-example'
---
::

::note
You must enable the `stacked` variant though the App component's `overlay` prop to use this feature.
::

### With footer slot

Use the `#footer` slot to add content after the Modal's body.
Expand Down
2 changes: 1 addition & 1 deletion playground/app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ useHead({

<template>
<template v-if="!$route.path.startsWith('/__nuxt_ui__')">
<UApp :toaster="appConfig.toaster">
<UApp :toaster="appConfig.toaster" :overlay="{ stacked: true }">
<div class="h-screen w-screen overflow-hidden flex flex-col lg:flex-row min-h-0 bg-default" data-vaul-drawer-wrapper>
<UNavigationMenu :items="items" orientation="vertical" class="hidden lg:flex border-e border-default overflow-y-auto w-48 p-4" />
<UNavigationMenu :items="items" orientation="horizontal" class="lg:hidden border-b border-default [&>div]:min-w-min overflow-x-auto" />
Expand Down
21 changes: 21 additions & 0 deletions playground/app/components/FirstModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script lang="ts" setup>
import SecondModal from './SecondModal.vue'

const emit = defineEmits(['close'])

const overlay = useOverlay()
function openSecondModal() {
overlay.create(SecondModal, {
destroyOnClose: true
}).open()
}
</script>

<template>
<UModal title="First modal" description="This is the first modal opened from the button.">
<template #footer>
<UButton label="Open second modal" color="neutral" variant="outline" @click="openSecondModal" />
<UButton color="neutral" label="Close" @click="emit('close')" />
</template>
</UModal>
</template>
11 changes: 11 additions & 0 deletions playground/app/components/SecondModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script setup lang="ts">
const emit = defineEmits(['close'])
</script>

<template>
<UModal title="Second modal" description="This is the second modal opened from the button.">
<template #footer>
<UButton color="neutral" label="Close" @click="emit('close')" />
</template>
</UModal>
</template>
9 changes: 9 additions & 0 deletions playground/app/pages/components/modal.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
import FirstModal from '../../components/FirstModal.vue'

const LazyModalExample = defineAsyncComponent(() => import('../../components/ModalExample.vue'))

Expand All @@ -18,6 +19,12 @@ function openModal() {

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

function openFirstModal() {
overlay.create(FirstModal, {
destroyOnClose: true
}).open()
}
</script>

<template>
Expand Down Expand Up @@ -70,6 +77,8 @@ function openModal() {

<UButton label="Open programmatically" color="neutral" variant="outline" @click="openModal" />

<UButton label="Stacked modal" color="neutral" variant="subtle" @click="openFirstModal" />

<UModal title="First modal">
<UButton color="neutral" variant="outline" label="Close with scoped slot close" />

Expand Down
6 changes: 4 additions & 2 deletions src/runtime/components/App.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<script lang="ts">
import type { ConfigProviderProps, TooltipProviderProps } from 'reka-ui'
import type { ToasterProps, Locale, Messages } from '../types'
import type { ToasterProps, Locale, Messages, OverlayProviderProps } from '../types'

export interface AppProps<T extends Messages = Messages> extends Omit<ConfigProviderProps, 'useId' | 'dir' | 'locale'> {
tooltip?: TooltipProviderProps
toaster?: ToasterProps | null
overlay?: OverlayProviderProps
locale?: Locale<T>
portal?: string | HTMLElement
}
Expand Down Expand Up @@ -36,6 +37,7 @@ defineSlots<AppSlots>()
const configProviderProps = useForwardProps(reactivePick(props, 'scrollBody'))
const tooltipProps = toRef(() => props.tooltip)
const toasterProps = toRef(() => props.toaster)
const overlayProps = toRef(() => props.overlay)

const locale = toRef(() => props.locale)
provide(localeContextInjectionKey, locale)
Expand All @@ -52,7 +54,7 @@ provide(portalTargetInjectionKey, portal)
</UToaster>
<slot v-else />

<UOverlayProvider />
<UOverlayProvider v-bind="overlayProps" />
</TooltipProvider>
</ConfigProvider>
</template>
40 changes: 39 additions & 1 deletion src/runtime/components/OverlayProvider.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,37 @@
<script lang="ts">
import type { AppConfig } from '@nuxt/schema'
import theme from '#build/ui/overlay-provider'
import type { ComponentConfig } from '../types/utils'

type OverlayProvider = ComponentConfig<typeof theme, AppConfig, 'overlayProvider'>

export interface OverlayProviderProps {
/**
* Allow the overlay to nicely stack on top of each other.
* @defaultValue false
*/
stacked?: boolean
class?: any
ui?: OverlayProvider['slots']
}
</script>

<script setup lang="ts">
import { computed } from 'vue'
import { useOverlay } from '../composables/useOverlay'
import type { Overlay } from '../composables/useOverlay'
import { useAppConfig } from '#imports'
import { tv } from '../utils/tv'

const props = withDefaults(defineProps<OverlayProviderProps>(), {
stacked: false
})

const appConfig = useAppConfig() as OverlayProvider['AppConfig']

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

const { overlays, unmount, close } = useOverlay()

Expand All @@ -20,10 +50,18 @@ const onClose = (id: symbol, value: any) => {
<template>
<component
:is="overlay.component"
v-for="overlay in mountedOverlays"
v-for="(overlay, index) in mountedOverlays"
:key="overlay.id"
v-bind="overlay.props"
v-model:open="overlay.isOpen"
:overlay="index === 0 ? true : false"
:content="{
style: {
'--overlay-count': mountedOverlays.length,
'--overlay-index': index
}
}"
:class="ui.base({ class: [props.ui?.base, props.class] })"
@close="(value:any) => onClose(overlay.id, value)"
@after:leave="onAfterLeave(overlay.id)"
/>
Expand Down
1 change: 1 addition & 0 deletions src/runtime/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export * from '../components/Kbd.vue'
export * from '../components/Link.vue'
export * from '../components/Modal.vue'
export * from '../components/NavigationMenu.vue'
export * from '../components/OverlayProvider.vue'
export * from '../components/Pagination.vue'
export * from '../components/PinInput.vue'
export * from '../components/Popover.vue'
Expand Down
1 change: 1 addition & 0 deletions src/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export { default as kbd } from './kbd'
export { default as link } from './link'
export { default as modal } from './modal'
export { default as navigationMenu } from './navigation-menu'
export { default as overlayProvider } from './overlay-provider'
export { default as pagination } from './pagination'
export { default as pinInput } from './pin-input'
export { default as popover } from './popover'
Expand Down
12 changes: 12 additions & 0 deletions src/theme/overlay-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export default {
slots: {
base: ''
},
variants: {
stacked: {
true: {
base: 'origin-top transition-transform duration-200 [--overlay-value:calc(var(--overlay-count)-var(--overlay-index)-1)] scale-[calc(1-0.05*var(--overlay-value))] transform-[translateY(calc(-1.25rem*var(--overlay-value)))]'
}
}
}
}
Loading