封面圖
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsICN4ETMfdHLkVGepZ2XtxSZ6l2clJ3LcBnYldHL0FWby9mZvwVPrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdsAjMfd3bkFGazxCMx8VesATMfhHLlN3XnxCMz8FdsYkRGZkRG9lcvx2bjxSa2EWNhJTW1AlUxEFeVRUUfRHelRHL2EzXlpXazxyayFWbyVGdhd3LcV2Zh1Wa9M3clN2byBXLzN3btg3PwJWZ35CM5gjNyUGMiFGNwQTNiZmZyYzX4IzNwADMwIzLchDMyIDMy8CXn9Gbi9CXzV2Zh1WavwVbvNmLvR3YxUjLyM3Lc9CX6MHc0RHaiojIsJye.webp)
魔女宅急便劇照
背景
在某個項目中,傳統的二次彈框總是使用一個大的modal來進行,對界面的流程有一定的阻斷效果。
同時,使用者看到彈框習慣直接點選确認按鈕,導緻操作失誤。
分析
二次确認的主要作用是防止誤操作,以及警示操作帶來的後果,避免使用者無意之間執行了本不想做的操作。從使用者流程圖中我們不難看出,二次确認是一種打斷使用者流程的設計,隻是迫不得已的折中方案。是以在是否使用,如何使用上需要有一定的考慮,否則會适得其反。
解決方案
針對上述問題,兩種比較好的設計方案如下:
- 誤操作左後,給定撤銷時間
- 多次詢問
确認時輸入指定字元
存在問題
這種彈框雖然可以解決問題,但是整個Modal彈出時,遮住整個頁面,使用者無法看清楚頁面的其他資訊。
基于此,決定将二次彈框封裝到propConfirm中。這樣做有兩個好處:
- 一是占用較少的界面空間,propConfirm彈出時不會占用太多界面空間。
- 二是使用者的關注點更加聚焦,一定程度上可以減少操作失誤的機率。
設計方案
RemindModal 提供常用的元件方案,RemindModalHook提供快捷方法。即使用者即可以使用常用的元件使用模式,在元件上添加各種參數,也可以用HOOK直接進行快捷注冊。
注意事項
- 鈎子函數
的首要任務是在元件挂載時,設定參數中傳遞過來的props。regist
- 子元素需要用
方法進行複制,在上面添加onclick方法、cloneVNodes
- 位置資訊的計算
問題
slot
進來元素不随父元素變化,怎麼實作?
- teleport 挂載到body
- 動态計算placement位置
- 提示框在頁面滾動時不随按鈕一起滾動
解決方案
- 子元素不采用slot , 而是用cloneVnodes複制一份
- 根據triggerRect 和 contentRect動态計算提示位置
- body添加onscroll 事件
相關代碼
// position.ts
export const useClientRect = (ele) => {
const res = ele && ele.$el ? ele.$el.getClientRects() : ele.getClientRects()
const { top, left, width, height, x, y } = res[0]
return {
top,
left,
width,
height,
x,
y
}
}
interface Position {
top?: number
left?: number
width?: number
height?: number
x?: number
y?: number
}
export const usePlaceMent = ({ triggerRect, contentRect, placeMent }) => {
if (placeMent === 'left') {
return {
top: triggerRect.top - contentRect.height / 2 + triggerRect.height / 2,
left: triggerRect.left - contentRect.width - 10
} as Position
}
if (placeMent === 'top') {
const lastScrollTop = document.body.scrollTop
return {
top: triggerRect.top - contentRect.height - 10 + lastScrollTop - document.body.scrollTop,
left: triggerRect.left + triggerRect.width / 2 - contentRect.width / 2
} as Position
}
if (placeMent === 'right') {
return {
top: triggerRect.top - contentRect.height / 2 + triggerRect.height / 2,
left: triggerRect.left + contentRect.width / 2 - triggerRect.width / 2 + 10
} as Position
}
if (placeMent === 'bottom') {
return {
top: triggerRect.top + triggerRect.height + 10,
left: triggerRect.left + triggerRect.width / 2 - contentRect.width / 2
} as Position
}
}
export const useScroll = (ele, onScroll: (evt: Event) => void) => {
const handleScroll = (evt: Event) => {
onScroll(evt)
}
const bindScroll = () => {
document.body.addEventListener('scroll', handleScroll, true)
}
bindScroll()
}
Reminder.vue
<script lang="tsx">
// ref, unref, computed, reactive, watchEffect
import type { Ref } from 'vue'
import { defineComponent, ref, computed, Teleport, onMounted } from 'vue'
import { cloneVNodes } from '../tool'
import { Button, Form, FormItem, Input } from 'ant-design-vue'
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'
// import BasicRemind from './basic.vue'
import { useDesign } from '/@/hooks/web/useDesign'
import { useClientRect, usePlaceMent, useScroll } from '../hooks/position'
const props = {
title: {
type: String as PropType<String>,
default: '确定執行此操作嗎~'
},
duration: {
type: Number as PropType<number>
// default: 5
},
userText: {
type: String as PropType<String>
},
cancleText: {
type: String as PropType<String>,
default: '取消'
},
confirmText: {
type: String as PropType<String>,
default: '确定'
},
placement: {
type: String as PropType<String>,
default: 'left'
},
reminderType: {
type: String as PropType<String> // userInput | countDown
},
onOk: {
type: Function as PropType<Function>,
default: function () {
console.log('onOk')
}
},
onCancle: {
type: Function as PropType<Function>,
default: function () {
console.log('onCancle')
}
}
}
export default defineComponent({
name: 'RemindModal',
components: { Button },
props,
emits: ['ok', 'cancle'],
// emit, expose
setup(props, { slots }) {
let disabled: Ref<boolean> = ref(false)
let countDownNum: Ref<number> = ref(props.duration)
let countDownTimer: Ref<any> = ref(null)
let triggerEl: Ref<any> = ref(null)
let position: Ref<Object> = ref({ top: 0, left: 0 })
let opacity: Ref<any> = ref(0)
let remindContainer: Ref<any> = ref({})
let contentEl: Ref<any> = ref(null)
let formRef: Ref<any> = ref(null)
let userConfirmText: Ref<any> = ref('')
const rules = {
userConfirmText: [
{
required: true,
// message: '請輸入相關确認字元~',
trigger: 'blur',
validator: () => {
if (userConfirmText.value === '') {
return Promise.reject('請輸入相關确認字元!')
} else if (userConfirmText.value !== props.userText) {
console.log('輸入和預期不一緻-----', userConfirmText.value)
return Promise.reject('輸入和預期不一緻!')
} else {
return Promise.resolve()
}
}
}
]
}
const onKeyDown = (e: KeyboardEvent) => {
console.log('i am copyed el', e)
}
const { prefixCls } = useDesign('remind')
const getRemindClass = computed(() => {
return [prefixCls, [`${prefixCls}-modal`]]
})
const getRemindContainerClass = computed(() => {
return [
prefixCls,
[`${prefixCls}-modal-content`, `${prefixCls}-modal-content-${props.placement}`]
]
})
console.log('getRemindContainerClass', getRemindContainerClass)
const countDown = () => {
disabled.value = true
countDownTimer.value = setInterval(() => {
countDownNum.value -= 1
if (countDownNum.value <= 0) {
clearInterval(countDownTimer.value)
disabled.value = false
countDownNum.value = props.duration
opacity.value = 0
}
}, 1000)
}
const calcPlaceMent = () => {
let triggerRect = useClientRect(triggerEl.value)
let contentRect = useClientRect(contentEl.value)
let calcPos = usePlaceMent({
triggerRect,
contentRect,
placeMent: props.placement
})
position.value = calcPos
}
const onConfirm = () => {
if (props.reminderType === 'userInput') {
console.log('formRef', formRef)
formRef.value
.validate()
.then(() => {
props.onOk()
})
.catch((error) => {
console.log('error', error)
})
}
if (props.reminderType === 'countDown') {
if (props.duration) {
countDown()
}
}
}
const conCancle = () => {
if (disabled.value && countDownNum.value) {
clearInterval(countDownTimer.value)
disabled.value = false
countDownNum.value = props.duration
}
if (props.reminderType === 'userInput' && props.userText) {
formRef.value.resetFields()
userConfirmText.value = ''
}
opacity.value = 0
}
onMounted(() => {
calcPlaceMent()
useScroll(triggerEl.value, () => {
calcPlaceMent()
})
})
const renderUserText = () => {
return (
<>
{props.userText ? (
<div>
<div>請輸入以下内容:</div>
<div>{`${props.userText}`}</div>
<Form ref={formRef} model={userConfirmText} rules={rules}>
<FormItem name={'userConfirmText'}>
<Input v-model:value={userConfirmText.value} placeholder="請輸入"
</FormItem>
</Form>
</div>
) : null}
</>
)
}
const renderTip = () => {
return (
<Teleport to="body">
<div
ref={contentEl}
class={getRemindContainerClass.value}
style={{
top: `${position.value?.top}px`,
left: `${position.value?.left}px`,
opacity: opacity.value
<div class="haomo-remind-modal-arrow">
<span class="haomo-remind-modal-arrow-content"></span>
</div>
<div class="haomo-remind-modal-inner">
<div class="ant-popover-inner-content">
<div class="ant-popover-message">
<span class="anticon anticon-exclamation-circle">
<ExclamationCircleOutlined style={{ color: '#faad14' }} />
</span>
<div class="ant-popover-message-title">{props.title}</div>
</div>
{renderUserText()}
<div class="ant-popover-buttons">
<Button type="default" size="small" onClick={() conCancle()}>
{props.cancleText}
</Button>
{disabled.value ? (
<Button
type="primary"
danger={true}
size="small"
disabled
style={{ background: '#ff7875', color: '#fff' }}
>
{`${countDownNum.value}s後執行`}
</Button>
) : (
<Button type="primary" size="small" onClick={() onConfirm()}>
{props.confirmText}
</Button>
)}
</div>
</div>
</div>
</div>
</Teleport>
)
}
return () => {
return (
<div class={getRemindClass.value} ref={remindContainer}>
{renderTip()}
{cloneVNodes(
slots.default?.() || [],
{
onKeydown: (e: KeyboardEvent) => {
onKeyDown(e)
},
onclick: () => {
opacity.value = opacity.value === 1 ? 0 : 1
calcPlaceMent()
},
ref: triggerEl
},
false
)}
</div>
最後
此開發案例并沒有采用popConfirm ,而是使用了它的樣式。在實際開發過程中,看了popConfirm的源碼,是基于多個基礎元件進行封裝的。比如
tooltip
,
trigger
等...