Files
zeroclock/src/components/GlobalSearchDialog.vue
Your Name c4703dfe98 tooltips, two-column timer, font selector, tray behavior, icons, readme
- Custom tooltip directive (WCAG AAA) on every button in the app
- Two-column timer layout with sticky hero and recent entries sidebar
- Timer font selector with 16 monospace Google Fonts and live preview
- UI font selector with 15+ Google Fonts
- Close-to-tray and minimize-to-tray settings
- New app icons (no-glow variants), platform icon set
- Mini timer pop-out window
- Favorites strip with drag-reorder and inline actions
- README with feature documentation
- Remove tracked files that belong in gitignore
2026-02-21 01:15:57 +02:00

193 lines
6.2 KiB
Vue

<script setup lang="ts">
import { watch, ref, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { invoke } from '@tauri-apps/api/core'
import { useFocusTrap } from '../utils/focusTrap'
import { useProjectsStore } from '../stores/projects'
import { useInvoicesStore } from '../stores/invoices'
import { Search, FolderKanban, Users, Clock, FileText } from 'lucide-vue-next'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: [] }>()
const router = useRouter()
const projectsStore = useProjectsStore()
const invoicesStore = useInvoicesStore()
const { activate, deactivate } = useFocusTrap()
const dialogRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const query = ref('')
const activeIndex = ref(0)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
interface SearchResult {
type: 'project' | 'client' | 'entry' | 'invoice'
id: number
label: string
sublabel: string
color?: string
route: string
}
const entryResults = ref<SearchResult[]>([])
const searching = ref(false)
const localResults = computed((): SearchResult[] => {
const q = query.value.toLowerCase().trim()
if (!q) return []
const results: SearchResult[] = []
for (const p of projectsStore.projects) {
if (results.length >= 5) break
if (p.name.toLowerCase().includes(q)) {
results.push({ type: 'project', id: p.id!, label: p.name, sublabel: p.archived ? 'Archived' : 'Active', color: p.color, route: '/projects' })
}
}
for (const inv of invoicesStore.invoices) {
if (results.length >= 10) break
if (inv.invoice_number.toLowerCase().includes(q)) {
results.push({ type: 'invoice', id: inv.id!, label: inv.invoice_number, sublabel: inv.status, route: '/invoices' })
}
}
return results
})
const allResults = computed(() => [...localResults.value, ...entryResults.value])
async function searchEntries(q: string) {
if (!q.trim()) {
entryResults.value = []
return
}
searching.value = true
try {
const rows = await invoke<any[]>('search_entries', { query: q, limit: 5 })
entryResults.value = rows.map(r => ({
type: 'entry' as const,
id: r.id,
label: r.description || '(no description)',
sublabel: r.project_name || 'Unknown project',
color: r.project_color,
route: '/entries',
}))
} catch {
entryResults.value = []
} finally {
searching.value = false
}
}
function onInput() {
activeIndex.value = 0
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => searchEntries(query.value), 200)
}
function navigate(result: SearchResult) {
router.push(result.route)
emit('close')
}
function onKeydown(e: KeyboardEvent) {
const total = allResults.value.length
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = (activeIndex.value + 1) % Math.max(total, 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value = (activeIndex.value - 1 + Math.max(total, 1)) % Math.max(total, 1)
} else if (e.key === 'Enter' && allResults.value[activeIndex.value]) {
e.preventDefault()
navigate(allResults.value[activeIndex.value])
} else if (e.key === 'Escape') {
emit('close')
}
}
const typeIcon: Record<string, any> = {
project: FolderKanban,
client: Users,
entry: Clock,
invoice: FileText,
}
watch(() => props.show, (val) => {
if (val) {
query.value = ''
entryResults.value = []
activeIndex.value = 0
nextTick(() => {
if (dialogRef.value) activate(dialogRef.value, { onDeactivate: () => emit('close') })
inputRef.value?.focus()
})
} else {
deactivate()
}
})
</script>
<template>
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-start justify-center pt-[15vh] p-4 z-50"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-label="Search"
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md overflow-hidden"
@keydown="onKeydown"
>
<div class="flex items-center gap-3 px-4 py-3 border-b border-border-subtle">
<Search class="w-4 h-4 text-text-tertiary shrink-0" :stroke-width="1.5" aria-hidden="true" />
<input
ref="inputRef"
v-model="query"
@input="onInput"
type="text"
class="flex-1 bg-transparent text-[0.875rem] text-text-primary placeholder-text-tertiary outline-none"
placeholder="Search projects, entries, invoices..."
aria-label="Search"
/>
<kbd class="text-[0.625rem] text-text-tertiary border border-border-subtle rounded px-1.5 py-0.5">Esc</kbd>
</div>
<div v-if="!query.trim()" class="px-4 py-8 text-center text-[0.75rem] text-text-tertiary">
Type to search...
</div>
<div v-else-if="allResults.length === 0 && !searching" class="px-4 py-8 text-center text-[0.75rem] text-text-tertiary">
No results for "{{ query }}"
</div>
<ul v-else class="max-h-80 overflow-y-auto py-2" role="listbox">
<li
v-for="(result, idx) in allResults"
:key="result.type + '-' + result.id"
role="option"
:aria-selected="idx === activeIndex"
class="flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors duration-100"
:class="idx === activeIndex ? 'bg-accent/10' : 'hover:bg-bg-elevated'"
@click="navigate(result)"
@mouseenter="activeIndex = idx"
>
<component :is="typeIcon[result.type]" class="w-4 h-4 text-text-tertiary shrink-0" :stroke-width="1.5" aria-hidden="true" />
<span v-if="result.color" class="w-2 h-2 rounded-full shrink-0" :style="{ backgroundColor: result.color }" aria-hidden="true" />
<div class="flex-1 min-w-0">
<p class="text-[0.8125rem] text-text-primary truncate">{{ result.label }}</p>
<p class="text-[0.6875rem] text-text-tertiary truncate">{{ result.sublabel }}</p>
</div>
<span class="text-[0.5625rem] text-text-tertiary uppercase tracking-wider shrink-0">{{ result.type }}</span>
</li>
</ul>
</div>
</div>
</Transition>
</template>