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
This commit is contained in:
192
src/components/GlobalSearchDialog.vue
Normal file
192
src/components/GlobalSearchDialog.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user