Skip to content

Commit 3d58777

Browse files
authored
Merge pull request #2 from Studio384/1-search
Search
2 parents 27156aa + f5b41ca commit 3d58777

5 files changed

Lines changed: 151 additions & 117 deletions

File tree

docs/src/app/Docs/pages/Changelog.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default function Changelog() {
2323
docs={[
2424
'The documentation has been rebuilt with Tailwind and Base UI.',
2525
'The category filter now keeps showing empty categories for easier navigation.',
26+
'The search function can now properly look for tags.',
2627
'Various categories have been updated and categories have been regrouped to make more sense.',
2728
'Major improvements to our documentation, including simpeler changelog markup.',
2829
'Fixes the bounce documentation not showing up in the documentation.'

docs/src/app/Docs/playground/Playground.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,6 @@ interface IPlaygroundProps {
3333
export default function Playground({ config }: IPlaygroundProps) {
3434
const [playgroundIcon, setPlaygroundIcon] = useState<string[]>([config.icons[0].name]);
3535

36-
console.log(config.icons, config.icons[0].name);
37-
3836
// Get the icon name
3937
function getIconName(icon: string): string {
4038
return `ai${icon

docs/src/app/Icons.tsx

Lines changed: 22 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { useCallback, useMemo } from 'react';
2-
import { useSearchParams } from 'react-router';
1+
import { useMemo } from 'react';
32

43
import categories from '@/data/categories';
54
import icons from '@/data/icons';
@@ -8,82 +7,25 @@ import { IconCard } from '@/design/components/IconCard';
87
import { Pagination } from '@/design/components/Pagination';
98
import { Search } from '@/design/components/Search';
109
import Header from '@/design/layout/LayoutElements/Header';
10+
import { useFilters } from '@/hooks/useFilters';
1111
import useSearch from '@/hooks/useSearch';
1212
import { ILibraryIcon } from '@/types';
1313

1414
import Amicon, { aiFilterXmark, aiXmark } from '@studio384/amicons';
1515
import clsx from 'clsx';
1616

1717
export default function Icons() {
18-
const [searchParams, setSearchParams] = useSearchParams();
19-
20-
const [searchCategories, searchQuery, searchPage]: [string[], string, number] = useMemo(() => {
21-
const categories = searchParams.get('category');
22-
const query = searchParams.get('search');
23-
const page = Number(searchParams.get('page') ?? 1);
24-
25-
return [categories?.split(',').filter((item) => item !== '') ?? [], query ?? '', page ?? 1];
26-
}, [searchParams]);
18+
const filters = useFilters();
2719

2820
const searchableList = useMemo(() => {
29-
if (searchCategories.length >= 1) {
30-
return icons.filter((icon) => searchCategories.every((_searchCategory) => icon.categories.includes(_searchCategory as never)));
21+
if (filters.query.categories.length >= 1) {
22+
return icons.filter((icon) => filters.query.categories.every((_searchCategory) => icon.categories.includes(_searchCategory as never)));
3123
}
3224

3325
return icons;
34-
}, [searchCategories]);
35-
36-
const { result } = useSearch(searchableList, ['slug', 'tags'], searchQuery);
37-
38-
// c: categories
39-
// q: query
40-
// p: page
41-
const setSearchQuery = useCallback(
42-
(type: 'q' | 'c' | 'p', value: string | number) => {
43-
let search = searchParams.get('search');
44-
let page = Number(searchParams.get('page'));
45-
let category =
46-
searchParams
47-
.get('category')
48-
?.split(',')
49-
.filter((item) => item !== '') ?? [];
50-
51-
switch (type) {
52-
case 'c': {
53-
if (typeof value === 'number') return;
26+
}, [filters.query.categories]);
5427

55-
if (category.includes(value)) {
56-
category = category.filter((item) => item !== value);
57-
} else {
58-
category.push(value);
59-
}
60-
61-
page = 1; // Always reset page
62-
break;
63-
}
64-
case 'q': {
65-
if (typeof value === 'number') return;
66-
67-
search = value;
68-
page = 1; // Always reset page
69-
break;
70-
}
71-
case 'p': {
72-
if (typeof value === 'string') return;
73-
74-
page = value;
75-
break;
76-
}
77-
}
78-
79-
setSearchParams({
80-
page: (page || 1).toString(),
81-
search: search ?? '',
82-
category: category.join(',') ?? ''
83-
});
84-
},
85-
[searchParams, setSearchParams]
86-
);
28+
const { result } = useSearch(searchableList, filters.query.search);
8729

8830
return (
8931
<>
@@ -100,13 +42,13 @@ export default function Icons() {
10042
return (
10143
<button
10244
key={_category.slug}
103-
onClick={() => setSearchQuery('c', _category.slug)}
104-
data-selected={searchCategories.includes(_category.slug) || undefined}
45+
onClick={() => filters.toggleCategory(_category.slug)}
46+
data-selected={filters.query.categories.includes(_category.slug) || undefined}
10547
data-noicons={categoryIcons.length === 0 ? true : undefined}
10648
className={clsx(
10749
'group grid h-8 grid-cols-[min-content_auto_min-content] items-center gap-2 rounded-sm px-2.5 text-start text-sm hover:cursor-pointer hover:bg-violet-200 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-violet-500 data-selected:focus-visible:outline-violet-700',
10850
{
109-
'bg-violet-500 text-white hover:bg-violet-600': searchCategories.includes(_category.slug)
51+
'bg-violet-500 text-white hover:bg-violet-600': filters.query.categories.includes(_category.slug)
11052
}
11153
)}
11254
>
@@ -123,46 +65,41 @@ export default function Icons() {
12365
<div className="flex items-baseline gap-2">
12466
<h2 className="font-display text-3xl font-medium">{result.length} icons</h2>
12567
<span className="text-zinc-600">
126-
Page {searchPage} of {Math.ceil(result.length / 96)}
68+
Page {filters.query.page} of {Math.ceil(result.length / 96)}
12769
</span>
12870
</div>
12971

13072
<div className="flex gap-1">
131-
<Search placeholder="Search" value={searchQuery} onValueChange={(value) => setSearchQuery('q', value)} />
73+
<Search placeholder="Search" value={filters.searchValue} onValueChange={(value) => filters.setSearch(value)} />
13274
<Button
13375
icon
13476
variant="secondary"
135-
disabled={searchQuery === '' && searchCategories.length === 0}
136-
onClick={() => {
137-
setSearchParams({
138-
search: '',
139-
category: ''
140-
});
141-
}}
77+
disabled={filters.searchValue === '' && filters.query.categories.length === 0}
78+
onClick={() => filters.resetQuery()}
14279
>
14380
<Amicon icon={aiFilterXmark} />
14481
</Button>
14582
</div>
14683
</div>
147-
{(searchQuery || searchCategories.length >= 1) && (
84+
{(filters.query.search || filters.query.categories.length >= 1) && (
14885
<div className="flex gap-1">
149-
{searchQuery && (
86+
{filters.query.search && (
15087
<div className="font-display flex items-center gap-1 rounded-full bg-zinc-100 py-1 ps-2.5 pe-1 text-sm">
151-
"{searchQuery}"
88+
"{filters.query.search}"
15289
<button
15390
className="text-md flex size-6 cursor-pointer items-center justify-center rounded-full bg-transparent hover:bg-zinc-300"
154-
onClick={() => setSearchQuery('q', '')}
91+
onClick={() => filters.setSearch('')}
15592
>
15693
<Amicon icon={aiXmark} /> <span className="sr-only">Delete category</span>
15794
</button>
15895
</div>
15996
)}
160-
{searchCategories.map((category) => (
97+
{filters.query.categories.map((category) => (
16198
<div key={category} className="font-display flex items-center gap-1 rounded-full bg-zinc-100 py-1 ps-2.5 pe-1 text-sm">
16299
{category}
163100
<button
164101
className="text-md flex size-6 cursor-pointer items-center justify-center rounded-full bg-transparent hover:bg-zinc-300"
165-
onClick={() => setSearchQuery('c', category)}
102+
onClick={() => filters.toggleCategory(category)}
166103
>
167104
<Amicon icon={aiXmark} /> <span className="sr-only">Delete category</span>
168105
</button>
@@ -171,12 +108,12 @@ export default function Icons() {
171108
</div>
172109
)}
173110
<div className="grid grid-cols-[repeat(auto-fill,minmax(min(9rem,100%),1fr))] gap-2">
174-
{result.slice((searchPage - 1) * 96, searchPage * 96).map((icon: ILibraryIcon) => (
111+
{result.slice((filters.query.page - 1) * 96, filters.query.page * 96).map((icon: ILibraryIcon) => (
175112
<IconCard key={icon.slug} icon={icon} />
176113
))}
177114
</div>
178115

179-
{result.length > 0 && <Pagination count={Math.ceil(result.length / 96)} page={searchPage} onChange={(_, page) => setSearchQuery('p', page)} />}
116+
{result.length > 0 && <Pagination count={Math.ceil(result.length / 96)} page={filters.query.page} onChange={(_, page) => filters.setPage(page)} />}
180117
</div>
181118
</div>
182119
</div>

docs/src/hooks/useFilters.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { useCallback, useMemo, useState } from 'react';
2+
import { useSearchParams } from 'react-router';
3+
4+
import { useDebouncedCallback } from '@tanstack/react-pacer';
5+
6+
export function useFilters() {
7+
const [searchParams, setSearchParams] = useSearchParams();
8+
9+
const initial = {
10+
page: 1,
11+
search: '',
12+
categories: [] as string[]
13+
};
14+
15+
const [searchValue, setSearchValue] = useState('');
16+
17+
const query = useMemo(() => {
18+
const page = Number(searchParams.get('page') ?? initial.page);
19+
const search = searchParams.get('search') ?? initial.search;
20+
const categories = searchParams.getAll('categories');
21+
22+
return { page, search, categories };
23+
}, [searchParams]);
24+
25+
const debouncedUpdate = useDebouncedCallback(
26+
(updater: (params: URLSearchParams) => void) => {
27+
setSearchParams((prev) => {
28+
const next = new URLSearchParams(prev);
29+
updater(next);
30+
return next;
31+
});
32+
},
33+
{ wait: 300 }
34+
);
35+
36+
const setPage = useCallback(
37+
(page: number) => {
38+
debouncedUpdate((params) => params.set('page', String(page)));
39+
},
40+
[debouncedUpdate]
41+
);
42+
43+
const setSearch = useCallback(
44+
(value: string) => {
45+
setSearchValue(value);
46+
debouncedUpdate((params) => {
47+
params.set('page', '1');
48+
if (value) {
49+
params.set('search', value);
50+
} else {
51+
params.delete('search');
52+
}
53+
});
54+
},
55+
[debouncedUpdate]
56+
);
57+
58+
const toggleCategory = useCallback(
59+
(category: string) => {
60+
debouncedUpdate((params) => {
61+
params.set('page', '1');
62+
63+
const current = params.getAll('categories');
64+
const exists = current.includes(category);
65+
66+
params.delete('categories');
67+
68+
const nextValue = exists ? current.filter((_category) => _category !== category) : [...current, category];
69+
70+
nextValue.forEach((_category) => params.append('categories', _category));
71+
});
72+
},
73+
[debouncedUpdate]
74+
);
75+
76+
const resetQuery = useCallback(() => {
77+
debouncedUpdate((params) => {
78+
params.set('page', String(initial.page));
79+
80+
params.delete('search');
81+
params.delete('page');
82+
params.delete('categories');
83+
setSearchValue('');
84+
});
85+
}, [debouncedUpdate]);
86+
87+
return {
88+
query,
89+
searchValue,
90+
setPage,
91+
setSearch,
92+
toggleCategory,
93+
resetQuery
94+
};
95+
}

docs/src/hooks/useSearch.ts

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,30 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
21
import { useCallback, useMemo } from 'react';
32

3+
import { IAmicon } from '@studio384/amicons';
4+
45
interface ISeachResults {
5-
[key: string]: any;
6+
categories: string[];
7+
component: string;
8+
icon: IAmicon;
9+
slug: string;
10+
tags: string[];
611
_score: number;
712
}
813

9-
export default function useSearch(haystack: any[] | undefined, keys: string[], needle: string) {
10-
const flattenObject = useCallback((item: { [key: string]: any }, prefix?: string) => {
11-
const flattened: { [key: string]: any } = {};
12-
prefix = prefix ? prefix + '.' : '';
13-
14-
for (const key in item) {
15-
if (typeof item[key] === 'object' && item[key] !== null) {
16-
Object.assign(flattened, flattenObject(item[key], prefix + key));
17-
} else {
18-
flattened[prefix + key] = item[key];
19-
}
20-
}
21-
22-
return flattened;
23-
}, []);
24-
25-
const scoreHaystackItem = useCallback((item: string, query: string) => {
26-
const searchable = item.toString().toLowerCase().trim();
14+
export default function useSearch(
15+
iconLibrary:
16+
| {
17+
categories: string[];
18+
component: string;
19+
icon: IAmicon;
20+
slug: string;
21+
tags: string[];
22+
}[]
23+
| undefined,
24+
needle: string
25+
) {
26+
const scoreIcon = useCallback((value: string, query: string) => {
27+
const searchable = value.toString().toLowerCase().trim();
2728

2829
// Check if the string is an exact match to this partial search query
2930
if (searchable === query) {
@@ -45,32 +46,34 @@ export default function useSearch(haystack: any[] | undefined, keys: string[], n
4546

4647
const result = useMemo(() => {
4748
if (needle === '') {
48-
return haystack || [];
49+
return iconLibrary || [];
4950
}
5051

5152
const results: ISeachResults[] = [];
5253
const cleanNeedle = needle.trim().toLowerCase();
5354

5455
// Loop through the haystack
55-
(haystack || []).map((item) => {
56-
const flatItem: { [key: string]: any } = flattenObject(item);
56+
(iconLibrary || []).map((icon) => {
5757
let matchScore = 0;
5858

59-
keys.forEach((key) => {
60-
if (flatItem[key]) {
61-
// Do a 1:1 comparison between all searchable items
62-
matchScore += scoreHaystackItem(flatItem[key], cleanNeedle);
63-
}
59+
if (icon.slug) {
60+
// Do a 1:1 comparison between all searchable items
61+
matchScore += scoreIcon(icon.slug, cleanNeedle);
62+
matchScore += scoreIcon(icon.slug.replaceAll('-', ' '), cleanNeedle);
63+
}
64+
65+
icon.tags.map((tag) => {
66+
matchScore += scoreIcon(tag, cleanNeedle);
6467
});
6568

6669
// If we have a score, set it
6770
if (matchScore) {
68-
results.push({ ...item, _score: matchScore });
71+
results.push({ ...icon, _score: matchScore });
6972
}
7073
});
7174

7275
return results.sort((a, b) => (a._score < b._score ? -1 : 1));
73-
}, [flattenObject, haystack, keys, needle, scoreHaystackItem]);
76+
}, [iconLibrary, needle, scoreIcon]);
7477

7578
return { result, needle };
7679
}

0 commit comments

Comments
 (0)