Skip to content

Application Features ​

Overview ​

This section will guide through creating a set of core features and a default layout, resulting in complete and reusable web application.

  • default layout with app bar, navigation bar and footer with version information.

  • settings theme, language, font size.

  • navigation helpers - menu, breadcrumbs

  • a11y enhancements - dynamic page title, skip link, route announcer, focus management

  • UI capabilities - alerts, snack-bars, loading.

  • page not found implementation.

Default Layout ​

  1. Update title and replace ./apps/public/favicon.ico

./apps/index.html ​

html
<!DOCTYPE html>
<html lang="en">
  <head>
<!-- ... -->
    <title>OdbVue</title>
<!-- ... -->
</head>
  <body>
<!-- ... -->
  </body>
</html>
  1. Clean the main App to have just wrapper for layouts

@/App.vue ​

vue
<template>
  <component :is="LayoutComponent">
    <RouterView />
  </component>
</template>

<script setup lang="ts">
//
</script>
  1. Add title to json package

./apps/package.json ​

json
{
  "name": "odbvue",
  "title" : "OdbVue",
  // ...
  1. Modify default layout

Add logo ./apps/public/logo.sv and change default layout.

@/layouts/DefaultLayout.vue ​

source
vue
<template>
  <v-app>
    <v-navigation-drawer v-model="drawer" app>
      <v-container>
        <v-row>
          <v-col cols="4">
            <a href="/" class="d-block">
              <v-img eager class="rounded-lg border-thin" :alt="title" src="/logo.svg"> </v-img>
            </a>
          </v-col>
        </v-row>
      </v-container>
      <v-divider />
      <v-list>
        <v-list-item
          v-for="page in pages"
          :key="page.path"
          :prepend-icon="page.icon || '$mdiMinus'"
          :to="page.path"
        >
          <v-list-item-title>{{ page.title }}</v-list-item-title>
        </v-list-item>
      </v-list>
    </v-navigation-drawer>
    <v-app-bar>
      <v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>
      <v-toolbar-title>{{ title }}</v-toolbar-title>
    </v-app-bar>
    <v-main class="ma-4">
      <slot />
    </v-main>
    <v-footer app>
      <v-row>
        <v-col>          
          <span class="text-caption">{{ app.version }}</span>
        </v-col>
        <v-col class="text-right">
          <v-btn
            icon
            href="https://github.com/odbvue/odbvue"
            target="_blank"
            rel="noopener"
            title="GitHub"
            size="xx-small"
            color="secondary"
            variant="flat"
          >
            <v-icon icon="$mdiGithub"></v-icon>
          </v-btn>
        </v-col>
      </v-row>
    </v-footer>
  </v-app>
</template>

<script setup lang="ts">
import { version, title } from '../../package.json'
const drawer = ref(false)
const pages = ref([
  { title: 'Home', icon: '$mdiHome', path: '/' },
  { title: 'About', icon: '$mdiInformation', path: '/about' },
  { title: 'Sandbox', icon: '$mdiCog', path: '/sandbox' },
])
</script>

Settings ​

  1. Create new main Application store - container for all application level stores

@/stores/index.ts ​

ts
import { defineStore, acceptHMRUpdate } from 'pinia'

export const useAppStore = defineStore('app', () => {
  const getSettings = () => useSettingsStore()

  return { settings: getSettings() }
})

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useSettingsStore, import.meta.hot))
}
  1. Enhance Settings store with locale and size capabilities

@/stores/app/settings.ts ​

source
ts
import { defineStore, acceptHMRUpdate } from 'pinia'
import { ref, watch, computed } from 'vue'
import { useTheme } from 'vuetify'
import { useI18n } from 'vue-i18n'

export const useSettingsStore = defineStore(
  'settings',
  () => {
    const themeManager = useTheme()
    const themes = computed(() => Object.keys(themeManager.themes.value))
    const theme = ref(themeManager.global.name.value)
    const themeIcon = computed(() => {
      if (themeManager.global.name.value === 'light') return '$mdiWeatherNight'
      if (themeManager.global.name.value === 'dark') return '$mdiWeatherSunny'
      return ''
    })
    function toggleTheme() {
      theme.value = theme.value === 'dark' ? 'light' : 'dark'
    }
    watch(theme, (newTheme) => {
      if (!themes.value.includes(newTheme)) {
        console.warn(`[Settings Store] Invalid theme: ${newTheme}`)
        const firstTheme = themes.value[0]
        if (firstTheme) {
          theme.value = firstTheme
        }
        return
      }
      themeManager.change(newTheme)
    })

    const i18nManager = useI18n()
    const locale = ref(i18nManager.locale.value)
    const locales = ref(i18nManager.availableLocales)
    function setLocale(newLocale: string) {
      locale.value = newLocale
    }
    watch(locale, (newLocale) => {
      if (!locales.value.includes(newLocale)) {
        console.warn(`[Settings Store] Invalid locale: ${newLocale}`)
        const firstLocale = locales.value[0]
        if (firstLocale) {
          locale.value = firstLocale
        }
        return
      }
      i18nManager.locale.value = newLocale
    })

    const fontSize = ref(100)
    const fontSizes = [100, 150, 200]
    function setFontSize(newFontSize: number) {
      if (!fontSizes.includes(newFontSize)) {
        console.warn(`[Settings Store] Invalid font size: ${newFontSize}`)
        fontSize.value = 100
        return
      }
      fontSize.value = newFontSize
      if (typeof document !== 'undefined') {
        document.documentElement.style.fontSize = `${newFontSize}%`
      }
    }

    return {
      theme,
      themes,
      themeIcon,
      toggleTheme,
      locale,
      locales,
      setLocale,
      fontSize,
      fontSizes,
      setFontSize,
    }
  },
  {
    storage: {
      adapter: 'localStorage',
      include: ['locale', 'theme', 'fontSize'],
    },
  } as Record<string, unknown>,
)

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useSettingsStore, import.meta.hot))
}
  1. Add Setting capabilities im application

@/layouts/DefaultLayout.vue ​

source
vue
<template>
    <!-- -->
    <v-app-bar>
      <v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>
      <v-toolbar-title>{{ title }}</v-toolbar-title>
      <v-btn v-if="mobile">
        <v-icon :icon="'$mdiDotsVertical'"></v-icon>
        <v-menu activator="parent">
          <v-list>
            <v-list-item link prepend-icon="$mdiMenuLeft">
              <v-list-item-title>
                <v-icon icon="$mdiEyePlusOutline"></v-icon>
              </v-list-item-title>
              <v-menu submenu activator="parent">
                <v-list>
                  <v-list-item
                    link
                    v-for="item in app.settings.fontSizes"
                    :key="item"
                    :value="item"
                    @click="app.settings.setFontSize(item)"
                  >
                    <v-list-item-title> {{ item }}% </v-list-item-title>
                  </v-list-item>
                </v-list>
              </v-menu>
            </v-list-item>
            <v-list-item link prepend-icon="$mdiMenuLeft">
              <v-list-item-title>
                {{ app.settings.locale }}
              </v-list-item-title>
              <v-menu submenu activator="parent">
                <v-list>
                  <v-list-item
                    link
                    v-for="item in app.settings.locales"
                    :key="item"
                    :value="item"
                    @click="app.settings.setLocale(item)"
                  >
                    <v-list-item-title>
                      {{ item }}
                    </v-list-item-title>
                  </v-list-item>
                </v-list>
              </v-menu>
            </v-list-item>
            <v-list-item
              @click="app.settings.toggleTheme()"
            >
              <v-list-item-title>
                <v-icon class="ml-10" :icon="app.settings.themeIcon"></v-icon>
              </v-list-item-title>
            </v-list-item>
          </v-list>
        </v-menu>
      </v-btn>
      <v-menu v-if="!mobile">
        <template #activator="{ props }">
          <v-btn variant="text" v-bind="props" prepend-icon="$mdiEyePlusOutline"></v-btn>
        </template>
        <v-list>
          <v-list-item v-for="(item, i) in app.settings.fontSizes" :key="i" :value="i">
            <v-list-item-title @click="app.settings.setFontSize(item)"
              >{{ item }}%</v-list-item-title
            >
          </v-list-item>
        </v-list>
      </v-menu>
      <v-menu v-if="!mobile">
        <template #activator="{ props }">
          <v-btn variant="text" v-bind="props">{{ app.settings.locale }}</v-btn>
        </template>
        <v-list>
          <v-list-item v-for="(item, i) in app.settings.locales" :key="i" :value="i">
            <v-list-item-title @click="app.settings.setLocale(item)">{{ item }}</v-list-item-title>
          </v-list-item>
        </v-list>
      </v-menu>
      <v-btn
        v-if="!mobile"
        variant="text"
        :prepend-icon="app.settings.themeIcon"
        @click="app.settings.toggleTheme()"
      ></v-btn>
    </v-app-bar>
    <!-- -->
</template>

<script setup lang="ts">
// ..
const { mobile } = useDisplay()
const app = useAppStore()
// ..
</script>
  1. Create a new store for populating navigation menu and breadcrumbs.

@/stores/app/navigation.ts ​

source
ts
import { defineStore, acceptHMRUpdate } from 'pinia'
import { useRouter, useRoute } from 'vue-router'
import { computed } from 'vue'

export const useNavigationStore = defineStore('navigation', () => {
  const routes = useRouter().getRoutes()
  const route = useRoute()

  const allPages = routes.map((route) => {
    return {
      path: route.path,
      level: route.path == '/' ? 0 : route.path.split('/').length - 1,
      children:
        routes.find((r) => r.path.includes(route.path) && r.path !== route.path) !== undefined,
      title:
        route.meta?.title?.toString() ||
        route.path
          .split('/')
          .at(-1)
          ?.split('-')
          .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
          .join(' ') ||
        '',
      description: route.meta?.description?.toString() || '',
      icon: (route.meta?.icon as string) || '$mdiMinus',
      color: (route.meta?.color as string) || '',
      role: (route.meta?.role as string) || '',
    }
  })

  const title = computed(() => (path: string) => {
    const page = allPages.find((page) => page.path === path)
    return page ? page.title : ''
  })

  const breadcrumb = ref('')

  const breadcrumbs = computed(() => {
    const paths = ['', ...route.path.split('/').filter(Boolean)].map((_, i, arr) => {
      const path = arr.slice(1, i + 1).join('/')
      return '/' + path
    })
    const crumbs = allPages
      .filter((page) => page.path !== '/:path(.*)')
      .filter((page) => paths.includes(page.path))
      .sort((a, b) => a.level - b.level)
      .map((page) => {
        return {
          title: page.title,
          disabled: route.path === page.path,
          href: page.path,
          icon: page.icon,
        }
      })
    if (breadcrumb.value)
      crumbs.push({
        title: breadcrumb.value,
        disabled: true,
        href: '',
        icon: '',
      })
    return crumbs
  })

  const pages = computed(() => {
    return allPages.filter((page) => page.level < 2).filter((page) => page.path !== '/:path(.*)')
  })

  return {
    pages,
    title,
    breadcrumb,
    breadcrumbs,
  }
})

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useNavigationStore, import.meta.hot))
}
  1. Add navigation store to main store

@/stores/index.ts ​

ts
import { defineStore, acceptHMRUpdate } from 'pinia'

export const useAppStore = defineStore('app', () => {
  const getSettings = () => useSettingsStore()
  const getNavigation = () => useNavigationStore()

  return { settings: getSettings(), navigation: getNavigation() }
})

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useSettingsStore, import.meta.hot))
  import.meta.hot.accept(acceptHMRUpdate(useNavigationStore, import.meta.hot))
}
  1. Add Breadcrumbs and Menu to Default Layout.

@/layots/DefaultLayout.vue ​

vue
</template>
    <!-- -->
      <v-list>
        <v-list-item
          v-for="page in app.navigation.pages"
          :key="page.path"
          :prepend-icon="page.icon"
          :to="page.path"
          @click="drawer = false"
        >
          <v-list-item-title>{{ page.title }}</v-list-item-title>
        </v-list-item>
      </v-list>
    </v-navigation-drawer>
    <!-- -->
    <v-app-bar>
      <v-breadcrumbs :items="app.navigation.breadcrumbs">
        <template v-slot:divider>
          <v-icon icon="$mdiChevronRight"></v-icon>
        </template>
        <template v-slot:title="{ item, index }">
          <v-breadcrumbs-item
            :to="item.href"
            :disabled="index === app.navigation.breadcrumbs.length - 1"
          >
            {{ item.title }}
          </v-breadcrumbs-item>
        </template>
      </v-breadcrumbs>
    </v-app-bar>
    <!-- -->
    <v-main class="ma-4">
      <slot />
    </v-main>
    <!-- -->
</template>

<script setup lang="ts">
import { version, title } from '../../package.json'
const { mobile } = useDisplay()
const app = useAppStore()
const drawer = ref(false)
const pages = ref([ 
  { title: 'Home', icon: '$mdiHome', path: '/' }, 
  { title: 'About', icon: '$mdiInformation', path: '/about' }, 
  { title: 'Sandbox', icon: '$mdiCog', path: '/sandbox' }, 
]) 
</script>
  1. Enhance Home view with cards representing pages.

@/pages/index.vue ​

source
vue
<template>
  <v-row>
    <v-col
      cols="12"
      md="4"
      v-for="page in app.navigation.pages.filter((page) => page.path !== '/')"
      :key="page.path"
    >
      <v-card
        min-height="8em"
        hover
        :style="useCardBackground(page.color || '#ffffff').value"
        :prepend-icon="page.icon"
        :title="page.title"
        :to="page.path"
        :text="page.description"
      >
      </v-card>
    </v-col>
  </v-row>
</template>

<script setup lang="ts">
definePage({
  meta: {
    title: 'Home',
    description: 'Welcome to the home page',
    icon: '$mdiHome',
    color: '#ABCDEF',
  },
})
import { useCardBackground } from '@/composables/ui'
const app = useAppStore()
</script>

Accessibility ​

Dynamic page titles ​

Updating the page title makes SPAs accessible and clear - screen readers announce the new page, browser tabs show the right name, and users always know where they are.

  1. Install Unhead to enable manipulation of page head data.
bash
pnpm install @unhead/vue
  1. Modify Vite Config to add Unhead to auto imports

./vite.config.ts ​

ts
// ...
import { unheadVueComposablesImports } from '@unhead/vue'
// ...
export default defineConfig({
  plugins: [
// ...
    AutoImport({
      imports: [
//...
        unheadVueComposablesImports,
//...
      ],
    })
  ],
})
  1. Create unhead instance in @/main.ts
ts
// ...
import { createHead } from '@unhead/vue/client'
// ...
app.use(createHead())
// ...
  1. Modify Router to enable dynamic page title

@/router/index.ts ​

ts
//...
import { title } from '../../package.json'
//...
router.beforeEach(async (to) => {
  const appTitle = title || 'OdbVue'
  const pageTitle = useNavigationStore().title(to.path)
  const documentTitle = pageTitle ? `${appTitle} - ${pageTitle}` : appTitle
  useHead({ title: documentTitle })
  return true
})
// ...

A skip link is a keyboard-visible anchor shown at the very top of the page that lets users jump straight to the main content (bypassing repeated navigation, headers, etc.). It appears when focused (e.g., by pressing Tab), and when activated it moves focus into <main> so keyboard and screen-reader users can start interacting with the content immediately.

  1. Add Skip Link in Default Layout

@/layouts/DefaultLayout.vue ​

source
vue
    <!-- -->
    <v-main class="ma-4" id="main" tabindex="-1">
      <!-- -->
      <a class="skip-link" href="#main" @click.prevent="focusMain">Skip to content</a>
      <slot />
    </v-main>
    <!-- -->

<script setup lang="ts">
// ...
function focusMain() {
  const el = document.getElementById('main')
  if (el) {
    el.focus({ preventScroll: true })
    el.scrollIntoView({ behavior: 'smooth', block: 'start' })
  }
}
</script>

<style scoped>
.skip-link {
  position: absolute;
  left: 0.5rem; top: 0.5rem;
  padding: .5rem .75rem;
  background: white;
  border-radius: .375rem;
  box-shadow: 0 2px 8px rgba(0,0,0,.15);
  transform: translateY(-150%);
  transition: transform .15s ease;
  z-index: 1000;
}
.skip-link:focus {
  transform: translateY(0);
}
</style>

Route Announcer ​

A route announcer is a hidden live region that tells screen-reader users when the view changes in your SPA - since there’s no full page reload to trigger announcements automatically.

  1. Add Route Announcer to Default Layout, before <v-main>

@/layouts/DefaultLayout.vue ​

vue
    <v-main class="ma-4" id="main" tabindex="-1">
      <div id="route-announcer" aria-live="polite" class="sr-only"></div>

<! -- -->

.sr-only {
  position: absolute !important;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden; clip: rect(0 0 0 0);
  white-space: nowrap; border: 0;
}
</style>
  1. Att to Router

@/router/index.ts ​

ts
// ...
router.afterEach((to) => {
  const live = document.getElementById('route-announcer')
  if (!live) return
  const page = typeof to.meta?.title === 'string' ? to.meta.title : 'Page'
  live.textContent = `${page} loaded`
})
// ...

Focus Management ​

Focus management ensures that after navigation in a SPA, keyboard and screen-reader users start at the new page’s main heading - just like a full page load. It improves orientation and makes tabbing predictable.

  1. Add Focus Management feature to Router

@/router/index.ts ​

ts
// ...
router.afterEach(async (to) => {
  // Update live region for screen readers
  const live = document.getElementById('route-announcer')
  const page = typeof to.meta?.title === 'string' ? to.meta.title : 'Page'
  if (live) {
    live.textContent = '' // clear to re-trigger
    setTimeout(() => (live.textContent = `${page} loaded`), 30)
  }
  // Wait for new view to render, then move focus to first heading
  await nextTick()
  const heading = document.querySelector('main h1') as HTMLElement | null
  if (heading) {
    heading.setAttribute('tabindex', '-1')
    heading.focus({ preventScroll: true })
    heading.scrollIntoView({ behavior: 'smooth', block: 'start' })
  }
})
// ...

UI Features ​

  1. Create store for managing UI state

@/stores/app/ui.ts ​

source
ts
import { defineStore, acceptHMRUpdate } from 'pinia'

export const useUiStore = defineStore('ui', () => {
  const loading = ref(false)
  const info = ref('')
  const success = ref('')
  const warning = ref('')
  const error = ref('')
  const snack = ref('')
  const snackTimeout = ref(0)
  const snackbar = computed(() => !!snack.value)

  function clearMessages() {
    info.value = ''
    success.value = ''
    warning.value = ''
    error.value = ''
    snack.value = ''
    snackTimeout.value = 0
  }

  function setInfo(message: string) {
    clearMessages()
    info.value = message
  }

  function setSuccess(message: string) {
    clearMessages()
    success.value = message
  }

  function setWarning(message: string) {
    clearMessages()
    warning.value = message
  }

  function setError(message: string) {
    clearMessages()
    error.value = message
  }

  function setSnack(message: string, timeout = 0) {
    clearMessages()
    snack.value = message
    if (timeout > 0) {
      snackTimeout.value = timeout
      setTimeout(() => {
        snack.value = ''
        snackTimeout.value = 0
      }, timeout)
    }
  }

  function startLoading() {
    loading.value = true
  }

  function stopLoading() {
    loading.value = false
  }

  return {
    loading,
    info,
    success,
    warning,
    error,
    snack,
    snackTimeout,
    snackbar,
    clearMessages,
    setInfo,
    setSuccess,
    setWarning,
    setError,
    setSnack,
    startLoading,
    stopLoading,
  }
})

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useUiStore, import.meta.hot))
}
  1. Add to Main Store

@/stores/index.ts ​

source
ts
import { defineStore, acceptHMRUpdate } from 'pinia'

export const useAppStore = defineStore('app', () => {
  const getSettings = () => useSettingsStore()
  const getNavigation = () => useNavigationStore()
  const getUi = () => useUiStore()

  return { settings: getSettings(), navigation: getNavigation(), ui: getUi() }
})

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useSettingsStore, import.meta.hot))
  import.meta.hot.accept(acceptHMRUpdate(useNavigationStore, import.meta.hot))
  import.meta.hot.accept(acceptHMRUpdate(useUiStore, import.meta.hot))
}
  1. Add Components to Default View

@/layouts/DefaultLayout.vue ​

source
vue
<template>
  <v-app>
    <v-navigation-drawer v-model="drawer" app>
      <!-- ... -->
    </v-navigation-drawer>
    <v-app-bar>
      <!-- ... -->
      <v-progress-linear
        :active="app.ui.loading"
        indeterminate
        absolute
        location="bottom"
        height="6"
      ></v-progress-linear>
    </v-app-bar>
    <v-app-bar>
      <v-alert
        type="info"
        :text="app.ui.info ? t(app.ui.info) : ''"
        v-show="app.ui.info.length > 0"
        class="mb-2"
      ></v-alert>
      <v-alert
        type="warning"
        :text="app.ui.warning ? t(app.ui.warning) : ''"
        v-show="app.ui.warning.length > 0"
        class="mb-2"
      ></v-alert>
      <v-alert
        type="error"
        :text="app.ui.error ? t(app.ui.error) : ''"
        v-show="app.ui.error.length > 0"
        class="mb-2"
      ></v-alert>
    </v-app-bar>
    <v-main class="ma-4" id="main" tabindex="-1">
      <!-- ... -->
      <v-snackbar v-model="app.ui.snackbar">
        {{ app.ui.snack }}
        <template v-slot:actions>
          <v-btn color="pink" variant="text" @click="app.ui.snack = ''">
            {{ t('close') }}
          </v-btn>
        </template>
      </v-snackbar>
      <v-overlay v-model="app.ui.loading" contained></v-overlay>
    </v-main>
    <v-footer app>
      <!-- ... -->
    </v-footer>
  </v-app>
</template>
  1. Refactor @/pages/sandbox/index.ts to test UI capabilities

@/pages/sandbox/index.ts ​

source
vue
<template>
  <v-card :style="cardBackground">
    <v-card-title>
      <v-icon icon="$mdiMinus" />{{ t('sandbox.title') }}
    </v-card-title>
    <v-card-subtitle>{{ t('sandbox.description') }}</v-card-subtitle>
    <v-card-text>
    <v-row>
      <v-col >
        <h3 class="mb-4">Alerts & snackbar</h3>
        <v-btn class="ma-1" color="info" @click="app.ui.setInfo('This is info!')">Show Info</v-btn>
        <v-btn class="ma-1" color="warning" @click="app.ui.setWarning('This is warning!')">Show Warning</v-btn>
        <v-btn class="ma-1" color="error" @click="app.ui.setError('This is error!')">Show Error</v-btn>
        <v-btn class="ma-1" color="success" @click="app.ui.setSuccess('This is success!')">Show Success</v-btn>
        <v-btn class="ma-1" color="primary" @click="app.ui.setSnack('This is snackbar info!', 3000)">Show Snackbar Info</v-btn>
        <v-btn class="ma-1" color="secondary" @click="app.ui.clearMessages()">Clear All</v-btn>
      </v-col>
    </v-row>
    <v-row>
      <v-col >
        <h3 class="mb-4">loading</h3>
        <v-btn class="ma-1" color="primary" @click="app.ui.startLoading()">Start Loading</v-btn>
      </v-col>
    </v-row>
    </v-card-text>
  </v-card>
</template>

<script setup lang="ts">
const cardBackground = useCardBackground('#ccccee')
const { t } = useI18n()
const app = useAppStore()
</script>
  1. Modify Router to clear all UI alerts on new routes

@/router/index.ts ​

ts
//...
router.beforeEach(async (to) => {
  const appTitle = title || 'OdbVue'
  const pageTitle = useNavigationStore().title(to.path)
  const documentTitle = pageTitle ? `${appTitle} - ${pageTitle}` : appTitle
  useHead({ title: documentTitle })
  useUiStore().clearMessages()
  return true
})
// ...

Page Not Found ​

Create Page Not Found page

@/pages/[...path].vue ​

vue
<template>
  <v-container fluid class="d-flex align-center justify-center" style="height: 100vh">
    <v-col cols="12" class="text-center">
      <img src="/logo.svg" alt="Not Found" />
      <h1>Page not found!</h1>
      <p>Ups! The page you are looking for does not exist.</p>
      <router-link to="/">Home</router-link>
    </v-col>
  </v-container>
</template>

<route>
  { meta: { layout: 'fullscreen' } }
</route>

Drag & Drop (composable) ​

For simple Kanban-style interactions (move cards between columns) you can use a small HTML5 drag-and-drop composable. It centralizes drag state, drop-target highlighting helpers, and provides default inline styles that follow the Vuetify theme.

@/composables/dnd.ts ​

source
ts
import { computed, reactive, ref, type CSSProperties } from 'vue'

export type DragDropOptions<Payload> = {
  mime: string
  toJson?: (payload: Payload) => unknown
  fromJson?: (value: unknown) => Payload | null
  toText?: (payload: Payload) => string
  fromText?: (text: string) => Payload | null
}

export function useHtml5DragDrop<Payload, DropKey extends string = string>(
  options: DragDropOptions<Payload>,
) {
  const draggingPayload = ref<Payload | null>(null)
  const activeDropKey = ref<DropKey | null>(null)
  const dragEnterCounts = reactive<Record<string, number>>({})

  // Default styles (theme-aware via Vuetify CSS variables when present)
  // Note: cursor is handled via CSS classes (.ov-dnd-card) for immediate responsiveness
  const dndCardBaseStyle: CSSProperties = {}

  const dndCardDraggingStyle: CSSProperties = {
    opacity: 0.85,
  }

  const dndDropColumnActiveStyle: CSSProperties = {
    outline: '2px dashed rgb(var(--v-theme-primary))',
    outlineOffset: '6px',
    backgroundColor: 'rgba(var(--v-theme-primary), 0.06)',
  }

  const isDragging = computed(() => draggingPayload.value !== null)

  const clearDropTargets = () => {
    activeDropKey.value = null
    for (const k of Object.keys(dragEnterCounts)) delete dragEnterCounts[k]
  }

  const endDrag = () => {
    draggingPayload.value = null
    clearDropTargets()
  }

  const startDrag = (payload: Payload, event: DragEvent) => {
    const dt = event.dataTransfer
    if (!dt) return

    draggingPayload.value = payload

    dt.effectAllowed = 'move'

    const jsonValue = options.toJson ? options.toJson(payload) : payload
    dt.setData(options.mime, JSON.stringify(jsonValue))

    const textValue = options.toText ? options.toText(payload) : ''
    if (textValue) dt.setData('text/plain', textValue)
  }

  const extractPayload = (event: DragEvent): Payload | null => {
    const dt = event.dataTransfer
    if (!dt) return null

    const raw = dt.getData(options.mime) || ''
    if (raw) {
      try {
        const parsed = JSON.parse(raw) as unknown
        if (!options.fromJson) return null
        return options.fromJson(parsed)
      } catch {
        // ignore
      }
    }

    const text = dt.getData('text/plain') || ''
    if (text && options.fromText) return options.fromText(text)

    return null
  }

  const enterDropTarget = (key: DropKey) => {
    if (!draggingPayload.value) return

    const k = String(key)
    dragEnterCounts[k] = (dragEnterCounts[k] ?? 0) + 1
    activeDropKey.value = key
  }

  const leaveDropTarget = (key: DropKey) => {
    const k = String(key)
    const next = (dragEnterCounts[k] ?? 0) - 1

    if (next <= 0) {
      delete dragEnterCounts[k]
      if (activeDropKey.value === key) activeDropKey.value = null
      return
    }

    dragEnterCounts[k] = next
  }

  return {
    draggingPayload,
    isDragging,
    activeDropKey,

    dndCardBaseStyle,
    dndCardDraggingStyle,
    dndDropColumnActiveStyle,

    startDrag,
    endDrag,
    extractPayload,
    enterDropTarget,
    leaveDropTarget,
    clearDropTargets,
  }
}