Make the pickup box button hold-to-open and hold-to-close

Adds a reusable HoldButton that fires after an 800ms hold (pointer or
Space/Enter) with a fill showing progress, and cancels on early release.
The pickup box button now uses it, with extra space above it on mobile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 11:35:05 +02:00
co-authored by Claude Opus 5.5
parent edd34a00a7
commit 3efde72524
4 changed files with 112 additions and 6 deletions
@@ -315,6 +315,7 @@
@media (max-width: $breakpoint-tablet) { @media (max-width: $breakpoint-tablet) {
flex-basis: 100%; flex-basis: 100%;
justify-content: center; justify-content: center;
margin-top: $space-2;
margin-bottom: $space-2; margin-bottom: $space-2;
} }
} }
+7 -6
View File
@@ -2,6 +2,7 @@ import { Link, useNavigate, useRouter } from '@tanstack/react-router'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { useState } from 'react' import { useState } from 'react'
import { HoldButton } from '#/components/ui/HoldButton.tsx'
import type { OrderStatus } from '#/db/schema.ts' import type { OrderStatus } from '#/db/schema.ts'
import type { OrderItemStatusView } from '#/domain/ordering.ts' import type { OrderItemStatusView } from '#/domain/ordering.ts'
import { useBasket } from '#/lib/basket.tsx' import { useBasket } from '#/lib/basket.tsx'
@@ -239,6 +240,7 @@ export const OrderStatusView = ({
paddingLeft: 0, paddingLeft: 0,
paddingRight: 0, paddingRight: 0,
borderWidth: 0, borderWidth: 0,
marginTop: 0,
marginRight: -8, marginRight: -8,
marginBottom: -8, marginBottom: -8,
transition: { transition: {
@@ -410,21 +412,20 @@ export const OrderStatusView = ({
<div className={styles.actions}> <div className={styles.actions}>
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{item.pickupBox && ( {item.pickupBox && (
<motion.button <HoldButton
key="pickupBox" key="pickupBox"
type="button"
className={styles.pickupButton} className={styles.pickupButton}
disabled={pendingItemId === item.id} disabled={pendingItemId === item.id}
onClick={() => onHold={() =>
void runAction(item.id, () => togglePickupBox(item)) void runAction(item.id, () => togglePickupBox(item))
} }
exit={pickupButtonExit} exit={pickupButtonExit}
> >
<LockIcon open={item.pickupBox.isOpen} /> <LockIcon open={item.pickupBox.isOpen} />
{item.pickupBox.isOpen {item.pickupBox.isOpen
? `Close box ${item.pickupBox.number}` ? `Hold to close box ${item.pickupBox.number}`
: `Open box ${item.pickupBox.number}`} : `Hold to open box ${item.pickupBox.number}`}
</motion.button> </HoldButton>
)} )}
</AnimatePresence> </AnimatePresence>
<button <button
+25
View File
@@ -0,0 +1,25 @@
.holdButton {
position: relative;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
touch-action: manipulation;
}
.fill {
position: absolute;
inset: 0;
background: currentColor;
opacity: 0.2;
transform: scaleX(0);
transform-origin: left;
pointer-events: none;
}
.content {
position: relative;
display: inline-flex;
align-items: center;
gap: inherit;
}
+79
View File
@@ -0,0 +1,79 @@
import { motion } from 'framer-motion'
import type { HTMLMotionProps } from 'framer-motion'
import { useState } from 'react'
import type { KeyboardEvent, ReactNode } from 'react'
import styles from './HoldButton.module.scss'
type HoldButtonProps = Omit<
HTMLMotionProps<'button'>,
'onClick' | 'children' | 'type'
> & {
onHold: () => void
holdMs?: number
children: ReactNode
}
const isHoldKey = (event: KeyboardEvent) =>
event.key === ' ' || event.key === 'Enter'
// Fires only after being held down for `holdMs`; letting go early, or
// sliding off the button, cancels. Space and Enter work the same way.
export const HoldButton = ({
onHold,
holdMs = 800,
children,
className,
disabled,
...rest
}: HoldButtonProps) => {
const [holding, setHolding] = useState(false)
const start = () => {
if (!disabled) setHolding(true)
}
const stop = () => setHolding(false)
return (
<motion.button
{...rest}
type="button"
disabled={disabled}
className={[styles.holdButton, className].filter(Boolean).join(' ')}
onPointerDown={(event) => {
if (event.button === 0) start()
}}
onPointerUp={stop}
onPointerLeave={stop}
onPointerCancel={stop}
onKeyDown={(event) => {
if (!isHoldKey(event)) return
event.preventDefault()
if (!event.repeat) start()
}}
onKeyUp={(event) => {
if (isHoldKey(event)) stop()
}}
onBlur={stop}
onContextMenu={(event) => event.preventDefault()}
>
<motion.span
className={styles.fill}
aria-hidden="true"
initial={false}
animate={{ scaleX: holding ? 1 : 0 }}
transition={
holding
? { duration: holdMs / 1000, ease: 'linear' }
: { duration: 0.2, ease: 'easeOut' }
}
onAnimationComplete={() => {
if (!holding) return
setHolding(false)
onHold()
}}
/>
<span className={styles.content}>{children}</span>
</motion.button>
)
}