@eightyfourthousand/lib-search 2026.3.1 → 2026.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eightyfourthousand/lib-search",
3
- "version": "2026.3.1",
3
+ "version": "2026.4.0",
4
4
  "description": "Shared translation search UI and server helpers for 84000 applications.",
5
5
  "private": false,
6
6
  "repository": {
@@ -17,6 +17,7 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
+ "@eightyfourthousand/client-graphql": "^2026.3.1",
20
21
  "@eightyfourthousand/data-access": "^2026.3.1",
21
22
  "@eightyfourthousand/design-system": "^2026.3.1",
22
23
  "@eightyfourthousand/lib-utils": "^2026.3.1"
@@ -1,20 +1,31 @@
1
+ import { GlossaryTermInstance, GlossaryTermInstanceDTO, glossaryTermInstanceFromDTO } from "@eightyfourthousand/data-access";
2
+
1
3
  export const RESULTS_ENTITIES = [
2
- 'passages',
3
4
  'alignments',
5
+ 'passages',
4
6
  'bibliographies',
5
7
  'glossaries',
6
8
  ] as const;
7
9
 
8
10
  export type ResultsEntity = (typeof RESULTS_ENTITIES)[number];
9
11
 
12
+ export const RESULTS_ENTITY_LABELS: Record<ResultsEntity, string> = {
13
+ alignments: 'Translation Passages',
14
+ passages: 'Other Passages',
15
+ bibliographies: 'Bibliographies',
16
+ glossaries: 'Glossaries',
17
+ } as const;
18
+
10
19
  export type BibliographySearchMatchDTO = {
11
20
  bibliography_uuid: string;
12
21
  content: string;
13
22
  };
14
23
 
15
24
  export type GlossarySearchMatchDTO = {
25
+ authority_uuid: string;
16
26
  glossary_uuid: string;
17
27
  content: string;
28
+ entry: GlossaryTermInstanceDTO;
18
29
  };
19
30
 
20
31
  export type PassageSearchMatchDTO = {
@@ -60,6 +71,7 @@ export type BibliographyMatch = SearchMatch & {
60
71
 
61
72
  export type GlossaryMatch = SearchMatch & {
62
73
  type: 'glossary';
74
+ entry: GlossaryTermInstance;
63
75
  };
64
76
 
65
77
  export type SearchResultsDTO = {
@@ -98,8 +110,9 @@ export const glossaryMatchFromDTO = (
98
110
  ): GlossaryMatch => {
99
111
  return {
100
112
  type: 'glossary',
101
- uuid: dto.glossary_uuid,
113
+ uuid: dto.authority_uuid,
102
114
  content: dto.content,
115
+ entry: glossaryTermInstanceFromDTO(dto.entry),
103
116
  };
104
117
  };
105
118
 
@@ -1,5 +1,7 @@
1
1
  'use client';
2
2
 
3
+ import type { PassageOccurrence } from '@eightyfourthousand/data-access';
4
+ import { getPassageOccurrences } from '@eightyfourthousand/data-access';
3
5
  import {
4
6
  Button,
5
7
  Dialog,
@@ -12,71 +14,349 @@ import {
12
14
  TabsContent,
13
15
  } from '@eightyfourthousand/design-system';
14
16
  import { Loader2Icon, SearchIcon, XIcon } from 'lucide-react';
15
- import { useEffect, useState } from 'react';
17
+ import type { ReactNode } from 'react';
18
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
19
  import { search } from '../data';
17
- import { RESULTS_ENTITIES, SearchResult, SearchResults } from '../types';
20
+ import type {
21
+ PassageMatch,
22
+ ResultsEntity,
23
+ SearchResult,
24
+ SearchResults,
25
+ } from '../types';
26
+ import { RESULTS_ENTITIES } from '../types';
18
27
  import { SearchResultsList } from './SearchResultsList';
19
28
  import { SearchResultTabs } from './SearchResultTab';
20
29
 
30
+ export type SearchPendingSelection =
31
+ | { kind: 'index'; index: number }
32
+ | { kind: 'cursor'; start: number; passageUuid: string };
33
+
34
+ export interface SearchActionContext {
35
+ activeOccurrence?: PassageOccurrence;
36
+ activeOccurrenceIndex: number;
37
+ activePassageLabel?: string;
38
+ activePassageUuid?: string;
39
+ moveActiveOccurrence: (direction: 'next' | 'previous') => void;
40
+ passageOccurrences: PassageOccurrence[];
41
+ passages: PassageMatch[];
42
+ refreshSearch: (options?: {
43
+ nextSelection?: SearchPendingSelection | null;
44
+ }) => Promise<void>;
45
+ searchQuery: string;
46
+ searching: boolean;
47
+ scrollActiveOccurrenceIntoView: () => void;
48
+ setActiveOccurrenceIndex: (index: number) => void;
49
+ setShouldScrollActiveOccurrence: (shouldScroll: boolean) => void;
50
+ }
51
+
52
+ export interface SearchButtonProps {
53
+ onResultSelected: (result: SearchResult) => void;
54
+ renderActions?: (context: SearchActionContext) => ReactNode;
55
+ toh?: string;
56
+ workUuid?: string;
57
+ }
58
+
59
+ const DEFAULT_RESULTS_TAB: ResultsEntity = 'alignments';
60
+
61
+ const getFirstResultsTab = (results?: SearchResults): ResultsEntity => {
62
+ return (
63
+ RESULTS_ENTITIES.find((tab) => (results?.[tab].length ?? 0) > 0) ??
64
+ DEFAULT_RESULTS_TAB
65
+ );
66
+ };
67
+
68
+ const getOccurrenceResultsTab = (
69
+ occurrence?: PassageOccurrence,
70
+ ): ResultsEntity | undefined => {
71
+ if (!occurrence) {
72
+ return undefined;
73
+ }
74
+
75
+ return occurrence.type === 'alignment' ? 'alignments' : 'passages';
76
+ };
77
+
21
78
  export const SearchButton = ({
79
+ renderActions,
22
80
  workUuid,
23
81
  toh,
24
82
  onResultSelected,
25
- }: {
26
- workUuid?: string;
27
- toh?: string;
28
- onResultSelected: (result: SearchResult) => void;
29
- }) => {
83
+ }: SearchButtonProps) => {
30
84
  const [open, setOpen] = useState(false);
31
85
  const [searching, setSearching] = useState(false);
32
86
  const [hasResults, setHasResults] = useState(false);
33
87
  const [searchQuery, setSearchQuery] = useState('');
34
88
  const [results, setResults] = useState<SearchResults>();
89
+ const [activeOccurrenceIndex, setActiveOccurrenceIndexState] = useState(0);
90
+ const [activeResultsTab, setActiveResultsTab] =
91
+ useState<ResultsEntity>(DEFAULT_RESULTS_TAB);
92
+ const [shouldScrollActiveOccurrence, setShouldScrollActiveOccurrence] =
93
+ useState(false);
94
+ const pendingOccurrenceSelectionRef = useRef<SearchPendingSelection | null>(
95
+ null,
96
+ );
35
97
 
36
- const onCardClick = (result: SearchResult) => {
37
- setOpen(false);
38
- onResultSelected(result);
39
- };
98
+ const passageOccurrences = useMemo(
99
+ () =>
100
+ getPassageOccurrences(
101
+ [...(results?.alignments || []), ...(results?.passages || [])],
102
+ searchQuery,
103
+ ),
104
+ [results?.alignments, results?.passages, searchQuery],
105
+ );
106
+ const activeOccurrence = passageOccurrences[activeOccurrenceIndex];
107
+ const activeOccurrenceStart = activeOccurrence?.start ?? null;
108
+ const activePassageUuid = activeOccurrence?.passageUuid;
109
+ const activePassageLabel = results?.passages.find(
110
+ (passage) => passage.uuid === activePassageUuid,
111
+ )?.label;
112
+ const passageOrder = useMemo(
113
+ () =>
114
+ new Map(
115
+ (results?.passages || []).map((passage, index) => [
116
+ passage.uuid,
117
+ index,
118
+ ]),
119
+ ),
120
+ [results?.passages],
121
+ );
40
122
 
41
- useEffect(() => {
42
- const performSearch = async () => {
123
+ const scrollActiveOccurrenceIntoView = useCallback(() => {
124
+ requestAnimationFrame(() => {
125
+ document
126
+ .querySelector<HTMLElement>('[data-search-result-active="true"]')
127
+ ?.scrollIntoView({
128
+ behavior: 'smooth',
129
+ block: 'start',
130
+ });
131
+ });
132
+ }, []);
133
+
134
+ const refreshSearch = useCallback(
135
+ async (options: { nextSelection?: SearchPendingSelection | null } = {}) => {
43
136
  if (!searchQuery || !workUuid || !toh) {
44
137
  setResults(undefined);
138
+ setHasResults(false);
45
139
  return;
46
140
  }
47
141
 
48
142
  setSearching(true);
49
-
50
- const results = await search({ text: searchQuery, uuid: workUuid, toh });
143
+ const nextResults = await search({
144
+ text: searchQuery,
145
+ uuid: workUuid,
146
+ toh,
147
+ });
51
148
  setHasResults(
52
- !!results &&
53
- (results.passages.length > 0 ||
54
- results.alignments.length > 0 ||
55
- results.bibliographies.length > 0 ||
56
- results.glossaries.length > 0),
149
+ !!nextResults &&
150
+ (nextResults.passages.length > 0 ||
151
+ nextResults.alignments.length > 0 ||
152
+ nextResults.bibliographies.length > 0 ||
153
+ nextResults.glossaries.length > 0),
57
154
  );
58
- setResults(results);
155
+ setResults(nextResults);
156
+
157
+ if ('nextSelection' in options) {
158
+ pendingOccurrenceSelectionRef.current = options.nextSelection ?? null;
159
+ }
160
+
59
161
  setSearching(false);
60
- };
162
+ },
163
+ [searchQuery, toh, workUuid],
164
+ );
165
+
166
+ const setActiveOccurrenceIndex = useCallback(
167
+ (nextIndex: number) => {
168
+ pendingOccurrenceSelectionRef.current = null;
169
+ setActiveOccurrenceIndexState(
170
+ Math.max(
171
+ 0,
172
+ Math.min(nextIndex, Math.max(passageOccurrences.length - 1, 0)),
173
+ ),
174
+ );
175
+ },
176
+ [passageOccurrences.length],
177
+ );
178
+
179
+ const moveActiveOccurrence = useCallback(
180
+ (direction: 'next' | 'previous') => {
181
+ if (passageOccurrences.length === 0) {
182
+ return;
183
+ }
184
+
185
+ pendingOccurrenceSelectionRef.current = null;
186
+ setActiveOccurrenceIndexState((current) => {
187
+ if (direction === 'previous') {
188
+ return Math.max(current - 1, 0);
189
+ }
190
+
191
+ return Math.min(current + 1, passageOccurrences.length - 1);
192
+ });
193
+ },
194
+ [passageOccurrences.length],
195
+ );
196
+
197
+ useEffect(() => {
198
+ const debounce = setTimeout(() => {
199
+ void refreshSearch();
200
+ }, 300);
61
201
 
62
- const debounce = setTimeout(performSearch, 300);
63
202
  return () => clearTimeout(debounce);
64
- }, [searchQuery, workUuid, toh]);
203
+ }, [refreshSearch]);
65
204
 
66
205
  useEffect(() => {
67
206
  setResults(undefined);
68
207
  setSearchQuery('');
208
+ setActiveOccurrenceIndexState(0);
209
+ setShouldScrollActiveOccurrence(false);
210
+ pendingOccurrenceSelectionRef.current = null;
69
211
  }, [open]);
70
212
 
213
+ useEffect(() => {
214
+ if (!results) {
215
+ setActiveResultsTab(DEFAULT_RESULTS_TAB);
216
+ return;
217
+ }
218
+
219
+ setActiveResultsTab((currentTab) =>
220
+ results[currentTab].length > 0 ? currentTab : getFirstResultsTab(results),
221
+ );
222
+ }, [results]);
223
+
224
+ useEffect(() => {
225
+ if (passageOccurrences.length === 0) {
226
+ setActiveOccurrenceIndexState(0);
227
+ pendingOccurrenceSelectionRef.current = null;
228
+ return;
229
+ }
230
+
231
+ const pendingSelection = pendingOccurrenceSelectionRef.current;
232
+ if (pendingSelection) {
233
+ if (pendingSelection.kind === 'index') {
234
+ setActiveOccurrenceIndexState(
235
+ Math.min(pendingSelection.index, passageOccurrences.length - 1),
236
+ );
237
+ pendingOccurrenceSelectionRef.current = null;
238
+ return;
239
+ }
240
+
241
+ const nextIndex = passageOccurrences.findIndex((occurrence) => {
242
+ if (
243
+ pendingSelection.kind === 'cursor' &&
244
+ occurrence.passageUuid === pendingSelection.passageUuid &&
245
+ occurrence.start >= pendingSelection.start
246
+ ) {
247
+ return true;
248
+ }
249
+
250
+ return false;
251
+ });
252
+
253
+ if (nextIndex >= 0) {
254
+ setActiveOccurrenceIndexState(nextIndex);
255
+ pendingOccurrenceSelectionRef.current = null;
256
+ return;
257
+ }
258
+
259
+ const currentPassageOrder =
260
+ pendingSelection.kind === 'cursor'
261
+ ? passageOrder.get(pendingSelection.passageUuid)
262
+ : undefined;
263
+ const nextPassageIndex =
264
+ pendingSelection.kind === 'cursor' && currentPassageOrder != null
265
+ ? passageOccurrences.findIndex((occurrence) => {
266
+ const occurrenceOrder = passageOrder.get(occurrence.passageUuid);
267
+ return (
268
+ occurrenceOrder != null && occurrenceOrder > currentPassageOrder
269
+ );
270
+ })
271
+ : -1;
272
+
273
+ setActiveOccurrenceIndexState(
274
+ nextPassageIndex >= 0 ? nextPassageIndex : 0,
275
+ );
276
+ pendingOccurrenceSelectionRef.current = null;
277
+ return;
278
+ }
279
+
280
+ setActiveOccurrenceIndexState((current) =>
281
+ Math.min(current, passageOccurrences.length - 1),
282
+ );
283
+ }, [passageOccurrences, passageOrder]);
284
+
285
+ useEffect(() => {
286
+ if (!shouldScrollActiveOccurrence || !activeOccurrence || !results) {
287
+ return;
288
+ }
289
+
290
+ const occurrenceTab = getOccurrenceResultsTab(activeOccurrence);
291
+ if (occurrenceTab && results[occurrenceTab].length > 0) {
292
+ setActiveResultsTab(occurrenceTab);
293
+ }
294
+ }, [activeOccurrence, results, shouldScrollActiveOccurrence]);
295
+
296
+ useEffect(() => {
297
+ if (
298
+ !shouldScrollActiveOccurrence ||
299
+ !activePassageUuid ||
300
+ activeOccurrenceStart == null
301
+ ) {
302
+ return;
303
+ }
304
+
305
+ scrollActiveOccurrenceIntoView();
306
+ }, [
307
+ activeOccurrenceStart,
308
+ activeResultsTab,
309
+ activePassageUuid,
310
+ scrollActiveOccurrenceIntoView,
311
+ shouldScrollActiveOccurrence,
312
+ ]);
313
+
314
+ const onCardClick = (result: SearchResult) => {
315
+ setOpen(false);
316
+ onResultSelected(result);
317
+ };
318
+
319
+ const actionContext = useMemo<SearchActionContext>(
320
+ () => ({
321
+ activeOccurrence,
322
+ activeOccurrenceIndex,
323
+ activePassageLabel,
324
+ activePassageUuid,
325
+ moveActiveOccurrence,
326
+ passageOccurrences,
327
+ passages: results?.passages || [],
328
+ refreshSearch,
329
+ searchQuery,
330
+ searching,
331
+ scrollActiveOccurrenceIntoView,
332
+ setActiveOccurrenceIndex,
333
+ setShouldScrollActiveOccurrence,
334
+ }),
335
+ [
336
+ activeOccurrence,
337
+ activeOccurrenceIndex,
338
+ activePassageLabel,
339
+ activePassageUuid,
340
+ moveActiveOccurrence,
341
+ passageOccurrences,
342
+ refreshSearch,
343
+ results?.passages,
344
+ scrollActiveOccurrenceIntoView,
345
+ searchQuery,
346
+ searching,
347
+ setActiveOccurrenceIndex,
348
+ ],
349
+ );
350
+
71
351
  return (
72
352
  <Dialog open={open} onOpenChange={setOpen}>
73
353
  <DialogTrigger asChild>
74
354
  <Button
75
355
  variant="ghost"
356
+ size="icon"
76
357
  className="bg-background my-auto [&_svg]:size-6 [&_svg]:stroke-1 hover:bg-background cursor-pointer text-base text-accent hover:text-accent/80"
77
358
  >
78
359
  <SearchIcon />
79
- <span className="hidden md:flex">Search</span>
80
360
  </Button>
81
361
  </DialogTrigger>
82
362
  <DialogContent
@@ -87,8 +367,8 @@ export const SearchButton = ({
87
367
  <DialogDescription className="hidden">
88
368
  Search this translation
89
369
  </DialogDescription>
90
- <div className="flex flex-col justify-start gap-2 h-[calc(100vh_-_2.5rem)]">
91
- <div className="w-full flex flex-col gap-2 text-foreground flex-shrink-0">
370
+ <div className="flex flex-col justify-start gap-2 h-[calc(100vh-2.5rem)]">
371
+ <div className="w-full flex flex-col gap-2 text-foreground shrink-0">
92
372
  <div className="flex justify-end">
93
373
  <Button
94
374
  className="text-secondary-foreground -me-3"
@@ -102,21 +382,36 @@ export const SearchButton = ({
102
382
  <Input
103
383
  autoFocus
104
384
  placeholder="Type to search..."
385
+ value={searchQuery}
105
386
  className="w-full text-foreground px-4 py-6"
106
- onChange={(e) => setSearchQuery(e.target.value)}
387
+ onChange={(e) => {
388
+ const nextValue = e.target.value;
389
+ if (nextValue === searchQuery) {
390
+ return;
391
+ }
392
+ setActiveOccurrenceIndexState(0);
393
+ pendingOccurrenceSelectionRef.current = null;
394
+ setSearchQuery(nextValue);
395
+ }}
107
396
  />
397
+ {searchQuery && renderActions?.(actionContext)}
108
398
  </div>
109
399
  {searchQuery && (
110
400
  <>
111
- <div className="text-sm text-secondary-foreground">
112
- Showing results for "<strong>{searchQuery}</strong>"
113
- {searching && (
114
- <Loader2Icon className="size-4 ml-2 animate-spin inline-block" />
115
- )}
401
+ <div className="flex flex-col gap-3 text-sm text-secondary-foreground">
402
+ <div>
403
+ Showing results for "<strong>{searchQuery}</strong>"
404
+ {searching && (
405
+ <Loader2Icon className="size-4 ml-2 animate-spin inline-block" />
406
+ )}
407
+ </div>
116
408
  </div>
117
409
  {results && hasResults ? (
118
410
  <Tabs
119
- defaultValue="passages"
411
+ value={activeResultsTab}
412
+ onValueChange={(tab) => {
413
+ setActiveResultsTab(tab as ResultsEntity);
414
+ }}
120
415
  className="flex flex-col grow min-h-0 pt-2"
121
416
  >
122
417
  <SearchResultTabs results={results} />
@@ -129,6 +424,13 @@ export const SearchButton = ({
129
424
  value={tab}
130
425
  >
131
426
  <SearchResultsList
427
+ activeOccurrenceStart={
428
+ activeOccurrenceStart != null &&
429
+ shouldScrollActiveOccurrence
430
+ ? activeOccurrenceStart
431
+ : undefined
432
+ }
433
+ activePassageUuid={activePassageUuid}
132
434
  query={searchQuery}
133
435
  results={results[tab]}
134
436
  onCardClick={onCardClick}
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { highlightText, removeHtmlTags, useIsMobile } from '@eightyfourthousand/lib-utils';
3
+ import { highlightText, removeDiacritics, removeHtmlTags, useIsMobile } from '@eightyfourthousand/lib-utils';
4
4
  import {
5
5
  AlignmentMatch,
6
6
  BibliographyMatch,
@@ -10,26 +10,87 @@ import {
10
10
  } from '../types';
11
11
  import { Separator } from '@eightyfourthousand/design-system';
12
12
 
13
+ const renderPassageHighlight = ({
14
+ activeOccurrenceStart,
15
+ query,
16
+ text,
17
+ }: {
18
+ activeOccurrenceStart?: number;
19
+ query: string;
20
+ text: string;
21
+ }) => {
22
+ if (!query.trim()) {
23
+ return text;
24
+ }
25
+
26
+ const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27
+ const parts = text.split(new RegExp(`(${escapedQuery})`, 'gi'));
28
+ let offset = 0;
29
+
30
+ return parts.map((part, index) => {
31
+ const isMatch = part.toLowerCase() === query.toLowerCase();
32
+
33
+ if (!isMatch) {
34
+ offset += part.length;
35
+ return part;
36
+ }
37
+
38
+ const start = offset;
39
+ offset += part.length;
40
+
41
+ return (
42
+ <mark
43
+ key={index}
44
+ className={
45
+ start === activeOccurrenceStart
46
+ ? 'bg-accent/20 text-foreground font-semibold ring-2 ring-accent ring-offset-1 ring-offset-background rounded-sm'
47
+ : 'bg-highlight text-foreground font-semibold'
48
+ }
49
+ >
50
+ {part}
51
+ </mark>
52
+ );
53
+ });
54
+ };
55
+
13
56
  export const PassageResult = ({
57
+ activeOccurrenceStart,
14
58
  match,
15
59
  query,
16
60
  }: {
61
+ activeOccurrenceStart?: number;
17
62
  match: PassageMatch;
18
63
  query: string;
19
64
  }) => {
20
- return <div>{highlightText(match.content, query)}</div>;
65
+ return (
66
+ <div>
67
+ {renderPassageHighlight({
68
+ activeOccurrenceStart,
69
+ query,
70
+ text: match.content,
71
+ })}
72
+ </div>
73
+ );
21
74
  };
22
75
 
23
76
  export const AlignmentResult = ({
77
+ activeOccurrenceStart,
24
78
  match,
25
79
  query,
26
80
  }: {
81
+ activeOccurrenceStart?: number;
27
82
  match: AlignmentMatch;
28
83
  query: string;
29
84
  }) => {
30
85
  return (
31
86
  <div className="grid md:grid-cols-2 gap-4">
32
- <div>{highlightText(match.content, query)}</div>
87
+ <div>
88
+ {renderPassageHighlight({
89
+ activeOccurrenceStart,
90
+ query,
91
+ text: match.content,
92
+ })}
93
+ </div>
33
94
  <div className="text-lg">{highlightText(match.source, query)}</div>
34
95
  </div>
35
96
  );
@@ -52,14 +113,31 @@ export const GlossaryResult = ({
52
113
  match: GlossaryMatch;
53
114
  query: string;
54
115
  }) => {
55
- return <div>{highlightText(removeHtmlTags(match.content), query)}</div>;
116
+ return (
117
+ <div className="flex flex-col gap-2">
118
+ <div className='text-primary font-bold'>{
119
+ highlightText(
120
+ removeDiacritics(match.content),
121
+ removeDiacritics(query)
122
+ )
123
+ }</div>
124
+ {match.entry.definition && <div
125
+ className="glossary-instance-definition"
126
+ dangerouslySetInnerHTML={{ __html: match.entry.definition }}
127
+ />}
128
+ </div>
129
+ );
56
130
  };
57
131
 
58
132
  export const SearchResultCard = ({
133
+ isActive = false,
134
+ activeOccurrenceStart,
59
135
  match,
60
136
  query,
61
137
  onClick,
62
138
  }: {
139
+ isActive?: boolean;
140
+ activeOccurrenceStart?: number;
63
141
  match: SearchResult;
64
142
  query: string;
65
143
  onClick: () => void;
@@ -72,10 +150,20 @@ export const SearchResultCard = ({
72
150
  const renderInner = (match: SearchResult) => {
73
151
  switch (match.type) {
74
152
  case 'passage':
75
- return <PassageResult match={match as PassageMatch} query={query} />;
153
+ return (
154
+ <PassageResult
155
+ activeOccurrenceStart={isActive ? activeOccurrenceStart : undefined}
156
+ match={match as PassageMatch}
157
+ query={query}
158
+ />
159
+ );
76
160
  case 'alignment':
77
161
  return (
78
- <AlignmentResult match={match as AlignmentMatch} query={query} />
162
+ <AlignmentResult
163
+ activeOccurrenceStart={isActive ? activeOccurrenceStart : undefined}
164
+ match={match as AlignmentMatch}
165
+ query={query}
166
+ />
79
167
  );
80
168
  case 'bibliography':
81
169
  return (
@@ -94,9 +182,13 @@ export const SearchResultCard = ({
94
182
  return (
95
183
  <div
96
184
  onClick={onClick}
97
- className="flex flex-col md:flex-row gap-4 py-6 px-4 md:px-6 font-serif text-sm text-foreground rounded-lg bg-background cursor-pointer border border-3 border-transparent hover:border-border transition-colors"
185
+ data-search-result-active={isActive ? 'true' : undefined}
186
+ className={`flex flex-col md:flex-row gap-4 py-6 px-4 md:px-6 font-serif text-sm text-foreground rounded-lg bg-background cursor-pointer border transition-colors ${isActive
187
+ ? 'border-accent border-2 scroll-mt-4'
188
+ : 'border-transparent hover:border-secondary border-2'
189
+ }`}
98
190
  >
99
- <div className="md:w-25 flex-shrink-0 flex md:flex-col gap-1 md:gap-2 md:text-right text-xs md:text-sm">
191
+ <div className="md:w-28 shrink-0 flex md:flex-col gap-1 md:gap-2 md:text-right text-xs md:text-sm">
100
192
  <div className="flex flex-col gap-1">
101
193
  <span className="text-secondary capitalize">{passage.type}</span>
102
194
  {passage.section && (
@@ -1,20 +1,20 @@
1
1
  import { TabsList, TabsTrigger } from '@eightyfourthousand/design-system';
2
- import { RESULTS_ENTITIES, SearchResults } from '../types';
2
+ import { RESULTS_ENTITIES, RESULTS_ENTITY_LABELS, SearchResults } from '../types';
3
3
 
4
4
  export const SearchResultTabs = ({ results }: { results: SearchResults }) => {
5
5
  return (
6
- <TabsList className="h-24 gap-4 flex-shrink-0 rounded-lg p-0 w-full bg-transparent">
6
+ <TabsList className="h-24 gap-4 flex-shrink-0 rounded-lg p-0 w-full bg-transparent sm:p-0">
7
7
  {RESULTS_ENTITIES.map(
8
8
  (tab) =>
9
9
  results[tab].length > 0 && (
10
10
  <TabsTrigger
11
11
  key={tab}
12
12
  value={tab}
13
- className="h-full border border-border rounded-xl flex-grow data-[state=inactive]:bg-muted data-[state=active]:border-border data-[state=active]:border-3 border-2"
13
+ className="h-full border border-border rounded-xl flex-grow data-[state=inactive]:bg-muted data-[state=active]:border-secondary data-[state=active]:border-3 border-2"
14
14
  >
15
15
  <div className="flex flex-col gap-1 items-center">
16
- <span className="capitalize text-secondary font-bold">
17
- {tab}
16
+ <span className="text-secondary font-bold">
17
+ {RESULTS_ENTITY_LABELS[tab]}
18
18
  </span>
19
19
  <span className="text-primary font-bold">
20
20
  {results[tab].length} matches
@@ -2,24 +2,34 @@ import { SearchResult } from '../types';
2
2
  import { SearchResultCard } from './SearchResultCard';
3
3
 
4
4
  export const SearchResultsList = ({
5
+ activeOccurrenceStart,
6
+ activePassageUuid,
5
7
  query,
6
8
  results,
7
9
  onCardClick,
8
10
  }: {
11
+ activeOccurrenceStart?: number;
12
+ activePassageUuid?: string;
9
13
  query: string;
10
14
  results: SearchResult[];
11
15
  onCardClick: (result: SearchResult) => void;
12
16
  }) => {
13
17
  return (
14
18
  <div className="h-full flex flex-col gap-3 pb-4 overflow-y-auto min-h-0">
15
- {results.map((result, index) => (
16
- <SearchResultCard
17
- key={index}
18
- match={result}
19
- query={query}
20
- onClick={() => onCardClick(result)}
21
- />
22
- ))}
19
+ {results.map((result, index) => {
20
+ const start = result.uuid === activePassageUuid ? activeOccurrenceStart : undefined;
21
+ const isActive = !!start;
22
+ return (
23
+ <SearchResultCard
24
+ key={index}
25
+ activeOccurrenceStart={start}
26
+ isActive={isActive}
27
+ match={result}
28
+ query={query}
29
+ onClick={() => onCardClick(result)}
30
+ />
31
+ )
32
+ })}
23
33
  </div>
24
34
  );
25
35
  };