universal-container-vue
Version:
A universal container component for functional Vue 3
771 lines (598 loc) • 16.5 kB
Markdown
# 🎭 Universal Container Vue
> Elegant and type-safe modal/dialog/etc system for Vue 3 with composable API
[](https://opensource.org/licenses/ISC)
[](https://vuejs.org/)
[](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