From 1cb408acc232b5952166e6933397ace12f033903 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 13 Apr 2026 19:20:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(fase-5.10):=20/lists/[id]=20row=20redesign?= =?UTF-8?q?=20=E2=80=94=20buttons=20+=20swipe-toggle=20+=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/messages/en.json | 8 +- apps/web/messages/es.json | 8 +- .../src/routes/(app)/lists/[id]/+page.svelte | 445 ++++++++++-------- apps/web/tests/e2e/items.test.ts | 41 +- apps/web/tests/e2e/realtime.test.ts | 51 +- 5 files changed, 319 insertions(+), 234 deletions(-) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a2ffdd4..981d278 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -164,5 +164,11 @@ "sync_syncing": "Syncing…", "undo": "Undo", "undo_deleted_item": "Deleted {name}", - "list_item_delete_aria": "Delete item" + "list_item_delete_aria": "Delete item", + "list_qty_decrease": "Decrease quantity", + "list_qty_increase": "Increase quantity", + "list_reorder_handle": "Reorder", + "list_edit_item": "Edit item", + "list_confirm": "Confirm", + "list_close": "Close" } diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 51d79ec..ad095b2 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -164,5 +164,11 @@ "sync_syncing": "Sincronizando…", "undo": "Deshacer", "undo_deleted_item": "{name} eliminado", - "list_item_delete_aria": "Eliminar producto" + "list_item_delete_aria": "Eliminar producto", + "list_qty_decrease": "Reducir cantidad", + "list_qty_increase": "Aumentar cantidad", + "list_reorder_handle": "Reordenar", + "list_edit_item": "Editar producto", + "list_confirm": "Confirmar", + "list_close": "Cerrar" } diff --git a/apps/web/src/routes/(app)/lists/[id]/+page.svelte b/apps/web/src/routes/(app)/lists/[id]/+page.svelte index 7230fa6..949515c 100644 --- a/apps/web/src/routes/(app)/lists/[id]/+page.svelte +++ b/apps/web/src/routes/(app)/lists/[id]/+page.svelte @@ -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>>([]); let suggestionsTimer: ReturnType; - // Inline edit - let editingId = $state(null); - let editName = $state(''); - let editQty = $state(null); + // Double-tap edit overlay (replaces inline edit) + let overlayItem = $state(null); + let overlayName = $state(''); + let lastTapById = new Map(); + 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>({}); - let openSwipeId = $state(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 @@

{m.list_items_empty_hint()}

{:else} - + {#if uncheckedItems.length > 0}
{ + void handleDndFinalize(e); + onDragEnd(); + }} + class="px-4 md:px-8" > {#each uncheckedItems as item (item.id)} -
- + {@const offset = swipeOffsets[item.id] ?? 0} +
+ - +
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)} > - - - - + - {#if editingId === item.id} - -
- 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" - /> - 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" - /> -
- {:else} - - + + - -
- - - {item.quantity ?? 1} - - -
- - + +
- {/if} + + {item.quantity ?? 1} + + +
{/each}
{/if} - + {#if checkedItems.length > 0} -
+

{m.list_checked()} ({checkedItems.length})

{#each checkedItems as item (item.id)} -
- - - - - + +
- - {item.name} - - - +
+ + + {item.quantity ?? 1} + +
{/each}
@@ -655,8 +668,10 @@ {/if} - -
+ +
+ + + {#if overlayItem} + + {/if} {/if} diff --git a/apps/web/tests/e2e/items.test.ts b/apps/web/tests/e2e/items.test.ts index 8f55fa2..a1b6bea 100644 --- a/apps/web/tests/e2e/items.test.ts +++ b/apps/web/tests/e2e/items.test.ts @@ -57,27 +57,29 @@ test.describe('Shopping items — member (Borja)', () => { await expect(page.getByText(itemName)).toBeVisible({ timeout: 10_000 }); }); - test('D-03: can check an item — toggles the aria-checked state', async ({ page }) => { + test('D-03: double-tapping a row opens the edit overlay (new interaction)', async ({ page }) => { await gotoSeedList(page); - // Add a fresh item so we don't race with other tests' check state. const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER); await expect(nameInput).toBeVisible({ timeout: 10_000 }); const itemName = `Check-me-${Date.now()}`; await nameInput.fill(itemName); await nameInput.press('Enter'); - await expect(page.getByText(itemName)).toBeVisible({ timeout: 5_000 }); - const row = itemRow(page, itemName); - const toggle = row.getByRole('button', { name: /toggle item|uncheck item/i }); - await toggle.click(); + await expect(row).toBeVisible({ timeout: 5_000 }); - // A checked item is moved into the "Checked" section. Verify it still - // shows on the page (it slides to the checked list with animation). - await expect(page.getByText(itemName)).toBeVisible({ timeout: 5_000 }); + // Double-tap opens the overlay — the new edit path. + await row.getByRole('button', { name: itemName }).dblclick(); + const overlay = page.getByTestId('edit-overlay'); + await expect(overlay).toBeVisible({ timeout: 3_000 }); + await expect(overlay.getByPlaceholder(/add item|añadir producto/i)).toHaveValue(itemName); + + // Close the overlay without saving — X button. + await overlay.getByRole('button', { name: /close|cerrar/i }).click(); + await expect(overlay).not.toBeVisible({ timeout: 3_000 }); }); - test('D-04: can delete an item on desktop via the trash button', async ({ page }) => { + test('D-04: overlay Delete button removes the row with an undo toast', async ({ page }) => { await gotoSeedList(page); const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER); await expect(nameInput).toBeVisible({ timeout: 10_000 }); @@ -85,20 +87,17 @@ test.describe('Shopping items — member (Borja)', () => { const itemName = `Delete-me-${Date.now()}`; await nameInput.fill(itemName); await nameInput.press('Enter'); - await expect(itemRow(page, itemName)).toBeVisible({ timeout: 5_000 }); - const row = itemRow(page, itemName); - await row.hover(); + await expect(row).toBeVisible({ timeout: 5_000 }); - // The row has 3 "Delete" buttons in markup (swipe-reveal behind the row, - // desktop-hover on the row, and a checked-state one). The desktop-hover - // button is the one with `sm:flex` — target it via class. - await row.locator('button.sm\\:flex[aria-label="Delete"]').click(); + // Double-tap → overlay → Delete. (The swipe gesture is a toggle now, + // not a delete; delete lives behind the overlay.) + await row.getByRole('button', { name: itemName }).dblclick(); + const overlay = page.getByTestId('edit-overlay'); + await expect(overlay).toBeVisible({ timeout: 3_000 }); + await overlay.getByRole('button', { name: /^delete$|^eliminar$/i }).click(); - // After Fase 5.5 the delete goes through the undo queue: the row is - // removed immediately, but a toast briefly shows "Deleted " — that - // substring matches `getByText(itemName)`. Scope the assertion to the - // item list (role="listitem") so the toast is ignored. + // Row gone from the list; toast scoped out via itemRow locator. await expect(itemRow(page, itemName)).not.toBeVisible({ timeout: 5_000 }); }); diff --git a/apps/web/tests/e2e/realtime.test.ts b/apps/web/tests/e2e/realtime.test.ts index 925c6e9..7c140b6 100644 --- a/apps/web/tests/e2e/realtime.test.ts +++ b/apps/web/tests/e2e/realtime.test.ts @@ -43,7 +43,12 @@ test.describe('Realtime sync — two sessions on the same list', () => { } }); - test('R-E-02: Ana checks an item → Borja sees it checked', async ({ browser }) => { + test('R-E-02: Ana renames an item → Borja sees the new name via Realtime UPDATE', async ({ browser }) => { + // Fase 5.10 removed the checkbox/toggle button from the list detail + // view. Check/uncheck is a swipe gesture (unreliable under Chromium + // touch emulation). Renaming an item via the double-tap overlay fires + // the same realtime UPDATE path — this test still covers Ana → Borja + // sync; Vitest integration R-02 covers the UPDATE-row-payload invariant. const ana = await loggedInListPage(browser, USERS.ana); const borja = await loggedInListPage(browser, USERS.borja); @@ -52,36 +57,34 @@ test.describe('Realtime sync — two sessions on the same list', () => { }); try { - const itemName = `R-E-02-${Date.now()}`; + const originalName = `R-E-02-orig-${Date.now()}`; + const renamedName = `R-E-02-new-${Date.now()}`; const anaInput = ana.page.getByPlaceholder(ADD_ITEM_PLACEHOLDER); - await anaInput.fill(itemName); + await anaInput.fill(originalName); await anaInput.press('Enter'); - await expect(ana.page.getByText(itemName)).toBeVisible({ timeout: 5_000 }); - await expect(borja.page.getByText(itemName)).toBeVisible({ timeout: 5_000 }); + await expect(ana.page.getByText(originalName)).toBeVisible({ timeout: 5_000 }); + await expect(borja.page.getByText(originalName)).toBeVisible({ timeout: 5_000 }); - // Ana checks the item. Wait 500ms after add to give the realtime - // echo + dedupe time to settle; otherwise the row might still have - // the temp UUID in the DOM and the button id changes under us. + // Wait a beat so the optimistic tempId is swapped for the real one. await ana.page.waitForTimeout(500); - const anaRow = ana.page.locator('[role="listitem"]').filter({ hasText: itemName }).first(); - await anaRow.getByRole('button', { name: /toggle item/i }).click(); - // Verify Ana's own UI flipped (she checked it locally) - await expect( - ana.page - .locator('[role="listitem"]') - .filter({ hasText: itemName }) - .getByRole('button', { name: /uncheck item/i }) - ).toBeVisible({ timeout: 5_000 }); + // Open the overlay via the row's name button (double-click = double-tap). + const anaRowNameBtn = ana.page + .locator('[role="listitem"]') + .filter({ hasText: originalName }) + .getByRole('button', { name: originalName }) + .first(); + await anaRowNameBtn.dblclick(); + const overlay = ana.page.getByTestId('edit-overlay'); + await expect(overlay).toBeVisible({ timeout: 3_000 }); + const overlayInput = overlay.getByPlaceholder(/add item|añadir producto/i); + await overlayInput.fill(renamedName); + await overlay.getByRole('button', { name: /^confirm$|^confirmar$/i }).click(); - // And Borja should see it checked too via Realtime UPDATE - await expect( - borja.page - .locator('[role="listitem"]') - .filter({ hasText: itemName }) - .getByRole('button', { name: /uncheck item/i }) - ).toBeVisible({ timeout: 10_000 }); + // Ana's UI shows the new name, and Borja's catches up via Realtime. + await expect(ana.page.getByText(renamedName)).toBeVisible({ timeout: 5_000 }); + await expect(borja.page.getByText(renamedName)).toBeVisible({ timeout: 10_000 }); } finally { await ana.context.close(); await borja.context.close();