目录
01: 处理 PC 端基础架构
02: 通用组件:search 搜索框能力分析
03: 通用组件:search 搜索框样式处理
04: 通用组件:Button 按钮能力分析
05: 通用组件:Button 按钮功能实现
06: 通用组件:完善 search 基本能力
07: 通用组件:popover 气泡卡片能力分析
08: 通用组件:popover 气泡卡片基础功能实现
09: 通用组件:popover 功能延伸,控制气泡展示位置
10: 通用组件:处理慢速移动时,气泡消失问题
01: 处理 PC 端基础架构
- layout
- - components
- - - header
- - - - index.vue
- - - floating.vue
- - - main.vue
- - index.vue
// 设置 header 和 main 区域高度
// tailwind.config.js
module.exports = {
……
theme: {
extend: {
……
height: {
header: '72px',
main: 'calc(100vh - 72px)'
}
}
}
}
// 使用
// l-white => shadow-l-white
// height => h-header
<header-vue class="h-header"></>
<main-vue class="h-main"></>
02: 通用组件:search 搜索框能力分析
既然是通用组件,就需要分析它的能力,它应该具备什么样的功能:
1. 输入内容实现双向数据绑定
2. 鼠标移入与获取焦点时的动画
3. 一键清空文本功能
4. 搜索触发功能
5. 可控制,可填充的下拉展示区
6. 监听到以下事件列表:
1. clear:删除所有文本事件
2. input:输入事件
3. focus:获取焦点事件
4. blur:失去焦点事件
5. search:触发搜索(点击或回车)事件
03: 通用组件:search 搜索框样式处理
- libs
- - search
- - - index.vue
<template>
<div
ref="containerTarget"
class="group relative p-0.5 rounded-xl border-white duration-500 hover:bg-red-100/40"
>
<div>
<!-- 搜索图标 -->
<m-svg-icon
class="w-1.5 h-1.5 absolute translate-y-[-50%] top-[50%] left-2"
name="search"
color="#707070"
/>
<!-- 输入框 -->
<input
class="block w-full h-[44px] pl-4 text-sm outline-0 bg-zinc-100 dark:bg-zinc-800 caret-zinc-400 rounded-xl text-zinc-900 dark:text-zinc-200 tracking-wide font-semibold border border-zinc-100 dark:border-zinc-700 duration-500 group-hover:bg-white dark:group-hover:bg-zinc-900 group-hover:border-zinc-200 dark:group-hover:border-zinc-700 focus:border-red-300"
type="text"
placeholder="搜索"
v-model="inputValue"
@focus="onFocusHandler"
@blur="onBlurHandler"
@keyup.enter="onSearchHandlder"
/>
<!-- 删除按钮 -->
<m-svg-icon
v-show="inputValue"
name="input-delete"
class="h-1.5 w-1.5 absolute translate-y-[-50%] top-[50%] right-9 duration-500 cursor-pointer"
@click="onClearClick"
></m-svg-icon>
<!-- 分割线 -->
<div
class="opacity-0 h-1.5 w-[1px] absolute translate-y-[-50%] top-[50%] right-[62px] duration-500 bg-zinc-200 group-hover:opacity-100"
></div>
<!-- TODO: 搜索按钮(通用组件) -->
<m-button
class="absolute translate-y-[-50%] top-[50%] right-1 rounded-xl duration-500 opacity-0 group-hover:opacity-100"
icon="search"
iconColor="#ffffff"
@click="onSearchHandlder"
></m-button>
</div>
<!-- 下拉区 -->
<transition name="slide">
<div
v-if="$slots.dropdown"
v-show="isFocus"
class="max-h-[368px] w-full text-base overflow-auto bg-white dark:bg-zinc-800 absolute z-20 left-0 top-[56px] p-2 rounded border border-zinc-200 dark:border-zinc-600 duration-200 hover:shadow-3xl scrollbar-thin scrollbar-thumb-zinc-200 dark:scrollbar-thumb-zinc-900 scrollbar-track-transparent"
>
<slot name="dropdown" />
</div>
</transition>
</div>
</template>
<script>
// 更新事件
const EMIT_UPDATE_MODELVALUE = 'update:modelValue'
// 触发搜索(点击或回车)事件
const EMIT_SEARCH = 'search'
// 删除所有文本事件
const EMIT_CLEAR = 'clear'
// 输入事件
const EMIT_INPUT = 'input'
// 获取焦点事件
const EMIT_FOCUS = 'focus'
// 失去焦点事件
const EMIT_BLUR = 'blur'
</script>
<script setup>
import { watch, ref } from 'vue'
import { useVModel, onClickOutside } from '@vueuse/core'
const props = defineProps({
modelValue: {
type: String,
required: true
}
})
const emits = defineEmits([
EMIT_UPDATE_MODELVALUE,
EMIT_CLEAR,
EMIT_INPUT,
EMIT_FOCUS,
EMIT_BLUR,
EMIT_SEARCH
])
// 输入文本
const inputValue = useVModel(props)
/**
* 清空文本
*/
const onClearClick = () => {
inputValue.value = ''
emits(EMIT_CLEAR, '')
}
/**
* 触发搜索
*/
const onSearchHandlder = () => {
emits(EMIT_SEARCH, inputValue.value)
}
/**
* 监听焦点行为
*/
const isFocus = ref(false)
const onFocusHandler = () => {
isFocus.value = true
emits(EMIT_FOCUS)
}
/**
* 失去焦点
*/
const onBlurHandler = () => {
emits(EMIT_BLUR)
}
/**
* 点击区域外隐藏 dropdown
*/
const containerTarget = ref(null)
onClickOutside(containerTarget, () => {
isFocus.value = false
})
/**
* 监听输入行为
*/
watch(inputValue, (val) => {
emits(EMIT_INPUT, val)
})
</script>
<style lang="scss" scoped>
.slide-enter-active {
transition: all 0.5s;
}
.slide-leave-active {
transition: all 0.5s;
}
.slide-enter-from,
.slide-leave-to {
transform: translateY(40px);
opacity: 0;
}
</style>
04: 通用组件:Button 按钮能力分析
对于这个按钮来说,我们期望拥有以下能力:
1. 可以显示文字按钮,并提供 loading 功能
2. 可以显示 icon 按钮,并可以任意指定 icon 颜色
3. 可以开关的点击动画
4. 可以指定各种风格和大小
5. 当指定的风格或大小不符合预设时,需要给开发者以提示消息
05: 通用组件:Button 按钮功能实现
- libs
- - button
- - - index.vue
/**
* 实现步骤:
* 1. 构建 type 风格可选项 和 size 大小可选项
* 2. 通过 props 让开发者控制按钮
* 3. 区分 icon button 和 text button
* 4. 依据当前数据,实现视图
* 5. 处理点击事件
*/
书写习惯:setup 是写逻辑的地方,不希望在这里写大量的常量。可以在 <script setup> 上面再去创建一个 <script>
// 定义 main 颜色
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
main: '#f44c58',
'hover-main': '#F2F9EC',
}
}
}
}
// 使用
class = "bg-main"
<script>
// type 可选项:表示按钮风格
const typeEnum = {
primary:
'text-white bg-zinc-800 dark:bg-zinc-900 hover:bg-zinc-900 dark:hover:bg-zinc-700 active:bg-zinc-800 dark:active:bg-zinc-700',
main: 'text-white bg-main dark:bg-zinc-900 hover:bg-hover-main dark:hover:bg-zinc-700 active:bg-main dark:active:bg-zinc-700',
info: 'text-zinc-800 dark:text-zinc-300 bg-zinc-200 dark:bg-zinc-700 hover:bg-zinc-300 dark:hover:bg-zinc-600 active:bg-zinc-200 dark:active:bg-zinc-700 '
}
// size 可选项:表示按钮大小。区分文字按钮和icon按钮
const sizeEnum = {
default: {
button: 'w-8 h-4 text-base',
icon: ''
},
'icon-default': {
button: 'w-4 h-4',
icon: 'w-1.5 h-1.5'
},
small: {
button: 'w-7 h-3 text-base',
icon: ''
},
'icon-small': {
button: 'w-3 h-3',
icon: 'w-1.5 h-1.5'
}
}
</script>
// 通过 props 让开发者控制按钮
<script setup>
const props = defineProps({
// icon 图标名字
icon: {
type: String
},
// icon 图标颜色
iconColor: {
type: String
},
// icon 图标类名(匹配 tailwind)
iconClass: {
type: String
},
// 按钮风格
type: {
type: String,
default: 'main',
validator(val) {
// 获取所有的可选的按钮风格
const keys = Object.keys(typeEnum)
// 开发者指定风格是否在可选风格中
const result = keys.includes(val)
// 如果不在则给开发者提示
if (!result) {
throw new Error(`你的 type 必须是 ${keys.join('、')} 中的一个`)
}
// 返回校验结果
return result
}
},
// 大小风格
size: {
type: String,
default: 'default',
validator(val) {
// 获取所有的可选的大小(注意剔除 icon 开头的元素,因为我们期望开发者输入 size="default",但不期望开发者输入 size="icon-default")
const keys = Object.keys(sizeEnum).filter((key) => !key.includes('icon'))
// 开发者指定大小是否在可选大小中
const result = keys.includes(val)
// 如果不在则给开发者提示
if (!result) {
throw new Error(`你的 size 必须是 ${keys.join('、')} 中的一个`)
}
// 返回校验结果
return result
}
},
// 按钮在点击时是否需要动画
isActiveAnim: {
type: Boolean,
default: true
},
// 加载状态
loading: {
type: Boolean,
default: false
}
})
</script>
// 区分 icon button 和 text button
// 传递了 icon props 则默认按钮类型为 icon button
// 处理大小的 key 值
const sizeKey = computed(() => {
return props.icon ? 'icon-' + props.size : props.size
})
// 依据当前的数据,实现视图
<template>
<button
class="text-sm text-center rounded duration-150 flex justify-center items-center"
:class="[
typeEnum[type],
sizeEnum[sizeKey].button,
{ 'active:scale-105': isActiveAnim }
]"
@click.stop="onBtnClick"
>
<!-- 展示 loading -->
<m-svg-icon
v-if="loading"
name="loading"
class="w-2 h-2 animate-spin mr-1"
></m-svg-icon>
<!-- icon 按钮 -->
<m-svg-icon
v-if="icon"
:name="icon"
class="m-auto"
:class="sizeEnum[sizeKey].icon"
:color="iconColor"
:fillClass="iconClass"
></m-svg-icon>
<!-- 文字按钮 -->
<slot v-else />
</button>
</template>
// 处理点击事件
const EMITS_CLICK = 'click'
const emits = defineEmits([EMITS_CLICK])
/**
* 按钮点击事件处理
*/
const onBtnClick = () => {
if (props.loading) {
return
}
emits(EMITS_CLICK)
}
06: 通用组件:完善 search 基本能力
/**
* 1. 输入内容实现双向数据绑定
* 2. 搜索按钮在 hover 时展示
* 3. 一键清空文本功能
* 4. 触发搜索
* 5. 控制下拉展示区的展示
* 6. 事件处理
*/
// 事件处理:
// 双向绑定
// search 搜索
// 删除所有文本
// 输入事件
// 获取焦点事件
// 失去焦点事件
07: 通用组件:popover 气泡卡片能力分析
/**
* 具备两个插槽。
* 第一个插槽描述触发弹出层的视图。这个视图可以定为具名插槽。
* 第二个插槽描述弹出层内容。这个内容可以定为匿名插槽。
* 弹出层气泡可以在指定位置弹出。
*/
08: 通用组件:popover 气泡卡片基础功能实现
- libs
- - popover
- - - index.vue
<template>
<div class="relative" @mouseleave="onMouseleave" @mouseenter="onMouseenter">
<div ref="referenceTarget">
<!-- 具名插槽 -->
<slot name="reference" />
</div>
<!-- 气泡展示动画 -->
<transition name="slide">
<div
v-show="isVisable"
ref="contentTarget"
class="absolute p-1 z-20 bg-white dark:bg-zinc-900 border rounded-md dark:border-zinc-700"
:style="contentStyle"
>
<!-- 匿名插槽 -->
<slot />
</div>
</transition>
</div>
</template>
<script>
// 延迟关闭时长
const DELAY_TIME = 100
const PROP_TOP_LEFT = 'top-left'
const PROP_TOP_RIGHT = 'top-right'
const PROP_BOTTOM_LEFT = 'bottom-left'
const PROP_BOTTOM_RIGHT = 'bottom-right'
// 定义指定位置的 Enum
const placementEnum = [
PROP_TOP_LEFT,
PROP_TOP_RIGHT,
PROP_BOTTOM_LEFT,
PROP_BOTTOM_RIGHT
]
</script>
<script setup>
import { ref, watch, nextTick } from 'vue'
const props = defineProps({
// 控制气泡弹出位置,并给出开发者错误的提示
placement: {
type: String,
default: 'bottom-left',
validator(val) {
const result = placementEnum.includes(val)
if (!result) {
throw new Error(
`你的 placement 必须是 ${placementEnum.join('、')} 中的一个`
)
}
return result
}
}
})
// 控制 menu 展示
const isVisable = ref(false)
// 控制延迟关闭
let timeout = null
/**
* 鼠标移入的触发行为
*/
const onMouseenter = () => {
isVisable.value = true
// 再次触发时,清理延时装置
if (timeout) {
clearTimeout(timeout)
}
}
/**
* 鼠标移出的触发行为
*/
const onMouseleave = () => {
// 延时装置
timeout = setTimeout(() => {
isVisable.value = false
timeout = null
}, DELAY_TIME)
}
/**
* 计算元素尺寸
*/
const referenceTarget = ref(null)
const contentTarget = ref(null)
const useElementSize = (target) => {
if (!target) return {}
return {
width: target.offsetWidth,
height: target.offsetHeight
}
}
/**
* 计算弹层位置
*/
const contentStyle = ref({
top: 0,
left: 0
})
/**
* 监听展示的变化,在展示时计算气泡位置
*/
watch(isVisable, (val) => {
if (!val) {
return
}
// 等待渲染成功之后
nextTick(() => {
switch (props.placement) {
// 左上
case PROP_TOP_LEFT:
contentStyle.value.top = 0
contentStyle.value.left =
-useElementSize(contentTarget.value).width + 'px'
break
// 右上
case PROP_TOP_RIGHT:
contentStyle.value.top = 0
contentStyle.value.left =
useElementSize(referenceTarget.value).width + 'px'
break
// 左下
case PROP_BOTTOM_LEFT:
contentStyle.value.top =
useElementSize(referenceTarget.value).height + 'px'
contentStyle.value.left =
-useElementSize(contentTarget.value).width + 'px'
break
// 右下
case PROP_BOTTOM_RIGHT:
contentStyle.value.top =
useElementSize(referenceTarget.value).height + 'px'
contentStyle.value.left =
useElementSize(referenceTarget.value).width + 'px'
break
}
})
})
</script>
<style lang="scss" scoped>
// slide 展示动画
.slide-enter-active {
transition: opacity 0.3s, transform 0.3s;
}
.slide-leave-active {
transition: opacity 0.3s, transform 0.3s;
}
.slide-enter-from,
.slide-leave-to {
transform: translateY(20px);
opacity: 0;
}
</style>
09: 通用组件:popover 功能延伸,控制气泡展示位置
/**
* 步骤:
* 1. 指定所有可选位置的常量,并生成 enum
* 2. 通过 prop 控制指定位置
* 3. 获取元素的 DOM;创建读取元素尺寸的方法
* 4. 生成气泡的样式对象,用来控制每个位置对应的样式
* 5. 根据 prop,计算样式对象
*/
10: 通用组件:处理慢速移动时,气泡消失问题
想要解决这个问题,可以利用 类似于防抖(debounce)的概念。
也就是:鼠标刚离开时,不去立刻修改 isVisible,而是延迟一段时间,如果在这段时间之内,再次触发了鼠标移入事件,则不再修改 isVisible。