diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib/shared/filter-items.test.ts | 53 | ||||
| -rw-r--r-- | src/lib/shared/filter-items.ts | 16 | ||||
| -rw-r--r-- | src/routes/projects/[id]/+page.svelte | 28 |
3 files changed, 94 insertions, 3 deletions
diff --git a/src/lib/shared/filter-items.test.ts b/src/lib/shared/filter-items.test.ts new file mode 100644 index 0000000..3477cd1 --- /dev/null +++ b/src/lib/shared/filter-items.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { filterByName } from './filter-items'; + +const routines = [ + { id: 1, name: 'Cuisine/Repas' }, + { id: 2, name: 'Cuisine/Vaisselle' }, + { id: 3, name: 'Salle de bain/Douche' }, + { id: 4, name: 'Étirements' }, + { id: 5, name: 'Course' }, +]; + +describe('filterByName', () => { + it('filters by a namespace query like "Cuisine/"', () => { + const res = filterByName(routines, 'Cuisine/'); + expect(res.map((r) => r.name)).toEqual([ + 'Cuisine/Repas', + 'Cuisine/Vaisselle', + ]); + }); + + it('matches a substring anywhere in the name', () => { + const res = filterByName(routines, 'epas'); + expect(res.map((r) => r.name)).toEqual(['Cuisine/Repas']); + }); + + it('is case-insensitive', () => { + expect(filterByName(routines, 'cUiSiNe/')).toHaveLength(2); + }); + + it('folds accents on both sides', () => { + expect(filterByName(routines, 'etirements').map((r) => r.name)).toEqual([ + 'Étirements', + ]); + expect(filterByName(routines, 'Étirements')).toHaveLength(1); + }); + + it('returns everything for an empty or whitespace query, order preserved', () => { + expect(filterByName(routines, '')).toEqual(routines); + expect(filterByName(routines, ' ')).toEqual(routines); + }); + + it('returns nothing when no name matches', () => { + expect(filterByName(routines, 'garage')).toEqual([]); + }); + + it('has no result cap', () => { + const many = Array.from({ length: 40 }, (_, i) => ({ + id: i, + name: `Cuisine/Tâche ${i}`, + })); + expect(filterByName(many, 'Cuisine/')).toHaveLength(40); + }); +}); diff --git a/src/lib/shared/filter-items.ts b/src/lib/shared/filter-items.ts new file mode 100644 index 0000000..e7e103b --- /dev/null +++ b/src/lib/shared/filter-items.ts @@ -0,0 +1,16 @@ +/** Lowercase and strip diacritics so "etirements" matches "Étirements". */ +function fold(value: string): string { + return value.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase(); +} + +/** Filter `items` to those whose `name` contains `query` as a literal + * substring, case- and accent-insensitive. Order is preserved and no result + * cap applies. Empty/whitespace query returns everything. */ +export function filterByName<T extends { name: string }>( + items: T[], + query: string, +): T[] { + const q = fold(query.trim()); + if (q === '') return items; + return items.filter((item) => fold(item.name).includes(q)); +} diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte index 8d62a39..7d9a146 100644 --- a/src/routes/projects/[id]/+page.svelte +++ b/src/routes/projects/[id]/+page.svelte @@ -13,6 +13,7 @@ } from '$lib/shared/routine-metrics'; import SectionTitle from '$lib/components/SectionTitle.svelte'; import { formatDayMonth } from '$lib/shared/temporal'; + import { filterByName } from '$lib/shared/filter-items'; let { data, form } = $props(); @@ -35,6 +36,11 @@ (a, b) => b.overdueRatio - a.overdueRatio || a.name.localeCompare(b.name), ); } + + let routineSearch = $state(''); + const filteredRoutines = $derived( + filterByName(sorted(data.routines), routineSearch), + ); </script> <main> @@ -102,8 +108,17 @@ <p class="error">{form.error}</p> {/if} + <input + class="form-input routine-search" + type="search" + bind:value={routineSearch} + placeholder="Rechercher une routine..." + autocomplete="off" + aria-label="Rechercher parmi les routines" + /> + <ul class="item-list"> - {#each sorted(data.routines) as routine (routine.id)} + {#each filteredRoutines as routine (routine.id)} <Row style="flex-wrap: wrap; padding: 0.75rem 1rem;"> <form method="POST" action="?/complete" use:enhance> <input type="hidden" name="id" value={routine.id} /> @@ -133,8 +148,10 @@ {/each} </ul> - {#if data.routines.length === 0} - <p class="empty">Aucune routine</p> + {#if filteredRoutines.length === 0} + <p class="empty"> + {data.routines.length === 0 ? 'Aucune routine' : 'Aucune correspondance.'} + </p> {/if} {#if data.archived.length > 0} @@ -220,6 +237,11 @@ flex-shrink: 0; } + .routine-search { + width: 100%; + margin-bottom: 0.75rem; + } + .page-footer { margin-top: 2rem; display: flex; |
