Files
caldav/web/dav/useFilter.tsx
pastilhasandClaude Opus 5 b578c2aa3b 2026.08.27.03 — a filter on both screens
a calendar of a few hundred events and an address book of a few hundred cards are
both lists you scroll until you give up. one box on each header narrows them.

CLIENT SIDE, because the data is already here: both screens fetch a whole
collection in one request and hold it, so filtering is a string comparison over
what is in hand. a round trip per keystroke would be slower, would need a search
endpoint on the sidecar, and would put a spinner where a person expects a list to
narrow as they type.

ACCENT-INSENSITIVE, and that is the half that decides whether this is usable
here: these contacts are portuguese, and a box where "jose" does not find "José"
is a box nobody uses twice. NFD splits the letter from its accent and the range
strips what is left, so it matches in both directions — "josé" finds "Jose" too.

every term must match somewhere, not all in one field, so "ana lisboa" finds the
ana whose address is in lisbon. events match on their start as well, which is an
ISO string, so "2026-08-29" narrows to a day without anything having to parse it.

the count in the header becomes "12 of 340" while filtering, and a filter that
matches nothing says so by name — otherwise it reads as a calendar that lost its
events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 14:20:50 +00:00

54 lines
2.2 KiB
TypeScript

import { useState } from 'react';
import { Search, X } from 'lucide-react';
/**
* A filter box for a list that is already in the browser.
*
* Client-side, deliberately. Both screens load a whole collection in one request and hold it — a
* calendar of a few hundred events, an address book of a few hundred cards — so filtering is a string
* comparison over data already in hand. A server round trip per keystroke would be slower, would need a
* search endpoint on the sidecar, and would show a spinner where a person expects a list to narrow.
*
* Matching is CASE- AND ACCENT-INSENSITIVE, and that second half is not decoration here: this owner's
* contacts are Portuguese. Typing "jose" must find "José" or the box is useless for half the address
* book. `NFD` splits a letter from its accent and the range strips what is left behind.
*
* Every term must match SOMEWHERE, not all in one field — so "ana lisboa" finds the Ana whose address is
* in Lisbon, which is how people actually search a contact list.
*/
const fold = (value: string) => value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
export const matches = (query: string, ...fields: (string | undefined)[]): boolean => {
const terms = fold(query).split(/\s+/).filter(Boolean);
if (!terms.length) return true;
const haystack = fold(fields.filter(Boolean).join(' '));
return terms.every((term) => haystack.includes(term));
};
export function useFilter(placeholder: string) {
const [query, setQuery] = useState('');
const input = (
<div className="relative min-w-0 flex-1 sm:max-w-64">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="w-full rounded-md border bg-background py-1 pl-7 pr-7 text-xs"
/>
{query && (
<button
onClick={() => setQuery('')}
title="Clear"
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
)}
</div>
);
return { query, input };
}