Add Evidence sidebar text filter for CE-WP-0010.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Filter input next to the Evidence caption; case-insensitive match on quote,
commentary, and status when the query has three or more characters.
This commit is contained in:
tegwick 2026-07-30 19:41:40 +02:00
parent 7f42564cf4
commit 664d7da100

View file

@ -104,6 +104,22 @@ function annotationOrderKey(annotation: Annotation | null): number {
return docOrderKey(annotation.selectors); return docOrderKey(annotation.selectors);
} }
const FILTER_MIN_CHARS = 3;
function matchesEvidenceFilter(
query: string,
quote: string,
commentary: string | undefined,
status: string,
): boolean {
const q = query.trim();
if (q.length < FILTER_MIN_CHARS) return true;
const needle = q.toLowerCase();
return [quote, commentary ?? "", status].some((p) =>
p.toLowerCase().includes(needle),
);
}
export function EvidenceSidebar(props: EvidenceSidebarProps) { export function EvidenceSidebar(props: EvidenceSidebarProps) {
const engine = useEngine(); const engine = useEngine();
const { document } = useActiveDocument(); const { document } = useActiveDocument();
@ -117,6 +133,8 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
const annotationUpdateTick = useEngineEventTick("AnnotationUpdated"); const annotationUpdateTick = useEngineEventTick("AnnotationUpdated");
const revision = useEngineRevision(); const revision = useEngineRevision();
const [filterQuery, setFilterQuery] = useState("");
// Build the sorted view-model: each item gets its order key + the // Build the sorted view-model: each item gets its order key + the
// first annotation up-front so the render below doesn't have to // first annotation up-front so the render below doesn't have to
// re-resolve them inside the map. // re-resolve them inside the map.
@ -139,6 +157,17 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
revision, revision,
]); ]);
const filteredItems = useMemo(() => {
return sortedItems.filter(({ item, annotation }) =>
matchesEvidenceFilter(
filterQuery,
annotation?.quote ?? "",
item.commentary,
item.status,
),
);
}, [sortedItems, filterQuery]);
const pendingOrder = useMemo<number>(() => { const pendingOrder = useMemo<number>(() => {
if (!pending) return Number.POSITIVE_INFINITY; if (!pending) return Number.POSITIVE_INFINITY;
const c = pending.capture; const c = pending.capture;
@ -148,15 +177,15 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
return c.startOffset; return c.startOffset;
}, [pending]); }, [pending]);
// Find the insert position for the pending capture form: first index // Insert pending capture among the *unfiltered* document order so a
// whose order > pendingOrder, or sortedItems.length to append. // narrow filter cannot hide the in-progress capture form.
const pendingInsertIndex = useMemo(() => { const pendingInsertIndex = useMemo(() => {
if (!pending) return -1; if (!pending) return -1;
for (let i = 0; i < sortedItems.length; i++) { for (let i = 0; i < filteredItems.length; i++) {
if (sortedItems[i]!.order > pendingOrder) return i; if (filteredItems[i]!.order > pendingOrder) return i;
} }
return sortedItems.length; return filteredItems.length;
}, [pending, pendingOrder, sortedItems]); }, [pending, pendingOrder, filteredItems]);
const [openExportFor, setOpenExportFor] = useState<EvidenceItemId | null>(null); const [openExportFor, setOpenExportFor] = useState<EvidenceItemId | null>(null);
const [editingId, setEditingId] = useState<EvidenceItemId | null>(null); const [editingId, setEditingId] = useState<EvidenceItemId | null>(null);
@ -249,6 +278,7 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
return ( return (
<aside <aside
aria-label="Evidence"
style={{ style={{
width: 320, width: 320,
borderLeft: "1px solid #ddd", borderLeft: "1px solid #ddd",
@ -259,7 +289,33 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
position: "relative", position: "relative",
}} }}
> >
<h2 style={{ marginTop: 0, fontSize: 16 }}>Evidence</h2> <div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 8,
flexWrap: "wrap",
}}
>
<h2 style={{ margin: 0, fontSize: 16, flex: "0 0 auto" }}>Evidence</h2>
<input
type="search"
value={filterQuery}
onChange={(e) => setFilterQuery(e.target.value)}
placeholder="Filter…"
aria-label="Filter evidence"
data-testid="evidence-filter"
style={{
flex: "1 1 100px",
minWidth: 80,
fontSize: 12,
padding: "4px 6px",
border: "1px solid #ccc",
borderRadius: 4,
}}
/>
</div>
{!document && ( {!document && (
<p style={{ fontSize: 12, color: "#888" }}>No document open.</p> <p style={{ fontSize: 12, color: "#888" }}>No document open.</p>
)} )}
@ -269,8 +325,19 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
capture. capture.
</p> </p>
)} )}
{document &&
sortedItems.length > 0 &&
filteredItems.length === 0 &&
!pending && (
<p
style={{ fontSize: 12, color: "#888" }}
data-testid="evidence-filter-empty"
>
No matches.
</p>
)}
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}> <ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{sortedItems.map((entry, i) => { {filteredItems.map((entry, i) => {
const slotForCapture = pendingInsertIndex === i; const slotForCapture = pendingInsertIndex === i;
return ( return (
<Fragment key={entry.item.id}> <Fragment key={entry.item.id}>
@ -314,7 +381,7 @@ export function EvidenceSidebar(props: EvidenceSidebarProps) {
</Fragment> </Fragment>
); );
})} })}
{pendingInsertIndex === sortedItems.length && ( {pendingInsertIndex === filteredItems.length && (
<li> <li>
<InlineCaptureForm /> <InlineCaptureForm />
</li> </li>