|
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; |