20260816220132
This commit is contained in:
1 parent
b6f9a4c912
commit
72993ecdcd
179 files changed
+20050
-633
No files matched your search
@@ -0,0 +1,213 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
|
||||
import { X } from '@lucide/vue'
|
||||
import { focusFirst, trapFocus } from '../utils/focus.js'
|
||||
import { lockScroll, unlockScroll } from '../utils/scroll.js'
|
||||
import { 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: '' },
|
||||
/** 滑出方向 */
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'right',
|
||||
validator: (v) => ['left', 'right', 'top', 'bottom'].includes(v),
|
||||
},
|
||||
/** 面板宽度(left/right 方向生效,px 数字或 CSS 宽度字符串) */
|
||||
width: { type: [Number, String], default: 378 },
|
||||
/** 面板高度(top/bottom 方向生效,px 数字或 CSS 高度字符串) */
|
||||
height: { type: [Number, String], default: 378 },
|
||||
/** 是否显示遮罩 */
|
||||
mask: { type: Boolean, default: true },
|
||||
/** 点击遮罩关闭 */
|
||||
maskClosable: { type: Boolean, default: true },
|
||||
/** Esc 关闭 */
|
||||
keyboard: { type: Boolean, default: true },
|
||||
/** 显示右上角关闭按钮 */
|
||||
closable: { type: Boolean, default: true },
|
||||
/** 允许拖动面板边缘调整尺寸(左右方向改宽度,上下方向改高度) */
|
||||
resizable: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'cancel'])
|
||||
|
||||
/* ---------- 公共状态 ---------- */
|
||||
const innerOpen = ref(props.defaultOpen)
|
||||
const panelRef = ref(null)
|
||||
// 标题与面板的关联 id,useId 保证多实例并存时不冲突
|
||||
const titleId = useId()
|
||||
// 打开前的活动元素,关闭后还原焦点
|
||||
let triggerEl = null
|
||||
// 缩放句柄 { destroy },面板打开期间存在
|
||||
let resizeHandle = null
|
||||
// 缩放后的尺寸(覆盖 width/height prop);关闭后重置
|
||||
const resizeSize = ref(null)
|
||||
const resizeRef = 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 isHorizontal = computed(() => props.placement === 'left' || props.placement === 'right')
|
||||
|
||||
// 左右方向用 width,上下方向用 height;数字转 px
|
||||
const panelSize = computed(() => {
|
||||
const value = isHorizontal.value ? props.width : props.height
|
||||
return typeof value === 'number' ? `${value}px` : value
|
||||
})
|
||||
|
||||
// 面板尺寸:缩放后使用实际像素值,否则用 prop
|
||||
const panelStyle = computed(() => {
|
||||
if (isHorizontal.value) {
|
||||
return { width: resizeSize.value ? `${resizeSize.value}px` : panelSize.value }
|
||||
}
|
||||
return { height: resizeSize.value ? `${resizeSize.value}px` : panelSize.value }
|
||||
})
|
||||
|
||||
// 缩放手柄方向:left 面板拖右缘、right 拖左缘、top 拖底缘、bottom 拖顶缘
|
||||
const resizeDirection = computed(() => {
|
||||
switch (props.placement) {
|
||||
case 'left':
|
||||
return 'e'
|
||||
case 'right':
|
||||
return 'w'
|
||||
case 'top':
|
||||
return 's'
|
||||
default:
|
||||
return 'n'
|
||||
}
|
||||
})
|
||||
|
||||
/* ---------- 公共方法 ---------- */
|
||||
function setOpen(value) {
|
||||
isOpen.value = value
|
||||
}
|
||||
|
||||
function onMaskClick() {
|
||||
if (props.maskClosable) requestClose()
|
||||
}
|
||||
|
||||
// 遮罩/Esc/关闭按钮统一入口
|
||||
function requestClose() {
|
||||
emit('cancel')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
/* ---------- 键盘与焦点 ---------- */
|
||||
function onPanelKeydown(event) {
|
||||
if (props.keyboard && event.key === 'Escape') {
|
||||
requestClose()
|
||||
return
|
||||
}
|
||||
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)
|
||||
// 边缘缩放手柄:只改对应轴,不超过视口
|
||||
if (props.resizable) {
|
||||
resizeHandle = bindResize(panelRef.value, resizeRef.value, {
|
||||
direction: resizeDirection.value,
|
||||
maxWidth: window.innerWidth,
|
||||
maxHeight: window.innerHeight,
|
||||
onResize: ({ width, height }) => {
|
||||
resizeSize.value = isHorizontal.value ? width : height
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
unlockScroll()
|
||||
if (panelRef.value) panelRef.value.removeEventListener('keydown', onPanelKeydown)
|
||||
if (resizeHandle) {
|
||||
resizeHandle.destroy()
|
||||
resizeHandle = null
|
||||
}
|
||||
// 重置缩放状态,下次打开不继承
|
||||
resizeSize.value = null
|
||||
if (triggerEl && typeof triggerEl.focus === 'function') triggerEl.focus()
|
||||
triggerEl = null
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (panelRef.value) panelRef.value.removeEventListener('keydown', onPanelKeydown)
|
||||
if (resizeHandle) resizeHandle.destroy()
|
||||
unlockScroll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fms-drawer-mask">
|
||||
<div v-if="isOpen && mask" class="fms-drawer-mask" aria-hidden="true" @click="onMaskClick"></div>
|
||||
</Transition>
|
||||
<Transition name="fms-drawer">
|
||||
<div v-if="isOpen" class="fms-drawer-wrap" :class="`fms-drawer-${placement}`">
|
||||
<section
|
||||
ref="panelRef"
|
||||
class="fms-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title ? undefined : '抽屉'"
|
||||
:aria-labelledby="title ? titleId : undefined"
|
||||
tabindex="-1"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<header v-if="title || closable || $slots.extra" class="fms-drawer-header">
|
||||
<div class="fms-drawer-header-main">
|
||||
<p v-if="title" :id="titleId" class="fms-drawer-title">{{ title }}</p>
|
||||
<div v-if="$slots.extra" class="fms-drawer-extra">
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="closable"
|
||||
type="button"
|
||||
class="fms-drawer-close"
|
||||
aria-label="关闭"
|
||||
@click="requestClose"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="fms-drawer-body">
|
||||
<slot />
|
||||
</div>
|
||||
<footer v-if="$slots.footer" class="fms-drawer-footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
<!-- 边缘缩放手柄:拖动调整面板尺寸 -->
|
||||
<div
|
||||
v-if="resizable"
|
||||
ref="resizeRef"
|
||||
class="fms-drawer-resizer"
|
||||
:class="`fms-drawer-resizer-${resizeDirection}`"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
@@ -0,0 +1,270 @@
|
||||
/* ---------- 遮罩 ---------- */
|
||||
.fms-drawer-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--fms-z-modal);
|
||||
background: var(--fms-mask-bg);
|
||||
}
|
||||
|
||||
.fms-drawer-mask-enter-active,
|
||||
.fms-drawer-mask-leave-active {
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.fms-drawer-mask-enter-from,
|
||||
.fms-drawer-mask-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ---------- 定位容器:按方向吸附到视口边缘 ---------- */
|
||||
.fms-drawer-wrap {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--fms-z-modal);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 面板本体可交互,容器不拦截遮罩之外区域的点击 */
|
||||
.fms-drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
background: var(--fms-card);
|
||||
box-shadow: var(--fms-popover-shadow);
|
||||
color: var(--fms-text);
|
||||
font-size: var(--fms-font-size);
|
||||
line-height: var(--fms-line-height);
|
||||
outline: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fms-drawer:focus-visible {
|
||||
outline: 2px solid var(--fms-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 左右方向:撑满垂直高度;上下方向:撑满水平宽度 */
|
||||
.fms-drawer-left .fms-drawer,
|
||||
.fms-drawer-right .fms-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.fms-drawer-top .fms-drawer,
|
||||
.fms-drawer-bottom .fms-drawer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.fms-drawer-left .fms-drawer {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.fms-drawer-right .fms-drawer {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.fms-drawer-top .fms-drawer {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.fms-drawer-bottom .fms-drawer {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* ---------- 头部:extra + 标题 + 关闭按钮 ---------- */
|
||||
.fms-drawer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
padding: var(--fms-drawer-header-padding);
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
.fms-drawer-header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fms-drawer-extra {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fms-drawer-title {
|
||||
margin: 0;
|
||||
font-size: var(--fms-font-size);
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.fms-drawer-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
}
|
||||
|
||||
.fms-drawer-close:hover {
|
||||
background: var(--fms-secondary-hover);
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
.fms-drawer-close:focus-visible {
|
||||
outline: 2px solid var(--fms-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---------- 内容区:撑满剩余空间并可滚动 ---------- */
|
||||
.fms-drawer-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--fms-drawer-body-padding);
|
||||
color: var(--fms-text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ---------- 底部 ---------- */
|
||||
.fms-drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: var(--fms-drawer-footer-padding);
|
||||
border-top: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
/* ---------- 缩放手柄:沿边缘可拖拽调整尺寸 ---------- */
|
||||
/* 命中区保持 6px 透明;拖拽线用 --fms-splitter-bar-size-hover 控制宽度,与 splitter 一致 */
|
||||
.fms-drawer-resizer {
|
||||
position: absolute;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* 拖拽线:hover / 拖拽时显示居中的灰色细线 */
|
||||
.fms-drawer-resizer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--fms-text-secondary);
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.fms-drawer-resizer:hover::before,
|
||||
.fms-drawer-resizer.is-resizing::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 左右抽屉:拖纵向边缘(e 右缘 / w 左缘)调整宽度 */
|
||||
.fms-drawer-resizer-e,
|
||||
.fms-drawer-resizer-w {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.fms-drawer-resizer-e::before,
|
||||
.fms-drawer-resizer-w::before {
|
||||
width: var(--fms-splitter-bar-size-hover);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fms-drawer-resizer-e {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.fms-drawer-resizer-w {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* 上下抽屉:拖横向边缘(s 底缘 / n 顶缘)调整高度 */
|
||||
.fms-drawer-resizer-s,
|
||||
.fms-drawer-resizer-n {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 6px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.fms-drawer-resizer-s::before,
|
||||
.fms-drawer-resizer-n::before {
|
||||
width: 100%;
|
||||
height: var(--fms-splitter-bar-size-hover);
|
||||
}
|
||||
|
||||
.fms-drawer-resizer-s {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.fms-drawer-resizer-n {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* ---------- 面板过渡:按方向滑入滑出 ---------- */
|
||||
.fms-drawer-enter-active,
|
||||
.fms-drawer-leave-active {
|
||||
/* fade 与 slide 时长保持同步(300ms),避免淡出提前完成导致滑出后半段不可见 */
|
||||
transition:
|
||||
opacity 300ms ease,
|
||||
transform 300ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.fms-drawer-enter-from,
|
||||
.fms-drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 位移动画作用于 wrap 自身(与 transition 同一元素,transition 不继承):
|
||||
全屏容器按方向移出视口,面板随之滑入/滑出 */
|
||||
.fms-drawer-left.fms-drawer-enter-from,
|
||||
.fms-drawer-left.fms-drawer-leave-to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.fms-drawer-right.fms-drawer-enter-from,
|
||||
.fms-drawer-right.fms-drawer-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.fms-drawer-top.fms-drawer-enter-from,
|
||||
.fms-drawer-top.fms-drawer-leave-to {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.fms-drawer-bottom.fms-drawer-enter-from,
|
||||
.fms-drawer-bottom.fms-drawer-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fms-drawer-enter-active,
|
||||
.fms-drawer-leave-active,
|
||||
.fms-drawer-mask-enter-active,
|
||||
.fms-drawer-mask-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user