Skip to content

feat(OverlayProvider): support stacking #4360

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: v3
Choose a base branch
from
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
2 changes: 1 addition & 1 deletion docs/app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ provide('navigation', mappedNavigation)
</script>

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

<template v-if="!route.path.startsWith('/examples')">
Expand Down
25 changes: 25 additions & 0 deletions docs/app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,28 @@ html[data-module="ui-pro"] .ui-only,
html[data-module="ui"] .ui-pro-only {
display: none;
}

/* For better modals stacking visual experience */
@keyframes light-slide-in-from-bottom {
from {
transform: translateY(20%);
opacity: 0;
}

to {
transform: translateY(0);
opacity: 1;
}
}

@keyframes light-slide-out-to-bottom {
from {
transform: translateY(0);
opacity: 1;
}

to {
transform: translateY(20%);
opacity: 0;
}
}
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>
24 changes: 24 additions & 0 deletions docs/app/components/content/examples/modal/StackingModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<script lang="ts" setup>
// eslint-disable-next-line import/no-self-import
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', content: 'data-[state=open]:animate-[light-slide-in-from-bottom_200ms_ease-out] data-[state=closed]:animate-[light-slide-out-to-bottom_200ms_ease-in]' }"
>
<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 @@ -91,7 +91,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,6 +1,36 @@
<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, 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 @@ -19,10 +49,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 @@ -31,6 +31,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 @@ -29,6 +29,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)))]'
}
}
}
}