Add item input now doubles as search bar for the list #10
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useListsStore } from '@/stores/lists'
|
import { useListsStore } from '@/stores/lists'
|
||||||
import type { LocalListItem } from '@/database/db'
|
import type { LocalListItem } from '@/database/db'
|
||||||
|
import { fuzzyMatch } from '@/utils/fuzzyMatch'
|
||||||
import ListItemRow from '@/components/ListItemRow.vue'
|
import ListItemRow from '@/components/ListItemRow.vue'
|
||||||
import DeleteListModal from '@/components/DeleteListModal.vue'
|
import DeleteListModal from '@/components/DeleteListModal.vue'
|
||||||
import { useDismissableMenu } from '@/composables/useDismissableMenu'
|
import { useDismissableMenu } from '@/composables/useDismissableMenu'
|
||||||
@@ -12,7 +13,8 @@ const props = defineProps<{ id: string }>()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const listsStore = useListsStore()
|
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 isAddingItem = ref(false)
|
||||||
const itemError = ref('')
|
const itemError = ref('')
|
||||||
const showDeleteModal = ref(false)
|
const showDeleteModal = ref(false)
|
||||||
@@ -44,11 +46,21 @@ function byModifiedDesc(a: LocalListItem, b: LocalListItem) {
|
|||||||
return (b.modified_at ?? '').localeCompare(a.modified_at ?? '')
|
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(() =>
|
const pendingItems = computed(() =>
|
||||||
items.value.filter((item) => !item.is_completed).sort(byModifiedDesc),
|
filteredItems.value.filter((item) => !item.is_completed).sort(byModifiedDesc),
|
||||||
)
|
)
|
||||||
const completedItems = computed(() =>
|
const completedItems = computed(() =>
|
||||||
items.value.filter((item) => item.is_completed).sort(byModifiedDesc),
|
filteredItems.value.filter((item) => item.is_completed).sort(byModifiedDesc),
|
||||||
)
|
)
|
||||||
|
|
||||||
function handleOpenDelete() {
|
function handleOpenDelete() {
|
||||||
@@ -72,14 +84,14 @@ async function handleConfirmDelete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddItem() {
|
async function handleAddItem() {
|
||||||
const title = newItemTitle.value.trim()
|
const title = filterQuery.value
|
||||||
if (!title) return
|
if (!title) return
|
||||||
|
|
||||||
itemError.value = ''
|
itemError.value = ''
|
||||||
isAddingItem.value = true
|
isAddingItem.value = true
|
||||||
try {
|
try {
|
||||||
await listsStore.createListItem(props.id, title)
|
await listsStore.createListItem(props.id, title)
|
||||||
newItemTitle.value = ''
|
itemInput.value = ''
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
itemError.value = err instanceof Error ? err.message : 'Failed to add item'
|
itemError.value = err instanceof Error ? err.message : 'Failed to add item'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -146,7 +158,7 @@ async function handleAddItem() {
|
|||||||
<form class="new-item-form" @submit.prevent="handleAddItem">
|
<form class="new-item-form" @submit.prevent="handleAddItem">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<input
|
<input
|
||||||
v-model="newItemTitle"
|
v-model="itemInput"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Add an item…"
|
placeholder="Add an item…"
|
||||||
:disabled="isAddingItem"
|
:disabled="isAddingItem"
|
||||||
@@ -155,7 +167,7 @@ async function handleAddItem() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary add-btn"
|
class="btn btn-primary add-btn"
|
||||||
:disabled="isAddingItem || !newItemTitle.trim()"
|
:disabled="isAddingItem || !filterQuery"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
@@ -177,6 +189,7 @@ async function handleAddItem() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p v-if="items.length === 0" class="empty-hint">No items yet — add your first one above.</p>
|
<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
|
<DeleteListModal
|
||||||
v-if="showDeleteModal && list"
|
v-if="showDeleteModal && list"
|
||||||
|
|||||||
Reference in New Issue
Block a user