🍀继分页器组件后,封装了个抽屉组件

Drawer 组件

效果图

使用

vue 复制代码
<template>
 <Button @click="openDrawer1">从右边显示抽屉</Button>
 <Button @click="openDrawer2">从左边显示抽屉</Button>
 <Button @click="openDrawer3">从上边显示抽屉</Button>
 <Button @click="openDrawer4">从下边显示抽屉</Button>

 <Drawer v-model="showDrawer1" direction="right" title="抽屉组件"></Drawer>
 <Drawer v-model="showDrawer2" direction="left" title="抽屉组件"></Drawer>
 <Drawer v-model="showDrawer3" direction="top" title="抽屉组件"></Drawer>
 <Drawer v-model="showDrawer4" direction="bottom" title="抽屉组件"></Drawer>
</template>
<script lang="ts" setup>
const showDrawer1 = ref(false);
const showDrawer2 = ref(false);
const showDrawer3 = ref(false);
const showDrawer4 = ref(false);
const openDrawer1 = () => {
  showDrawer1.value = true
}
const openDrawer2 = () => {
  showDrawer2.value = true
}
const openDrawer3 = () => {
  showDrawer3.value = true
}
const openDrawer4 = () => {
  showDrawer4.value = true
}
</script>

一、组件目录结构

text 复制代码
|-Drawer
   |--Drawer.vue(组件)
   |--style.css(样式)
   |--types.ts(类型定义)
   |--index.ts(入口文件)

二、Drawer.vue

vue 复制代码
<template>
  <Overlay :modelValue="modelValue" :modalClass="modalClass">
    <Transition :name="transitionName">
      <div
        ref="drawerRef"
        class="vh-drawer"
        :class="[`vh-drawer--${direction}`, customClass]"
        :style="drawerStyle"
        v-if="modelValue"
      >
        <div class="vh-drawer__header" v-if="title || $slots.header">
          <slot name="header">{{ title }}</slot>
          <Icon class="vh-drawer__close" icon="xmark" v-if="showClose" @click.stop="handleClose" />
        </div>
        <div class="vh-drawer__body">
          <slot />
        </div>
        <div class="vh-drawer__footer" v-if="$slots.footer">
          <slot name="footer" />
        </div>
      </div>
    </Transition>
  </Overlay>
</template>

<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import Icon from '../Icon/Icon.vue'
import Overlay from '../Overlay/Overlay.vue'
import type { DrawerProps, DrawerEmits } from './types'
import { isFunction } from 'lodash-es'

defineOptions({ name: 'vhDrawer' })

const props = withDefaults(defineProps<DrawerProps>(), {
  direction: 'right',
  size: '30%',
  title: '',
  showClose: true,
  closeOnClickModal: true,
  closeOnPressEscape: true,
  beforeClose: undefined,
  customClass: '',
  modal: true,
  modalClass: '',
  openDelay: 0,
  closeDelay: 0
})

const emits = defineEmits<DrawerEmits>()

const drawerRef = ref<HTMLElement>()
const isVisible = ref(false)

// 计算抽屉样式
const drawerStyle = computed(() => {
  const style: Record<string, string> = {}
  const isHorizontal = ['left', 'right'].includes(props.direction)

  if (isHorizontal) {
    style.width = typeof props.size === 'number' ? `${props.size}px` : props.size
  } else {
    style.height = typeof props.size === 'number' ? `${props.size}px` : props.size
  }

  return style
})

// 根据方向计算过渡动画名称
const transitionName = computed(() => {
  return `vh-drawer-${props.direction}`
})

// 关闭抽屉
const handleClose = () => {
  if (isFunction(props.beforeClose)) {
    props.beforeClose(() => {
      emits('update:modelValue', false)
    })
  } else {
    emits('update:modelValue', false)
  }
}

// 处理点击外部关闭
const handleWrapperClick = (e: MouseEvent) => {
  if (e.target === e.currentTarget && props.closeOnClickModal) {
    handleClose()
  }
}

// 处理ESC键关闭
const handleKeydown = (e: KeyboardEvent) => {
  if (e.key === 'Escape' && props.closeOnPressEscape) {
    handleClose()
  }
}

// 过渡动画钩子
const handleBeforeEnter = () => {
  isVisible.value = true
  emits('open')
}

const handleAfterEnter = () => {
  emits('opened')
}

const handleBeforeLeave = () => {
  emits('close')
}

const handleAfterLeave = () => {
  isVisible.value = false
  emits('closed')
}

// 监听显示状态
watch(
  () => props.modelValue,
  (newVal) => {
    if (newVal) {
      nextTick(() => {
        document.addEventListener('keydown', handleKeydown)
      })
    } else {
      document.removeEventListener('keydown', handleKeydown)
    }
  }
)

// 组件卸载时清理事件监听
onBeforeUnmount(() => {
  document.removeEventListener('keydown', handleKeydown)
})
</script>

types.ts

typescript 复制代码
import type { PropType } from 'vue'

export type PaginationLayout = string

export interface PaginationProps {
  // 当前页码
  pageNum?: number
  // 每页显示条数
  pageSize?: number
  // 总条数
  total: number
  // 每页显示条数选择器的选项
  pageSizes?: number[]export interface DrawerProps {
    modelValue: boolean;
    direction?: 'left' | 'right' | 'top' | 'bottom';
    size?: number | string;
    title?: string;
    showClose?: boolean;
    closeOnClickModal?: boolean;
    closeOnPressEscape?: boolean;
    beforeClose?: (done: () => void) => void;
    customClass?: string;
    modal?: boolean;
    modalClass?: string;
    openDelay?: number;
    closeDelay?: number;
}

export interface DrawerEmits {
  (e: 'update:modelValue', value: boolean): void;
  (e: 'open'): void;
  (e: 'opened'): void;
  (e: 'close'): void;
  (e: 'closed'): void;
}
  // 布局配置 (total, sizes, prev, pager, next, jumper)
  layout?: PaginationLayout
  // 背景是否显示
  background?: boolean
  // 是否禁用
  disabled?: boolean
  // 是否显示小型分页
  small?: boolean
  // 页码按钮的数量,当总页数超过该值时会折叠
  pagerCount?: number
  // 上一页按钮的文本
  prevText?: string
  // 下一页按钮的文本
  nextText?: string
  // 省略时显示的内容
  ellipsisText?: string
  // 自定义跳转内容
  jumper?: boolean
  // 自定义大小选择器的内容
  sizeSelector?: boolean
  // 自定义总条数显示的内容
  totalSelector?: boolean
}

// 事件类型定义
export interface PaginationEmits {
  (e: 'update:pageNum', value: number): void
  (e: 'update:pageSize', value: number): void
  (e: 'size-change', size: number): void
  (e: 'current-change', current: number): void
  (e: 'prev-click', current: number): void
  (e: 'next-click', current: number): void
}

index.ts

typescript 复制代码
import type { App } from 'vue'
import Drawer from './Drawer.vue'

Drawer.install = (app: App) => {
  app.component(Drawer.name, Drawer)
}

export default Drawer

export * from './types'

style.css

css 复制代码
.vh-drawer__wrapper {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 2000;
    overflow: hidden;
  }
  
  .vh-drawer {
    position: fixed;
    background-color: var(--vh-bg-color);
    transition: all var(--vh-transition-duration) ease;
    box-shadow: 0 0 30px rgba(0, 0, 0, 0.1);
    display: flex;
    flex-direction: column;
    z-index: 2001;
  
    &--left {
      top: 0;
      left: 0;
      bottom: 0;
      border-right: var(--vh-border);
    }
  
    &--right {
      top: 0;
      right: 0;
      bottom: 0;
      border-left: var(--vh-border);
    }
  
    &--top {
      top: 0;
      left: 0;
      right: 0;
      border-bottom: var(--vh-border);
    }
  
    &--bottom {
      bottom: 0;
      left: 0;
      right: 0;
      border-top: var(--vh-border);
    }
  }
  
  .vh-drawer__header {
    padding: 20px 24px;
    border-bottom: var(--vh-border);
    display: flex;
    align-items: center;
    justify-content: space-between;
    font-size: var(--vh-font-size-large);
    font-weight: var(--vh-font-weight-primary);
    color: var(--vh-text-color-primary);
  }
  
  .vh-drawer__close {
    color: var(--vh-text-color-regular);
    font-size: var(--vh-font-size-large);
    cursor: pointer;
    transition: color var(--vh-transition-duration);
  
    &:hover {
      color: var(--vh-text-color-primary);
    }
  }
  
  .vh-drawer__body {
    flex: 1;
    padding: 24px;
    overflow-y: auto;
  }
  
  .vh-drawer__footer {
    padding: 16px 24px;
    border-top: var(--vh-border);
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 12px;
  }
  
  .vh-drawer-right-enter-from {
    transform: translateX(100%);
  }
  
  .vh-drawer-right-leave-to {
    transform: translateX(100%);
  }
  
  /* 左侧抽屉动画 */
  .vh-drawer-left-enter-from {
    transform: translateX(-100%);
  }
  
  .vh-drawer-left-leave-to {
    transform: translateX(-100%);
  }
  
  /* 顶部抽屉动画 */
  .vh-drawer-top-enter-from {
    transform: translateY(-100%);
  }
  
  .vh-drawer-top-leave-to {
    transform: translateY(-100%);
  }
  
  /* 底部抽屉动画 */
  .vh-drawer-bottom-enter-from {
    transform: translateY(100%);
  }
  
  .vh-drawer-bottom-leave-to {
    transform: translateY(100%);
  }

其他文章

# 🍀上班摸鱼,手搓了个分页器组件

# 我很好奇客户会用得懂这个组件吗

#【🍀新鲜出炉 】十个 "如何"从零搭建 Nuxt3 项目

# 图解封装多种数据结构(栈、队列、优先级队列、链表、双向链表、二叉树)

# 面试官提问:为什么表单提交不会出现跨域

# 【前端】整理了一些网络相关面试题(🍀拿走不谢🍀)

# 学完 Pinia 真香,不想用 vuex 了

# 非标题党:前端项目编程规范化配置(大厂规范)

相关推荐
前端大卫2 小时前
Vue3 + Element-Plus 自定义虚拟表格滚动实现方案【附源码】
前端
却尘2 小时前
Next.js 请求最佳实践 - vercel 2026一月发布指南
前端·react.js·next.js
ccnocare2 小时前
浅浅看一下设计模式
前端
Lee川2 小时前
🎬 从标签到屏幕:揭秘现代网页构建与适配之道
前端·面试
Ticnix3 小时前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人3 小时前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl3 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅3 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人3 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼3 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端