Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature]: Calling dialog using vue hooks #748

Closed
2 tasks
han1548772930 opened this issue Sep 2, 2024 · 9 comments
Closed
2 tasks

[Feature]: Calling dialog using vue hooks #748

han1548772930 opened this issue Sep 2, 2024 · 9 comments

Comments

@han1548772930
Copy link

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.
@Saeid-Za
Copy link
Contributor

Saeid-Za commented Sep 2, 2024

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

  1. open(props?: ComponentProps): Opens the dialog and accepts optional props to pass to the dialog component.
  2. close(): Closes the dialog.
  3. update(props: Record<string, any>): update props of the dialog.
  4. onOk(fn: (...args: any) => any): Registers a callback function to be executed when the user confirms the dialog (e.g., clicks "OK").
  5. 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 ?

@kikky7
Copy link
Contributor

kikky7 commented Sep 2, 2024

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>

@Saeid-Za
Copy link
Contributor

Saeid-Za commented Sep 3, 2024

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.

@sadeghbarati
Copy link
Collaborator

sadeghbarati commented Sep 4, 2024

Hey guys, thanks for the input

These sources might be helpful

@han1548772930
Copy link
Author

Hey guys, thanks for the input

These sources might be helpful

Thank you for your help.

@han1548772930
Copy link
Author

han1548772930 commented Sep 5, 2024

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');

@han1548772930
Copy link
Author

han1548772930 commented Sep 5, 2024

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,
    };
}

@hi-reeve
Copy link

for those who are looking the cleaner way, i've created myself a reuseable function

useDialog.ts

import type { VNode } from "vue";

export type DialogProps = {
	title: string;
	content: string | VNode;
	okText?: string;
	cancelText?: string;
};
const DialogTemplate = createTemplatePromise<boolean, [DialogProps]>();

export const useDialog = () => DialogTemplate;

AppDialogProvider.vue

<script setup lang="ts">
const DialogTemplate = useDialog();
const { t } = useI18n();
</script>

<template>
  <DialogTemplate v-slot="{ resolve, args: [props] }">
    <AlertDialog :open="true">
      <AlertDialogContent>
        <AlertDialogHeader>
            <AlertDialogTitle>
            {{ props.title }}
            </AlertDialogTitle>
            <AlertDialogDescription>
              <p v-if="typeof props.content === 'string'">
              {{ props.content }}
              </p>
              <component v-else :is="props.content" />
            </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
            <AlertDialogCancel @click="resolve(false)">
              {{ props.cancelText ?? t("button.cancel") }}
            </AlertDialogCancel>
            <AlertDialogAction @click="resolve(true)">
              {{ props.okText ?? t("button.ok") }}
            </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  </DialogTemplate>
</template>

app.vue

<template>
  <NuxtLayout>
     <NuxtPage />
  </NuxtLayout>
  <AppDialogProvider />
</template>

<style lang="scss">
.page-enter-active,
.page-leave-active {
	@apply transition-all duration-300 ease-in-out;
}
.page-enter-from {
	@apply -translate-x-4 opacity-0;
}
.page-leave-to {
	@apply translate-x-10 opacity-0;
}

.fade-enter-active,
.fade-leave-active {
	@apply transition-all duration-300 ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
	@apply opacity-0;
}
</style>

example of usage

const dialog = useDialog();
const handleConfirmation = async () => {
    const res = await dialog.start({
        title: "Confirmation",
        content: "Do you really want to delete the user?",
    });
    if (res) {
        //do something
    };
};

@Naxisigut
Copy link

thanks guys you are so helpful

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants