aboutsummaryrefslogtreecommitdiff
path: root/src/lib/shared
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-29 00:22:56 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-29 00:22:56 +0200
commit4f83f39f8343116ef9fbb1b5fb0da7f8a064124d (patch)
tree780667f01a25bfb73df56375da908756560e2b47 /src/lib/shared
parentfd189e1221bfc6f93b492ddbad18a6c82de33919 (diff)
Add live search to the project routines list
Diffstat (limited to 'src/lib/shared')
-rw-r--r--src/lib/shared/filter-items.test.ts53
-rw-r--r--src/lib/shared/filter-items.ts16
2 files changed, 69 insertions, 0 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));
+}