Files
zeroclock/src/components/EntryTemplatePicker.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>