291 lines
9.6 KiB
Vue
291 lines
9.6 KiB
Vue
<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>
|