feat: add item input now doubles as search bar for the list

This commit is contained in:
2026-08-31 18:02:52 +02:00
parent 3369b49eb6
commit ca0317a01a
3 changed files with 89 additions and 7 deletions
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest'
import { fuzzyMatch } from '../fuzzyMatch'
describe('fuzzyMatch', () => {
it('matches an empty query against anything', () => {
expect(fuzzyMatch('', 'Groceries')).toBe(true)
expect(fuzzyMatch(' ', 'Groceries')).toBe(true)
})
it('matches an exact substring, case-insensitively', () => {
expect(fuzzyMatch('milk', 'Buy milk and eggs')).toBe(true)
expect(fuzzyMatch('MILK', 'buy milk and eggs')).toBe(true)
})
it('tolerates a couple of off characters', () => {
expect(fuzzyMatch('mikl', 'Buy milk and eggs')).toBe(true)
expect(fuzzyMatch('grocries', 'Groceries')).toBe(true)
})
it('does not match unrelated text', () => {
expect(fuzzyMatch('xyz', 'Groceries')).toBe(false)
expect(fuzzyMatch('banana', 'Groceries')).toBe(false)
})
})
+45
View File
@@ -0,0 +1,45 @@
function levenshtein(a: string, b: string): number {
const rows = a.length + 1
const cols = b.length + 1
let prev: number[] = Array.from({ length: cols }, (_, j) => j)
for (let i = 1; i < rows; i++) {
const curr: number[] = [i]
for (let j = 1; j < cols; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
curr.push(Math.min((curr[j - 1] ?? 0) + 1, (prev[j] ?? 0) + 1, (prev[j - 1] ?? 0) + cost))
}
prev = curr
}
return prev[cols - 1] ?? 0
}
function maxDistanceFor(queryLength: number): number {
if (queryLength <= 4) return 1
return 2
}
/**
* Cheap, local fuzzy substring match: true if `text` contains a run of
* characters within edit-distance of `query` (tolerating ~1-2 typos).
*/
export function fuzzyMatch(query: string, text: string): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
const t = text.toLowerCase()
if (t.includes(q)) return true
const maxDistance = maxDistanceFor(q.length)
const minLen = Math.max(1, q.length - maxDistance)
const maxLen = q.length + maxDistance
for (let len = minLen; len <= maxLen; len++) {
for (let start = 0; start + len <= t.length; start++) {
if (levenshtein(q, t.substring(start, start + len)) <= maxDistance) return true
}
}
return false
}
+20 -7
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useListsStore } from '@/stores/lists'
import type { LocalListItem } from '@/database/db'
import { fuzzyMatch } from '@/utils/fuzzyMatch'
import ListItemRow from '@/components/ListItemRow.vue'
import DeleteListModal from '@/components/DeleteListModal.vue'
import { useDismissableMenu } from '@/composables/useDismissableMenu'
@@ -12,7 +13,8 @@ const props = defineProps<{ id: string }>()
const router = useRouter()
const listsStore = useListsStore()
const newItemTitle = ref('')
// Doubles as the "add item" text box and the live filter query on the list below.
const itemInput = ref('')
const isAddingItem = ref(false)
const itemError = ref('')
const showDeleteModal = ref(false)
@@ -44,11 +46,21 @@ function byModifiedDesc(a: LocalListItem, b: LocalListItem) {
return (b.modified_at ?? '').localeCompare(a.modified_at ?? '')
}
// Filtering is local and fuzzy only, for display purposes — it never touches
// the server or the sync queue.
const filterQuery = computed(() => itemInput.value.trim())
const filteredItems = computed(() => {
const query = filterQuery.value
if (!query) return items.value
return items.value.filter((item) => fuzzyMatch(query, item.title))
})
const pendingItems = computed(() =>
items.value.filter((item) => !item.is_completed).sort(byModifiedDesc),
filteredItems.value.filter((item) => !item.is_completed).sort(byModifiedDesc),
)
const completedItems = computed(() =>
items.value.filter((item) => item.is_completed).sort(byModifiedDesc),
filteredItems.value.filter((item) => item.is_completed).sort(byModifiedDesc),
)
function handleOpenDelete() {
@@ -72,14 +84,14 @@ async function handleConfirmDelete() {
}
async function handleAddItem() {
const title = newItemTitle.value.trim()
const title = filterQuery.value
if (!title) return
itemError.value = ''
isAddingItem.value = true
try {
await listsStore.createListItem(props.id, title)
newItemTitle.value = ''
itemInput.value = ''
} catch (err) {
itemError.value = err instanceof Error ? err.message : 'Failed to add item'
} finally {
@@ -146,7 +158,7 @@ async function handleAddItem() {
<form class="new-item-form" @submit.prevent="handleAddItem">
<div class="field">
<input
v-model="newItemTitle"
v-model="itemInput"
type="text"
placeholder="Add an item…"
:disabled="isAddingItem"
@@ -155,7 +167,7 @@ async function handleAddItem() {
<button
type="submit"
class="btn btn-primary add-btn"
:disabled="isAddingItem || !newItemTitle.trim()"
:disabled="isAddingItem || !filterQuery"
>
+
</button>
@@ -177,6 +189,7 @@ async function handleAddItem() {
</section>
<p v-if="items.length === 0" class="empty-hint">No items yet add your first one above.</p>
<p v-else-if="filteredItems.length === 0" class="empty-hint">No items match "{{ filterQuery }}".</p>
<DeleteListModal
v-if="showDeleteModal && list"