diff --git a/web/src/styles/search.css b/web/src/styles/search.css new file mode 100644 index 0000000..5f647d6 --- /dev/null +++ b/web/src/styles/search.css @@ -0,0 +1,370 @@ +.unified-search { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + overflow: auto; + color: var(--t1); + line-height: 1.45; +} +.search-heading { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 12px; + padding: 22px 28px 6px; +} +.search-heading h1 { + margin: 0; + font-size: 22px; + font-weight: 500; + letter-spacing: -0.015em; +} +.search-heading p { + margin: 0; + color: var(--t3); + font-size: 13px; + overflow-wrap: anywhere; +} +.search-heading > button { + margin-left: auto; + display: inline-flex; + align-items: center; + align-self: center; + gap: 6px; + height: 30px; + min-height: 30px; + padding: 0 12px; + border-radius: 9px; + background: var(--raise); + color: var(--t2); + font-size: 12.5px; +} +.search-content { + display: flex; + flex-direction: column; + gap: 8px; + padding: 6px 28px 24px; +} +.search-query { + display: flex; + gap: 8px; + min-width: 0; +} +.search-input { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--line2); + border-radius: 8px; + background: var(--main); +} +.search-input:focus-within { + border-color: var(--t1); + box-shadow: 0 0 0 1px var(--t1); +} +.search-input svg { + color: var(--t3); + flex: none; +} +.search-input input { + width: 100%; + min-width: 0; + border: 0; + background: transparent; + padding: 0; + outline: none; + color: var(--t1); +} +.search-scope { + margin: 0; + color: var(--t3); + font-size: 12px; +} +.search-tabs { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 4px 0; +} +.search-tabs button { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + min-height: 28px; + border: 0; + border-radius: 999px; + background: var(--raise); + padding: 0 11px; + color: var(--t2); + font-size: 12px; +} +.search-tabs button[aria-pressed="true"] { + background: var(--btn); + color: var(--btnt); +} +.search-tabs span { + margin-left: 0; +} +.search-group { + display: flex; + flex-direction: column; + gap: 8px; +} +.search-group h2 { + display: flex; + align-items: baseline; + justify-content: space-between; + margin: 0; + padding: 12px 0 6px; + font-size: 13px; + font-weight: 500; + color: var(--t1); +} +.search-group h2 span { + color: var(--t3); + font-size: 12px; + font-weight: 400; +} +.search-group ul { + display: flex; + flex-direction: column; + gap: 8px; + list-style: none; + padding: 0; + margin: 0; +} +.search-result { + display: flex; + align-items: stretch; + flex-direction: column; + gap: 3px; + width: 100%; + padding: 10px 12px; + text-align: left; + background: transparent; + border: 0; + border-radius: 10px; + margin-bottom: 0; + color: var(--t2); + overflow-wrap: anywhere; +} +.search-result:hover { + background: var(--sel); +} +.search-result, +.search-tabs button { + transition: + background-color 150ms ease, + color 150ms ease, + border-color 150ms ease; +} +.search-result b { + display: flex; + align-items: flex-start; + gap: 6px; + font-size: 13px; + font-weight: 500; + color: var(--t1); + min-width: 0; +} +.search-result b svg { + flex: none; + margin-top: 1px; +} +.search-result small { + color: var(--t3); + font-size: 12px; + line-height: 1.45; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.search-match { + background: var(--sel); + color: var(--t1); + border-radius: 3px; + padding: 0 2px; + box-shadow: inset 0 -2px 0 var(--t3); +} +.search-pagination { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin: 8px 0 12px; +} +.search-pagination span { + margin-right: auto; + font-size: 12px; + color: var(--t3); +} +.search-feedback, +.search-page-feedback { + padding: 16px 0; +} +.search-feedback h2, +.search-empty h2 { + font-size: 18px; + font-weight: 500; + margin: 0 0 8px; +} +.search-feedback p, +.search-page-feedback p, +.search-empty p, +.search-navigation, +.search-no-category { + color: var(--t3); + margin: 0 0 10px; +} +.search-feedback button + button, +.search-page-feedback button + button { + margin-left: 6px; +} +.search-empty { + padding: 52px 0; +} +@media (max-width: 600px) { + .search-result small { + white-space: normal; + overflow: visible; + text-overflow: clip; + } + .search-heading { + padding: 20px 20px 6px; + } + .search-content { + padding: 14px 20px 24px; + } + .search-heading > button { + margin-left: 0; + } + .search-pagination span { + flex-basis: 100%; + } +} +.workspace-route, +.workspace-search { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + min-width: 0; +} +.workspace-search { + position: absolute; + inset: 0; + z-index: 2; + overflow: hidden; + background: var(--main); + animation: search-route-in var(--m-page, 240ms) + var(--m-out, cubic-bezier(0.2, 0.8, 0.2, 1)) both; +} +.workspace-search[data-phase="closing"] { + pointer-events: none; + animation: search-route-out var(--m-page-out, 170ms) + var(--m-io, cubic-bezier(0.4, 0, 0.2, 1)) both; +} +@keyframes search-route-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes search-route-out { + from { + opacity: 1; + transform: none; + } + to { + opacity: 0; + transform: translateY(6px); + } +} +@keyframes search-route-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes search-route-fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} +@media (prefers-reduced-motion: reduce) { + :root:not([data-motion="on"]) .workspace-search { + animation: search-route-fade-in 150ms !important; + transform: none !important; + } + :root:not([data-motion="on"]) .workspace-search[data-phase="closing"] { + animation: search-route-fade-out 150ms forwards !important; + } +} +:root[data-motion="reduced"] .workspace-search { + animation: search-route-fade-in 150ms !important; + transform: none !important; +} +:root[data-motion="reduced"] .workspace-search[data-phase="closing"] { + animation: search-route-fade-out 150ms forwards !important; +} +.workspace-route[hidden] { + display: none; +} +.search-return { + padding: 8px 28px; + border-bottom: 1px solid var(--line); +} +.search-card-preview { + width: min(640px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); +} +.search-card-text { + white-space: pre-wrap; + overflow-wrap: anywhere; + font: inherit; + line-height: 1.6; + max-height: 45dvh; + overflow: auto; + border: 1px solid var(--line2); + border-radius: 10px; + padding: 16px; +} +.search-card-preview .actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 18px; +} +.search-page-range:focus-visible { + outline: 2px solid var(--t3); + outline-offset: 3px; + border-radius: 3px; +} +.note-actor-pool, +.note-actor { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; +} +.note-actor-pool[hidden], +.note-actor[hidden] { + display: none; +} diff --git a/web/src/ui/SearchSurface.tsx b/web/src/ui/SearchSurface.tsx new file mode 100644 index 0000000..bfb4397 --- /dev/null +++ b/web/src/ui/SearchSurface.tsx @@ -0,0 +1,72 @@ +import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; + +/** Ordinary visual exit only; navigation is already admitted. Never retain a content snapshot. */ +export function SearchSurface({ + active, + privacyVersion, + children, +}: { + active: boolean; + privacyVersion: number; + children: ReactNode; +}) { + const [phase, setPhase] = useState<"open" | "closing" | "closed">( + active ? "open" : "closed", + ); + const previousPrivacy = useRef(privacyVersion), + ticket = useRef(0), + currentActive = useRef(active), + surface = useRef(null); + currentActive.current = active; + const revoked = previousPrivacy.current !== privacyVersion; + useLayoutEffect(() => { + const invocation = ++ticket.current; + if (previousPrivacy.current !== privacyVersion) { + previousPrivacy.current = privacyVersion; + setPhase("closed"); + return; + } + if (active) { + setPhase("open"); + return; + } + if (phase === "closed") return; + setPhase("closing"); + const style = surface.current ? getComputedStyle(surface.current) : null; + const ms = (value: string) => + value.trim().endsWith("ms") + ? parseFloat(value) + : parseFloat(value) * 1000; + const durations = style?.animationDuration.split(",").map(ms) ?? []; + const delays = style?.animationDelay.split(",").map(ms) ?? []; + const ends = durations + .map( + (value, index) => + value + (delays[index % Math.max(1, delays.length)] || 0), + ) + .filter(Number.isFinite); + const timer = setTimeout( + () => { + if (invocation === ticket.current && !currentActive.current) + setPhase("closed"); + }, + ends.length ? Math.max(0, ...ends) : 170, + ); + return () => { + ticket.current++; + clearTimeout(timer); + }; + }, [active, privacyVersion]); + if (revoked || (!active && phase === "closed")) return null; + return ( +
+ {children} +
+ ); +} diff --git a/web/src/ui/SearchView.tsx b/web/src/ui/SearchView.tsx new file mode 100644 index 0000000..c86d672 --- /dev/null +++ b/web/src/ui/SearchView.tsx @@ -0,0 +1,407 @@ +import { useRef, type ReactNode } from "react"; +import { FileText, Layers, Paperclip, Search, X } from "lucide-react"; +import type { SearchExcerpt } from "../search/matching"; +import type { + SearchCursor, + SearchHandle, + SearchKind, + SearchLocator, + SearchState, +} from "../search/results"; +import "../styles/search.css"; + +export type SearchTab = "all" | SearchKind; +export type SearchViewProps = { + state: SearchState; + query: string; + tab: SearchTab; + onQueryChange: (raw: string) => void; + onSubmit: (raw: string) => void; + onTabChange: (tab: SearchTab) => void; + onClear: () => void; + onOpen: (locator: SearchLocator) => void; + onPage: (cursor: SearchCursor) => void; + onContinue: (handle: SearchHandle) => void; + onCancelPage: () => void; + onCancel: () => void; + navigationState?: "opening" | "blocked" | "unavailable" | null; +}; +const names = { + all: "All", + note: "Notes", + clip: "Clips", + card: "Cards", +} as const; +const fields = { + all: "all", + note: "notes", + clip: "clips", + card: "cards", +} as const; +const tabs: readonly SearchTab[] = ["all", "note", "clip", "card"]; + +/** Escaped original text only. Ranges are presentation hints, never HTML or navigation identity. */ +function MarkedExcerpt({ excerpt }: { excerpt: SearchExcerpt }) { + const nodes: ReactNode[] = []; + let position = 0; + for (const range of excerpt.matches) { + if (!Number.isSafeInteger(range.from) || !Number.isSafeInteger(range.to)) + continue; + let from = Math.max(position, Math.min(excerpt.text.length, range.from)); + let to = Math.max(from, Math.min(excerpt.text.length, range.to)); + // Never cut a surrogate pair even if a malformed presentation range arrives. + if ( + from > 0 && + /[\uDC00-\uDFFF]/.test(excerpt.text[from] ?? "") && + /[\uD800-\uDBFF]/.test(excerpt.text[from - 1] ?? "") + ) + from--; + from = Math.max(position, from); + if ( + to < excerpt.text.length && + /[\uDC00-\uDFFF]/.test(excerpt.text[to] ?? "") && + /[\uD800-\uDBFF]/.test(excerpt.text[to - 1] ?? "") + ) + to++; + if (to <= from) continue; + nodes.push(excerpt.text.slice(position, from)); + nodes.push( + + {excerpt.text.slice(from, to)} + , + ); + position = to; + } + nodes.push(excerpt.text.slice(position)); + return ( + <> + {excerpt.before && "…"} + {nodes} + {excerpt.after && "…"} + + ); +} + +/** Controlled presentation. The owning route admits work, scopes navigation and disposes private projections. */ +export function SearchView(props: SearchViewProps) { + const { state, query, tab, navigationState } = props; + const composing = useRef(false); + const ready = state.status === "ready" ? state : null; + const empty = ready?.counts.all === 0; + const busy = state.status === "pending"; + const error = + state.status === "error" + ? { + "query-too-long": "Use at most 200 characters for a search.", + "query-invalid": + "Finish entering the current character before searching.", + "query-too-many-terms": + "Use at most 12 different words for a search.", + "source-unavailable": "Couldn’t search your local data. Try again.", + }[state.code] + : ""; + return ( +
+
+

Search

+ {ready && ( +

+ {ready.counts.all} {ready.counts.all === 1 ? "result" : "results"} + {!empty && <> for “{ready.raw}”} +

+ )} + {!empty && ( + + )} +
+
+
{ + event.preventDefault(); + if (!composing.current) props.onSubmit(query); + }} + > + + +
+

+ Notes, clips and cards. Journal stays private. +

+
{ + if ( + !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key) || + !(event.target instanceof HTMLButtonElement) + ) + return; + const buttons = Array.from( + event.currentTarget.querySelectorAll("button"), + ); + const position = buttons.indexOf(event.target); + if (position < 0) return; + event.preventDefault(); + const next = + event.key === "Home" + ? 0 + : event.key === "End" + ? buttons.length - 1 + : (position + + (event.key === "ArrowRight" ? 1 : -1) + + buttons.length) % + buttons.length; + buttons[next]?.focus(); + props.onTabChange(tabs[next]!); + }} + > + {tabs.map((value) => ( + + ))} +
+ {state.status === "idle" && ( +
+

Search your notes, clips and cards.

+
+ )} + {busy && ( +
+

Searching local notes, clips and cards…

+ +
+ )} + {state.status === "paused-limit" && ( +
+

Search is paused

+

Continue to check the remaining items.

+ + +
+ )} + {state.status === "changed" && ( +
+

Your library changed

+

Search again to see current results.

+ +
+ )} + {error && ( +
+

{error}

+ {state.status === "error" && + state.code === "source-unavailable" && ( + + )} +
+ )} + {navigationState && ( +

+ {navigationState === "opening" + ? "Opening item…" + : navigationState === "blocked" + ? "Finish or discard your unsaved changes before leaving." + : "This item is no longer available."} +

+ )} + {empty && ( +
+

No results for “{ready.raw}”

+

Nothing in your notes, clips or cards. Try fewer words.

+ +
+ )} + {ready && + !empty && + ready.groups + .filter((group) => tab === "all" || tab === group.kind) + .map((group) => { + const total = ready.counts[fields[group.kind]], + page = ready.page?.kind === group.kind ? ready.page : null; + const Icon = + group.kind === "note" + ? FileText + : group.kind === "clip" + ? Paperclip + : Layers; + return ( +
+

+ {names[group.kind]} {total} +

+ {group.items.length === 0 ? ( +

+ No {names[group.kind].toLowerCase()} for “{ready.raw}”. +

+ ) : ( +
    + {group.items.map((hit, index) => ( +
  • + +
  • + ))} +
+ )} + {(group.previous || group.next || group.offset > 0) && ( +
+ + {names[group.kind]} {group.offset + 1}– + {group.offset + group.items.length} of {total} + + + +
+ )} + {page && ( +
+

+ {page.status === "pending" + ? "Loading results…" + : page.status === "paused" + ? "This results page needs more time." + : "This results page couldn’t load."} +

+ {page.status !== "pending" && ( + + )} + +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/web/tests/ui/search-motion.test.tsx b/web/tests/ui/search-motion.test.tsx new file mode 100644 index 0000000..b6954f8 --- /dev/null +++ b/web/tests/ui/search-motion.test.tsx @@ -0,0 +1,105 @@ +import { StrictMode } from "react"; +import { act, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { SearchSurface } from "../../src/ui/SearchSurface"; +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); +it("keeps only current live children through an inert170ms ordinary exit, without deferring navigation", () => { + vi.useFakeTimers(); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + animationDuration: "0.17s", + animationDelay: "0s", + } as CSSStyleDeclaration); + const v = render( + +

Current actual query

+
, + ); + expect(screen.getByText("Current actual query")).toBeVisible(); + v.rerender( + +

Actual changed query

+
, + ); + const node = screen.getByText("Actual changed query").parentElement!; + expect(node).toHaveAttribute("inert"); + expect(node).toHaveAttribute("aria-hidden", "true"); + expect(node).toHaveAttribute("data-phase", "closing"); + expect(screen.queryByText("Current actual query")).not.toBeInTheDocument(); + act(() => vi.advanceTimersByTime(169)); + expect(screen.getByText("Actual changed query")).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(1)); + expect(screen.queryByText("Actual changed query")).not.toBeInTheDocument(); +}); +it("uses the actual rendered150ms reduced exit and cancels it immediately on privacy revocation", () => { + vi.useFakeTimers(); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + animationDuration: "0.15s", + animationDelay: "0s", + } as CSSStyleDeclaration); + const v = render( + + Protected query + , + ); + v.rerender( + + Protected query + , + ); + act(() => vi.advanceTimersByTime(149)); + expect(screen.getByText("Protected query")).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(1)); + expect(screen.queryByText("Protected query")).not.toBeInTheDocument(); + v.rerender( + + New query + , + ); + v.rerender( + + New query + , + ); + v.rerender( + + New query + , + ); + expect(screen.queryByText("New query")).not.toBeInTheDocument(); + expect(vi.getTimerCount()).toBe(0); +}); +it("fences a prior exit timer when the user reenters, including StrictMode effect replay", () => { + vi.useFakeTimers(); + const v = render( + + + Actual query + + , + ); + v.rerender( + + + Actual query + + , + ); + act(() => vi.advanceTimersByTime(50)); + v.rerender( + + + Actual query + + , + ); + act(() => vi.advanceTimersByTime(500)); + expect( + screen.getByText("Actual query").closest(".workspace-search"), + ).toHaveAttribute("data-phase", "open"); + expect( + screen.getByText("Actual query").closest(".workspace-search"), + ).not.toHaveAttribute("inert"); +}); diff --git a/web/tests/ui/search.test.tsx b/web/tests/ui/search.test.tsx new file mode 100644 index 0000000..f133d2e --- /dev/null +++ b/web/tests/ui/search.test.tsx @@ -0,0 +1,404 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import { SearchView, type SearchViewProps } from "../../src/ui/SearchView"; +import type { + SearchCursor, + SearchHandle, + SearchState, + SearchHit, +} from "../../src/search/results"; + +// Explicit boundary fixtures; these are not a production search service or encrypted feature proof. +const handle = Object.freeze({}) as SearchHandle; +const cursor = Object.freeze({}) as SearchCursor; +const hit: SearchHit = { + locator: { + kind: "note", + accountId: "private-account", + libraryEpoch: "private-epoch", + noteId: "private-note", + }, + entityRevision: 1, + primary: { + text: "", + matches: [{ from: 8, to: 14 }], + before: false, + after: false, + }, + secondary: [ + { + text: "Actual original match", + matches: [{ from: 16, to: 21 }], + before: true, + after: true, + }, + ], +}; +function ready( + overrides: Partial> = {}, +): Extract { + return { + status: "ready", + raw: "lisbon", + handle, + versions: { + accountId: "account", + library: { epoch: "library", revision: 1 }, + collections: { epoch: "collections", revision: 1 }, + }, + counts: { all: 1, notes: 1, clips: 0, cards: 0 }, + groups: [ + { kind: "note", items: [hit], offset: 0, previous: null, next: null }, + { kind: "clip", items: [], offset: 0, previous: null, next: null }, + { kind: "card", items: [], offset: 0, previous: null, next: null }, + ], + page: null, + ...overrides, + }; +} +function props(state: SearchState = ready()): SearchViewProps { + return { + state, + query: "lisbon", + tab: "all", + onQueryChange: vi.fn(), + onSubmit: vi.fn(), + onTabChange: vi.fn(), + onClear: vi.fn(), + onOpen: vi.fn(), + onPage: vi.fn(), + onContinue: vi.fn(), + onCancelPage: vi.fn(), + onCancel: vi.fn(), + }; +} +it("renders exact grouped counts and escaped original marks; only exact locator is sent to navigation", () => { + const p = props(); + const view = render(); + expect(screen.getByText("1 result for “lisbon”")).toBeVisible(); + const group = screen.getByRole("region", { name: "Notes results" }); + expect(group.querySelector("mark")).toHaveTextContent("Lisbon"); + expect(view.container.querySelector("script")).toBeNull(); + expect(view.container.innerHTML).not.toContain("private-account"); + expect(view.container.innerHTML).not.toContain("private-note"); + fireEvent.click(within(group).getByRole("button")); + expect(p.onOpen).toHaveBeenCalledWith(hit.locator); + expect( + screen.getByText("Notes, clips and cards. Journal stays private."), + ).toBeVisible(); + expect( + within( + screen.getByRole("group", { name: "Search categories" }), + ).getAllByRole("button"), + ).toHaveLength(4); +}); +it("never presents pending, budget pause, changed or error as a zero-result certificate", () => { + const p = props({ status: "pending", raw: "lisbon", progress: null }); + const v = render(); + expect( + screen.getByText("Searching local notes, clips and cards…"), + ).toBeVisible(); + expect( + screen.queryByText(/0 results|No results for/), + ).not.toBeInTheDocument(); + v.rerender( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Continue" })); + expect(p.onContinue).toHaveBeenCalledWith(handle); + expect( + screen.queryByText(/0 results|No results for/), + ).not.toBeInTheDocument(); + v.rerender( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Search again" })); + expect(p.onSubmit).toHaveBeenCalledWith("lisbon"); + v.rerender( + , + ); + expect(screen.getByRole("alert")).toHaveTextContent( + "Couldn’t search your local data.", + ); + expect( + screen.queryByText(/0 results|No results for/), + ).not.toBeInTheDocument(); +}); +it("shows the exact empty frame only for a complete zero result with one clear action", () => { + const p = props( + ready({ counts: { all: 0, notes: 0, clips: 0, cards: 0 }, groups: [] }), + ); + render(); + expect( + screen.getByRole("heading", { name: "No results for “lisbon”" }), + ).toBeVisible(); + expect( + screen.getByText("Nothing in your notes, clips or cards. Try fewer words."), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "All 0" })).toHaveAttribute( + "aria-pressed", + "true", + ); + fireEvent.click(screen.getByRole("button", { name: "Clear search" })); + expect(p.onClear).toHaveBeenCalledOnce(); +}); +it("preserves raw input and suppresses submit during IME composition; Escape clears then returns explicitly", () => { + const p = props({ status: "error", raw: "lisbon", code: "query-too-long" }); + const v = render(); + const input = screen.getByRole("textbox", { + name: "Search notes, clips and cards", + }); + fireEvent.compositionStart(input); + fireEvent.change(input, { target: { value: "unfinished 漢" } }); + fireEvent.submit(input.closest("form")!); + expect(p.onQueryChange).toHaveBeenCalledWith("unfinished 漢"); + expect(p.onSubmit).not.toHaveBeenCalled(); + fireEvent.compositionEnd(input); + fireEvent.submit(input.closest("form")!); + expect(p.onSubmit).toHaveBeenCalledWith("lisbon"); + fireEvent.keyDown(input, { key: "Escape" }); + expect(p.onCancel).toHaveBeenCalledOnce(); + expect(p.onQueryChange).toHaveBeenLastCalledWith(""); + expect(p.onClear).not.toHaveBeenCalled(); + v.rerender(); + fireEvent.keyDown(input, { key: "Escape" }); + expect(p.onClear).toHaveBeenCalledOnce(); +}); +it("retains exact totals and previous complete rows through page pending/pause/error and uses opaque cursors", () => { + const state = ready({ + counts: { all: 120, notes: 120, clips: 0, cards: 0 }, + groups: [ + { + kind: "note", + items: [hit], + offset: 50, + previous: cursor, + next: cursor, + }, + ], + }); + const p = props(state), + v = render(); + fireEvent.click(screen.getByRole("button", { name: "Next Notes results" })); + expect(p.onPage).toHaveBeenCalledWith(cursor); + expect(screen.getByText("Notes 51–51 of 120")).toBeVisible(); + for (const status of ["pending", "paused", "error"] as const) { + v.rerender( + , + ); + expect(screen.getByText("120 results for “lisbon”")).toBeVisible(); + expect( + screen.getByRole("button", { name: /