Skip to content

MoloF/universal-container-vue

Repository files navigation

🎭 Universal Container Vue

Elegant and type-safe modal/dialog/etc system for Vue 3 with composable API

License: ISC Vue 3 TypeScript

✨ 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

npm install universal-container-vue

πŸš€ Quick Start

1. Add UniversalContainer to your app root

<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

<!-- 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

// 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

<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

<!-- 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>
// useSimpleModal.ts
import { createEntityFactory } from 'universal-container-vue'
import { defineAsyncComponent } from 'vue'

const SimpleModal = defineAsyncComponent(() => import('./SimpleModal.vue'))

export const useSimpleModal = createEntityFactory(SimpleModal)
<!-- 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

<!-- 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>
// useChoiceModal.ts
import { createEntityFactory } from 'universal-container-vue'
import { defineAsyncComponent } from 'vue'

const ChoiceModal = defineAsyncComponent(() => import('./ChoiceModal.vue'))

export const useChoiceModal = createEntityFactory(ChoiceModal)
<!-- 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

<!-- 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>
// useInputModal.ts
import { createEntityFactory } from 'universal-container-vue'
import { defineAsyncComponent } from 'vue'

const InputModal = defineAsyncComponent(() => import('./InputModal.vue'))

export const useInputModal = createEntityFactory(InputModal)
<!-- 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

<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

<!-- 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>
// 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:

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:

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:

<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:

<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:

<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:

<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:

    const MyModal = defineAsyncComponent(() => import('./MyModal.vue'))
  2. Always handle errors with try/catch:

    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:

    <div v-show="show" class="modal">
  4. Type your props and emits for full TypeScript support:

    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:

    // Cancel
    emit('cancel', new Error('User cancelled'))
    
    // Success
    emit('close', result)
  6. Manual cleanup when needed:

    await modal.open()
    modal.destroy() // Remove from DOM immediately
  7. Initialize composables in component setup, not globally:

    // βœ… 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

πŸ”— Links


Made with ❀️ and Vue 3

About

A universal container for Vue 3 with a minimalist API

Resources

Stars

Watchers

Forks

Packages

No packages published