20260816220132
This commit is contained in:
1 parent
b6f9a4c912
commit
72993ecdcd
179 files changed
+20050
-633
No files matched your search
@@ -0,0 +1,274 @@
|
||||
<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>
|
||||
Reference in new issue
Block a user