Files
workspace/code/fms/fms-vue/src/components/ui/modal/modal.vue
T
2026-08-17 22:52:27 +08:00

291 lines
9.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
import { Maximize, Minimize, X } from '@lucide/vue'
import Button from '../button/button.vue'
import { focusFirst, trapFocus } from '../utils/focus.js'
import { lockScroll, unlockScroll } from '../utils/scroll.js'
import { bindDrag, bindResize } from '../utils/drag.js'
/* ---------- props / emits / slots ---------- */
const props = defineProps({
/** 受控打开状态;不传则非受控,由 defaultOpen + 内部状态控制 */
open: { type: Boolean, default: undefined },
/** 非受控时的初始打开状态 */
defaultOpen: { type: Boolean, default: false },
/** 标题文字 */
title: { type: String, default: '' },
/** 面板宽度(px 数字或任意 CSS 宽度字符串) */
width: { type: [Number, String], default: 520 },
/** 垂直居中显示 */
centered: { type: Boolean, default: false },
/** 是否显示遮罩 */
mask: { type: Boolean, default: true },
/** 点击遮罩关闭 */
maskClosable: { type: Boolean, default: true },
/** Esc 关闭 */
keyboard: { type: Boolean, default: true },
/** 显示右上角关闭按钮 */
closable: { type: Boolean, default: true },
/** 显示右上角全屏切换按钮 */
fullscreen: { type: Boolean, default: true },
/** 允许拖动右下角手柄调整宽高 */
resizable: { type: Boolean, default: true },
/** 显示底部按钮区(确定/取消);配合 #footer 插槽自定义,传 false 隐藏 */
footer: { type: Boolean, default: true },
/** 确定按钮文字 */
okText: { type: String, default: '确定' },
/** 取消按钮文字 */
cancelText: { type: String, default: '取消' },
/** 确定按钮加载态;加载期间阻止一切关闭操作 */
confirmLoading: { type: Boolean, default: false },
})
const emit = defineEmits(['update:open', 'ok', 'cancel'])
/* ---------- 公共状态 ---------- */
const innerOpen = ref(props.defaultOpen)
const panelRef = ref(null)
const headerRef = ref(null)
// 标题/描述与面板的关联 id,useId 保证多实例并存时不冲突
const titleId = useId()
// 打开前的活动元素,关闭后还原焦点
let triggerEl = null
// 拖拽句柄 { reset, destroy },面板打开期间存在
let dragHandle = null
// 缩放句柄数组 { destroy },面板打开期间存在
let resizeHandles = []
// 全屏状态;关闭后重置,下次打开不继承
const isFullscreen = ref(false)
// 缩放后的尺寸(覆盖 width prop);关闭后重置
const resizeSize = ref(null)
const resizeERef = ref(null)
const resizeWRef = ref(null)
const resizeSRef = ref(null)
const resizeNRef = ref(null)
const isOpen = computed({
get: () => (props.open !== undefined ? props.open : innerOpen.value),
set: (value) => {
if (props.open === undefined) innerOpen.value = value
emit('update:open', value)
},
})
const panelWidth = computed(() =>
typeof props.width === 'number' ? `${props.width}px` : props.width,
)
// 面板尺寸:缩放后使用实际宽高(px),否则用 width prop
const panelStyle = computed(() => {
if (resizeSize.value) {
return {
width: `${resizeSize.value.width}px`,
height: `${resizeSize.value.height}px`,
}
}
return { width: panelWidth.value }
})
/* ---------- 公共方法 ---------- */
// 所有关闭入口(遮罩/Esc/关闭按钮/取消按钮)统一走这里;
// confirmLoading 期间不响应,避免异步提交流程被打断
function requestClose() {
if (props.confirmLoading) return
emit('cancel')
setOpen(false)
}
function setOpen(value) {
isOpen.value = value
}
function onOkClick() {
emit('ok')
}
function toggleFullscreen() {
isFullscreen.value = !isFullscreen.value
}
// 遮罩点击关闭:wrap 铺满视口且层级高于 mask,事件实际落在 wrap 上;
// 只响应点击 wrap 自身(面板外空白/遮罩区域),点击面板内部不关闭
function onWrapClick(event) {
if (event.target === event.currentTarget && props.maskClosable) {
requestClose()
}
}
/* ---------- 键盘与焦点 ---------- */
function onPanelKeydown(event) {
if (props.keyboard && event.key === 'Escape') {
requestClose()
return
}
// Tab 在面板内循环;焦点被陷阱约束在面板中,Esc 与 Tab 必然到达此监听
trapFocus(event, panelRef.value)
}
/* ---------- 生命周期:打开/关闭时接管焦点与滚动 ---------- */
watch(isOpen, (value) => {
if (value) {
// 记录触发元素,关闭后还原焦点
triggerEl = document.activeElement
lockScroll()
nextTick(() => {
if (!isOpen.value || !panelRef.value) return
focusFirst(panelRef.value)
panelRef.value.addEventListener('keydown', onPanelKeydown)
// 标题栏作为拖拽手柄(面板与手柄此刻已渲染完成)
dragHandle = bindDrag(panelRef.value, headerRef.value)
// 四边缩放手柄:e/w 调宽度、s/n 调高度,均不超过视口(wrap 留出 32px 边距 / 124px 顶底)
if (props.resizable) {
resizeHandles = [
{ el: resizeERef.value, direction: 'e' },
{ el: resizeWRef.value, direction: 'w' },
{ el: resizeSRef.value, direction: 's' },
{ el: resizeNRef.value, direction: 'n' },
]
.filter((item) => item.el)
.map(({ el, direction }) =>
bindResize(panelRef.value, el, {
direction,
maxWidth: window.innerWidth - 32,
maxHeight: window.innerHeight - 124,
onResize: (size) => {
resizeSize.value = size
},
}),
)
}
})
} else {
unlockScroll()
if (panelRef.value) panelRef.value.removeEventListener('keydown', onPanelKeydown)
// 不清除 transform:面板保持拖拽位置播放消失动画(v-if 随后销毁 DOM,无残留)
if (dragHandle) {
dragHandle.destroy()
dragHandle = null
}
resizeHandles.forEach((h) => h.destroy())
resizeHandles = []
// 重置全屏与缩放状态,下次打开不继承
isFullscreen.value = false
resizeSize.value = null
if (triggerEl && typeof triggerEl.focus === 'function') triggerEl.focus()
triggerEl = null
}
})
// 全屏与拖拽互斥:进入全屏时停用拖拽并复位位置,退出后重新启用
watch(isFullscreen, (value) => {
if (!isOpen.value) return
if (value) {
if (dragHandle) {
dragHandle.reset()
dragHandle.destroy()
dragHandle = null
}
} else if (panelRef.value && headerRef.value) {
dragHandle = bindDrag(panelRef.value, headerRef.value)
}
})
onBeforeUnmount(() => {
if (panelRef.value) panelRef.value.removeEventListener('keydown', onPanelKeydown)
if (dragHandle) dragHandle.destroy()
resizeHandles.forEach((h) => h.destroy())
unlockScroll()
})
</script>
<template>
<Teleport to="body">
<Transition name="fms-modal-mask">
<div v-if="isOpen && mask" class="fms-modal-mask" aria-hidden="true"></div>
</Transition>
<Transition name="fms-modal">
<div
v-if="isOpen"
class="fms-modal-wrap"
:class="{ 'fms-modal-centered': centered }"
@click="onWrapClick"
>
<div
ref="panelRef"
class="fms-modal"
:class="{ 'fms-modal-fullscreen': isFullscreen }"
role="dialog"
aria-modal="true"
:aria-label="title ? undefined : '对话框'"
:aria-labelledby="title ? titleId : undefined"
tabindex="-1"
:style="panelStyle"
>
<div ref="headerRef" class="fms-modal-header">
<p v-if="title" :id="titleId" class="fms-modal-title">{{ title }}</p>
<div class="fms-modal-header-actions">
<button
v-if="fullscreen"
type="button"
class="fms-modal-header-btn"
:aria-label="isFullscreen ? '退出全屏' : '全屏'"
@click="toggleFullscreen"
>
<Minimize v-if="isFullscreen" :size="14" />
<Maximize v-else :size="14" />
</button>
<button
v-if="closable"
type="button"
class="fms-modal-header-btn"
aria-label="关闭"
@click="requestClose"
>
<X :size="14" />
</button>
</div>
</div>
<div class="fms-modal-body">
<slot />
</div>
<div v-if="footer" class="fms-modal-footer">
<slot name="footer">
<Button type="outline" :disabled="confirmLoading" @click="requestClose">
{{ cancelText }}
</Button>
<Button :loading="confirmLoading" @click="onOkClick">{{ okText }}</Button>
</slot>
</div>
<!-- 四边缩放手柄:拖动调整面板宽高,全屏时不可用 -->
<template v-if="resizable && !isFullscreen">
<div
ref="resizeERef"
class="fms-modal-resizer fms-modal-resizer-e"
aria-hidden="true"
></div>
<div
ref="resizeWRef"
class="fms-modal-resizer fms-modal-resizer-w"
aria-hidden="true"
></div>
<div
ref="resizeSRef"
class="fms-modal-resizer fms-modal-resizer-s"
aria-hidden="true"
></div>
<div
ref="resizeNRef"
class="fms-modal-resizer fms-modal-resizer-n"
aria-hidden="true"
></div>
</template>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped lang="scss">
@use './index.scss';
</style>