feat(fase-5.10): /lists/[id] row redesign — buttons + swipe-toggle + overlay

Replaces the checkbox/hover-delete model with an explicit gesture+overlay
flow on /lists/[id].

Row anatomy (new)
  [drag-handle]  [name as button]  [qty stepper  − N +]
  - No checkbox in normal mode.
  - Checked items render with strikethrough + muted text.
  - Drag handle always visible on mobile; revealed on hover on desktop
    (md:opacity-0 md:group-hover:opacity-100). The handle owns the drag
    (pointerdown → dragEnabled=true), so row swipes + taps keep working.

Swipe gestures (symmetric toggle, no delete)
  unchecked + swipe RIGHT → mark checked (green success reveal)
  checked   + swipe LEFT  → mark unchecked (neutral reveal)
  opposite direction for each state is a no-op (snaps back).
  SWIPE_COMMIT_THRESHOLD=120 (was 200 for delete); REVEAL_WIDTH=96.
  Symmetry means undo is the inverse swipe — no undoQueue on toggles.

Double-tap overlay (name + delete + confirm)
  Short tap: no-op. Two taps within DOUBLE_TAP_WINDOW_MS (300 ms) open a
  full-screen dialog (bottom sheet on mobile) with:
    - X close button (top-right)
    - name input
    - Delete button (red) → scheduleUndoable → UndoToast
    - Confirm button (primary) → updateItem({ name })
  Click outside / Escape / X all dismiss without saving.

Tests
  items.test.ts
    D-03 rewritten: double-tap opens overlay, value matches, X closes.
    D-04 rewritten: overlay Delete removes the row; toast locator scoped
      out of itemRow so it doesn't shadow the assertion.
  realtime.test.ts
    R-E-02 updated: Ana renames via overlay → Borja sees new name over
      Realtime UPDATE. (The check/uncheck path is now gestural only and
      chromium touch emulation is too flaky to drive it in E2E; Vitest
      integration R-02 still covers the UPDATE-row-payload invariant.)
  mobile-swipe-delete.test.ts stays describe.skip — will be renamed and
  rewritten for the new semantics once WebKit install lands in CI.

i18n
  list_qty_decrease, list_qty_increase, list_reorder_handle,
  list_edit_item, list_confirm, list_close — en/es.

Verification
  just test-e2e → 40 passed, 2 skipped (same as pre-change).
  Type-check clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 19:20:55 +02:00
parent d14d6cd5ab
commit 1cb408acc2
5 changed files with 319 additions and 234 deletions

View File

@@ -35,7 +35,8 @@
Trash2,
MoreHorizontal,
RotateCcw,
Check
Check,
X
} from 'lucide-svelte';
import * as m from '$lib/paraglide/messages';
@@ -61,25 +62,35 @@
let suggestions = $state<Awaited<ReturnType<typeof fetchSuggestions>>>([]);
let suggestionsTimer: ReturnType<typeof setTimeout>;
// Inline edit
let editingId = $state<string | null>(null);
let editName = $state('');
let editQty = $state<number | null>(null);
// Double-tap edit overlay (replaces inline edit)
let overlayItem = $state<ShoppingItem | null>(null);
let overlayName = $state('');
let lastTapById = new Map<string, number>();
const DOUBLE_TAP_WINDOW_MS = 300;
// Swipe-to-delete (touch only)
// Symmetric toggle swipe — replaces the old swipe-to-delete.
// Unchecked + swipe right → check. Checked + swipe left → uncheck.
// The opposite direction for each state is a no-op (snaps back).
let swipeOffsets = $state<Record<string, number>>({});
let openSwipeId = $state<string | null>(null);
let swipeStartX = 0;
let swipeStartY = 0;
let swipeHorizontal = false;
// Red zone peek — width revealed when resting in "open" state.
const SWIPE_MAX = 96;
// Drag distance required to latch the red zone open (instead of snapping back).
const SWIPE_THRESHOLD = 48;
// Drag distance (negative, i.e. leftward) past which a full-swipe commit fires.
// Mobile cards are ~375px wide; 200px ≈ "more than half" which matches the
// iOS-style commit gesture the mockups imply.
const SWIPE_COMMIT_THRESHOLD = 200;
const SWIPE_REVEAL_WIDTH = 96;
const SWIPE_COMMIT_THRESHOLD = 120;
// Drag handles: the dndzone is disabled by default; pressing the handle
// flips it to enabled for the duration of that interaction so swipes on
// the rest of the row don't trigger drag, and vice versa.
let dragEnabled = $state(false);
function enableDragFromHandle(e: PointerEvent) {
e.stopPropagation();
dragEnabled = true;
}
function onDragEnd() {
dragEnabled = false;
}
// List action menu
let showMenu = $state(false);
@@ -283,48 +294,63 @@
await updateItem(item.id, { quantity: next });
}
// ── Inline edit ────────────────────────────────────────────────────────────
// ── Edit overlay (double-tap) ──────────────────────────────────────────────
function startEdit(item: ShoppingItem) {
// Don't start edit while swiped open
if (openSwipeId === item.id) return;
editingId = item.id;
editName = item.name;
editQty = item.quantity;
function onRowTap(item: ShoppingItem) {
// Single tap: no-op. Double-tap (two presses within DOUBLE_TAP_WINDOW_MS)
// opens the edit overlay.
const now = Date.now();
const last = lastTapById.get(item.id) ?? 0;
if (now - last <= DOUBLE_TAP_WINDOW_MS) {
lastTapById.delete(item.id);
openOverlay(item);
} else {
lastTapById.set(item.id, now);
}
}
async function commitEdit(item: ShoppingItem) {
if (editingId !== item.id) return;
editingId = null;
const name = editName.trim();
if (!name) return;
if (name === item.name && editQty === item.quantity) return;
items = items.map((i) =>
i.id === item.id ? { ...i, name, quantity: editQty } : i
);
await updateItem(item.id, { name, quantity: editQty });
function openOverlay(item: ShoppingItem) {
overlayItem = item;
overlayName = item.name;
}
function handleEditKeydown(e: KeyboardEvent, item: ShoppingItem) {
if (e.key === 'Enter') commitEdit(item);
if (e.key === 'Escape') editingId = null;
function closeOverlay() {
overlayItem = null;
overlayName = '';
}
// ── Delete ─────────────────────────────────────────────────────────────────
async function overlayConfirm() {
if (!overlayItem) return;
const name = overlayName.trim();
const target = overlayItem;
closeOverlay();
if (!name || name === target.name) return;
items = items.map((i) => (i.id === target.id ? { ...i, name } : i));
await updateItem(target.id, { name });
}
function overlayDelete() {
if (!overlayItem) return;
const id = overlayItem.id;
closeOverlay();
void handleDelete(id);
}
function handleOverlayKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') void overlayConfirm();
if (e.key === 'Escape') closeOverlay();
}
// ── Delete (called from overlay + bulk actions) ────────────────────────────
async function handleDelete(id: string) {
const item = items.find((i) => i.id === id);
if (!item) return;
// Optimistic local removal; undoQueue decides whether to persist.
swipeOffsets[id] = 0;
openSwipeId = null;
const snapshot = { ...item };
items = items.filter((i) => i.id !== id);
scheduleUndoable({
label: m.undo_deleted_item({ name: snapshot.name }),
restore: () => {
// Re-insert at original sort order (stable-ish against concurrent inserts).
items = [...items, snapshot].sort((a, b) => a.sort_order - b.sort_order);
},
commit: async () => {
@@ -333,52 +359,58 @@
});
}
// ── Swipe to reveal delete (touch) ─────────────────────────────────────────
// ── Swipe-toggle (symmetric check / uncheck) ───────────────────────────────
//
// Unchecked item + swipe RIGHT → mark checked (reveals success zone).
// Checked item + swipe LEFT → mark unchecked (reveals neutral zone).
// Opposite direction for each state is a no-op; row snaps back.
function onSwipeStart(e: PointerEvent, id: string) {
function swipeDirectionFor(item: ShoppingItem): 'right' | 'left' {
return item.is_checked ? 'left' : 'right';
}
function onSwipeStart(e: PointerEvent, _id: string) {
if (e.pointerType === 'mouse') return;
swipeStartX = e.clientX;
swipeStartY = e.clientY;
swipeHorizontal = false;
// Close other open swipe
if (openSwipeId && openSwipeId !== id) {
swipeOffsets[openSwipeId] = 0;
openSwipeId = null;
}
}
function onSwipeMove(e: PointerEvent, id: string) {
function onSwipeMove(e: PointerEvent, item: ShoppingItem) {
if (e.pointerType === 'mouse') return;
const dx = e.clientX - swipeStartX;
const dy = e.clientY - swipeStartY;
if (!swipeHorizontal) {
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
if (Math.abs(dy) >= Math.abs(dx)) return; // vertical dominates
if (Math.abs(dy) >= Math.abs(dx)) return;
swipeHorizontal = true;
}
// Allow dragging past SWIPE_MAX (with rubber-banding feel) so the user
// can commit a full-swipe delete.
swipeOffsets[id] = Math.min(0, dx);
// Clamp to the valid direction for this item's state.
const dir = swipeDirectionFor(item);
const clamped = dir === 'right' ? Math.max(0, dx) : Math.min(0, dx);
swipeOffsets[item.id] = clamped;
}
function onSwipeEnd(e: PointerEvent, id: string) {
async function onSwipeEnd(e: PointerEvent, item: ShoppingItem) {
if (e.pointerType === 'mouse') return;
if (!swipeHorizontal) return;
const offset = swipeOffsets[id] ?? 0;
if (offset <= -SWIPE_COMMIT_THRESHOLD) {
// Full-swipe commit → delete with undo toast.
void handleDelete(id);
} else if (offset <= -SWIPE_THRESHOLD) {
swipeOffsets[id] = -SWIPE_MAX;
openSwipeId = id;
} else {
swipeOffsets[id] = 0;
openSwipeId = null;
if (!swipeHorizontal) {
swipeOffsets[item.id] = 0;
return;
}
const offset = swipeOffsets[item.id] ?? 0;
const dir = swipeDirectionFor(item);
const crossed =
dir === 'right'
? offset >= SWIPE_COMMIT_THRESHOLD
: offset <= -SWIPE_COMMIT_THRESHOLD;
swipeOffsets[item.id] = 0;
swipeHorizontal = false;
if (crossed && $currentUser) {
await handleCheck(item);
}
}
// ── Drag & drop reorder ────────────────────────────────────────────────────
@@ -494,160 +526,141 @@
<p class="text-sm text-text-muted">{m.list_items_empty_hint()}</p>
</div>
{:else}
<!-- Unchecked items (drag & droppable) -->
<!-- Unchecked items (drag & droppable via left handle) -->
{#if uncheckedItems.length > 0}
<div
use:dndzone={{ items: uncheckedItems, flipDurationMs, type: 'items' }}
use:dndzone={{
items: uncheckedItems,
flipDurationMs,
type: 'items',
dragDisabled: !dragEnabled
}}
onconsider={handleDndConsider}
onfinalize={handleDndFinalize}
class="px-8"
onfinalize={(e) => {
void handleDndFinalize(e);
onDragEnd();
}}
class="px-4 md:px-8"
>
{#each uncheckedItems as item (item.id)}
<div animate:flip={{ duration: flipDurationMs }} class="relative overflow-hidden">
<!-- Delete zone (behind item, revealed on swipe) -->
{@const offset = swipeOffsets[item.id] ?? 0}
<div
animate:flip={{ duration: flipDurationMs }}
class="group relative overflow-hidden"
>
<!-- Reveal zone (behind row) — green success for check-on-swipe -->
<div
class="absolute inset-y-0 right-0 flex w-[72px] items-center justify-center
bg-red-500 rounded-r-lg"
class="absolute inset-y-0 left-0 flex items-center bg-emerald-500 pl-4 rounded-l-lg text-white"
style="width: {Math.min(SWIPE_REVEAL_WIDTH, Math.abs(offset))}px; opacity: {offset > 0 ? 1 : 0};"
aria-hidden="true"
>
<button
onclick={() => handleDelete(item.id)}
class="flex h-full w-full items-center justify-center text-white"
aria-label={m.list_item_delete_aria()}
>
<Trash2 size={18} strokeWidth={1.5} />
</button>
<Check size={18} strokeWidth={2} />
</div>
<!-- Item row -->
<!-- Item row — button, no checkbox -->
<div
role="listitem"
class="relative flex items-center gap-3 py-3 bg-background touch-pan-y select-none
transition-transform duration-150"
style="transform: translateX({swipeOffsets[item.id] ?? 0}px)"
data-testid="item-row"
class="relative flex items-center gap-2 py-3 bg-background touch-pan-y select-none transition-transform duration-150"
style="transform: translateX({offset}px)"
onpointerdown={(e) => onSwipeStart(e, item.id)}
onpointermove={(e) => onSwipeMove(e, item.id)}
onpointerup={(e) => onSwipeEnd(e, item.id)}
onpointermove={(e) => onSwipeMove(e, item)}
onpointerup={(e) => onSwipeEnd(e, item)}
>
<!-- Drag handle (desktop) -->
<div class="shrink-0 cursor-grab text-text-muted active:cursor-grabbing hidden sm:flex">
<GripVertical size={16} strokeWidth={1.5} />
</div>
<!-- Checkbox -->
<!-- Drag handle (always visible on mobile, reveal on hover on desktop).
Pointerdown enables drag for this interaction only — row
swipes keep working when the user grabs elsewhere. -->
<button
onclick={() => handleCheck(item)}
class="shrink-0 h-5 w-5 rounded border-2 border-slate-300 dark:border-slate-600
flex items-center justify-center hover:border-slate-400 dark:hover:border-slate-500
transition-colors"
aria-label="Toggle item"
type="button"
aria-label={m.list_reorder_handle()}
onpointerdown={enableDragFromHandle}
class="drag-handle shrink-0 cursor-grab text-text-muted touch-none active:cursor-grabbing md:opacity-0 md:group-hover:opacity-100 transition-opacity"
>
<GripVertical size={16} strokeWidth={1.5} />
</button>
{#if editingId === item.id}
<!-- Inline edit form -->
<div class="flex flex-1 items-center gap-2">
<input
type="text"
bind:value={editName}
onblur={() => commitEdit(item)}
onkeydown={(e) => handleEditKeydown(e, item)}
class="flex-1 min-w-0 bg-transparent text-sm text-text-primary outline-none
border-b border-slate-300 dark:border-slate-600 pb-0.5"
/>
<input
type="number"
bind:value={editQty}
onblur={() => commitEdit(item)}
min="0"
placeholder={m.list_qty_label()}
class="w-14 bg-transparent text-sm text-text-secondary outline-none
border-b border-slate-300 dark:border-slate-600 pb-0.5 text-right"
/>
</div>
{:else}
<!-- Item display -->
<button
class="flex-1 min-w-0 text-left"
onclick={() => startEdit(item)}
>
<span class="block truncate text-sm text-slate-800 dark:text-slate-200">
{item.name}
</span>
</button>
<!-- Name as button; single tap does nothing, double-tap opens overlay -->
<button
type="button"
onclick={() => onRowTap(item)}
class="flex-1 min-w-0 text-left"
aria-label={item.name}
>
<span class="block truncate text-sm text-slate-800 dark:text-slate-200">
{item.name}
</span>
</button>
<!-- Quantity stepper -->
<div class="flex shrink-0 items-center gap-1">
<button
onclick={() => adjustQty(item, -1)}
class="flex h-6 w-6 items-center justify-center rounded text-text-muted
hover:bg-black/5 dark:hover:bg-white/5"
aria-label="Decrease quantity"
>
<Minus size={12} strokeWidth={2} />
</button>
<span class="w-7 text-center text-sm tabular-nums text-slate-700 dark:text-slate-300">
{item.quantity ?? 1}
</span>
<button
onclick={() => adjustQty(item, 1)}
class="flex h-6 w-6 items-center justify-center rounded text-text-muted
hover:bg-black/5 dark:hover:bg-white/5"
aria-label="Increase quantity"
>
<Plus size={12} strokeWidth={2} />
</button>
</div>
<!-- Desktop delete button (hover) -->
<!-- Quantity stepper — always visible -->
<div class="flex shrink-0 items-center gap-1">
<button
onclick={() => handleDelete(item.id)}
class="hidden shrink-0 rounded p-1 text-text-muted hover:text-destructive
hover:bg-destructive/10 sm:flex"
aria-label={m.action_delete()}
type="button"
onclick={() => adjustQty(item, -1)}
class="flex h-7 w-7 items-center justify-center rounded text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
aria-label={m.list_qty_decrease()}
>
<Trash2 size={14} strokeWidth={1.5} />
<Minus size={14} strokeWidth={2} />
</button>
{/if}
<span class="w-7 text-center text-sm tabular-nums text-slate-700 dark:text-slate-300">
{item.quantity ?? 1}
</span>
<button
type="button"
onclick={() => adjustQty(item, 1)}
class="flex h-7 w-7 items-center justify-center rounded text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
aria-label={m.list_qty_increase()}
>
<Plus size={14} strokeWidth={2} />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
<!-- Checked items section -->
<!-- Checked items section — separate zone, symmetric swipe-to-uncheck -->
{#if checkedItems.length > 0}
<div class="px-8 mt-4">
<div class="px-4 md:px-8 mt-4">
<p class="mb-2 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-muted">
{m.list_checked()} ({checkedItems.length})
</p>
{#each checkedItems as item (item.id)}
<div role="listitem" class="flex items-center gap-3 py-3 opacity-50">
<!-- Drag handle placeholder -->
<div class="hidden sm:flex shrink-0 w-4"></div>
<!-- Checked checkbox -->
<button
onclick={() => handleCheck(item)}
class="shrink-0 h-5 w-5 rounded border-2 border-slate-400 bg-slate-400
dark:border-slate-500 dark:bg-slate-500
flex items-center justify-center transition-colors"
aria-label="Uncheck item"
{@const offset = swipeOffsets[item.id] ?? 0}
<div class="relative overflow-hidden">
<!-- Reveal zone on the right for checked items (swipe left to reveal) -->
<div
class="absolute inset-y-0 right-0 flex items-center justify-end bg-slate-400 pr-4 rounded-r-lg text-white dark:bg-slate-600"
style="width: {Math.min(SWIPE_REVEAL_WIDTH, Math.abs(offset))}px; opacity: {offset < 0 ? 1 : 0};"
aria-hidden="true"
>
<Check size={12} strokeWidth={2.5} class="text-white dark:text-slate-900" />
</button>
<RotateCcw size={18} strokeWidth={2} />
</div>
<span class="flex-1 min-w-0 truncate text-sm text-text-muted line-through">
{item.name}
</span>
<button
onclick={() => handleDelete(item.id)}
class="shrink-0 rounded p-1 text-text-muted hover:text-destructive
hover:bg-destructive/10"
aria-label={m.action_delete()}
<div
role="listitem"
data-testid="item-row"
class="relative flex items-center gap-2 py-3 bg-background opacity-70 touch-pan-y select-none transition-transform duration-150"
style="transform: translateX({offset}px)"
onpointerdown={(e) => onSwipeStart(e, item.id)}
onpointermove={(e) => onSwipeMove(e, item)}
onpointerup={(e) => onSwipeEnd(e, item)}
>
<Trash2 size={14} strokeWidth={1.5} />
</button>
<div class="shrink-0 w-4"></div>
<button
type="button"
onclick={() => onRowTap(item)}
class="flex-1 min-w-0 text-left"
aria-label={item.name}
>
<span class="block truncate text-sm text-text-muted line-through">
{item.name}
</span>
</button>
<span class="shrink-0 text-xs tabular-nums text-text-muted">
{item.quantity ?? 1}
</span>
</div>
</div>
{/each}
</div>
@@ -655,8 +668,10 @@
{/if}
</div>
<!-- Sticky bottom: suggestions + add form -->
<div class="absolute bottom-0 left-56 right-0 bg-background/80 backdrop-blur-[12px]">
<!-- Sticky bottom: suggestions + add form.
Mobile: inset-x-0 mb-16 (bottom tab bar lives at 0). Desktop: left-56
respects the DesktopSidebar width. -->
<div class="absolute inset-x-0 bottom-0 bg-background/80 backdrop-blur-[12px] mb-16 md:left-56 md:right-0 md:mb-0">
<!-- Suggestions -->
<ItemSuggestions
{suggestions}
@@ -700,4 +715,60 @@
</div>
</div>
</div>
<!-- Double-tap edit overlay -->
{#if overlayItem}
<div
class="fixed inset-0 z-50 flex items-end justify-center bg-black/40 md:items-center"
role="dialog"
aria-modal="true"
aria-label={m.list_edit_item()}
onclick={(e) => {
if (e.target === e.currentTarget) closeOverlay();
}}
onkeydown={(e) => e.key === 'Escape' && closeOverlay()}
tabindex="-1"
>
<div
class="w-full max-w-md rounded-t-2xl bg-surface p-5 shadow-[0px_20px_40px_rgba(15,23,42,0.15)] md:rounded-2xl"
data-testid="edit-overlay"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-base font-semibold text-text-primary">{m.list_edit_item()}</h2>
<button
type="button"
onclick={closeOverlay}
aria-label={m.list_close()}
class="rounded p-1 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5"
>
<X size={18} strokeWidth={1.5} />
</button>
</div>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={overlayName}
onkeydown={handleOverlayKeydown}
placeholder={m.list_item_placeholder()}
autofocus
class="mb-4 w-full rounded-md bg-background px-3 py-2 text-base text-text-primary outline-none ring-1 ring-black/10 focus:ring-slate-900 dark:ring-white/10 dark:focus:ring-slate-100"
/>
<div class="flex items-center justify-between gap-2">
<button
type="button"
onclick={overlayDelete}
class="rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
>
{m.action_delete()}
</button>
<button
type="button"
onclick={overlayConfirm}
class="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900"
>
{m.list_confirm()}
</button>
</div>
</div>
</div>
{/if}
{/if}