mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-20 15:01:46 +01:00
feat(useOverlay)!: handle programmatic modals and slideovers (#3279)
Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
@@ -26,8 +26,7 @@ import { toRef, useId, provide } from 'vue'
|
||||
import { ConfigProvider, TooltipProvider, useForwardProps } from 'reka-ui'
|
||||
import { reactivePick } from '@vueuse/core'
|
||||
import UToaster from './Toaster.vue'
|
||||
import UModalProvider from './ModalProvider.vue'
|
||||
import USlideoverProvider from './SlideoverProvider.vue'
|
||||
import UOverlayProvider from './OverlayProvider.vue'
|
||||
|
||||
const props = defineProps<AppProps>()
|
||||
defineSlots<AppSlots>()
|
||||
@@ -48,8 +47,7 @@ provide(localeContextInjectionKey, locale)
|
||||
</UToaster>
|
||||
<slot v-else />
|
||||
|
||||
<UModalProvider />
|
||||
<USlideoverProvider />
|
||||
<UOverlayProvider />
|
||||
</TooltipProvider>
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
|
||||
@@ -56,7 +56,9 @@ export interface ModalProps extends DialogRootProps {
|
||||
ui?: Partial<typeof modal.slots>
|
||||
}
|
||||
|
||||
export interface ModalEmits extends DialogRootEmits {}
|
||||
export interface ModalEmits extends DialogRootEmits {
|
||||
'after:leave': []
|
||||
}
|
||||
|
||||
export interface ModalSlots {
|
||||
default(props: { open: boolean }): any
|
||||
@@ -126,7 +128,7 @@ const ui = computed(() => modal({
|
||||
<DialogPortal :disabled="!portal">
|
||||
<DialogOverlay v-if="overlay" :class="ui.overlay({ class: props.ui?.overlay })" />
|
||||
|
||||
<DialogContent :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" v-on="contentEvents">
|
||||
<DialogContent :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" @after-leave="emits('after:leave')" v-on="contentEvents">
|
||||
<VisuallyHidden v-if="!!slots.content && ((title || !!slots.title) || (description || !!slots.description))">
|
||||
<DialogTitle v-if="title || !!slots.title">
|
||||
<slot name="title">
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { useModal, modalInjectionKey } from '../composables/useModal'
|
||||
|
||||
const modalState = inject(modalInjectionKey)
|
||||
|
||||
const { isOpen } = useModal()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="modalState.component" v-if="modalState" v-bind="modalState.props" v-model:open="isOpen" />
|
||||
</template>
|
||||
26
src/runtime/components/OverlayProvider.vue
Normal file
26
src/runtime/components/OverlayProvider.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
const { overlays, unMount, close } = useOverlay()
|
||||
|
||||
const mountedOverlays = computed(() => overlays.filter(overlay => overlay.isMounted))
|
||||
|
||||
const onAfterLeave = (id: symbol) => {
|
||||
close(id)
|
||||
unMount(id)
|
||||
}
|
||||
|
||||
const onClose = (id: symbol, value: any) => {
|
||||
close(id, value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="overlay.component"
|
||||
v-for="overlay in mountedOverlays"
|
||||
:key="overlay.id"
|
||||
v-bind="overlay.props"
|
||||
v-model:open="overlay.modelValue"
|
||||
@close="(value:any) => onClose(overlay.id, value)"
|
||||
@after:leave="onAfterLeave(overlay.id)"
|
||||
/>
|
||||
</template>
|
||||
@@ -55,7 +55,9 @@ export interface SlideoverProps extends DialogRootProps {
|
||||
ui?: Partial<typeof slideover.slots>
|
||||
}
|
||||
|
||||
export interface SlideoverEmits extends DialogRootEmits {}
|
||||
export interface SlideoverEmits extends DialogRootEmits {
|
||||
'after:leave': []
|
||||
}
|
||||
|
||||
export interface SlideoverSlots {
|
||||
default(props: { open: boolean }): any
|
||||
@@ -126,7 +128,7 @@ const ui = computed(() => slideover({
|
||||
<DialogPortal :disabled="!portal">
|
||||
<DialogOverlay v-if="overlay" :class="ui.overlay({ class: props.ui?.overlay })" />
|
||||
|
||||
<DialogContent :data-side="side" :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" v-on="contentEvents">
|
||||
<DialogContent :data-side="side" :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })" v-bind="contentProps" @after-leave="emits('after:leave')" v-on="contentEvents">
|
||||
<VisuallyHidden v-if="!!slots.content && ((title || !!slots.title) || (description || !!slots.description))">
|
||||
<DialogTitle v-if="title || !!slots.title">
|
||||
<slot name="title">
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
import { slideoverInjectionKey, useSlideover } from '../composables/useSlideover'
|
||||
|
||||
const slideoverState = inject(slideoverInjectionKey)
|
||||
|
||||
const { isOpen } = useSlideover()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="slideoverState.component" v-if="slideoverState" v-bind="slideoverState.props" v-model:open="isOpen" />
|
||||
</template>
|
||||
@@ -1,71 +0,0 @@
|
||||
import { ref, inject } from 'vue'
|
||||
import type { ShallowRef, Component, InjectionKey } from 'vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import type { ModalProps } from '../types'
|
||||
|
||||
export interface ModalState {
|
||||
component: Component | string
|
||||
props: ModalProps
|
||||
}
|
||||
|
||||
export const modalInjectionKey: InjectionKey<ShallowRef<ModalState>> = Symbol('nuxt-ui.modal')
|
||||
|
||||
function _useModal() {
|
||||
const modalState = inject(modalInjectionKey)
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
function open<T extends Component>(component: T, props?: ModalProps & ComponentProps<T>) {
|
||||
if (!modalState) {
|
||||
throw new Error('useModal() is called without provider')
|
||||
}
|
||||
|
||||
modalState.value = {
|
||||
component,
|
||||
props: props ?? {}
|
||||
}
|
||||
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
async function close() {
|
||||
if (!modalState) return
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (!modalState) return
|
||||
|
||||
modalState.value = {
|
||||
component: 'div',
|
||||
props: {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows updating the modal props
|
||||
*/
|
||||
function patch<T extends Component = Record<string, never>>(props: Partial<ModalProps & ComponentProps<T>>) {
|
||||
if (!modalState) return
|
||||
|
||||
modalState.value = {
|
||||
...modalState.value,
|
||||
props: {
|
||||
...modalState.value.props,
|
||||
...props
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
reset,
|
||||
patch,
|
||||
isOpen
|
||||
}
|
||||
}
|
||||
|
||||
export const useModal = createSharedComposable(_useModal)
|
||||
118
src/runtime/composables/useOverlay.ts
Normal file
118
src/runtime/composables/useOverlay.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { Component } from 'vue'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
export type OverlayOptions<OverlayAttrs = Record<string, any>> = {
|
||||
defaultOpen?: boolean
|
||||
props?: OverlayAttrs
|
||||
destroyOnClose?: boolean
|
||||
}
|
||||
|
||||
type ManagedOverlayOptionsPrivate<T extends Component> = {
|
||||
component?: T
|
||||
id: symbol
|
||||
isMounted: boolean
|
||||
modelValue: boolean
|
||||
resolvePromise?: (value: unknown) => void
|
||||
}
|
||||
export type Overlay = OverlayOptions<Component> & ManagedOverlayOptionsPrivate<Component>
|
||||
|
||||
interface OverlayInstance<T> {
|
||||
open: (props?: ComponentProps<T>) => Promise<any>
|
||||
close: (value?: any) => void
|
||||
patch: (props: Partial<ComponentProps<T>>) => void
|
||||
}
|
||||
|
||||
function _useOverlay() {
|
||||
const overlays = shallowReactive<Overlay[]>([])
|
||||
|
||||
const create = <T extends Component>(component: T, _options?: OverlayOptions<ComponentProps<T>>): OverlayInstance<T> => {
|
||||
const { props: props, defaultOpen, destroyOnClose } = _options || {}
|
||||
|
||||
const options = reactive<Overlay>({
|
||||
id: Symbol(import.meta.dev ? 'useOverlay' : ''),
|
||||
modelValue: !!defaultOpen,
|
||||
component: markRaw(component!),
|
||||
isMounted: !!defaultOpen,
|
||||
destroyOnClose: !!destroyOnClose,
|
||||
props: props || {}
|
||||
})
|
||||
|
||||
overlays.push(options)
|
||||
|
||||
return {
|
||||
open: <T extends Component>(props?: ComponentProps<T>) => open(options.id, props),
|
||||
close: value => close(options.id, value),
|
||||
patch: <T extends Component>(props: Partial<ComponentProps<T>>) => patch(options.id, props)
|
||||
}
|
||||
}
|
||||
|
||||
const open = <T extends Component>(id: symbol, props?: ComponentProps<T>): Promise<any> => {
|
||||
const overlay = getOverlay(id)
|
||||
|
||||
// If props are provided, update the overlay's props
|
||||
if (props) {
|
||||
patch(overlay.id, props)
|
||||
}
|
||||
|
||||
overlay.modelValue = true
|
||||
overlay.isMounted = true
|
||||
|
||||
// Return a new promise that will be resolved when close is called
|
||||
return new Promise((resolve) => {
|
||||
overlay.resolvePromise = resolve
|
||||
})
|
||||
}
|
||||
|
||||
const close = (id: symbol, value?: any): void => {
|
||||
const overlay = getOverlay(id)
|
||||
|
||||
overlay.modelValue = false
|
||||
|
||||
// Resolve the promise if it exists
|
||||
if (overlay.resolvePromise) {
|
||||
overlay.resolvePromise(value)
|
||||
overlay.resolvePromise = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const unMount = (id: symbol): void => {
|
||||
const overlay = getOverlay(id)
|
||||
|
||||
overlay.isMounted = false
|
||||
|
||||
if (overlay.destroyOnClose) {
|
||||
const index = overlays.findIndex(overlay => overlay.id === id)
|
||||
overlays.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const patch = <T extends Component>(id: symbol, props: Partial<ComponentProps<T>>): void => {
|
||||
const overlay = getOverlay(id)
|
||||
|
||||
Object.entries(props!).forEach(([key, value]) => {
|
||||
(overlay.props as any)[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
const getOverlay = (id: symbol): Overlay => {
|
||||
const overlay = overlays.find(overlay => overlay.id === id)
|
||||
|
||||
if (!overlay) {
|
||||
throw new Error('Overlay not found')
|
||||
}
|
||||
|
||||
return overlay
|
||||
}
|
||||
|
||||
return {
|
||||
overlays,
|
||||
open,
|
||||
close,
|
||||
create,
|
||||
patch,
|
||||
unMount
|
||||
}
|
||||
}
|
||||
|
||||
export const useOverlay = createSharedComposable(_useOverlay)
|
||||
@@ -1,71 +0,0 @@
|
||||
import { ref, inject } from 'vue'
|
||||
import type { ShallowRef, Component, InjectionKey } from 'vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import type { SlideoverProps } from '../types'
|
||||
|
||||
export interface SlideoverState {
|
||||
component: Component | string
|
||||
props: SlideoverProps
|
||||
}
|
||||
|
||||
export const slideoverInjectionKey: InjectionKey<ShallowRef<SlideoverState>> = Symbol('nuxt-ui.slideover')
|
||||
|
||||
function _useSlideover() {
|
||||
const slideoverState = inject(slideoverInjectionKey)
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
function open<T extends Component>(component: T, props?: SlideoverProps & ComponentProps<T>) {
|
||||
if (!slideoverState) {
|
||||
throw new Error('useSlideover() is called without provider')
|
||||
}
|
||||
|
||||
slideoverState.value = {
|
||||
component,
|
||||
props: props ?? {}
|
||||
}
|
||||
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
async function close() {
|
||||
if (!slideoverState) return
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (!slideoverState) return
|
||||
|
||||
slideoverState.value = {
|
||||
component: 'div',
|
||||
props: {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows updating the slideover props
|
||||
*/
|
||||
function patch<T extends Component = Record<string, never>>(props: Partial<SlideoverState & ComponentProps<T>>) {
|
||||
if (!slideoverState) return
|
||||
|
||||
slideoverState.value = {
|
||||
...slideoverState.value,
|
||||
props: {
|
||||
...slideoverState.value.props,
|
||||
...props
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
reset,
|
||||
patch,
|
||||
isOpen
|
||||
}
|
||||
}
|
||||
|
||||
export const useSlideover = createSharedComposable(_useSlideover)
|
||||
@@ -1,13 +0,0 @@
|
||||
import { shallowRef } from 'vue'
|
||||
import { defineNuxtPlugin } from '#imports'
|
||||
// FIXME: https://github.com/nuxt/module-builder/issues/141#issuecomment-2078248248
|
||||
import type {} from '#app'
|
||||
import { modalInjectionKey, type ModalState } from '../composables/useModal'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const modalState = shallowRef<ModalState>({
|
||||
component: 'div',
|
||||
props: {}
|
||||
})
|
||||
nuxtApp.vueApp.provide(modalInjectionKey, modalState)
|
||||
})
|
||||
@@ -1,14 +0,0 @@
|
||||
import { shallowRef } from 'vue'
|
||||
import { defineNuxtPlugin } from '#imports'
|
||||
// FIXME: https://github.com/nuxt/module-builder/issues/141#issuecomment-2078248248
|
||||
import type {} from '#app'
|
||||
import { slideoverInjectionKey, type SlideoverState } from '../composables/useSlideover'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const slideoverState = shallowRef<SlideoverState>({
|
||||
component: 'div',
|
||||
props: {}
|
||||
})
|
||||
|
||||
nuxtApp.vueApp.provide(slideoverInjectionKey, slideoverState)
|
||||
})
|
||||
Reference in New Issue
Block a user