132 lines
4.6 KiB
Vue
132 lines
4.6 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, nextTick, onUnmounted } from 'vue'
|
|
import { useFocusTrap } from '../utils/focusTrap'
|
|
import { useAnnouncer } from '../composables/useAnnouncer'
|
|
import { useEntryTemplatesStore, type EntryTemplate } from '../stores/entryTemplates'
|
|
import { useProjectsStore } from '../stores/projects'
|
|
import { X, FileText } from 'lucide-vue-next'
|
|
|
|
const props = defineProps<{
|
|
show: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
select: [template: EntryTemplate]
|
|
cancel: []
|
|
}>()
|
|
|
|
const templatesStore = useEntryTemplatesStore()
|
|
const projectsStore = useProjectsStore()
|
|
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
|
|
const { announce } = useAnnouncer()
|
|
const dialogRef = ref<HTMLElement | null>(null)
|
|
const activeIndex = ref(0)
|
|
|
|
function getProjectName(projectId: number): string {
|
|
return projectsStore.projects.find(p => p.id === projectId)?.name || 'Unknown'
|
|
}
|
|
|
|
function formatDuration(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600)
|
|
const m = Math.floor((seconds % 3600) / 60)
|
|
if (h > 0 && m > 0) return `${h}h ${m}m`
|
|
if (h > 0) return `${h}h`
|
|
return `${m}m`
|
|
}
|
|
|
|
watch(() => props.show, async (val) => {
|
|
if (val) {
|
|
activeIndex.value = 0
|
|
await templatesStore.fetchTemplates()
|
|
await nextTick()
|
|
if (dialogRef.value) {
|
|
activateTrap(dialogRef.value, { onDeactivate: () => emit('cancel') })
|
|
}
|
|
announce(`Template picker opened. ${templatesStore.templates.length} templates available.`)
|
|
} else {
|
|
deactivateTrap()
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => deactivateTrap())
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
const len = templatesStore.templates.length
|
|
if (!len) return
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
activeIndex.value = (activeIndex.value + 1) % len
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
activeIndex.value = (activeIndex.value - 1 + len) % len
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
emit('select', templatesStore.templates[activeIndex.value])
|
|
}
|
|
}
|
|
|
|
function selectTemplate(tpl: EntryTemplate) {
|
|
emit('select', tpl)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<Transition name="modal">
|
|
<div
|
|
v-if="show"
|
|
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
|
@click.self="$emit('cancel')"
|
|
>
|
|
<div
|
|
ref="dialogRef"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="template-picker-title"
|
|
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-sm p-6"
|
|
@keydown="onKeydown"
|
|
>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 id="template-picker-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary">
|
|
From Template
|
|
</h2>
|
|
<button
|
|
@click="$emit('cancel')"
|
|
class="p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
|
aria-label="Close"
|
|
>
|
|
<X class="w-4 h-4" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="templatesStore.templates.length > 0" class="space-y-1 max-h-64 overflow-y-auto" role="listbox" aria-label="Entry templates">
|
|
<button
|
|
v-for="(tpl, i) in templatesStore.templates"
|
|
:key="tpl.id"
|
|
@click="selectTemplate(tpl)"
|
|
role="option"
|
|
:aria-selected="i === activeIndex"
|
|
:class="[
|
|
'w-full text-left px-3 py-2 rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
|
|
i === activeIndex ? 'bg-accent-muted' : 'hover:bg-bg-elevated'
|
|
]"
|
|
>
|
|
<p class="text-[0.8125rem] text-text-primary">{{ tpl.name }}</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary">
|
|
{{ getProjectName(tpl.project_id) }}
|
|
<span v-if="tpl.duration"> - {{ formatDuration(tpl.duration) }}</span>
|
|
</p>
|
|
</button>
|
|
</div>
|
|
|
|
<div v-else class="py-8 text-center">
|
|
<FileText class="w-8 h-8 text-text-tertiary mx-auto mb-2" :stroke-width="1.5" aria-hidden="true" />
|
|
<p class="text-[0.8125rem] text-text-secondary">No templates saved yet</p>
|
|
<p class="text-[0.6875rem] text-text-tertiary mt-1">Use "Save as Template" when editing an entry</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|