Skip to content

Instantly share code, notes, and snippets.

@55Cancri
Last active May 2, 2023 13:41
Show Gist options
  • Save 55Cancri/2513329d894e8b6179cda0ab6eb8b34b to your computer and use it in GitHub Desktop.
Save 55Cancri/2513329d894e8b6179cda0ab6eb8b34b to your computer and use it in GitHub Desktop.
atoms
import React from "react";
import { motion } from "framer-motion";
import { BeatLoader } from "react-spinners";
import { faCopy } from "@fortawesome/free-solid-svg-icons";
import { toast } from "react-hot-toast";
import Icon from "atoms/icon";
import useStopwatch from "hooks/use-stopwatch";
import useDebounceFn from "hooks/use-debounceFn";
import * as theme from "modules/theme";
import * as Dom from "modules/dom";
export const variants = {
google: "google",
ghost: "ghost",
video: "video",
naked: "naked",
copy: "copy",
};
const Stitch = theme.styled(motion.button, {
// base styles
cursor: "pointer",
paddingBlock: 4,
paddingInline: 10,
borderRadius: 4,
border: "1px solid transparent",
fontWeight: "normal",
color: "$primary",
background: "$blue9",
"&:focus": {
outline: "2px solid $blue8",
},
"&:disabled": {
opacity: 0.6,
bg: theme.dark.gray7,
cursor: "not-allowed",
},
variants: {
variant: {
[variants.naked]: {
all: "unset",
display: "grid",
padding: 0,
border: "transparent",
background: "transparent",
},
[variants.copy]: {
all: "unset",
display: "grid",
gridTemplateColumns: "max-content max-content",
padding: 0,
border: "transparent",
background: "transparent",
},
[variants.google]: {
// primary styles
["--aura-size"]: ".4rem",
["--border-size"]: 0,
display: "grid",
placeItems: "center",
placeContent: "center",
padding: "4px 14px",
margin: "var(--aura-size)",
border: 0,
borderRadius: ".6ch",
textShadow: "0 1px 0 hsl(210 11% 15%)",
boxShadow:
"0 3px 5px -2px hsl(220 3% 15% / 4%),0 7px 14px -5px hsl(220 3% 15% / 6%), 0 0px hsl(210 14% 89%), 0 0 0 var(--border-size) hsl(210 10% 71% / 25%)",
background: "#1e1e1e",
cursor: "pointer",
transition: "145ms all ease",
["&:not(:active):hover"]: {
"--border-size": "var(--aura-size)",
},
["&:not(:active):focus-visible"]: {
outlineOffset: 5,
},
},
["ghost"]: {
display: "grid",
position: "relative",
placeItems: "center",
width: "max-content",
margin: 2, // to offset 2px blue outline on focus
border: 0,
borderRadius: 5,
background: 0,
isolation: "isolate",
transition: "145ms all ease",
zIndex: 0,
["&:hover"]: {
border: 0,
},
["&::before"]: {
content: '""',
position: "absolute",
width: "100%",
height: "100%",
borderRadius: 5,
// borderRadius: (props: any) => props.borderRadius ?? 5,
background: theme.dark.gray7,
transform: "scale(0)",
transition: "145ms all ease",
zIndex: -2,
},
["&:hover::before"]: {
transform: "scale(1)",
},
["&:focus::before"]: {
transform: "scale(1)",
},
},
// secondary styles
[variants.video]: {
display: "grid",
position: "relative",
placeItems: "center",
width: "max-content",
border: 0,
borderRadius: 5,
background: 0,
isolation: "isolate",
transition: "145ms all ease",
zIndex: 0,
["&:hover"]: {
border: 0,
color: "hsl(0, 0%, 75%)",
},
["&::before"]: {
content: '""',
position: "absolute",
width: "100%",
height: "100%",
borderRadius: 5,
background: theme.colors.blackA9,
transform: "scale(0)",
transition: "145ms all ease",
zIndex: -2,
},
["&:hover::before"]: {
transform: "scale(1)",
},
["&:focus::before"]: {
transform: "scale(1)",
},
// secondary styles
},
},
},
});
type Custom = {
copyText?: string;
before?: theme.Css;
active?: theme.Css;
activeHover?: theme.Css;
focus?: theme.Css;
variant?: keyof typeof variants;
isLoading?: boolean;
loadingText?: React.ReactNode;
loadingIcon?: React.ReactNode;
children?: React.ReactNode;
/** only call longpress fn once */
longPressOnce?: boolean;
/** the number of ms needed to trigger a longpress */
longPressThreshold?: number;
onLongPress?: (
e:
| React.MouseEvent<HTMLButtonElement>
| React.TouchEvent<HTMLButtonElement>,
pressDuration: number
) => void;
};
export type Props = theme.GetProps<typeof Stitch, Custom>;
export type ButtonProps = Props;
const Button = React.forwardRef<HTMLButtonElement, Props>((props, ref) => {
const { children, onClick, onLongPress, ...rest } = props;
const [styles, args] = theme.extractStyles(rest);
// ::::: number of clicks :::::
// track click count on both browser and mobile using e.detail
const [clickCount, setClickCount] = React.useState(0);
useDebounceFn(clickCount, () => setClickCount(0), 400);
// ::::: longpress :::::
const evt = React.useRef<any | null>(null);
const longPressRef = React.useRef<any>();
const clickRef = React.useRef<any>();
const stopwatch = useStopwatch();
const [touched, setTouched] = React.useState(false);
const [longPressedOnce, setLongPressedOnce] = React.useState(false);
const pressDurationRef = React.useRef(0);
pressDurationRef.current = stopwatch.time;
const longPressThreshold = props.longPressThreshold ?? 500;
React.useEffect(() => {
longPressRef.current = onLongPress;
clickRef.current = onClick;
}, [onLongPress, onClick]);
React.useEffect(() => {
const pressDuration = pressDurationRef.current;
if (touched) {
stopwatch.start();
} else {
if (pressDuration && pressDuration < 500) {
const updatedClickCount = clickCount + 1;
setClickCount(updatedClickCount);
evt.current.detail = updatedClickCount;
clickRef.current?.(evt.current);
}
stopwatch.reset();
}
}, [touched]);
React.useEffect(() => {
if (!longPressRef.current) return;
const pressDuration = pressDurationRef.current;
if (pressDuration > longPressThreshold) {
if (props.longPressOnce) {
if (longPressedOnce || !touched) return;
longPressRef.current(evt, pressDuration);
setLongPressedOnce(true);
} else {
longPressRef.current(evt, pressDuration);
}
}
}, [pressDurationRef.current, longPressThreshold, longPressedOnce, touched]);
const isMobile = window.matchMedia("(max-width: 767px)").matches;
const withStyles = {
transition: ".15s all ease-in-out",
margin: 3,
["&:not(:active):focus-visible"]: { outlineOffset: 3 },
...styles,
} as theme.Css;
if (props.variant === "copy") {
return (
<Stitch
{...args}
ref={ref}
css={withStyles}
onContextMenu={(e) => e.preventDefault()}
onTap={async (e, info) => {
props.onTap?.(e, info);
const copyText = props.copyText ?? Dom.getNodeText(children);
await navigator.clipboard.writeText(String(copyText));
toast(`Copied ${copyText}`);
}}
>
{children}
<Icon icon={faCopy} />
</Stitch>
);
}
return (
<Stitch
{...args}
ref={ref}
css={withStyles}
onContextMenu={(e) => e.preventDefault()}
{...(isMobile
? {
onTouchStart: (e) => {
evt.current = e;
setTouched(true);
props.onTouchStart?.(e);
},
onTouchEnd: (e) => {
setLongPressedOnce(false);
setTouched(false);
props.onTouchEnd?.(e);
},
}
: {
onMouseDown: (e) => {
// globally store the click event
evt.current = e;
setTouched(true);
props.onMouseDown?.(e);
},
onMouseUp: (e) => {
setLongPressedOnce(false);
setTouched(false);
props.onMouseUp?.(e);
},
onMouseLeave: (e) => {
setLongPressedOnce(false);
setTouched(false);
props.onMouseLeave?.(e);
},
})}
>
{props.isLoading
? props.loadingText
? props.loadingText
: null
: children}
{props.isLoading &&
!props.loadingText &&
(props.loadingIcon ?? (
<BeatLoader size={8} color={theme.colors.blue12 as any} />
))}
</Stitch>
);
});
Button.displayName = "Button";
export default Button as (props: Props) => JSX.Element;
import React from "react";
/**
* Debounce a function
* @param pulse
* @param fn
* @param delay
*/
export default function useDebounceFn<T = unknown>(
pulse: T,
fn: () => void,
delay: number = 500
) {
const callbackRef = React.useRef(fn);
React.useLayoutEffect(() => {
callbackRef.current = fn;
});
// reset the timer to call the fn everytime the pulse value changes
// for example, everytime a click happens within 200 ms, increment clicks
// and reset the timer without calling fn. If a click does not happen within
// 200ms, call the fn, which could be to reset the click counts back to 0
React.useEffect(() => {
const timerId = setTimeout(fn, delay);
return () => clearTimeout(timerId);
}, [pulse, delay]);
}
import React from "react";
export default function useStopwatch() {
const [time, setTime] = React.useState(0);
const [active, setActive] = React.useState(false);
React.useEffect(() => {
let interval: NodeJS.Timer | null = null;
if (active) {
interval = setInterval(() => {
setTime((prevTime) => prevTime + 10);
}, 10);
} else {
clearInterval(interval!);
}
return () => clearInterval(interval!);
}, [active]);
const start = () => setActive(true);
const reset = () => {
setActive(false);
setTime(0);
};
return { time, start, reset };
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment