Add dialog composable

This commit is contained in:
arabcoders 2025-08-25 22:21:32 +03:00
parent 3adc4eb062
commit fe38837b9e
2 changed files with 278 additions and 0 deletions

View file

@ -0,0 +1,151 @@
<style scoped>
/* container fades */
.dialog-enter-active,
.dialog-leave-active {
transition: opacity .18s ease;
}
.dialog-enter-from,
.dialog-leave-to {
opacity: 0;
}
/* animate the card itself */
.dialog-enter-active .modal-card,
.dialog-leave-active .modal-card {
transition: transform .18s ease, opacity .18s ease;
}
.dialog-enter-from .modal-card {
transform: translateY(-8px);
opacity: .98;
}
.dialog-leave-to .modal-card {
transform: translateY(-8px);
opacity: .98;
}
</style>
<template>
<Teleport to="body">
<transition name="dialog" @after-enter="focusInput">
<div id="app-dialog-host" v-if="state.current" class="modal is-active" @keydown.esc="onCancel">
<div class="modal-background" @click="onCancel" />
<div class="modal-card" @keydown.enter.stop.prevent="onEnter">
<header class="modal-card-head p-4">
<p class="modal-card-title">{{ state.current?.opts.title ?? defaultTitle }}</p>
<button class="delete" aria-label="close" @click="onCancel" />
</header>
<section class="modal-card-body">
<p v-if="state.current?.opts.message" class="mb-3">
{{ state.current?.opts.message }}
</p>
<!-- prompt input -->
<div v-if="state.current?.type === 'prompt'" class="field">
<div class="control">
<input ref="inputEl" class="input" type="text" v-model="localInput"
:placeholder="(state.current?.opts as any)?.placeholder ?? ''" @keyup.stop />
</div>
<p v-if="state.errorMsg" class="help is-danger is-bold is-unselectable">
<span class="icon-text">
<span class="icon"><i class="fas fa-exclamation-triangle" /></span>
<span>{{ state.errorMsg }}</span>
</span>
</p>
</div>
</section>
<footer class="modal-card-foot p-4 is-justify-content-flex-end">
<template v-if="state.current?.type === 'alert'">
<button class="button is-danger" @click="onEnter">
<span class="icon-text">
<span class="icon"><i class="fas fa-check" /></span>
<span>{{ (state.current?.opts as any)?.confirmText ?? 'OK' }}</span>
</span>
</button>
</template>
<template v-else-if="state.current?.type === 'confirm' || state.current?.type === 'prompt'">
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" @click="onEnter">
<span class="icon-text">
<span class="icon"><i class="fas fa-check" /></span>
<span>{{ (state.current?.opts as any)?.confirmText ?? 'OK' }}</span>
</span>
</button>
</div>
<div class="control">
<button class="button is-info" @click="onCancel">
<span class="icon-text">
<span class="icon"><i class="fas fa-times" /></span>
<span>{{ (state.current?.opts as any)?.cancelText ?? 'Cancel' }}</span>
</span>
</button>
</div>
</div>
</template>
</footer>
</div>
</div>
</transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, computed } from 'vue'
import { useDialog } from '~/composables/useDialog'
const { state, confirm, cancel } = useDialog()
const localInput = ref('')
watch(() => state.current, (cur) => {
if (state.current) {
disableOpacity()
}
else {
enableOpacity()
}
localInput.value = cur?.type === 'prompt' ? (cur.opts as any).initial ?? '' : ''
}, { immediate: true })
const inputEl = ref<HTMLInputElement>()
const focusPrimary = () => {
const root = document.getElementById('app-dialog-host')
if (!root) {
return
}
const btn = root.querySelector<HTMLButtonElement>('.modal-card-foot .button.is-primary')
btn?.focus()
}
const focusInput = async () => {
await nextTick()
if (state.current?.type === 'prompt') {
requestAnimationFrame(() => inputEl.value?.focus({ preventScroll: true }))
return
}
requestAnimationFrame(focusPrimary)
}
const onCancel = () => cancel()
const onEnter = () => confirm(localInput.value)
const defaultTitle = computed(() => {
if (!state.current) {
return ''
}
switch (state.current.type) {
case 'alert':
return 'Alert'
case 'confirm':
return 'Confirm'
case 'prompt':
return 'Input required'
}
})
</script>

View file

@ -0,0 +1,127 @@
import {reactive, readonly} from 'vue'
export type DialogResult<T = string | null> = { status: boolean; value: T }
type BaseOptions = {
/**
* Title of the dialog
*/
title?: string
/**
* Message to display in the dialog
*/
message?: string
/**
* Text for the confirm button
*/
confirmText?: string
}
export type PromptOptions = BaseOptions & {
/**
* Text for the input field
*/
initial?: string
/**
* Placeholder text for the input field
*/
placeholder?: string
/**
* Text for the cancel button
*/
cancelText?: string
/**
* Function to validate the input value
* @returns true if valid, or an error message string if invalid
*/
validate?: (v: string) => true | string
}
export type ConfirmOptions = BaseOptions & {
/**
* Text for the confirm button
*/
cancelText?: string
}
export type AlertOptions = BaseOptions & {}
type QueueItem = {
type: 'prompt' | 'confirm' | 'alert'
opts: PromptOptions | ConfirmOptions | AlertOptions
resolve: (r: DialogResult<any>) => void
}
type DialogState = {
current: QueueItem | null
queue: QueueItem[]
errorMsg: string | null
input: string
}
export const useDialog = () => {
const raw = useState<DialogState>('dialog:state', () => reactive({
current: null,
queue: [],
errorMsg: null,
input: '',
} as DialogState))
const state = raw.value
const _dequeue = () => {
if (!state.current && state.queue.length) {
state.current = state.queue.shift()!
state.errorMsg = null
state.input = state.current.type === 'prompt' ? (state.current.opts as PromptOptions).initial ?? '' : ''
}
}
const promptDialog = (opts: PromptOptions) => new Promise<DialogResult<string>>((resolve) => {
state.queue.push({type: 'prompt', opts, resolve})
_dequeue()
})
const confirmDialog = (opts: ConfirmOptions) => new Promise<DialogResult<null>>((resolve) => {
state.queue.push({type: 'confirm', opts, resolve})
_dequeue()
})
const alertDialog = (opts: AlertOptions) => new Promise<DialogResult<null>>((resolve) => {
state.queue.push({type: 'alert', opts, resolve})
_dequeue()
})
const confirm = (value?: string) => {
if (!state.current) return
if (state.current.type === 'prompt') {
const val = value ?? state.input
const v = (state.current.opts as PromptOptions).validate?.(val)
if (v && v !== true) {
state.errorMsg = v
return
}
state.current.resolve({status: true, value: val})
} else {
state.current.resolve({status: true, value: null})
}
state.current = null
_dequeue()
}
const cancel = () => {
if (!state.current) return
state.current.resolve({status: false, value: null})
state.current = null
_dequeue()
}
return {
promptDialog,
confirmDialog,
alertDialog,
confirm,
cancel,
state: readonly(state) as Readonly<DialogState>,
}
}