|
| 1 | +import { type PointerEvent, useRef, useState } from "react" |
| 2 | + |
| 3 | +import { cva } from "class-variance-authority" |
| 4 | + |
| 5 | +import { Button, type ButtonProps } from "./button" |
| 6 | +import { animate } from "../../utils/animate" |
| 7 | +import { cn } from "../../utils/cn" |
| 8 | +import { measureText } from "../../utils/measure-text" |
| 9 | +import { type ButtonPrimitiveProps } from "../primitive/button-primitive" |
| 10 | + |
| 11 | +const holdButton = cva("overflow-hidden", { |
| 12 | + variants: { |
| 13 | + look: { |
| 14 | + key: "active:bgl-layer-invert/15", |
| 15 | + ghost: "active:bgl-layer/10", |
| 16 | + flat: "active:bgl-layer/10", |
| 17 | + destructive: "active:bg-alert-error/15", |
| 18 | + }, |
| 19 | + }, |
| 20 | +}) |
| 21 | + |
| 22 | +const holdButtonFill = cva( |
| 23 | + "absolute inset-0 z-0 block -translate-x-full [&~*,*:has(~&)]:z-1", |
| 24 | + { |
| 25 | + variants: { |
| 26 | + look: { |
| 27 | + key: "bg-max-invert/50", |
| 28 | + ghost: "bg-max-invert/15", |
| 29 | + flat: "bg-max-invert/15", |
| 30 | + destructive: "bg-alert-error/15", |
| 31 | + }, |
| 32 | + }, |
| 33 | + } |
| 34 | +) |
| 35 | + |
| 36 | +const states = animate.states({ |
| 37 | + idle: { translate: "-100%" }, |
| 38 | + hold: { translate: "0" }, |
| 39 | +}) |
| 40 | +const animateHold = (holdDuration: number) => { |
| 41 | + let anim: ReturnType<typeof animate> | null = null |
| 42 | + |
| 43 | + const start = (span: HTMLElement | null) => { |
| 44 | + if (!span) return |
| 45 | + anim = animate([ |
| 46 | + [span, states.idle, {}], |
| 47 | + [span, states.hold, { duration: holdDuration }], |
| 48 | + [span, states.hold, { duration: holdDuration / 2 }], |
| 49 | + [span, states.idle, { duration: holdDuration / 2 }], |
| 50 | + ]) |
| 51 | + void anim.then(() => (anim = null)) |
| 52 | + } |
| 53 | + |
| 54 | + const cancel = (span: HTMLElement | null) => { |
| 55 | + if (!anim || !span) return |
| 56 | + anim.cancel() |
| 57 | + const { translate } = window.getComputedStyle(span) |
| 58 | + void animate([ |
| 59 | + [span, { translate }], |
| 60 | + [span, states.idle, { duration: 300 }], |
| 61 | + ]) |
| 62 | + } |
| 63 | + |
| 64 | + return { start, cancel } |
| 65 | +} |
| 66 | + |
| 67 | +type DefaultButtonProps = Omit<ButtonProps, keyof ButtonPrimitiveProps> & |
| 68 | + Omit< |
| 69 | + ButtonPrimitiveProps<"button">, |
| 70 | + "asChild" | "href" | "target" | "aria-current" |
| 71 | + > |
| 72 | + |
| 73 | +export type HoldButtonProps = Omit< |
| 74 | + DefaultButtonProps, |
| 75 | + "asChild" | "aria-current" | "look" |
| 76 | +> & { |
| 77 | + look?: Exclude<DefaultButtonProps["look"], "link"> |
| 78 | + holdDuration?: number |
| 79 | + captions: { |
| 80 | + idle: string |
| 81 | + holding: string |
| 82 | + triggered: string |
| 83 | + } |
| 84 | +} |
| 85 | +export const HoldButton = ({ |
| 86 | + holdDuration = 1500, |
| 87 | + onClick, |
| 88 | + onPointerDown, |
| 89 | + onPointerUp, |
| 90 | + className, |
| 91 | + look = "flat", |
| 92 | + captions, |
| 93 | + ...props |
| 94 | +}: HoldButtonProps) => { |
| 95 | + const timeout = useRef<number>(undefined) |
| 96 | + const fillSpan = useRef<HTMLSpanElement>(null) |
| 97 | + const hold = useRef(animateHold(holdDuration)) |
| 98 | + |
| 99 | + const [status, setStatus] = useState<"idle" | "holding" | "triggered">("idle") |
| 100 | + |
| 101 | + const start = (event: PointerEvent<HTMLButtonElement>) => { |
| 102 | + setStatus("holding") |
| 103 | + hold.current.start(fillSpan.current) |
| 104 | + window.clearTimeout(timeout.current) |
| 105 | + timeout.current = window.setTimeout(() => { |
| 106 | + setStatus("triggered") |
| 107 | + timeout.current = window.setTimeout(() => setStatus("idle"), holdDuration) |
| 108 | + onClick?.(event) |
| 109 | + }, holdDuration) |
| 110 | + } |
| 111 | + |
| 112 | + const stop = () => { |
| 113 | + if (status !== "holding") return |
| 114 | + setStatus("idle") |
| 115 | + window.clearTimeout(timeout.current) |
| 116 | + hold.current.cancel(fillSpan.current) |
| 117 | + } |
| 118 | + |
| 119 | + return ( |
| 120 | + <Button |
| 121 | + {...props} |
| 122 | + onClick={() => null} |
| 123 | + className={cn(holdButton({ look }), className)} |
| 124 | + look={look} |
| 125 | + onPointerDown={event => { |
| 126 | + onPointerDown?.(event) |
| 127 | + start(event) |
| 128 | + }} |
| 129 | + onPointerUp={event => { |
| 130 | + onPointerUp?.(event) |
| 131 | + stop() |
| 132 | + }} |
| 133 | + onPointerLeave={event => { |
| 134 | + onPointerUp?.(event) |
| 135 | + stop() |
| 136 | + }} |
| 137 | + > |
| 138 | + <span ref={fillSpan} className={holdButtonFill({ look })} /> |
| 139 | + <div |
| 140 | + ref={div => { |
| 141 | + if (!div) return |
| 142 | + const minWidth = Math.max( |
| 143 | + ...Object.values(captions).map(text => measureText(text, div)) |
| 144 | + ) |
| 145 | + div.style.setProperty("min-width", minWidth + "px") |
| 146 | + }} |
| 147 | + > |
| 148 | + {captions[status]} |
| 149 | + </div> |
| 150 | + </Button> |
| 151 | + ) |
| 152 | +} |
0 commit comments