From 6a7f97769396151c378a7b88a20d903ad29c30ac Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Fri, 21 Aug 2026 10:04:34 -0600 Subject: [PATCH 1/8] Add search, sort, filter, and windowing to the analysis catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog panel listed every analysis in one fixed order with no way to narrow it, so a draft of any size was only navigable by scrolling. The query core already supported all of this; only the UI that varies it was missing. Search, sort, and filter state is ephemeral useState inside the panel. The panel is mounted only while open, so closing it clears the query — a filter that survived a reload would leave rows missing with nothing on screen saying why. Filters sit behind one control that reports how many are active, so a panel narrow enough to need filtering is not itself filled with them. All four groups ship: the facet-derived ones (books, part of speech, confidence, and each named feature), missing gloss, breakdown, and unused-only. Against today's data only books raises a control, since no write path records the others yet — the facets are rightly absent rather than offering a lone choice. Facets are derived from every row rather than from the rows a filter left standing, so a selection cannot collapse the facet that would widen it back. The new useRowWindow mounts a growing leading slice of the listing, extending as the end comes into reach and starting over when the query changes. It is deliberately not useSegmentWindow: a row list has no counterpart to the scripture reference that hook holds still, so it needs none of that geometry bookkeeping. A listing narrowed to nothing now says so, rather than reusing "No analyses recorded yet" and telling readers their draft is empty when they have merely mistyped. That message and the panel's original one both go through the platform EmptyState. Stubs the platform SearchBar, Select, MultiSelectComboBox, and EmptyState, each documenting where it diverges from the component it stands in for. --- __mocks__/lucide-react.tsx | 7 + __mocks__/platform-bible-react.tsx | 251 +++++++++ contributions/localizedStrings.json | 24 + .../components/AnalysisCatalogPanel.test.tsx | 531 +++++++++++++++++- src/components/AnalysisCatalogPanel.tsx | 85 ++- src/components/CatalogFilterPopover.tsx | 361 ++++++++++++ src/components/CatalogQueryControls.tsx | 129 +++++ src/hooks/useRowWindow.ts | 92 +++ 8 files changed, 1449 insertions(+), 31 deletions(-) create mode 100644 src/components/CatalogFilterPopover.tsx create mode 100644 src/components/CatalogQueryControls.tsx create mode 100644 src/hooks/useRowWindow.ts diff --git a/__mocks__/lucide-react.tsx b/__mocks__/lucide-react.tsx index 38b2544b..78370dde 100644 --- a/__mocks__/lucide-react.tsx +++ b/__mocks__/lucide-react.tsx @@ -98,3 +98,10 @@ export function ChevronRight(props: Readonly<{ className?: string }>): ReactElem export function ChevronDown(props: Readonly<{ className?: string }>): ReactElement { return ; } + +/** + * Stub for the ListFilter icon, marking the catalog's filter control. + */ +export function ListFilter(props: Readonly<{ className?: string }>): ReactElement { + return ; +} diff --git a/__mocks__/platform-bible-react.tsx b/__mocks__/platform-bible-react.tsx index e59ecf58..7dc24400 100644 --- a/__mocks__/platform-bible-react.tsx +++ b/__mocks__/platform-bible-react.tsx @@ -459,6 +459,65 @@ export const Input = forwardRef< ); }); +/** + * Stub search bar. The real component wraps a platform `Input` in a positioned container with a + * search icon and, once the query is non-empty, a clear button that reports `''`; this stub keeps + * the input and that button and drops the icon and positioning, which are visual only. + * + * It takes no `aria-label` and puts its `id` on the wrapper rather than the input, exactly as the + * real component does, so the input's accessible name comes from its placeholder alone — which is + * how a test has to find it. + */ +export const SearchBar = forwardRef< + HTMLInputElement, + Readonly<{ + value: string; + onSearch: (searchQuery: string) => void; + placeholder?: string; + isFullWidth?: boolean; + className?: string; + isDisabled?: boolean; + id?: string; + }> +>(function SearchBarImpl( + { value, onSearch, placeholder, isFullWidth: _isFullWidth, className, isDisabled, id }, + ref, +) { + return ( +
+ onSearch(e.target.value)} + placeholder={placeholder} + type="text" + value={value} + /> + {value && ( + + )} +
+ ); +}); + +/** + * Stub empty state rendered as the `

` the real component produces, taking its + * `data-testid` from the `id` prop as that component does. + */ +export function EmptyState({ + message, + id, + className, +}: Readonly<{ message: string; id?: string; className?: string }>): ReactElement { + return ( +

+ {message} +

+ ); +} + /** * Stub textarea rendered as a native `