@eightyfourthousand/lib-search 2026.4.0 → 2026.4.2

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.
Files changed (43) hide show
  1. package/index.js +4 -0
  2. package/index.js.map +1 -0
  3. package/lib/data/index.js +2 -0
  4. package/lib/data/index.js.map +1 -0
  5. package/lib/data/search.d.ts +5 -0
  6. package/lib/data/search.js +17 -0
  7. package/lib/data/search.js.map +1 -0
  8. package/lib/types/index.js +2 -0
  9. package/lib/types/index.js.map +1 -0
  10. package/lib/types/search.d.ts +71 -0
  11. package/lib/types/search.js +58 -0
  12. package/lib/types/search.js.map +1 -0
  13. package/lib/ui/SearchButton.d.ts +35 -0
  14. package/lib/ui/SearchButton.js +221 -0
  15. package/lib/ui/SearchButton.js.map +1 -0
  16. package/lib/ui/SearchResultCard.d.ts +26 -0
  17. package/lib/ui/SearchResultCard.js +67 -0
  18. package/lib/ui/SearchResultCard.js.map +1 -0
  19. package/lib/ui/SearchResultTab.d.ts +4 -0
  20. package/lib/ui/SearchResultTab.js +7 -0
  21. package/lib/ui/SearchResultTab.js.map +1 -0
  22. package/lib/ui/SearchResultsList.d.ts +8 -0
  23. package/lib/ui/SearchResultsList.js +10 -0
  24. package/lib/ui/SearchResultsList.js.map +1 -0
  25. package/lib/ui/index.js +2 -0
  26. package/lib/ui/index.js.map +1 -0
  27. package/package.json +19 -6
  28. package/.babelrc +0 -12
  29. package/.eslintrc.json +0 -18
  30. package/README.md +0 -9
  31. package/project.json +0 -20
  32. package/src/lib/data/search.ts +0 -28
  33. package/src/lib/types/search.ts +0 -153
  34. package/src/lib/ui/SearchButton.tsx +0 -453
  35. package/src/lib/ui/SearchResultCard.tsx +0 -209
  36. package/src/lib/ui/SearchResultTab.tsx +0 -28
  37. package/src/lib/ui/SearchResultsList.tsx +0 -35
  38. package/tsconfig.json +0 -17
  39. package/tsconfig.lib.json +0 -26
  40. /package/{src/index.ts → index.d.ts} +0 -0
  41. /package/{src/lib/data/index.ts → lib/data/index.d.ts} +0 -0
  42. /package/{src/lib/types/index.ts → lib/types/index.d.ts} +0 -0
  43. /package/{src/lib/ui/index.ts → lib/ui/index.d.ts} +0 -0
@@ -1,453 +0,0 @@
1
- 'use client';
2
-
3
- import type { PassageOccurrence } from '@eightyfourthousand/data-access';
4
- import { getPassageOccurrences } from '@eightyfourthousand/data-access';
5
- import {
6
- Button,
7
- Dialog,
8
- DialogContent,
9
- DialogDescription,
10
- DialogTitle,
11
- DialogTrigger,
12
- Input,
13
- Tabs,
14
- TabsContent,
15
- } from '@eightyfourthousand/design-system';
16
- import { Loader2Icon, SearchIcon, XIcon } from 'lucide-react';
17
- import type { ReactNode } from 'react';
18
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
19
- import { search } from '../data';
20
- import type {
21
- PassageMatch,
22
- ResultsEntity,
23
- SearchResult,
24
- SearchResults,
25
- } from '../types';
26
- import { RESULTS_ENTITIES } from '../types';
27
- import { SearchResultsList } from './SearchResultsList';
28
- import { SearchResultTabs } from './SearchResultTab';
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
-
78
- export const SearchButton = ({
79
- renderActions,
80
- workUuid,
81
- toh,
82
- onResultSelected,
83
- }: SearchButtonProps) => {
84
- const [open, setOpen] = useState(false);
85
- const [searching, setSearching] = useState(false);
86
- const [hasResults, setHasResults] = useState(false);
87
- const [searchQuery, setSearchQuery] = useState('');
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
- );
97
-
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
- );
122
-
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 } = {}) => {
136
- if (!searchQuery || !workUuid || !toh) {
137
- setResults(undefined);
138
- setHasResults(false);
139
- return;
140
- }
141
-
142
- setSearching(true);
143
- const nextResults = await search({
144
- text: searchQuery,
145
- uuid: workUuid,
146
- toh,
147
- });
148
- setHasResults(
149
- !!nextResults &&
150
- (nextResults.passages.length > 0 ||
151
- nextResults.alignments.length > 0 ||
152
- nextResults.bibliographies.length > 0 ||
153
- nextResults.glossaries.length > 0),
154
- );
155
- setResults(nextResults);
156
-
157
- if ('nextSelection' in options) {
158
- pendingOccurrenceSelectionRef.current = options.nextSelection ?? null;
159
- }
160
-
161
- setSearching(false);
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);
201
-
202
- return () => clearTimeout(debounce);
203
- }, [refreshSearch]);
204
-
205
- useEffect(() => {
206
- setResults(undefined);
207
- setSearchQuery('');
208
- setActiveOccurrenceIndexState(0);
209
- setShouldScrollActiveOccurrence(false);
210
- pendingOccurrenceSelectionRef.current = null;
211
- }, [open]);
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
-
351
- return (
352
- <Dialog open={open} onOpenChange={setOpen}>
353
- <DialogTrigger asChild>
354
- <Button
355
- variant="ghost"
356
- size="icon"
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"
358
- >
359
- <SearchIcon />
360
- </Button>
361
- </DialogTrigger>
362
- <DialogContent
363
- showCloseButton={false}
364
- className="bg-transparent top-4 max-w-4xl shadow-none border-0 text-secondary translate-y-0"
365
- >
366
- <DialogTitle className="hidden">Search</DialogTitle>
367
- <DialogDescription className="hidden">
368
- Search this translation
369
- </DialogDescription>
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">
372
- <div className="flex justify-end">
373
- <Button
374
- className="text-secondary-foreground -me-3"
375
- variant="ghost"
376
- size="icon"
377
- onClick={() => setOpen(false)}
378
- >
379
- <XIcon className="size-3 my-auto" />
380
- </Button>
381
- </div>
382
- <Input
383
- autoFocus
384
- placeholder="Type to search..."
385
- value={searchQuery}
386
- className="w-full text-foreground px-4 py-6"
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
- }}
396
- />
397
- {searchQuery && renderActions?.(actionContext)}
398
- </div>
399
- {searchQuery && (
400
- <>
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>
408
- </div>
409
- {results && hasResults ? (
410
- <Tabs
411
- value={activeResultsTab}
412
- onValueChange={(tab) => {
413
- setActiveResultsTab(tab as ResultsEntity);
414
- }}
415
- className="flex flex-col grow min-h-0 pt-2"
416
- >
417
- <SearchResultTabs results={results} />
418
- {RESULTS_ENTITIES.map(
419
- (tab) =>
420
- results[tab].length > 0 && (
421
- <TabsContent
422
- key={tab}
423
- className="grow min-h-0"
424
- value={tab}
425
- >
426
- <SearchResultsList
427
- activeOccurrenceStart={
428
- activeOccurrenceStart != null &&
429
- shouldScrollActiveOccurrence
430
- ? activeOccurrenceStart
431
- : undefined
432
- }
433
- activePassageUuid={activePassageUuid}
434
- query={searchQuery}
435
- results={results[tab]}
436
- onCardClick={onCardClick}
437
- />
438
- </TabsContent>
439
- ),
440
- )}
441
- </Tabs>
442
- ) : (
443
- <div className="mt-4 text-secondary-foreground">
444
- No results found.
445
- </div>
446
- )}
447
- </>
448
- )}
449
- </div>
450
- </DialogContent>
451
- </Dialog>
452
- );
453
- };
@@ -1,209 +0,0 @@
1
- 'use client';
2
-
3
- import { highlightText, removeDiacritics, removeHtmlTags, useIsMobile } from '@eightyfourthousand/lib-utils';
4
- import {
5
- AlignmentMatch,
6
- BibliographyMatch,
7
- GlossaryMatch,
8
- PassageMatch,
9
- SearchResult,
10
- } from '../types';
11
- import { Separator } from '@eightyfourthousand/design-system';
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
-
56
- export const PassageResult = ({
57
- activeOccurrenceStart,
58
- match,
59
- query,
60
- }: {
61
- activeOccurrenceStart?: number;
62
- match: PassageMatch;
63
- query: string;
64
- }) => {
65
- return (
66
- <div>
67
- {renderPassageHighlight({
68
- activeOccurrenceStart,
69
- query,
70
- text: match.content,
71
- })}
72
- </div>
73
- );
74
- };
75
-
76
- export const AlignmentResult = ({
77
- activeOccurrenceStart,
78
- match,
79
- query,
80
- }: {
81
- activeOccurrenceStart?: number;
82
- match: AlignmentMatch;
83
- query: string;
84
- }) => {
85
- return (
86
- <div className="grid md:grid-cols-2 gap-4">
87
- <div>
88
- {renderPassageHighlight({
89
- activeOccurrenceStart,
90
- query,
91
- text: match.content,
92
- })}
93
- </div>
94
- <div className="text-lg">{highlightText(match.source, query)}</div>
95
- </div>
96
- );
97
- };
98
-
99
- export const BibliographyResult = ({
100
- match,
101
- query,
102
- }: {
103
- match: BibliographyMatch;
104
- query: string;
105
- }) => {
106
- return <div>{highlightText(removeHtmlTags(match.content), query)}</div>;
107
- };
108
-
109
- export const GlossaryResult = ({
110
- match,
111
- query,
112
- }: {
113
- match: GlossaryMatch;
114
- query: string;
115
- }) => {
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
- );
130
- };
131
-
132
- export const SearchResultCard = ({
133
- isActive = false,
134
- activeOccurrenceStart,
135
- match,
136
- query,
137
- onClick,
138
- }: {
139
- isActive?: boolean;
140
- activeOccurrenceStart?: number;
141
- match: SearchResult;
142
- query: string;
143
- onClick: () => void;
144
- }) => {
145
- const isMobile = useIsMobile();
146
-
147
- // cast as passage but treat properties as optional
148
- const passage = match as PassageMatch;
149
-
150
- const renderInner = (match: SearchResult) => {
151
- switch (match.type) {
152
- case 'passage':
153
- return (
154
- <PassageResult
155
- activeOccurrenceStart={isActive ? activeOccurrenceStart : undefined}
156
- match={match as PassageMatch}
157
- query={query}
158
- />
159
- );
160
- case 'alignment':
161
- return (
162
- <AlignmentResult
163
- activeOccurrenceStart={isActive ? activeOccurrenceStart : undefined}
164
- match={match as AlignmentMatch}
165
- query={query}
166
- />
167
- );
168
- case 'bibliography':
169
- return (
170
- <BibliographyResult
171
- match={match as BibliographyMatch}
172
- query={query}
173
- />
174
- );
175
- case 'glossary':
176
- return <GlossaryResult match={match as GlossaryMatch} query={query} />;
177
- default:
178
- return null;
179
- }
180
- };
181
-
182
- return (
183
- <div
184
- onClick={onClick}
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
- }`}
190
- >
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">
192
- <div className="flex flex-col gap-1">
193
- <span className="text-secondary capitalize">{passage.type}</span>
194
- {passage.section && (
195
- <span className="text-foreground/50 capitalize">
196
- {passage.section.replace('Header', '')}
197
- </span>
198
- )}
199
- </div>
200
- <div className="grow" />
201
- {passage.label && (
202
- <span className="text-accent mt-auto">{passage.label}</span>
203
- )}
204
- </div>
205
- <Separator orientation={isMobile ? 'horizontal' : 'vertical'} />
206
- {renderInner(match)}
207
- </div>
208
- );
209
- };
@@ -1,28 +0,0 @@
1
- import { TabsList, TabsTrigger } from '@eightyfourthousand/design-system';
2
- import { RESULTS_ENTITIES, RESULTS_ENTITY_LABELS, SearchResults } from '../types';
3
-
4
- export const SearchResultTabs = ({ results }: { results: SearchResults }) => {
5
- return (
6
- <TabsList className="h-24 gap-4 flex-shrink-0 rounded-lg p-0 w-full bg-transparent sm:p-0">
7
- {RESULTS_ENTITIES.map(
8
- (tab) =>
9
- results[tab].length > 0 && (
10
- <TabsTrigger
11
- key={tab}
12
- value={tab}
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
- >
15
- <div className="flex flex-col gap-1 items-center">
16
- <span className="text-secondary font-bold">
17
- {RESULTS_ENTITY_LABELS[tab]}
18
- </span>
19
- <span className="text-primary font-bold">
20
- {results[tab].length} matches
21
- </span>
22
- </div>
23
- </TabsTrigger>
24
- ),
25
- )}
26
- </TabsList>
27
- );
28
- };