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
- Update title and replace
./apps/public/favicon.ico
./apps/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<!-- ... -->
<title>OdbVue</title>
<!-- ... -->
</head>
<body>
<!-- ... -->
</body>
</html>- Clean the main App to have just wrapper for layouts
@/App.vue
<template>
<component :is="LayoutComponent">
<RouterView />
</component>
</template>
<script setup lang="ts">
//
</script>- Add title to json package
./apps/package.json
{
"name": "odbvue",
"title" : "OdbVue",
// ...- Modify default layout
Add logo ./apps/public/logo.sv and change default layout.
@/layouts/DefaultLayout.vue
source
<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
- Create new main Application store - container for all application level stores
@/stores/index.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))
}- Enhance Settings store with locale and size capabilities
@/stores/app/settings.ts
source
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))
}- Add Setting capabilities im application
@/layouts/DefaultLayout.vue
source
<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>Navigation
- Create a new store for populating navigation menu and breadcrumbs.
@/stores/app/navigation.ts
source
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))
}- Add navigation store to main store
@/stores/index.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))
}- Add Breadcrumbs and Menu to Default Layout.
@/layots/DefaultLayout.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>- Enhance Home view with cards representing pages.
@/pages/index.vue
source
<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.
- Install Unhead to enable manipulation of page head data.
pnpm install @unhead/vue- Modify Vite Config to add Unhead to auto imports
./vite.config.ts
// ...
import { unheadVueComposablesImports } from '@unhead/vue'
// ...
export default defineConfig({
plugins: [
// ...
AutoImport({
imports: [
//...
unheadVueComposablesImports,
//...
],
})
],
})- Create
unheadinstance in@/main.ts
// ...
import { createHead } from '@unhead/vue/client'
// ...
app.use(createHead())
// ...- Modify Router to enable dynamic page title
@/router/index.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
})
// ...Skip Link
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.
- Add Skip Link in Default Layout
@/layouts/DefaultLayout.vue
source
<!-- -->
<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.
- Add Route Announcer to Default Layout, before
<v-main>
@/layouts/DefaultLayout.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>- Att to Router
@/router/index.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.
- Add Focus Management feature to Router
@/router/index.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
- Create store for managing UI state
@/stores/app/ui.ts
source
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))
}- Add to Main Store
@/stores/index.ts
source
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))
}- Add Components to Default View
@/layouts/DefaultLayout.vue
source
<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>- Refactor
@/pages/sandbox/index.ts to test UI capabilities
@/pages/sandbox/index.ts
source
<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>- Modify Router to clear all UI alerts on new routes
@/router/index.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
<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
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,
}
}