Skip to content

Commit 06fd485

Browse files
committed
feat(HoldButton): Add button that must be held for destructive actions
1 parent debb7ad commit 06fd485

6 files changed

Lines changed: 255 additions & 8 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite"
2+
import { HoldButton, type HoldButtonProps } from "boring-blocks"
3+
import { Fragment } from "react/jsx-runtime"
4+
5+
import { argType } from "../utils/arg-type"
6+
7+
const titleSides = [
8+
"top",
9+
"bottom",
10+
"left",
11+
"right",
12+
] satisfies HoldButtonProps["titleSide"][]
13+
14+
const kinds = [
15+
"key",
16+
"ghost",
17+
"flat",
18+
"destructive",
19+
] satisfies HoldButtonProps["look"][]
20+
21+
const captions = {
22+
idle: "Idle",
23+
holding: "Holding",
24+
triggered: "Triggered",
25+
}
26+
27+
const meta = {
28+
title: "Buttons/HoldButton",
29+
component: HoldButton,
30+
argTypes: {
31+
ref: argType.disabled(),
32+
captions: argType.disabled(),
33+
disabled: argType.boolean(),
34+
title: argType.string(),
35+
titleSide: argType.enum("select", titleSides),
36+
active: argType.boolean(),
37+
look: argType.enum("select", kinds),
38+
size: argType.enum("radio", ["md", "sm"]),
39+
icon: argType.props.icon,
40+
iconColor: argType.props.iconColor,
41+
42+
holdDuration: argType.number(),
43+
onPointerDown: argType.callback(),
44+
onPointerUp: argType.callback(),
45+
onPointerEnter: argType.callback(),
46+
onPointerLeave: argType.callback(),
47+
48+
onClick: argType.callback(),
49+
onBlur: argType.callback(),
50+
onFocus: argType.callback(),
51+
},
52+
args: {
53+
holdDuration: 1500,
54+
captions,
55+
look: "flat",
56+
size: "md",
57+
active: false,
58+
disabled: false,
59+
icon: argType.props.icon.default,
60+
iconColor: "current",
61+
},
62+
render: args => <HoldButton key={args.titleSide} {...args} />,
63+
} satisfies Meta<typeof HoldButton>
64+
65+
export default meta
66+
type Story = StoryObj<typeof meta>
67+
68+
export const Default: Story = {
69+
name: "HoldButton",
70+
render: args => (
71+
<div className="inline-grid grid-cols-[auto_auto_auto_auto] items-center gap-1">
72+
{kinds.map(look => (
73+
<Fragment key={look}>
74+
<span className="mr-2 text-text-gentle">{look}:</span>
75+
<HoldButton {...args} look={look} />
76+
<HoldButton {...args} look={look} active />
77+
<HoldButton {...args} look={look} disabled />
78+
</Fragment>
79+
))}
80+
</div>
81+
),
82+
}

src/components/primitive/button-primitive.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ type AnchorHtmlProps = HTMLProps<HTMLAnchorElement>
1414
type SharedKeys =
1515
| "ref"
1616
| "aria-current"
17-
| "onMouseDown"
18-
| "onMouseUp"
17+
| "onPointerDown"
18+
| "onPointerUp"
19+
| "onPointerLeave"
20+
| "onPointerEnter"
1921
| "onFocus"
2022
| "onBlur"
2123

src/components/ui/hold-button.tsx

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from "./components/ui/context-info"
77
export * from "./components/ui/date-input"
88
export * from "./components/ui/dialog"
99
export * from "./components/ui/divider"
10+
export * from "./components/ui/hold-button"
1011
export * from "./components/ui/file-input"
1112
export * from "./components/ui/icon"
1213
export * from "./components/ui/icon-button"

src/utils/cn.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,22 @@
11
import {
22
mergeConfigs,
33
extendTailwindMerge,
4-
validators,
54
type Config,
65
twJoin,
76
type ClassNameValue,
87
} from "tailwind-merge"
98

10-
const getAny = () => [validators.isAny] as const
11-
129
const withBgl = <ClassGroupIds extends string, ThemeGroupIds extends string>(
1310
prevConfig: Config<ClassGroupIds, ThemeGroupIds>
1411
) =>
1512
mergeConfigs(prevConfig, {
1613
extend: {
1714
classGroups: {
18-
"bgl-base": [{ "bgl-base": getAny() }],
19-
"bgl-layer": [{ "bgl-layer": getAny() }],
15+
"bgl-base": [{ bgl: [(key: string) => key.startsWith("base")] }],
16+
"bgl-layer": [{ bgl: [(key: string) => key.startsWith("layer")] }],
2017
},
2118
conflictingClassGroups: {
22-
"bg-color": ["bgl-base", "bgl-layer", "bgl"],
19+
"bg-color": ["bgl-base", "bgl-layer"],
2320
},
2421
},
2522
})

src/utils/measure-text.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export const measureText = (text: string, reference: HTMLElement) => {
2+
const element = document.createElement("span")
3+
element.style.font = window.getComputedStyle(reference).font
4+
element.style.display = "inline"
5+
element.style.position = "absolute"
6+
element.style.opacity = "0"
7+
element.style.whiteSpace = "nowrap"
8+
element.innerHTML = text
9+
document.body.appendChild(element)
10+
const width = element.offsetWidth
11+
document.body.removeChild(element)
12+
return width
13+
}

0 commit comments

Comments
 (0)