Merge pull request #5 from phosphor-icons/fuzzy-search

Fuzzy search
This commit is contained in:
Tobias Fried
2020-09-15 01:16:04 -04:00
committed by GitHub
6 changed files with 62 additions and 44 deletions

View File

@@ -23,6 +23,7 @@
"dependencies": { "dependencies": {
"file-saver": "^2.0.2", "file-saver": "^2.0.2",
"framer-motion": "^2.1.0", "framer-motion": "^2.1.0",
"fuse.js": "^6.4.1",
"phosphor-react": "^0.2.2", "phosphor-react": "^0.2.2",
"react": "^17.0.0-rc.0", "react": "^17.0.0-rc.0",
"react-dom": "^17.0.0-rc.0", "react-dom": "^17.0.0-rc.0",

View File

@@ -5,6 +5,8 @@ import Header from "../Header/Header";
import Toolbar from "../Toolbar/Toolbar"; import Toolbar from "../Toolbar/Toolbar";
import IconGrid from "../IconGrid/IconGrid"; import IconGrid from "../IconGrid/IconGrid";
import Footer from "../Footer/Footer"; import Footer from "../Footer/Footer";
import ErrorBoundary from "../ErrorBoundary/ErrorBoundary";
import Warn from "../Warn/Warn";
const App: React.FC<any> = () => { const App: React.FC<any> = () => {
return ( return (
@@ -12,9 +14,11 @@ const App: React.FC<any> = () => {
<Header /> <Header />
<main> <main>
<Toolbar /> <Toolbar />
<Suspense fallback={<div>Loading...</div>}> <ErrorBoundary fallback={<Warn message="Search error"/>}>
<IconGrid /> <Suspense fallback={<div>Loading...</div>}>
</Suspense> <IconGrid />
</Suspense>
</ErrorBoundary>
</main> </main>
<Footer /> <Footer />
</> </>

View File

@@ -2,19 +2,15 @@ import React, { useRef, useEffect } from "react";
import { useRecoilValue } from "recoil"; import { useRecoilValue } from "recoil";
import { motion, useAnimation } from "framer-motion"; import { motion, useAnimation } from "framer-motion";
import { useWindowSize } from "react-use"; import { useWindowSize } from "react-use";
import { IconContext, Warning } from "phosphor-react"; import { IconContext } from "phosphor-react";
import { import { iconStyleAtom, iconSizeAtom, iconColorAtom } from "../../state/atoms";
iconStyleAtom,
iconSizeAtom,
iconColorAtom,
searchQueryAtom,
} from "../../state/atoms";
import { import {
filteredQueryResultsSelector, filteredQueryResultsSelector,
isDarkThemeSelector, isDarkThemeSelector,
} from "../../state/selectors"; } from "../../state/selectors";
import GridItem from "./IconGridItem"; import GridItem from "./IconGridItem";
import Warn from "../Warn/Warn";
import "./IconGrid.css"; import "./IconGrid.css";
type IconGridProps = {}; type IconGridProps = {};
@@ -23,7 +19,6 @@ const IconGrid: React.FC<IconGridProps> = () => {
const weight = useRecoilValue(iconStyleAtom); const weight = useRecoilValue(iconStyleAtom);
const size = useRecoilValue(iconSizeAtom); const size = useRecoilValue(iconSizeAtom);
const color = useRecoilValue(iconColorAtom); const color = useRecoilValue(iconColorAtom);
const query = useRecoilValue(searchQueryAtom);
const isDark = useRecoilValue(isDarkThemeSelector); const isDark = useRecoilValue(isDarkThemeSelector);
const { width } = useWindowSize(); const { width } = useWindowSize();
@@ -38,22 +33,7 @@ const IconGrid: React.FC<IconGridProps> = () => {
controls.start("visible"); controls.start("visible");
}, [controls, filteredQueryResults]); }, [controls, filteredQueryResults]);
if (!filteredQueryResults.length) if (!filteredQueryResults.length) return <Warn />;
return (
<div style={isDark ? { backgroundColor: "#35313D", color: "white" } : {}}>
<motion.div
className="empty-list"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
<Warning size={128} color="currentColor" weight="fill" />
<p>
No results for '<code>{query}</code>'
</p>
</motion.div>
</div>
);
return ( return (
<IconContext.Provider value={{ weight, size, color, mirrored: false }}> <IconContext.Provider value={{ weight, size, color, mirrored: false }}>

View File

@@ -0,0 +1,36 @@
import React from "react";
import { motion } from "framer-motion";
import { useRecoilValue } from "recoil";
import { isDarkThemeSelector } from "../../state/selectors";
import { searchQueryAtom } from "../../state/atoms";
import { Warning } from "phosphor-react";
interface WarnProps {
message?: string;
}
const Warn: React.FC<WarnProps> = ({ message }) => {
const isDark = useRecoilValue(isDarkThemeSelector);
const query = useRecoilValue(searchQueryAtom);
return (
<div style={isDark ? { backgroundColor: "#35313D", color: "white" } : {}}>
<motion.div
className="empty-list"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
<Warning size={128} color="currentColor" weight="fill" />
{message ?? (
<p>
No results for '<code>{query}</code>'
</p>
)}
</motion.div>
</div>
);
};
export default Warn;

View File

@@ -3086,13 +3086,13 @@ export const icons: Readonly<IconEntry[]> = [
{ {
name: "trash", name: "trash",
categories: [IconCategory.DOCUMENT, IconCategory.SYSTEM], categories: [IconCategory.DOCUMENT, IconCategory.SYSTEM],
tags: ["garbage", "delete", "destroy", "recycle", "recycling"], tags: ["garbage", "remove", "delete", "destroy", "recycle", "recycling"],
Icon: Icon.Trash, Icon: Icon.Trash,
}, },
{ {
name: "trash-handle", name: "trash-handle",
categories: [IconCategory.DOCUMENT, IconCategory.SYSTEM], categories: [IconCategory.DOCUMENT, IconCategory.SYSTEM],
tags: ["garbage", "delete", "destroy", "recycle", "recycling"], tags: ["garbage", "remove", "delete", "destroy", "recycle", "recycling"],
Icon: Icon.TrashHandle, Icon: Icon.TrashHandle,
}, },
{ {

View File

@@ -1,28 +1,25 @@
import { selector, selectorFamily } from "recoil"; import { selector, selectorFamily } from "recoil";
import TinyColor from "tinycolor2"; import TinyColor from "tinycolor2";
import Fuse from "fuse.js";
import { searchQueryAtom, iconStyleAtom, iconColorAtom } from "./atoms"; import { searchQueryAtom, iconColorAtom } from "./atoms";
import { IconEntry, IconCategory } from "../lib"; import { IconEntry, IconCategory } from "../lib";
import { icons } from "../lib/icons"; import { icons } from "../lib/icons";
const isQueryMatch = (icon: IconEntry, query: string): boolean => { const fuse = new Fuse(icons, {
return ( keys: [{ name: "name", weight: 2 }, "tags", "categories"],
icon.name.includes(query) || threshold: 0.2, // Tweak this to what feels like the right number of results
icon.tags.some((tag) => tag.toLowerCase().includes(query)) || // shouldSort: false, // We may want to sort if we find too many results?
icon.categories.some((category) => category.toLowerCase().includes(query)) });
);
};
export const filteredQueryResultsSelector = selector<Readonly<IconEntry[]>>({ export const filteredQueryResultsSelector = selector<Readonly<IconEntry[]>>({
key: "filteredQueryResultsSelector", key: "filteredQueryResultsSelector",
get: async ({ get }) => { get: ({ get }) => {
const query = get(searchQueryAtom).trim().toLowerCase(); const query = get(searchQueryAtom).trim().toLowerCase();
const style = get(iconStyleAtom); if (!query) return icons;
if (!query && !style) return icons;
return new Promise((resolve) => return new Promise((resolve) =>
resolve(icons.filter((icon) => isQueryMatch(icon, query))) resolve(fuse.search(query).map((value) => value.item))
); );
}, },
}); });
@@ -33,7 +30,7 @@ export const categorizedQueryResultsSelector = selector<
Readonly<CategorizedIcons> Readonly<CategorizedIcons>
>({ >({
key: "categorizedQueryResultsSelector", key: "categorizedQueryResultsSelector",
get: async ({ get }) => { get: ({ get }) => {
const filteredResults = get(filteredQueryResultsSelector); const filteredResults = get(filteredQueryResultsSelector);
return new Promise((resolve) => return new Promise((resolve) =>
resolve( resolve(
@@ -54,7 +51,7 @@ export const singleCategoryQueryResultsSelector = selectorFamily<
IconCategory IconCategory
>({ >({
key: "singleCategoryQueryResultsSelector", key: "singleCategoryQueryResultsSelector",
get: (category: IconCategory) => async ({ get }) => { get: (category: IconCategory) => ({ get }) => {
const filteredResults = get(filteredQueryResultsSelector); const filteredResults = get(filteredQueryResultsSelector);
return new Promise((resolve) => return new Promise((resolve) =>
resolve( resolve(