feat: 新增 AppConfirmDialog 通用确认弹窗组件

基于 Teleport 的 Promise 式确认弹窗,支持键盘关闭、焦点管理、可访问性属性与响应式触控区域,供路由离开等场景替代 window.confirm。
This commit is contained in:
caoxiaozhu
2026-07-11 14:48:54 +08:00
parent 771c258929
commit 9e16f6358d

View File

@@ -0,0 +1,366 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, reactive, ref, useId, watch } from 'vue'
type ConfirmDialogTone = 'primary' | 'warning' | 'danger'
interface ConfirmDialogOptions {
title: string
message: string
confirmText?: string
cancelText?: string
tone?: ConfirmDialogTone
closeOnOverlay?: boolean
}
const visible = ref(false)
const dialogRef = ref<HTMLElement>()
const cancelButtonRef = ref<HTMLButtonElement>()
const titleId = `app-confirm-title-${useId()}`
const messageId = `app-confirm-message-${useId()}`
const options = reactive<Required<ConfirmDialogOptions>>({
title: '请确认操作',
message: '',
confirmText: '确定',
cancelText: '取消',
tone: 'warning',
closeOnOverlay: false,
})
let resolver: ((confirmed: boolean) => void) | null = null
let previousFocus: HTMLElement | null = null
function settle(confirmed: boolean) {
visible.value = false
const currentResolver = resolver
resolver = null
currentResolver?.(confirmed)
}
function open(nextOptions: ConfirmDialogOptions) {
if (resolver) settle(false)
Object.assign(options, {
confirmText: '确定',
cancelText: '取消',
tone: 'warning',
closeOnOverlay: false,
...nextOptions,
})
visible.value = true
return new Promise<boolean>((resolve) => {
resolver = resolve
})
}
function handleOverlayClick() {
if (options.closeOnOverlay) settle(false)
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
settle(false)
return
}
if (event.key !== 'Tab') return
const focusableElements = Array.from(
dialogRef.value?.querySelectorAll<HTMLElement>('button:not([disabled])') ?? [],
)
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
if (!firstElement || !lastElement) return
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault()
lastElement.focus()
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault()
firstElement.focus()
}
}
watch(visible, async (isVisible) => {
if (isVisible) {
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
await nextTick()
cancelButtonRef.value?.focus()
return
}
await nextTick()
previousFocus?.focus()
previousFocus = null
})
onBeforeUnmount(() => {
resolver?.(false)
resolver = null
})
defineExpose({ open })
</script>
<template>
<Teleport to="body">
<Transition name="app-confirm">
<div v-if="visible" class="app-confirm-overlay" @mousedown.self="handleOverlayClick">
<section
ref="dialogRef"
class="app-confirm-dialog"
:class="`is-${options.tone}`"
role="alertdialog"
:aria-modal="true"
:aria-labelledby="titleId"
:aria-describedby="messageId"
@keydown="handleKeydown"
>
<header class="app-confirm-header">
<div class="app-confirm-heading">
<span class="app-confirm-icon" aria-hidden="true">
<i :class="options.tone === 'primary' ? 'fa fa-question-circle' : 'fa fa-exclamation-triangle'" />
</span>
<h2 :id="titleId">{{ options.title }}</h2>
</div>
<button class="app-confirm-close" type="button" aria-label="关闭确认弹窗" @click="settle(false)">
<i class="fa fa-times" aria-hidden="true" />
</button>
</header>
<div class="app-confirm-body">
<p :id="messageId">{{ options.message }}</p>
</div>
<footer class="app-confirm-actions">
<button ref="cancelButtonRef" class="app-confirm-button is-cancel" type="button" @click="settle(false)">
{{ options.cancelText }}
</button>
<button class="app-confirm-button is-confirm" type="button" @click="settle(true)">
{{ options.confirmText }}
</button>
</footer>
</section>
</div>
</Transition>
</Teleport>
</template>
<style scoped lang="scss">
.app-confirm-overlay {
position: fixed;
z-index: 2000;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
box-sizing: border-box;
background: rgba(15, 23, 42, 0.44);
}
.app-confirm-dialog {
position: relative;
width: min(480px, 100%);
overflow: hidden;
background: #fff;
border: 1px solid #dfe3ea;
border-radius: 8px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
}
.app-confirm-header {
display: flex;
min-height: 52px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 10px 0 20px;
border-bottom: 1px solid #e7eaf0;
}
.app-confirm-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
h2 {
margin: 0;
overflow: hidden;
color: #273142;
font-size: 15px;
font-weight: 650;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.app-confirm-close {
display: inline-grid;
width: 32px;
height: 32px;
flex: 0 0 32px;
place-items: center;
padding: 0;
color: #7b8495;
background: transparent;
border: 0;
border-radius: 4px;
cursor: pointer;
transition: color 0.18s ease, background-color 0.18s ease;
&:hover {
color: #273142;
background: #f2f4f7;
}
&:focus-visible {
outline: 2px solid rgba(91, 80, 242, 0.45);
outline-offset: 1px;
}
}
.app-confirm-icon {
display: inline-grid;
width: 28px;
height: 28px;
flex: 0 0 28px;
place-items: center;
color: #a15c07;
background: #fff8e6;
border: 1px solid #f3dfad;
border-radius: 6px;
font-size: 13px;
}
.app-confirm-dialog.is-danger .app-confirm-icon {
color: #c43232;
background: #fff1f1;
border-color: #f2c7c7;
}
.app-confirm-dialog.is-primary .app-confirm-icon {
color: #4f46e5;
background: #f3f2ff;
border-color: #d9d6ff;
}
.app-confirm-body {
padding: 16px 20px 18px;
p {
margin: 0;
color: #5f6878;
font-size: 13px;
line-height: 1.7;
}
}
.app-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
background: #f8f9fb;
border-top: 1px solid #e7eaf0;
}
.app-confirm-button {
height: 34px;
min-width: 72px;
padding: 0 13px;
color: #344054;
font-size: 13px;
font-weight: 500;
background: #fff;
border: 1px solid #cfd5df;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease;
&:hover {
color: #273142;
background: #f2f4f7;
border-color: #b9c1cd;
}
&:focus-visible {
outline: 2px solid rgba(91, 80, 242, 0.45);
outline-offset: 1px;
}
&.is-confirm {
color: #fff;
background: #a15c07;
border-color: #a15c07;
&:hover {
background: #844b06;
border-color: #844b06;
}
}
}
.app-confirm-dialog.is-danger .app-confirm-button.is-confirm {
background: #c43232;
border-color: #c43232;
&:hover {
background: #a92828;
border-color: #a92828;
}
}
.app-confirm-dialog.is-primary .app-confirm-button.is-confirm {
background: #4f46e5;
border-color: #4f46e5;
&:hover {
background: #4338ca;
border-color: #4338ca;
}
}
.app-confirm-enter-active,
.app-confirm-leave-active {
transition: opacity 0.18s ease;
.app-confirm-dialog {
transition: opacity 0.18s ease, transform 0.18s ease;
}
}
.app-confirm-enter-from,
.app-confirm-leave-to {
opacity: 0;
.app-confirm-dialog {
opacity: 0;
transform: translateY(4px);
}
}
@media (max-width: 520px) {
.app-confirm-overlay {
padding: 12px;
}
.app-confirm-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.app-confirm-button {
height: auto;
min-width: 0;
min-height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.app-confirm-enter-active,
.app-confirm-leave-active,
.app-confirm-enter-active .app-confirm-dialog,
.app-confirm-leave-active .app-confirm-dialog {
transition: none;
}
}
</style>