[Feature]: Calling dialog using vue hooks
han1548772930 opened this issue · 7 comments
Describe the feature
Calling dialog using vue hooks
Additional information
- I intend to submit a PR for this feature.
- I have already implemented and/or tested this feature.
Hello There!
I've implemented a composable to control Dialogs
a while back.
It's heavily inspired by Quasar
dialog plugin.
Implementation Steps
Install vue-component-type-helpers
for typescript support.
npm install vue-component-type-helpers
dialog.ts
/* eslint-disable ts/no-unsafe-function-type */
import type { App, Component, ComponentInternalInstance } from "vue"
import { createApp, h } from "vue"
import type { ComponentProps } from "vue-component-type-helpers"
export function useDialog<T extends Component>(component: T, parentApp: ComponentInternalInstance | null = getCurrentInstance()) {
const isOpen = ref(false)
const currentProps = shallowRef<Record<string, any>>({})
const okCallbacks: Function[] = []
const cancelCallbacks: Function[] = []
let instance: App<Element> | null = null
let htmlNode: HTMLDivElement | null = null
const api = {
isOpen: readonly(isOpen),
onOk,
onCancel,
update,
open,
close,
}
onUnmounted(() => close(), parentApp)
watch(isOpen, () => {
update({ open: isOpen.value })
if (!isOpen.value)
close()
})
function open(props?: ComponentProps<T>) {
isOpen.value = true
currentProps.value = { ...props, open: isOpen.value }
mount(component)
return api
}
function close() {
isOpen.value = false
const node = document.body.querySelector<HTMLDivElement>(`div[role="dialog"][data-state="open"]`)
if (node) {
node.addEventListener("animationend", destroy, { once: true })
}
else {
// Forcefully destory everything
destroy()
}
return api
}
function update(props: Record<string, any>) {
currentProps.value = { ...currentProps.value, ...props }
return api
}
function onOk(fn: (...args: any) => any) {
okCallbacks.push(fn)
return api
}
function onCancel(fn: (...args: any) => any) {
cancelCallbacks.push(fn)
return api
}
function destroy() {
instance?.unmount()
if (htmlNode)
window.document.body.removeChild(htmlNode)
instance = null
htmlNode = null
}
function mount(component: Component) {
htmlNode = window.document.createElement("div")
window.document.body.appendChild(htmlNode)
instance = createComponent(component)
instance.config.globalProperties = parentApp!.appContext.config.globalProperties
instance._context.directives = parentApp!.appContext.directives
instance._context.provides = parentApp!.appContext.provides
instance.mount(htmlNode)
return instance
}
function createComponent(component: Component) {
return createApp({
render() {
return h(component, {
"onUpdate:open": (open: boolean) => {
isOpen.value = open
},
"onOk": async (values: any) => {
for (const cb of okCallbacks)
await cb(values)
},
"onCancel": async (values: any) => {
for (const cb of cancelCallbacks)
await cb(values)
},
...currentProps.value,
})
},
})
}
return api
}
MyDialog.vue
<script lang="ts" setup>
const emit = defineEmits<{
ok: [number]
cancel: []
}>()
const open = defineModel<boolean>("open")
</script>
<template>
<UDialog v-model:open="open">
<UDialogContent>
<UDialogTitle>
My Dialog
</UDialogTitle>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Ipsum, doloribus nisi! Totam fugit, quidem perspiciatis harum quo labore doloremque iste quisquam magnam veritatis suscipit inventore facere voluptatibus, pariatur modi maiores!
<UButton @click="emit('ok', 123)">
Ok
</UButton>
<UButton
variant="destructive"
@click="emit('cancel')">
Cancel
</UButton>
</UDialogContent>
</UDialog>
</template>
Usage, in app.vue
<script setup lang="ts">
// Import `dialog.ts`
const dialog = useDialog()
dialog.open()
.onOk((payload) => {
console.log("OK with payload: ", payload)
dialog.close()
})
.onCancel(() => {
console.log("Cancel")
dialog.close()
})
</script>
<template>
<Button @click="dialog.open()">
Open Dialog
</Button>
</template>
Explanation
- open(props?: ComponentProps): Opens the dialog and accepts optional props to pass to the dialog component.
- close(): Closes the dialog.
- update(props: Record<string, any>): update props of the dialog.
- onOk(fn: (...args: any) => any): Registers a callback function to be executed when the user confirms the dialog (e.g., clicks "OK").
- onCancel(fn: (...args: any) => any): Registers a callback function to be executed when the user cancels the dialog (e.g., clicks "Cancel").
There are some improvements that could be made, but i need this to be reviewed first. It requires a bit refinement, but we could add it as a reusable composable.
What do you think @sadeghbarati ?
This seems like a lot of unnecessary code. If you want to control opening and closing of dialogs manually, you can use this (it's what I use). It works with dialogs, sheets, popups, and any other component with open state.
composable/useDialog.ts
import { ref } from 'vue';
export const dialogState = () => {
const isOpen = ref<boolean>(false);
function openDialog() {
isOpen.value = true;
}
function closeDialog() {
isOpen.value = false;
}
return { isOpen, openDialog, closeDialog };
};
Then in your Vue components:
import { dialogState } from '@/composable/useDialog';
// Not using open since you should use Trigger components to open dialogs, but you can use openDialog to open them outside of trigger button
const { isOpen, closeDialog } = dialogState();
const doSomething = () => {
console.log('Do something and close dialog...');
// On form submit or something
closeDialog();
};
<Dialog v-model:open="isOpen">...</Dialog>
Or with named props when you have multiple dialogs
import { dialogState } from '@/composable/useDialog';
const { isOpen: openConfirm, closeDialog: closeConfirm } = dialogState();
const { isOpen: openDelete, closeDialog: closeDelete } = dialogState();
<Dialog v-model:open="openConfirm">Confirm dialog...</Dialog>
<Dialog v-model:open="openDelete">Delete dialog...</Dialog>
Oh I understand,
The main advantage here is that you wouldn't need to call the dialog component in the template section or anywhere.
This feels natural to me, the huge amount of code you see, is to manually render and auto-bind the props to the component.
Hey guys, thanks for the input
These sources might be helpful
Hey guys, thanks for the input
These sources might be helpful
Thank you for your help.
app.vue
<script setup lang="ts">
import {Toaster} from '@/components/ui/sonner'
import {createTemplatePromise} from "@vueuse/core";
import {provide} from "vue";
type DialogResult = 'ok' | 'cancel'
const TemplatePromise = createTemplatePromise<DialogResult, [string]>({
transition: {
name: 'fade',
appear: true,
},
})
provide('templatePromise', TemplatePromise);
function asyncFn() {
return new Promise<DialogResult>((resolve) => {
setTimeout(() => {
resolve('ok')
}, 1000)
})
}
</script>
<template>
<RouterView></RouterView>
<Toaster class="pointer-events-auto"/>
<TemplatePromise v-slot="{ resolve, args, isResolving }">
<div class="fixed inset-0 bg-black/10 flex items-center z-30">
<dialog open class="border-gray/10 shadow rounded ma">
<div>Dialog {{ args[0] }}</div>
<p>Open console to see logs</p>
<div class="flex gap-2 justify-end">
<button class="w-35" @click="resolve('cancel')">
Cancel
</button>
<button class="w-35" :disabled="isResolving" @click="resolve(asyncFn())">
{{ isResolving ? 'Confirming...' : 'OK' }}
</button>
</div>
</dialog>
</div>
</TemplatePromise>
</template>
use like this
const templatePromise = inject('templatePromise');
This is also possible
useDialog.ts
import {h, defineComponent, createApp} from 'vue';
import {createTemplatePromise} from "@vueuse/core";
type DialogResult = 'ok' | 'cancel';
export function useTemplatePromise() {
const TemplatePromise = createTemplatePromise<DialogResult, [string]>({
transition: {
name: 'fade',
appear: true,
},
});
type TemplatePromiseSlots = InstanceType<typeof TemplatePromise>['$slots'];
const DialogComponent = defineComponent({
setup() {
return () => h(TemplatePromise, {}, {
default: ({
resolve,
args
}: Parameters<TemplatePromiseSlots['default']>[0]) => h('div', {class: 'fixed inset-0 bg-black/10 flex items-center z-30'}, [
h('dialog', {open: true, class: 'border-gray/10 shadow rounded ma'}, [
h('div', `Dialog ${args ? args[0] : ''}`),
h('p', 'Open console to see logs'),
h('div', {class: 'flex gap-2 justify-end'}, [
h('button', {class: 'w-35', onClick: () => resolve('cancel')}, 'Cancel'),
h('button', {
class: 'w-35',
onClick: () => resolve(asyncFn())
}, 'OK')
])
])
])
});
}
});
function asyncFn() {
return new Promise<DialogResult>((resolve) => {
setTimeout(() => {
resolve('ok');
}, 1000);
});
}
const app = createApp(DialogComponent);
const containerId = 'dialog-container';
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
document.body.appendChild(container);
} else {
const existingApp = (container as any).__vue_app__;
if (existingApp) {
existingApp.unmount();
}
}
app.mount(container);
return {
start: TemplatePromise.start,
};
}