mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-11 13:28:19 +00:00
Add fuzzy search (#511)
* Add fuzzy search * Improve search performance
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
"@next/mdx": "^11.0.0",
|
||||
"downloadjs": "^1.4.7",
|
||||
"framer-motion": "^4",
|
||||
"fuse.js": "^6.5.3",
|
||||
"gray-matter": "^4.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jszip": "^3.7.0",
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
import { Button, Flex, Grid, Text, useToast } from '@chakra-ui/react';
|
||||
import download from 'downloadjs';
|
||||
import { Grid } from '@chakra-ui/react';
|
||||
import Link from 'next/link';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { IconStyleContext } from './CustomizeIconContext';
|
||||
import { IconWrapper } from './IconWrapper';
|
||||
import { useRouter } from 'next/router';
|
||||
import ModifiedTooltip from './ModifiedTooltip';
|
||||
import { memo } from 'react';
|
||||
import { IconEntity } from '../types';
|
||||
import IconListItem from './IconListItem';
|
||||
|
||||
const IconList = ({ icons }) => {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const { color, size, strokeWidth } = useContext(IconStyleContext);
|
||||
const { search } = router.query;
|
||||
interface IconListProps {
|
||||
icons: IconEntity[];
|
||||
}
|
||||
|
||||
const query = useMemo(() => (search !== undefined ? { search } : {}), [search]);
|
||||
const IconList = memo(({ icons }: IconListProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Grid templateColumns={`repeat(auto-fill, minmax(150px, 1fr))`} gap={5} marginBottom="320px">
|
||||
{icons.map(icon => {
|
||||
const actualIcon = icon.item ? icon.item : icon;
|
||||
const { name, content, contributors } = actualIcon;
|
||||
|
||||
{icons.map((icon) => {
|
||||
return (
|
||||
<Link
|
||||
key={name}
|
||||
key={icon.name}
|
||||
scroll={false}
|
||||
shallow={true}
|
||||
href={{
|
||||
pathname: '/icon/[iconName]',
|
||||
query: {
|
||||
...query,
|
||||
iconName: name,
|
||||
...router.query,
|
||||
iconName: icon.name,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -42,6 +34,6 @@ const IconList = ({ icons }) => {
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default IconList;
|
||||
|
||||
@@ -1,114 +1,32 @@
|
||||
import {
|
||||
Box,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Text,
|
||||
useColorMode,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import IconList from './IconList';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
import React, { useState } from 'react';
|
||||
import useSearch from '../lib/useSearch';
|
||||
import { useRouter } from 'next/router';
|
||||
import theme from '../lib/theme';
|
||||
import { Search as SearchIcon } from 'lucide-react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { IconEntity } from '../types';
|
||||
import IconList from './IconList';
|
||||
import { SearchInput } from './SearchInput';
|
||||
|
||||
const isFilledString = (string) => string !== undefined && string !== null && string !== '';
|
||||
interface IconOverviewProps {
|
||||
data: IconEntity[];
|
||||
}
|
||||
|
||||
const IconOverview = ({ data }) => {
|
||||
const router = useRouter();
|
||||
const { search } = router.query;
|
||||
const IconOverview = ({ data }: IconOverviewProps) => {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const [queryText, setQueryText] = useState(search);
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
const inputElement = useRef(null);
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (event.key === '/' && inputElement.current !== document.activeElement) {
|
||||
event.preventDefault();
|
||||
inputElement.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const setQueryParam = (searchString) => {
|
||||
const { query, asPath } = router;
|
||||
if(isFilledString(searchString)) {
|
||||
let route = {
|
||||
pathname: '',
|
||||
query
|
||||
}
|
||||
|
||||
if(query.iconName) {
|
||||
route.query.iconName = query.iconName;
|
||||
route.pathname = '/icon/[iconName]';
|
||||
}
|
||||
|
||||
route.query.search = searchString;
|
||||
|
||||
router.replace(route);
|
||||
}
|
||||
else {
|
||||
if (query?.search) {
|
||||
delete query.search;
|
||||
router.replace({
|
||||
query
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const searchResults = useMemo(() => useSearch(data, queryText), [data, queryText])
|
||||
|
||||
const handleSearchInput = debounce((event) => {
|
||||
event.persist();
|
||||
const { value = '' } = inputElement?.current;
|
||||
|
||||
setQueryText(value)
|
||||
setQueryParam(value)
|
||||
}, 400)
|
||||
|
||||
useEffect(() => {
|
||||
setQueryText(search)
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
const searchResults = useSearch(query, data, [
|
||||
{ name: 'name', weight: 2 },
|
||||
{ name: 'tags', weight: 1 },
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputGroup position="sticky" top={4} zIndex={1}>
|
||||
<InputLeftElement
|
||||
children={
|
||||
<Icon>
|
||||
<SearchIcon />
|
||||
</Icon>
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
ref={inputElement}
|
||||
placeholder={`Search ${Object.keys(data).length} icons (Press "/" to focus)`}
|
||||
onChange={handleSearchInput}
|
||||
defaultValue={queryText}
|
||||
bg={colorMode == 'light' ? theme.colors.white : theme.colors.gray[700]}
|
||||
/>
|
||||
</InputGroup>
|
||||
<SearchInput onChange={setQuery} count={data.length} />
|
||||
|
||||
<Box marginTop={5}>
|
||||
{searchResults.length > 0 ? (
|
||||
<IconList icons={searchResults} />
|
||||
) : (
|
||||
<Text
|
||||
fontSize="2xl"
|
||||
fontWeight="bold"
|
||||
textAlign="center"
|
||||
style={{ wordBreak: 'break-word' }}
|
||||
>
|
||||
No results found for "{queryText}"
|
||||
<Text fontSize="2xl" fontWeight="bold" textAlign="center" wordBreak="break-word">
|
||||
No results found for "{query}"
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Icon,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
useColorMode,
|
||||
useUpdateEffect
|
||||
} from '@chakra-ui/react';
|
||||
import { Search as SearchIcon } from 'lucide-react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import theme from '../lib/theme';
|
||||
import { useDebounce } from '../lib/useDebounce';
|
||||
import { useRouterParam } from '../lib/useRouterParam';
|
||||
|
||||
interface SearchInputProps {
|
||||
onChange(value: string): void;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const SearchInput = (
|
||||
({ onChange, count }: SearchInputProps) => {
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
const [urlValue, setUrlValue] = useRouterParam('search');
|
||||
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const debouncedValue = useDebounce(inputValue.trim(), 300);
|
||||
|
||||
useUpdateEffect(() => {
|
||||
onChange(debouncedValue);
|
||||
setUrlValue(debouncedValue);
|
||||
}, [debouncedValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (urlValue && !inputValue) {
|
||||
setInputValue(urlValue);
|
||||
onChange(urlValue);
|
||||
}
|
||||
}, [urlValue]);
|
||||
|
||||
const ref = useRef(null);
|
||||
|
||||
// Keyboard `/` shortcut
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === '/' && ref.current !== document.activeElement) {
|
||||
event.preventDefault();
|
||||
ref.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<InputGroup position="sticky" top={4} zIndex={1}>
|
||||
<InputLeftElement
|
||||
children={
|
||||
<Icon>
|
||||
<SearchIcon />
|
||||
</Icon>
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
ref={ref}
|
||||
placeholder={`Search ${count} icons (Press "/" to focus)`}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
value={inputValue}
|
||||
bg={colorMode == 'light' ? theme.colors.white : theme.colors.gray[700]}
|
||||
/>
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { DependencyList, EffectCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export const useUpdateEffect = (effect: EffectCallback, deps?: DependencyList) => {
|
||||
const isFirstRef = useRef(true);
|
||||
|
||||
if (isFirstRef.current) {
|
||||
isFirstRef.current = false;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFirstRef.current) {
|
||||
return effect();
|
||||
}
|
||||
}, deps);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export const useRouterParam = (key: string) => {
|
||||
const router = useRouter();
|
||||
|
||||
const queryValue = router.query[key];
|
||||
const urlValue = typeof queryValue === 'string' ? queryValue : '';
|
||||
|
||||
const setParam = (value: string) => {
|
||||
router.push(
|
||||
{
|
||||
query: value ? { [key]: value } : undefined,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
scroll: false,
|
||||
shallow: true,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return [urlValue, setParam] as const;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import Fuse from 'fuse.js';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const useSearch = <T>(query = '', collection: T[], keys: Fuse.FuseOptionKey[]) => {
|
||||
const index = useMemo(() => {
|
||||
return new Fuse(collection, {
|
||||
threshold: 0.2,
|
||||
keys,
|
||||
});
|
||||
}, [collection]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (query) {
|
||||
return index.search(query).map((result) => result.item);
|
||||
}
|
||||
|
||||
return collection;
|
||||
}, [query, index]);
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export default useSearch;
|
||||
@@ -1,19 +0,0 @@
|
||||
interface Icon {
|
||||
name: string;
|
||||
tags: string[],
|
||||
}
|
||||
|
||||
function useSearch(icons: Icon[], query:string) {
|
||||
if(!query) return icons;
|
||||
|
||||
const searchString = query.toLowerCase()
|
||||
|
||||
return icons.filter(({ name, tags }: Icon) => [name, ...tags].some(
|
||||
(item:string) => item
|
||||
.toLowerCase()
|
||||
.includes(searchString)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default useSearch;
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface IconEntity {
|
||||
content: string;
|
||||
contributors: Contributor[];
|
||||
name: string;
|
||||
src: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface Contributor {
|
||||
author: string;
|
||||
commit: string;
|
||||
}
|
||||
@@ -3785,6 +3785,11 @@ functional-red-black-tree@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
|
||||
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
|
||||
|
||||
fuse.js@^6.5.3:
|
||||
version "6.5.3"
|
||||
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.5.3.tgz#7446c0acbc4ab0ab36fa602e97499bdb69452b93"
|
||||
integrity sha512-sA5etGE7yD/pOqivZRBvUBd/NaL2sjAu6QuSaFoe1H2BrJSkH/T/UXAJ8CdXdw7DvY3Hs8CXKYkDWX7RiP5KOg==
|
||||
|
||||
gensync@^1.0.0-beta.1, gensync@^1.0.0-beta.2:
|
||||
version "1.0.0-beta.2"
|
||||
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||
|
||||
Reference in New Issue
Block a user