feat: FaModal 和 FaDrawer 分别增加命令式调用API

This commit is contained in:
Hooray
2025-07-25 14:09:50 +08:00
parent 6549b8ffbf
commit d390daeead
5 changed files with 305 additions and 165 deletions
+110
View File
@@ -0,0 +1,110 @@
import type { Component, HTMLAttributes } from 'vue'
import { isVNode } from 'vue'
import Drawer from './index.vue'
export interface DrawerProps {
id?: string
modelValue?: boolean
side?: 'top' | 'bottom' | 'left' | 'right'
title?: string
description?: string
loading?: boolean
closable?: boolean
centered?: boolean
bordered?: boolean
overlay?: boolean
overlayBlur?: boolean
showConfirmButton?: boolean
showCancelButton?: boolean
confirmButtonText?: string
cancelButtonText?: string
confirmButtonDisabled?: boolean
confirmButtonLoading?: boolean
beforeClose?: (
action: 'confirm' | 'cancel' | 'close',
done: () => void
) => void
header?: boolean
footer?: boolean
closeOnClickOverlay?: boolean
closeOnPressEscape?: boolean
destroyOnClose?: boolean
contentClass?: HTMLAttributes['class']
headerClass?: HTMLAttributes['class']
footerClass?: HTMLAttributes['class']
}
export interface DrawerEmits {
'update:modelValue': [value: boolean]
'open': []
'opened': []
'close': []
'closed': []
'confirm': []
'cancel': []
}
type BaseOptions = Omit<DrawerProps, 'modelValue'> & {
content?: Component | VNode | string
onOpen?: () => void
onOpened?: () => void
onClose?: () => void
onClosed?: () => void
onConfirm?: () => void
onCancel?: () => void
}
export function useFaDrawer() {
function create(initialOptions: BaseOptions) {
const container = document.createElement('div')
const visible = ref(false)
const options = reactive({ ...initialOptions })
const app = createApp({
render() {
return h(Drawer, Object.assign({
'id': useId(),
'modelValue': visible.value,
'onUpdate:modelValue': (val: boolean) => {
visible.value = val
},
}, options), {
default: () => {
if (typeof options.content === 'string') {
return options.content
}
else if (isVNode(options.content)) {
return options.content
}
else if (options.content) {
return h(options.content)
}
return null
},
})
},
})
// 继承主应用的上下文
const instance = getCurrentInstance()
if (instance && instance.appContext) {
Object.assign(app._context, instance.appContext)
}
app.mount(container)
const open = () => {
visible.value = true
}
const close = () => {
visible.value = false
}
const update = (newOptions: BaseOptions) => {
Object.assign(options, newOptions)
}
return {
open,
close,
update,
}
}
return {
create,
}
}
+66 -56
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { DrawerEmits, DrawerProps } from '.'
import { cn } from '@/utils'
import {
Sheet,
@@ -15,32 +15,7 @@ defineOptions({
})
const props = withDefaults(
defineProps<{
modelValue?: boolean
side?: 'top' | 'bottom' | 'left' | 'right'
title: string
description?: string
loading?: boolean
closable?: boolean
centered?: boolean
bordered?: boolean
overlay?: boolean
overlayBlur?: boolean
showConfirmButton?: boolean
showCancelButton?: boolean
confirmButtonText?: string
cancelButtonText?: string
confirmButtonDisabled?: boolean
confirmButtonLoading?: boolean
header?: boolean
footer?: boolean
closeOnClickOverlay?: boolean
closeOnPressEscape?: boolean
destroyOnClose?: boolean
contentClass?: HTMLAttributes['class']
headerClass?: HTMLAttributes['class']
footerClass?: HTMLAttributes['class']
}>(),
defineProps<DrawerProps>(),
{
modelValue: false,
side: 'right',
@@ -64,19 +39,9 @@ const props = withDefaults(
},
)
const emits = defineEmits<{
'update:modelValue': [value: boolean]
'open': []
'opened': []
'close': []
'closed': []
'confirm': []
'cancel': []
}>()
const id = useId()
provide('DrawerId', id)
const emits = defineEmits<DrawerEmits>()
const drawerId = shallowRef(props.id ?? useId())
const isOpen = ref(props.modelValue)
watch(() => props.modelValue, (newValue) => {
@@ -86,9 +51,16 @@ watch(() => props.modelValue, (newValue) => {
const hasOpened = ref(false)
const isClosed = ref(true)
watch(() => isOpen.value, (value) => {
watch(isOpen, (val) => {
emits('update:modelValue', val)
if (val) {
emits('open')
}
else {
emits('close')
}
isClosed.value = false
if (value && !hasOpened.value) {
if (val && !hasOpened.value) {
hasOpened.value = true
}
}, {
@@ -97,25 +69,62 @@ watch(() => isOpen.value, (value) => {
const forceMount = computed(() => !props.destroyOnClose && hasOpened.value)
function updateOpen(value: boolean) {
isOpen.value = value
emits('update:modelValue', value)
async function updateOpen(value: boolean) {
if (value) {
isOpen.value = value
emits('open')
}
else {
emits('close')
if (props.beforeClose) {
await props.beforeClose(
'close',
() => {
isOpen.value = value
emits('close')
},
)
}
else {
isOpen.value = value
emits('close')
}
}
}
function onConfirm() {
updateOpen(false)
emits('confirm')
const isConfirmButtonLoading = ref(false)
async function onConfirm() {
if (props.beforeClose) {
isConfirmButtonLoading.value = true
await props.beforeClose(
'confirm',
() => {
isOpen.value = false
emits('confirm')
},
)
isConfirmButtonLoading.value = false
}
else {
isOpen.value = false
emits('confirm')
}
}
function onCancel() {
updateOpen(false)
emits('cancel')
async function onCancel() {
if (props.beforeClose) {
await props.beforeClose(
'cancel',
() => {
isOpen.value = false
emits('cancel')
},
)
}
else {
isOpen.value = false
emits('cancel')
}
}
function handleFocusOutside(e: Event) {
@@ -124,7 +133,7 @@ function handleFocusOutside(e: Event) {
}
function handleClickOutside(e: Event) {
if (!props.closeOnClickOverlay || (e.target as HTMLElement).dataset.drawerId !== id) {
if (!props.closeOnClickOverlay || (e.target as HTMLElement).dataset.drawerId !== drawerId.value) {
e.preventDefault()
e.stopPropagation()
}
@@ -151,8 +160,9 @@ function handleAnimationEnd() {
<template>
<Sheet :modal="false" :open="isOpen" @update:open="updateOpen">
<SheetContent
:closable="props.closable"
:drawer-id="drawerId"
:open="isOpen"
:closable="props.closable"
:overlay="props.overlay"
:overlay-blur="props.overlayBlur"
:class="cn('w-full flex flex-col gap-0 p-0', props.contentClass, {
@@ -187,10 +197,10 @@ function handleAnimationEnd() {
<div class="p-4">
<slot />
</div>
<div v-show="props.loading" class="absolute inset-0 z-1000 size-full flex-center bg-popover/75">
<FaIcon name="i-line-md:loading-twotone-loop" class="size-10" />
</div>
</FaScrollArea>
<div v-show="props.loading" class="absolute inset-0 z-1000 size-full flex-center bg-popover/75">
<FaIcon name="i-line-md:loading-twotone-loop" class="size-10" />
</div>
</div>
<SheetFooter
v-if="footer" :class="cn('p-2 gap-y-2', props.footerClass, {
@@ -15,6 +15,7 @@ import { cn } from '@/utils'
import { sheetVariants } from '.'
interface SheetContentProps extends DialogContentProps {
drawerId: string
class?: HTMLAttributes['class']
open?: boolean
side?: SheetVariants['side']
@@ -51,8 +52,6 @@ watch(showOverlay, (val) => {
isLocked.value = false
}
})
const id = inject('DrawerId')
</script>
<template>
@@ -70,7 +69,7 @@ const id = inject('DrawerId')
>
<div
v-if="showOverlay"
:data-drawer-id="id"
:data-drawer-id="props.drawerId"
:class="cn('fixed inset-0 z-2000 data-[state=closed]:animate-out data-[state=open]:animate-in bg-black/50 data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0', {
'backdrop-blur-sm': props.overlayBlur,
})"
+116 -97
View File
@@ -1,7 +1,9 @@
import type { HTMLAttributes } from 'vue'
import type { Component, HTMLAttributes } from 'vue'
import { isVNode } from 'vue'
import Modal from './index.vue'
export interface ModalProps {
id?: string
modelValue?: boolean
title?: string
description?: string
@@ -46,125 +48,142 @@ export interface ModalEmits {
'cancel': []
}
type alertOptions = Pick<ModalProps, 'title' | 'description' | 'icon' | 'alignCenter' | 'overlay' | 'overlayBlur' | 'confirmButtonText' | 'confirmButtonDisabled' | 'confirmButtonLoading' | 'closeOnClickOverlay' | 'closeOnPressEscape' | 'class' | 'headerClass' | 'contentClass' | 'footerClass'> & {
content: string
onConfirm?: () => void
}
type confirmOptions = Pick<ModalProps, 'title' | 'description' | 'alignCenter' | 'overlay' | 'overlayBlur' | 'confirmButtonText' | 'cancelButtonText' | 'confirmButtonDisabled' | 'confirmButtonLoading' | 'beforeClose' | 'closeOnClickOverlay' | 'closeOnPressEscape' | 'class' | 'headerClass' | 'contentClass' | 'footerClass'> & {
content: string
type BaseOptions = Omit<ModalProps, 'modelValue'> & {
content?: Component | VNode | string
onOpen?: () => void
onOpened?: () => void
onClose?: () => void
onClosed?: () => void
onConfirm?: () => void
onCancel?: () => void
}
type alertOptions = Pick<BaseOptions, 'title' | 'description' | 'icon' | 'alignCenter' | 'overlay' | 'overlayBlur' | 'confirmButtonText' | 'confirmButtonDisabled' | 'confirmButtonLoading' | 'closeOnClickOverlay' | 'closeOnPressEscape' | 'class' | 'headerClass' | 'contentClass' | 'footerClass' | 'content' | 'onConfirm'>
type confirmOptions = Pick<BaseOptions, 'title' | 'description' | 'alignCenter' | 'overlay' | 'overlayBlur' | 'confirmButtonText' | 'cancelButtonText' | 'confirmButtonDisabled' | 'confirmButtonLoading' | 'beforeClose' | 'closeOnClickOverlay' | 'closeOnPressEscape' | 'class' | 'headerClass' | 'contentClass' | 'footerClass' | 'content' | 'onConfirm' | 'onCancel'>
export function useFaModal() {
function info(options: alertOptions) {
function create(initialOptions: BaseOptions) {
const container = document.createElement('div')
const visible = ref(false)
const options = reactive({ ...initialOptions })
const app = createApp({
render() {
return h(Modal, Object.assign({
modelValue: true,
icon: 'info',
closable: false,
border: false,
alignCenter: true,
closeOnClickOverlay: false,
contentClass: 'py-0 min-h-auto',
footerClass: 'p-4',
onClosed: () => {
app.unmount()
'id': useId(),
'modelValue': visible.value,
'onUpdate:modelValue': (val: boolean) => {
visible.value = val
},
}, options), () => options.content)
}, options), {
default: () => {
if (typeof options.content === 'string') {
return options.content
}
else if (isVNode(options.content)) {
return options.content
}
else if (options.content) {
return h(options.content)
}
return null
},
})
},
})
// 继承主应用的上下文
const instance = getCurrentInstance()
if (instance && instance.appContext) {
Object.assign(app._context, instance.appContext)
}
app.mount(container)
const open = () => {
visible.value = true
}
const close = () => {
visible.value = false
}
const update = (newOptions: BaseOptions) => {
Object.assign(options, newOptions)
}
return {
open,
close,
update,
}
}
function info(options: alertOptions) {
const defaultOptions: BaseOptions = {
icon: 'info',
closable: false,
border: false,
alignCenter: true,
closeOnClickOverlay: false,
destroyOnClose: true,
contentClass: 'py-0 min-h-auto',
footerClass: 'p-4',
}
const { open } = create(Object.assign(defaultOptions, options))
open()
}
function success(options: alertOptions) {
const container = document.createElement('div')
const app = createApp({
render() {
return h(Modal, Object.assign({
modelValue: true,
icon: 'success',
closable: false,
border: false,
alignCenter: true,
closeOnClickOverlay: false,
contentClass: 'py-0 min-h-auto',
footerClass: 'p-4',
onClosed: () => {
app.unmount()
},
}, options), () => options.content)
},
})
app.mount(container)
const defaultOptions: BaseOptions = {
icon: 'success',
closable: false,
border: false,
alignCenter: true,
closeOnClickOverlay: false,
destroyOnClose: true,
contentClass: 'py-0 min-h-auto',
footerClass: 'p-4',
}
const { open } = create(Object.assign(defaultOptions, options))
open()
}
function warning(options: alertOptions) {
const container = document.createElement('div')
const app = createApp({
render() {
return h(Modal, Object.assign({
modelValue: true,
icon: 'warning',
closable: false,
border: false,
alignCenter: true,
closeOnClickOverlay: false,
contentClass: 'py-0 min-h-auto',
footerClass: 'p-4',
onClosed: () => {
app.unmount()
},
}, options), () => options.content)
},
})
app.mount(container)
const defaultOptions: BaseOptions = {
icon: 'warning',
closable: false,
border: false,
alignCenter: true,
closeOnClickOverlay: false,
destroyOnClose: true,
contentClass: 'py-0 min-h-auto',
footerClass: 'p-4',
}
const { open } = create(Object.assign(defaultOptions, options))
open()
}
function error(options: alertOptions) {
const container = document.createElement('div')
const app = createApp({
render() {
return h(Modal, Object.assign({
modelValue: true,
icon: 'error',
closable: false,
border: false,
alignCenter: true,
closeOnClickOverlay: false,
contentClass: 'py-0 min-h-auto',
footerClass: 'p-4',
onClosed: () => {
app.unmount()
},
}, options), () => options.content)
},
})
app.mount(container)
const defaultOptions: BaseOptions = {
icon: 'error',
closable: false,
border: false,
alignCenter: true,
closeOnClickOverlay: false,
destroyOnClose: true,
contentClass: 'py-0 min-h-auto',
footerClass: 'p-4',
}
const { open } = create(Object.assign(defaultOptions, options))
open()
}
function confirm(options: confirmOptions) {
const container = document.createElement('div')
const app = createApp({
render() {
return h(Modal, Object.assign({
modelValue: true,
closable: false,
border: false,
alignCenter: true,
closeOnClickOverlay: false,
contentClass: 'py-0 min-h-auto',
footerClass: 'p-4',
showCancelButton: true,
onClosed: () => {
app.unmount()
},
}, options), () => options.content)
},
})
app.mount(container)
const defaultOptions: BaseOptions = {
closable: false,
border: false,
alignCenter: true,
showCancelButton: true,
closeOnClickOverlay: false,
destroyOnClose: true,
contentClass: 'py-0 min-h-auto',
footerClass: 'p-4',
}
const { open } = create(Object.assign(defaultOptions, options))
open()
}
return {
create,
info,
success,
warning,
+11 -9
View File
@@ -61,7 +61,7 @@ defineExpose({
areaRef: dialogAreaRef,
})
const modalId = useId()
const modalId = shallowRef(props.id ?? useId())
const isOpen = ref(props.modelValue)
const isMaximize = ref(props.maximize)
@@ -72,9 +72,16 @@ watch(() => props.modelValue, (newValue) => {
const hasOpened = ref(false)
const isClosed = ref(true)
watch(() => isOpen.value, (value) => {
watch(isOpen, (val) => {
emits('update:modelValue', val)
if (val) {
emits('open')
}
else {
emits('close')
}
isClosed.value = false
if (value && !hasOpened.value) {
if (val && !hasOpened.value) {
hasOpened.value = true
}
}, {
@@ -98,7 +105,6 @@ function setTransform() {
}
watch(isOpen, (val) => {
emits('update:modelValue', val)
if (val) {
nextTick(() => {
if (dialogContentRef.value) {
@@ -106,10 +112,6 @@ watch(isOpen, (val) => {
setTransform()
}
})
emits('open')
}
else {
emits('close')
}
})
@@ -177,7 +179,7 @@ function handleFocusOutside(e: Event) {
}
function handleClickOutside(e: Event) {
if (!props.closeOnClickOverlay || (e.target as HTMLElement).dataset.modalId !== modalId) {
if (!props.closeOnClickOverlay || (e.target as HTMLElement).dataset.modalId !== modalId.value) {
e.preventDefault()
e.stopPropagation()
}