feat: mobile experience overhaul — touch targets, calendar mobile layout, swipe gestures
This commit is contained in:
@@ -194,57 +194,142 @@ function ListItemRow({
|
||||
onCommit: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const swipeStart = useRef<number | null>(null);
|
||||
const swipeOrigin = useRef<{ x: number; y: number } | null>(null);
|
||||
const isScrolling = useRef(false);
|
||||
const [swipeX, setSwipeX] = useState(0);
|
||||
|
||||
const THRESHOLD = 80; // px to commit the swipe action
|
||||
const MAX_SWIPE = 100; // px maximum visual displacement
|
||||
|
||||
function handlePointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
swipeOrigin.current = { x: e.clientX, y: e.clientY };
|
||||
isScrolling.current = false;
|
||||
setSwipeX(0);
|
||||
}
|
||||
|
||||
function handlePointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (!swipeOrigin.current) return;
|
||||
const dx = e.clientX - swipeOrigin.current.x;
|
||||
const dy = Math.abs(e.clientY - swipeOrigin.current.y);
|
||||
// If the gesture is primarily vertical, treat as scroll and abort swipe.
|
||||
if (!isScrolling.current && dy > Math.abs(dx) && dy > 8) {
|
||||
isScrolling.current = true;
|
||||
setSwipeX(0);
|
||||
return;
|
||||
}
|
||||
if (isScrolling.current) return;
|
||||
setSwipeX(Math.max(-MAX_SWIPE, Math.min(MAX_SWIPE, dx)));
|
||||
}
|
||||
|
||||
function handlePointerUp(e: React.PointerEvent<HTMLDivElement>) {
|
||||
if (swipeOrigin.current && !isScrolling.current) {
|
||||
const dx = e.clientX - swipeOrigin.current.x;
|
||||
if (dx > THRESHOLD) onToggle(!item.done); // right → complete / uncomplete
|
||||
if (dx < -THRESHOLD) onRemove(); // left → delete
|
||||
}
|
||||
swipeOrigin.current = null;
|
||||
setSwipeX(0);
|
||||
}
|
||||
|
||||
function handlePointerCancel() {
|
||||
swipeOrigin.current = null;
|
||||
setSwipeX(0);
|
||||
}
|
||||
|
||||
// Background tint that deepens as the swipe approaches the threshold.
|
||||
const swipeProgress = Math.abs(swipeX) / MAX_SWIPE;
|
||||
const swipeBg =
|
||||
swipeX > 16
|
||||
? `color-mix(in oklab, var(--ok) ${swipeProgress * 55}%, var(--card))`
|
||||
: swipeX < -16
|
||||
? `color-mix(in oklab, var(--bad) ${swipeProgress * 55}%, var(--card))`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`list-row ${item.done ? "checked" : ""} group/row`}
|
||||
onPointerDown={(event) => {
|
||||
swipeStart.current = event.clientX;
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const start = swipeStart.current;
|
||||
if (start !== null && event.clientX - start < -60) onRemove();
|
||||
swipeStart.current = null;
|
||||
}}
|
||||
className="relative overflow-hidden border-b-[0.5px] last:border-b-0 group/row"
|
||||
style={{ borderColor: "var(--hair)", background: swipeBg, touchAction: "pan-y" }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Complete ${item.text}`}
|
||||
aria-pressed={item.done}
|
||||
className={`checkbox ${item.done ? "on" : ""}`}
|
||||
onClick={() => onToggle(!item.done)}
|
||||
{/* Swipe affordance icons revealed behind the sliding content */}
|
||||
<div
|
||||
className="absolute inset-y-0 left-3 flex items-center"
|
||||
aria-hidden
|
||||
style={{ opacity: Math.max(0, swipeX - 16) / (MAX_SWIPE - 16), color: "var(--ok)" }}
|
||||
>
|
||||
{item.done && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12l5 5L20 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<Input
|
||||
aria-label={`${item.text} text`}
|
||||
className="!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 row-text flex-1 px-0 h-7 text-[13.5px]"
|
||||
value={item.text}
|
||||
onChange={(event) => onEdit(event.target.value)}
|
||||
onBlur={onCommit}
|
||||
/>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`Delete ${item.text}`}
|
||||
onClick={onRemove}
|
||||
className="opacity-0 group-hover/row:opacity-100 transition-opacity"
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12l5 5L20 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
className="absolute inset-y-0 right-3 flex items-center"
|
||||
aria-hidden
|
||||
style={{ opacity: Math.max(0, -swipeX - 16) / (MAX_SWIPE - 16), color: "var(--bad)" }}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Trash2 size={16} />
|
||||
</div>
|
||||
|
||||
{/* Sliding content row */}
|
||||
<div
|
||||
className={`flex items-center gap-3 px-[14px] py-[10px] hover:bg-[var(--shade)] ${item.done ? "checked" : ""}`}
|
||||
style={{
|
||||
transform: `translateX(${swipeX}px)`,
|
||||
transition: swipeX === 0 ? "transform 0.22s ease-out" : "none",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Complete ${item.text}`}
|
||||
aria-pressed={item.done}
|
||||
className={`checkbox ${item.done ? "on" : ""}`}
|
||||
onClick={() => onToggle(!item.done)}
|
||||
>
|
||||
{item.done && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12l5 5L20 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<Input
|
||||
aria-label={`${item.text} text`}
|
||||
className={`!border-0 !bg-transparent !shadow-none focus-visible:!ring-0 row-text flex-1 px-0 h-7 text-[13.5px] ${item.done ? "line-through text-[var(--ink-faint)]" : ""}`}
|
||||
value={item.text}
|
||||
onChange={(event) => onEdit(event.target.value)}
|
||||
onBlur={onCommit}
|
||||
/>
|
||||
{/* Always visible on touch (list-row-delete makes opacity-0 override to 1 on mobile) */}
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`Delete ${item.text}`}
|
||||
onClick={onRemove}
|
||||
className="list-row-delete opacity-0 group-hover/row:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user