IconGrid: massive refactor including component extraction

This patch extracts InfoPanel from IconGridItem, adds style tweaks to
match the spec, and in general reduces prop-drilling by tapping into
recoil state for most config, and making use of IconContext to style
member icons where appropriate :)
This commit is contained in:
rektdeckard
2020-07-29 12:27:50 -04:00
parent c269343014
commit 6941250d10
4 changed files with 283 additions and 94 deletions

View File

@@ -1,21 +1,28 @@
.grid { .grid-container {
/* grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); */ padding: 0px 16px 8px;
/* background-color: #35313D;
color: white; */
}
/* display: grid; */ .grid {
/* grid-template-columns: repeat(auto-fill, 160px); /* display: grid;
grid-area: auto;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
grid-gap: 10px; grid-gap: 10px;
grid-auto-rows: minmax(160px, auto); */ grid-auto-rows: 160px;
grid-auto-columns: auto;
max-width: 1120px; */
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
justify-content: space-between; justify-content: space-between;
margin: 16px; max-width: 1120px;
margin: auto;
/* min-height: 100vh; */
} }
.grid-item { .grid-item {
display: flex; display: flex;
box-sizing: border-box;
width: 160px; width: 160px;
height: 160px; height: 160px;
flex-direction: column; flex-direction: column;
@@ -25,20 +32,82 @@
border-radius: 16px; border-radius: 16px;
user-select: none; user-select: none;
cursor: pointer; cursor: pointer;
/* background-color: rgba(255, 255, 255, 0); */
} }
.grid-item:focus { .grid-item:focus {
outline: none; outline: none;
box-shadow: 0 0 0 2px rgb(246, 242, 243);
}
.grid-item p {
font-size: 12px;
line-height: 16px;
color: #7F7F7F;
margin-top: 12px;
} }
.info-box { .info-box {
display: flex; display: flex;
margin: 4px;
padding: 16px;
width: 100%; width: 100%;
height: 0px; height: 0px;
margin: 0px;
padding-right: 10%;
border-radius: 16px; border-radius: 16px;
box-shadow: 0 0 0 2px rgb(0, 0, 0); overflow: hidden;
}
.icon-preview {
height: 396px;
width: 30%;
display: flex;
text-align: center;
flex-direction: column;
align-items: center;
justify-content: center;
}
.icon-preview p {
margin: 12px 0 0;
font-size: 12px;
line-height: 16px;
}
.icon-usage {
flex: 1;
padding: 56px 0px 56px;
/* display: flex; */
/* flex-direction: column; */
/* justify-content: flex-start; */
/* vertical-align: middle; */
}
.snippet {
margin-bottom: 24px;
}
.snippet pre {
text-overflow: ellipsis;
/* overflow-x: auto; */
}
.snippet button {
background-color: transparent;
margin: 0;
padding: 0;
height: 24px;
float: right;
}
.button-row {
display: flex;
}
.button-row button {
background-color: transparent;
font-size: 16px;
line-height: 24px;
margin: 0 48px 0 0;
padding: 0;
height: 48px;
} }
.empty-list { .empty-list {

View File

@@ -2,30 +2,30 @@ 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 TinyColor from "tinycolor2";
import { IconContext, WarningTriangle } from "phosphor-react";
import { filteredQueryResultsSelector } from "../../state/selectors";
import { import {
iconColorAtom,
iconSizeAtom,
styleQueryAtom, styleQueryAtom,
iconSizeAtom,
iconColorAtom,
searchQueryAtom, searchQueryAtom,
} from "../../state/atoms"; } from "../../state/atoms";
import "./IconGrid.css"; import { filteredQueryResultsSelector } from "../../state/selectors";
import GridItem from "./IconGridItem"; import GridItem from "./IconGridItem";
import { WarningTriangle, IconProps } from "phosphor-react"; import "./IconGrid.css";
type IconGridProps = {}; type IconGridProps = {};
const IconGridAnimated: React.FC<IconGridProps> = () => { const IconGridAnimated: React.FC<IconGridProps> = () => {
const weight = useRecoilValue(styleQueryAtom); const weight = useRecoilValue(styleQueryAtom);
const query = useRecoilValue(searchQueryAtom);
const size = useRecoilValue(iconSizeAtom); const size = useRecoilValue(iconSizeAtom);
const color = useRecoilValue(iconColorAtom); const color = useRecoilValue(iconColorAtom);
const iconProps: IconProps = { weight, color, size }; const query = useRecoilValue(searchQueryAtom);
const isDark = TinyColor(color).isLight();
const { width } = useWindowSize(); const { width } = useWindowSize();
const spans = Math.floor((width - 32) / 172); const spans = Math.floor(Math.min(width - 32, 1120) / 168);
const filteredQueryResults = useRecoilValue(filteredQueryResultsSelector); const filteredQueryResults = useRecoilValue(filteredQueryResultsSelector);
@@ -50,23 +50,30 @@ const IconGridAnimated: React.FC<IconGridProps> = () => {
); );
return ( return (
<IconContext.Provider value={{ weight, size, color, mirrored: false }}>
<div
className="grid-container"
style={{ backgroundColor: isDark ? "#35313D" : "" }}
>
<motion.div <motion.div
className="grid" className="grid"
initial="hidden" initial="hidden"
animate={controls} animate={controls}
variants={{}} variants={{}}
> >
{filteredQueryResults.map((iconEntry, i) => ( {filteredQueryResults.map((iconEntry, index) => (
<GridItem <GridItem
key={i} key={index}
index={i} index={index}
spans={spans} spans={spans}
isDark={isDark}
{...iconEntry} {...iconEntry}
{...iconProps}
originOffset={originOffset} originOffset={originOffset}
/> />
))} ))}
</motion.div> </motion.div>
</div>
</IconContext.Provider>
); );
}; };

View File

@@ -2,6 +2,7 @@ import React, {
useRef, useRef,
useLayoutEffect, useLayoutEffect,
useEffect, useEffect,
useMemo,
MutableRefObject, MutableRefObject,
} from "react"; } from "react";
import { useRecoilState } from "recoil"; import { useRecoilState } from "recoil";
@@ -9,15 +10,22 @@ import { motion, AnimatePresence } from "framer-motion";
import { iconPreviewOpenAtom } from "../../state/atoms"; import { iconPreviewOpenAtom } from "../../state/atoms";
import { IconProps, Icon } from "phosphor-react"; import { IconProps, Icon } from "phosphor-react";
import InfoPanel from "./InfoPanel";
interface IconGridItemProps extends IconProps { interface IconGridItemProps extends IconProps {
index: number; index: number;
spans: number;
isDark: boolean;
name: string; name: string;
Icon: Icon; Icon: Icon;
originOffset: MutableRefObject<{ top: number; left: number }>; originOffset: MutableRefObject<{ top: number; left: number }>;
spans: number;
} }
const whileTap = { boxShadow: "0 0 0 4px rgb(73, 70, 80)" };
const transition = { duration: 0.2 };
const originIndex = 0;
const delayPerPixel = 0.0004;
const itemVariants = { const itemVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: (delayRef: any) => ({ visible: (delayRef: any) => ({
@@ -25,30 +33,24 @@ const itemVariants = {
transition: { delay: delayRef.current }, transition: { delay: delayRef.current },
}), }),
}; };
const whileHover = { boxShadow: "0 0 0 2px rgb(0, 0, 0)" };
const whileTap = { boxShadow: "0 0 0 4px rgb(139, 0, 139)" };
const transition = { duration: 0.2 };
const originIndex = 0;
const delayPerPixel = 0.0004;
const infoVariants = {
open: { opacity: 1, height: 176, marginTop: 4, marginBottom: 4, padding: 16 },
collapsed: {
opacity: 0,
height: 0,
marginTop: 0,
marginBottom: 0,
padding: 0,
},
};
const IconGridItem: React.FC<IconGridItemProps> = (props) => { const IconGridItem: React.FC<IconGridItemProps> = (props) => {
const { index, spans, originOffset, name, Icon, ...iconProps } = props; const { index, isDark, originOffset, name, Icon } = props;
const [open, setOpen] = useRecoilState(iconPreviewOpenAtom); const [open, setOpen] = useRecoilState(iconPreviewOpenAtom);
const isOpen = open === name;
const delayRef = useRef<number>(0); const delayRef = useRef<number>(0);
const offset = useRef({ top: 0, left: 0 }); const offset = useRef({ top: 0, left: 0 });
const ref = useRef<any>(); const ref = useRef<any>();
const handleOpen = () => setOpen(isOpen ? false : name);
const whileHover = useMemo(
() => ({
backgroundColor: isDark ? "rgb(73, 70, 80)" : "rgb(246, 242, 243)",
}),
[isDark]
);
// The measurement for all elements happens in the layoutEffect cycle // The measurement for all elements happens in the layoutEffect cycle
// This ensures that when we calculate distance in the effect cycle // This ensures that when we calculate distance in the effect cycle
// all elements have already been measured // all elements have already been measured
@@ -64,67 +66,44 @@ const IconGridItem: React.FC<IconGridItemProps> = (props) => {
if (index === originIndex) { if (index === originIndex) {
originOffset.current = offset.current; originOffset.current = offset.current;
} }
}, []); }, [index, originOffset]);
useEffect(() => { useEffect(() => {
const dx = Math.abs(offset.current.left - originOffset.current.left); const dx = Math.abs(offset.current.left - originOffset.current.left);
const dy = Math.abs(offset.current.top - originOffset.current.top); const dy = Math.abs(offset.current.top - originOffset.current.top);
const d = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)); const d = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
delayRef.current = d * delayPerPixel; delayRef.current = d * delayPerPixel;
}, []); }, [originOffset]);
return ( return (
<> <>
<motion.div <motion.div
className="grid-item" className="grid-item"
ref={ref}
style={{ order: index }}
custom={delayRef}
key={name} key={name}
ref={ref}
tabIndex={0}
// onFocus={console.log}
style={{
order: index,
backgroundColor: isDark
? "rgba(73, 70, 80, 0)"
: "rgba(246, 242, 243, 0)",
}}
custom={delayRef}
whileHover={whileHover} whileHover={whileHover}
whileTap={whileTap} whileTap={whileTap}
transition={transition} transition={transition}
variants={itemVariants} variants={itemVariants}
onClick={() => onKeyPress={(e) => e.key === "Enter" && handleOpen()}
setOpen((openName) => (name === openName ? false : name)) onClick={handleOpen}
}
> >
<Icon {...iconProps} /> <Icon />
<p>{name}</p> <p>{name}</p>
</motion.div> </motion.div>
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{open === name && <InfoPanel {...props} />} {isOpen && <InfoPanel {...props} />}
</AnimatePresence> </AnimatePresence>
</> </>
); );
}; };
const InfoPanel: React.FC<IconGridItemProps> = (props) => {
const { index, spans, name, Icon, color, weight } = props;
return (
<motion.section
className="info-box"
animate="open"
exit="collapsed"
variants={infoVariants}
style={{ order: index + (spans - (index % spans)) }}
>
<div style={{ height: "100%" }}>
<Icon color={color} weight={weight} size={128} />
<p>{name}</p>
</div>
<div style={{ flex: 1, padding: 32 }}>
HTML
<pre>{`<i class="ph-${name}${
weight === "regular" ? "" : `-${weight}`
}"></i>`}</pre>
React
<pre>{`<${Icon.displayName} ${
weight === "regular" ? "" : `weight="${weight}"`
}/>`}</pre>
</div>
</motion.section>
);
};
export default IconGridItem; export default IconGridItem;

View File

@@ -0,0 +1,134 @@
import React, { useRef } from "react";
import { useRecoilValue } from "recoil";
import { motion } from "framer-motion";
import { saveAs } from "file-saver";
import { styleQueryAtom, iconSizeAtom, iconColorAtom } from "../../state/atoms";
import { Icon, ArrowUpRightCircle, Copy } from "phosphor-react";
const infoVariants = {
open: {
opacity: 1,
height: 396,
margin: 4,
// transition: { stiffness: 600, damping: 32, duration: 0.2 },
},
collapsed: {
opacity: 0,
height: 0,
margin: 0,
// transition: { stiffness: 600, damping: 32, duration: 0.2 },
},
};
interface InfoPanelProps {
index: number;
spans: number;
isDark: boolean;
name: string;
Icon: Icon;
}
const InfoPanel: React.FC<InfoPanelProps> = (props) => {
const { index, spans, isDark, name, Icon } = props;
const weight = useRecoilValue(styleQueryAtom);
const size = useRecoilValue(iconSizeAtom);
const color = useRecoilValue(iconColorAtom);
const ref = useRef<SVGSVGElement>(null);
const htmlString = `<i class="ph-${name}${
weight === "regular" ? "" : `-${weight}`
}"></i>`;
const reactString = `<${Icon.displayName} size={${size}} color="${color}" ${
weight === "regular" ? "" : `weight="${weight}" `
}/>`;
const handleCopySnippet = (data: string) => {
data && navigator.clipboard.writeText(data);
};
const handleDownloadSVG = () => {
if (!ref.current?.outerHTML) return;
const blob = new Blob([ref.current.outerHTML]);
saveAs(blob, `${name}.svg`);
};
const handleCopySVG = () => {
ref.current && navigator.clipboard.writeText(ref.current.outerHTML);
};
return (
<motion.section
className="info-box"
animate="open"
exit="collapsed"
variants={infoVariants}
style={{
order: index + (spans - (index % spans)),
backgroundColor: isDark ? "rgb(73, 70, 80)" : "rgb(246, 242, 243)",
color: isDark ? "white" : "black",
}}
>
<div className="icon-preview">
<div>
<Icon ref={ref} color={color} weight={weight} size={192} />
<p>{name}</p>
</div>
</div>
<div className="icon-usage">
<div className="snippet">
HTML/CSS
<pre style={{ color: "black" }}>
{htmlString}
<button
title="Copy snippet"
onClick={() => handleCopySnippet(htmlString)}
>
<Copy size={24} color="currentColor" weight="regular" />
</button>
</pre>
</div>
<div className="snippet">
React
<pre style={{ color: "black" }}>
{reactString}
<button
title="Copy snippet"
onClick={() => handleCopySnippet(reactString)}
>
<Copy size={24} color="currentColor" weight="regular" />
</button>
</pre>
</div>
<div className="button-row">
<button
style={{ color: isDark ? "white" : "black" }}
onClick={handleDownloadSVG}
>
<ArrowUpRightCircle
size={32}
style={{ marginRight: 8 }}
color="currentColor"
weight="regular"
/>{" "}
Download SVG
</button>
<button
style={{ color: isDark ? "white" : "black" }}
onClick={handleCopySVG}
>
<Copy
size={32}
style={{ marginRight: 8 }}
color="currentColor"
weight="regular"
/>{" "}
Copy SVG
</button>
</div>
</div>
</motion.section>
);
};
export default InfoPanel;