Add dialog composable
This commit is contained in:
parent
3adc4eb062
commit
fe38837b9e
2 changed files with 278 additions and 0 deletions
151
ui/app/components/Dialog.vue
Normal file
151
ui/app/components/Dialog.vue
Normal 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>
|
||||
127
ui/app/composables/useDialog.ts
Normal file
127
ui/app/composables/useDialog.ts
Normal 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>,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue