20260816220132

This commit is contained in:
oneao committed 2026-08-16 22:01:32 +08:00
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;
}
}