-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Expand file tree
/
Copy pathsearchUtils.tsx
More file actions
315 lines (280 loc) · 9.24 KB
/
Copy pathsearchUtils.tsx
File metadata and controls
315 lines (280 loc) · 9.24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
'use client';
import {Content, Heading, IllustratedMessage} from '@react-spectrum/s2';
import {getLibraryFromPage} from './library';
// @ts-ignore
import {iconList, useIconFilter} from './IconSearchView';
import {type Library, TAB_DEFS} from './constants';
// eslint-disable-next-line monorepo/no-internal-import
import NoSearchResults from '@react-spectrum/s2/illustrations/linear/NoSearchResults';
// @ts-ignore
import {Page} from '@parcel/rsc';
import React, {useEffect, useMemo, useRef, useState} from 'react';
import {style} from '@react-spectrum/s2/style' with {type: 'macro'};
export interface SearchableItem {
name: string,
tags: string[],
date?: string
}
export interface ComponentItem {
id: string,
name: string,
href: string,
section: string,
tags: string[],
description?: string,
date?: string
}
export interface Section {
id: string,
name: string,
children: ComponentItem[]
}
export interface Tag {
id: string,
name: string
}
export interface SearchOptions<T> {
/**
* Function to extract the name from an item.
*/
getName: (item: T) => string,
/**
* Function to extract tags from an item.
*/
getTags: (item: T) => string[],
/**
* Optional function to extract date from an item for date-based sorting.
*/
getDate?: (item: T) => string | undefined,
/**
* Optional function to determine if an item should use date-based sorting.
* If not provided, all items use alphabetical sorting.
*/
shouldUseDateSort?: (item: T) => boolean
}
export function filterSearchItems<T>(
items: T[],
searchValue: string,
options: SearchOptions<T>
): T[] {
if (!searchValue.trim()) {
return items;
}
const searchLower = searchValue.toLowerCase();
const {getName, getTags} = options;
return items.filter(item => {
const name = getName(item).toLowerCase();
const tags = getTags(item);
const nameMatch = name.includes(searchLower);
const tagMatch = tags.some(tag => tag.toLowerCase().includes(searchLower));
return nameMatch || tagMatch;
});
}
/**
* Sorts items to prioritize `startsWith` matches, then `includes` matches, then tag matches.
*/
export function sortSearchItems<T>(
items: T[],
searchValue: string,
options: SearchOptions<T>
): T[] {
if (!searchValue.trim()) {
return items;
}
const searchLower = searchValue.toLowerCase();
const {getName, getDate, shouldUseDateSort} = options;
return [...items].sort((a, b) => {
const aName = getName(a).toLowerCase();
const bName = getName(b).toLowerCase();
const aNameStartsWith = aName.startsWith(searchLower);
const bNameStartsWith = bName.startsWith(searchLower);
const aNameIncludes = aName.includes(searchLower);
const bNameIncludes = bName.includes(searchLower);
// Check if either item should use date sorting
const aUseDateSort = shouldUseDateSort ? shouldUseDateSort(a) : false;
const bUseDateSort = shouldUseDateSort ? shouldUseDateSort(b) : false;
const bothUseDateSort = aUseDateSort && bUseDateSort;
// Prioritize startsWith matches
if (aNameStartsWith && !bNameStartsWith) {
return -1;
}
if (!aNameStartsWith && bNameStartsWith) {
return 1;
}
// If both start with, sort by date (if both use date sort) or alphabetically
if (aNameStartsWith && bNameStartsWith) {
if (bothUseDateSort && getDate) {
const aDate = getDate(a);
const bDate = getDate(b);
if (aDate && bDate) {
return new Date(bDate).getTime() - new Date(aDate).getTime();
} else if (aDate && !bDate) {
return 1;
} else if (!aDate && bDate) {
return -1;
}
}
return aName.localeCompare(bName);
}
// Prioritize includes matches over tag matches
if (aNameIncludes && !bNameIncludes) {
return -1;
}
if (!aNameIncludes && bNameIncludes) {
return 1;
}
// If both match by name (includes), sort by date (if both use date sort) or alphabetically
if (aNameIncludes && bNameIncludes) {
if (bothUseDateSort && getDate) {
const aDate = getDate(a);
const bDate = getDate(b);
if (aDate && bDate) {
return new Date(bDate).getTime() - new Date(aDate).getTime();
} else if (aDate && !bDate) {
return 1;
} else if (!aDate && bDate) {
return -1;
}
}
return aName.localeCompare(bName);
}
// If both match by tag, maintain original order
return 0;
});
}
export function filterAndSortSearchItems<T>(
items: T[],
searchValue: string,
options: SearchOptions<T>
): T[] {
const filtered = filterSearchItems(items, searchValue, options);
return sortSearchItems(filtered, searchValue, options);
}
export function getPageTitle(page: Page): string {
return page.exports?.title ?? page.tableOfContents?.[0]?.title ?? page.name;
}
export function getOrderedLibraries(currentPage: Page) {
const allLibraries = (Object.keys(TAB_DEFS) as Library[]).map(id => ({id, ...TAB_DEFS[id]}));
const currentLibId = getLibraryFromPage(currentPage);
const currentIndex = allLibraries.findIndex(lib => lib.id === currentLibId);
if (currentIndex > 0) {
const currentLib = allLibraries.splice(currentIndex, 1)[0];
allLibraries.unshift(currentLib);
}
return allLibraries;
}
export function getResourceTags(library: Library): Tag[] {
if (library === 'react-spectrum') {
return [{id: 'icons', name: 'Icons'}];
}
return [];
}
export function useFilteredIcons(searchValue: string) {
const iconFilter = useIconFilter();
return useMemo(() => {
if (!searchValue.trim()) {
return iconList;
}
return iconList.filter(item => iconFilter(item.id, searchValue));
}, [searchValue, iconFilter]);
}
export function useSearchTagSelection(
searchValue: string,
sectionTags: Tag[],
resourceTags: Tag[],
initialTagId: string
) {
const [selectedTagId, setSelectedTagId] = useState<string>(initialTagId);
const prevSearchWasEmptyRef = useRef<boolean>(true);
// Ensure selected tag is valid for the current library
const baseSectionIds = sectionTags.map(s => s.id);
const resourceTagIds = resourceTags.map(t => t.id);
const allBaseIds = useMemo(() => [...baseSectionIds, ...resourceTagIds], [baseSectionIds, resourceTagIds]);
const isResourceSelected = selectedTagId && resourceTagIds.includes(selectedTagId);
const sectionIds = useMemo(() => {
return searchValue.trim().length > 0 && !isResourceSelected ? ['all', ...allBaseIds] : allBaseIds;
}, [searchValue, isResourceSelected, allBaseIds]);
useEffect(() => {
if (!selectedTagId || !sectionIds.includes(selectedTagId)) {
setSelectedTagId(sectionIds[0] || 'components');
}
}, [selectedTagId, sectionIds, setSelectedTagId]);
// Auto-select "All" when search starts (unless resource is selected)
useEffect(() => {
const isEmpty = searchValue.trim().length === 0;
if (prevSearchWasEmptyRef.current && !isEmpty && !isResourceSelected) {
setSelectedTagId('all');
}
prevSearchWasEmptyRef.current = isEmpty;
}, [searchValue, isResourceSelected]);
return [selectedTagId, setSelectedTagId] as const;
}
export function useSectionTagsForDisplay(
sections: Section[],
searchValue: string,
selectedTagId: string,
resourceTagIds: string[]
): Tag[] {
return useMemo(() => {
const base = sections.map(s => ({id: s.id, name: s.name}));
if (searchValue.trim().length > 0 && !resourceTagIds.includes(selectedTagId)) {
return [{id: 'all', name: 'All'}, ...base];
}
return base;
}, [sections, searchValue, selectedTagId, resourceTagIds]);
}
export function sortItemsForDisplay<T extends {name: string, date?: string}>(items: T[], searchValue: string): T[] {
if (searchValue.trim().length === 0) {
return [...items].sort((a, b) => {
const aIsIntro = a.name === 'Introduction';
const bIsIntro = b.name === 'Introduction';
// Date sorting for Blog/Releases
if (a.date && b.date) {
const aDate = new Date(a.date);
const bDate = new Date(b.date);
return bDate.getTime() - aDate.getTime();
} else if (a.date && !b.date) {
return 1;
} else if (!a.date && b.date) {
return -1;
}
// Introduction first
if (aIsIntro && !bIsIntro) {
return -1;
}
if (!aIsIntro && bIsIntro) {
return 1;
}
return 0;
});
}
return items;
}
export function createSearchOptions<T extends {name: string, tags: string[], date?: string, section?: string}>() {
return {
getName: (item: T) => item.name,
getTags: (item: T) => item.tags,
getDate: (item: T) => item.date,
shouldUseDateSort: (item: T) => {
const section = item.section;
return section === 'Blog' || section === 'Releases';
}
};
}
export function SearchEmptyState({searchValue, libraryLabel}: {searchValue: string, libraryLabel: string}) {
return (
<IllustratedMessage styles={style({margin: 32})}>
<NoSearchResults />
<Heading>No results</Heading>
{searchValue.trim().length > 0 ? (
<Content>
No results found for <strong className={style({fontWeight: 'bold'})}>{searchValue}</strong> in {libraryLabel}.
</Content>
) : (
<Content>
No results found in {libraryLabel}.
</Content>
)}
</IllustratedMessage>
);
}