367 lines
8.0 KiB
Vue
367 lines
8.0 KiB
Vue
|
|
<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>
|