UNPKG

universal-container-vue

Version:

A universal container component for functional Vue 3

771 lines (598 loc) 16.5 kB
# 🎭 Universal Container Vue > Elegant and type-safe modal/dialog/etc system for Vue 3 with composable API [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](https://opensource.org/licenses/ISC) [![Vue 3](https://img.shields.io/badge/Vue-3.x-brightgreen.svg)](https://vuejs.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg)](https://www.typescriptlang.org/) ## ✨ Features - 🎯 **Full TypeScript support** - automatic type inference from component props - 🚀 **Promise-based API** - work with modals as async/await - 🔄 **Composable approach** - use modals as Vue 3 composables - 🎨 **Flexible design** - full control over modal appearance - 📦 **Lightweight** - minimal dependencies - 🔌 **Easy integration** - single container component for all modals - ♻️ **Smart lifecycle** - automatic cleanup on unmount ## 📦 Installation ```bash npm install universal-container-vue ``` ## 🚀 Quick Start ### 1. Add UniversalContainer to your app root ```vue <script setup lang="ts"> import { UniversalContainer } from 'universal-container-vue' </script> <template> <div id="app"> <router-view /> <!-- Add modal container --> <UniversalContainer /> </div> </template> ``` ### 2. Create a modal component ```vue <!-- ConfirmModal.vue --> <script setup lang="ts"> defineProps<{ show: boolean input: { title: string message: string } }>() const emit = defineEmits<{ close: [result: boolean] cancel: [reason: Error] }>() function onConfirm() { emit('close', true) } function onCancel() { emit('cancel', new Error('User cancelled')) } </script> <template> <div v-show="show" class="modal"> <div class="overlay" @click="onCancel" /> <div class="content"> <h2>{{ input.title }}</h2> <p>{{ input.message }}</p> <div class="actions"> <button @click="onCancel"> Cancel </button> <button @click="onConfirm"> Confirm </button> </div> </div> </div> </template> <style scoped> .modal { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 1000; } .overlay { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.5); } .content { position: relative; background: white; padding: 24px; border-radius: 12px; max-width: 400px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); } .actions { display: flex; gap: 12px; margin-top: 24px; } button { flex: 1; padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; } </style> ``` ### 3. Create a composable factory ```ts // useConfirmModal.ts import { createEntityFactory } from 'universal-container-vue' import { defineAsyncComponent } from 'vue' const ConfirmModal = defineAsyncComponent(() => import('./ConfirmModal.vue')) export const useConfirmModal = createEntityFactory(ConfirmModal) ``` ### 4. Use the modal in your component ```vue <script setup lang="ts"> import { useConfirmModal } from './useConfirmModal' // Initialize the modal composable const confirmModal = useConfirmModal() async function handleDelete() { try { const confirmed = await confirmModal.open({ title: 'Delete item?', message: 'This action cannot be undone', }) if (confirmed) { // User confirmed the action console.log('Deleting item...') } } catch (error) { // User cancelled the action console.log('Action cancelled') } } </script> <template> <button @click="handleDelete"> Delete </button> </template> ``` ## 📚 Usage Examples ### Simple modal without input ```vue <!-- SimpleModal.vue --> <script setup lang="ts"> defineProps<{ show: boolean }>() const emit = defineEmits<{ close: [] }>() function close() { emit('close') } </script> <template> <div v-show="show" class="modal"> <div class="overlay" @click="close" /> <div class="content"> <h2>Hello!</h2> <button @click="close"> Close </button> </div> </div> </template> ``` ```ts // useSimpleModal.ts import { createEntityFactory } from 'universal-container-vue' import { defineAsyncComponent } from 'vue' const SimpleModal = defineAsyncComponent(() => import('./SimpleModal.vue')) export const useSimpleModal = createEntityFactory(SimpleModal) ``` ```vue <!-- Usage in component --> <script setup lang="ts"> import { useSimpleModal } from './useSimpleModal' const simpleModal = useSimpleModal() async function showModal() { await simpleModal.open() // TypeScript knows props are not needed! } </script> <template> <button @click="showModal"> Show Simple Modal </button> </template> ``` ### Modal with multiple results ```vue <!-- ChoiceModal.vue --> <script setup lang="ts"> defineProps<{ show: boolean input: { question: string } }>() const emit = defineEmits<{ close: [result?: 'yes' | 'no' | 'maybe'] cancel: [reason: Error] }>() function onSelect(value: 'yes' | 'no' | 'maybe') { emit('close', value) } function onCancel() { emit('cancel', new Error('cancelled')) } </script> <template> <div v-show="show" class="modal"> <div class="overlay" @click="onCancel" /> <div class="content"> <h2>{{ input.question }}</h2> <div class="choices"> <button @click="onSelect('yes')"> Yes </button> <button @click="onSelect('no')"> No </button> <button @click="onSelect('maybe')"> Maybe </button> </div> </div> </div> </template> ``` ```ts // useChoiceModal.ts import { createEntityFactory } from 'universal-container-vue' import { defineAsyncComponent } from 'vue' const ChoiceModal = defineAsyncComponent(() => import('./ChoiceModal.vue')) export const useChoiceModal = createEntityFactory(ChoiceModal) ``` ```vue <!-- Usage in component --> <script setup lang="ts"> import { useChoiceModal } from './useChoiceModal' const choiceModal = useChoiceModal() async function askUser() { try { const answer = await choiceModal.open({ question: 'Do you like Vue 3?', }) console.log('User answered:', answer) // answer: 'yes' | 'no' | 'maybe' | undefined } catch (error) { console.log('User cancelled') } } </script> <template> <button @click="askUser"> Ask Question </button> </template> ``` ### Input form modal ```vue <!-- InputModal.vue --> <script setup lang="ts"> import { ref } from 'vue' defineProps<{ show: boolean input: { title: string placeholder: string } }>() const emit = defineEmits<{ close: [result: string] cancel: [reason: Error] }>() const inputValue = ref('') function onSubmit() { if (inputValue.value) { emit('close', inputValue.value) } } function onCancel() { emit('cancel', new Error('cancelled')) } </script> <template> <div v-show="show" class="modal"> <div class="overlay" @click="onCancel" /> <div class="content"> <h2>{{ input.title }}</h2> <input v-model="inputValue" :placeholder="input.placeholder" @keyup.enter="onSubmit" > <div class="actions"> <button @click="onCancel"> Cancel </button> <button :disabled="!inputValue" @click="onSubmit"> Save </button> </div> </div> </div> </template> ``` ```ts // useInputModal.ts import { createEntityFactory } from 'universal-container-vue' import { defineAsyncComponent } from 'vue' const InputModal = defineAsyncComponent(() => import('./InputModal.vue')) export const useInputModal = createEntityFactory(InputModal) ``` ```vue <!-- Usage in component --> <script setup lang="ts"> import { useInputModal } from './useInputModal' const inputModal = useInputModal() async function promptUserName() { try { const name = await inputModal.open({ title: 'What is your name?', placeholder: 'Enter your name', }) console.log('Hello,', name) } catch (error) { console.log('User cancelled input') } } </script> <template> <button @click="promptUserName"> Ask Name </button> </template> ``` ### Nested modals ```vue <script setup lang="ts"> import { useConfirmModal } from './useConfirmModal' import { useInputModal } from './useInputModal' const confirmModal = useConfirmModal() const inputModal = useInputModal() async function openNestedModals() { try { // Open first modal const name = await inputModal.open({ title: 'Create user', placeholder: 'Enter name', }) // Open second modal for confirmation const confirmed = await confirmModal.open({ title: 'Confirm creation', message: `Create user ${name}?`, }) if (confirmed) { console.log('User created:', name) } } catch (error) { console.log('Operation cancelled at some stage') } } </script> <template> <button @click="openNestedModals"> Create User </button> </template> ``` ### Circular/Recursive modals ```vue <!-- CircularModal.vue --> <script setup lang="ts"> import { useCircularModal } from './useCircularModal' const props = defineProps<{ show: boolean input: { level: number } }>() const emit = defineEmits<{ close: [] cancel: [reason: Error] }>() const circularModal = useCircularModal() async function openAnotherModal() { await circularModal.open({ level: props.input.level + 1, }) // Manually destroy this modal instance after the nested one closes circularModal.destroy() } </script> <template> <div v-show="show" class="modal"> <div class="overlay" @click="emit('cancel', new Error('cancelled'))" /> <div class="content"> <h2>Level {{ input.level }}</h2> <button @click="openAnotherModal"> Open Level {{ input.level + 1 }} </button> <button @click="emit('close')"> Close </button> </div> </div> </template> ``` ```ts // useCircularModal.ts import { createEntityFactory } from 'universal-container-vue' import { defineAsyncComponent } from 'vue' const CircularModal = defineAsyncComponent(() => import('./CircularModal.vue')) export const useCircularModal = createEntityFactory(CircularModal) ``` ## 🔧 API Reference ### `createEntityFactory(component)` Creates a composable factory for modal management with automatic type inference. **Parameters:** - `component` - Vue component to use as modal/dialog **Returns:** A composable function that returns `EntityController<Input, Output>` **Example:** ```ts import { createEntityFactory } from 'universal-container-vue' import { defineAsyncComponent } from 'vue' const MyModal = defineAsyncComponent(() => import('./MyModal.vue')) export const useMyModal = createEntityFactory(MyModal) ``` ### `EntityController` Controller instance for managing a modal. **Methods:** - `open(props)` - Opens the modal and returns a Promise that resolves with the result - `close(result)` - Manually closes the modal with a result - `cancel(reason)` - Manually cancels the modal with a reason - `destroy()` - Destroys the modal instance completely (removes from DOM) **Example:** ```ts const modal = useMyModal() // Open modal const result = await modal.open({ title: 'Hello' }) // Manual close (usually not needed, use emit in component) modal.close(result) // Manual destroy (removes from DOM) modal.destroy() ``` ### Modal Component Requirements Your modal component must follow this structure: **Required Props:** - `show: boolean` - Controls modal visibility (use with `v-show`) - `input?: YourInputType` - Input data for the modal (optional if no input needed) **Required Emits:** - `close: [result?: YourResultType]` - Emitted when modal successfully closes - `cancel: [reason: Error]` - Emitted when modal is cancelled **Example:** ```vue <script setup lang="ts"> defineProps<{ show: boolean input: { title: string } }>() const emit = defineEmits<{ close: [result: string] cancel: [reason: Error] }>() </script> <template> <div v-show="show" class="modal"> <!-- Modal content --> </div> </template> ``` ## ⚡ Component Lifecycle **Important:** Understanding the modal lifecycle is crucial for proper memory management. ### How it works: 1. **On `open()`**: Modal component is created or shown (if already exists) 2. **On `close/cancel`**: Modal is **hidden** (`show = false`) but **NOT destroyed** 3. **On parent unmount**: Modal is automatically destroyed when the parent component that called the composable unmounts 4. **Manual destroy**: You can manually call `destroy()` to remove the modal from DOM ### Visual representation: ``` Component Mount → useModal() → open() → [Modal Created] ↓ [Modal Visible] ↓ close()/cancel() emitted ↓ [Modal Hidden] (still in DOM) ↓ ┌──────────────────────────────┐ ↓ ↓ Component Unmount manual destroy() ↓ ↓ [Modal Destroyed] [Modal Destroyed] (removed from DOM) (removed from DOM) ``` ### Why this approach? - **Performance**: Reusing modal instances is faster than recreating them - **State preservation**: Modal state is preserved between opens (can be useful) - **Animations**: Allows for smooth exit animations ### Manual cleanup example: ```vue <script setup lang="ts"> import { useConfirmModal } from './useConfirmModal' const confirmModal = useConfirmModal() async function showModal() { await confirmModal.open({ title: 'Confirm?' }) // Manually destroy the modal after it closes // This is useful if you want immediate cleanup confirmModal.destroy() } </script> ``` ### Automatic cleanup: ```vue <script setup lang="ts"> import { useConfirmModal } from './useConfirmModal' const confirmModal = useConfirmModal() // Modal will be automatically destroyed when this component unmounts // No manual cleanup needed in most cases </script> ``` ## 🎨 Styling The library doesn't impose any styles. You have full control over modal appearance through component CSS. **Important:** Use `v-show="show"` instead of `v-if` to properly handle visibility: ```vue <template> <div v-show="show" class="modal"> <!-- Overlay for background dimming --> <div class="overlay" @click="onClose" /> <!-- Modal content --> <div class="content"> <!-- Your content --> </div> </div> </template> ``` ## 💡 Best Practices 1. **Use `defineAsyncComponent`** for lazy loading modals: ```ts const MyModal = defineAsyncComponent(() => import('./MyModal.vue')) ``` 2. **Always handle errors** with try/catch: ```ts try { const result = await modal.open() } catch (error) { // User cancelled or error occurred } ``` 3. **Use `v-show` instead of `v-if`** in your modal component: ```vue <div v-show="show" class="modal"> ``` 4. **Type your props and emits** for full TypeScript support: ```ts defineProps<{ show: boolean, input: { title: string } }>() const emit = defineEmits<{ close: [result: string], cancel: [reason: Error] }>() ``` 5. **Use emit('cancel')** for user cancellation, **emit('close')** for success: ```ts // Cancel emit('cancel', new Error('User cancelled')) // Success emit('close', result) ``` 6. **Manual cleanup** when needed: ```ts await modal.open() modal.destroy() // Remove from DOM immediately ``` 7. **Initialize composables in component setup**, not globally: ```ts // ✅ Good - in component const modal = useMyModal() // ❌ Bad - outside component const modal = useMyModal() // Won't cleanup automatically ``` ## 🤝 Contributing Contributions, issues and feature requests are welcome! ## 📝 License ISC © [Max Frolov](https://github.com/MoloF) ## 🔗 Links - [GitHub](https://github.com/MoloF/universal-container-vue) - [Issues](https://github.com/MoloF/universal-container-vue/issues) --- Made with ❤️ and Vue 3