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:
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user