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

@@ -164,5 +164,11 @@
"sync_syncing": "Syncing…", "sync_syncing": "Syncing…",
"undo": "Undo", "undo": "Undo",
"undo_deleted_item": "Deleted {name}", "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"
} }

View File

@@ -164,5 +164,11 @@
"sync_syncing": "Sincronizando…", "sync_syncing": "Sincronizando…",
"undo": "Deshacer", "undo": "Deshacer",
"undo_deleted_item": "{name} eliminado", "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"
} }

View File

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

View File

@@ -57,27 +57,29 @@ test.describe('Shopping items — member (Borja)', () => {
await expect(page.getByText(itemName)).toBeVisible({ timeout: 10_000 }); 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); await gotoSeedList(page);
// Add a fresh item so we don't race with other tests' check state.
const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER); const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await expect(nameInput).toBeVisible({ timeout: 10_000 }); await expect(nameInput).toBeVisible({ timeout: 10_000 });
const itemName = `Check-me-${Date.now()}`; const itemName = `Check-me-${Date.now()}`;
await nameInput.fill(itemName); await nameInput.fill(itemName);
await nameInput.press('Enter'); await nameInput.press('Enter');
await expect(page.getByText(itemName)).toBeVisible({ timeout: 5_000 });
const row = itemRow(page, itemName); const row = itemRow(page, itemName);
const toggle = row.getByRole('button', { name: /toggle item|uncheck item/i }); await expect(row).toBeVisible({ timeout: 5_000 });
await toggle.click();
// A checked item is moved into the "Checked" section. Verify it still // Double-tap opens the overlay — the new edit path.
// shows on the page (it slides to the checked list with animation). await row.getByRole('button', { name: itemName }).dblclick();
await expect(page.getByText(itemName)).toBeVisible({ timeout: 5_000 }); 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); await gotoSeedList(page);
const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER); const nameInput = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await expect(nameInput).toBeVisible({ timeout: 10_000 }); await expect(nameInput).toBeVisible({ timeout: 10_000 });
@@ -85,20 +87,17 @@ test.describe('Shopping items — member (Borja)', () => {
const itemName = `Delete-me-${Date.now()}`; const itemName = `Delete-me-${Date.now()}`;
await nameInput.fill(itemName); await nameInput.fill(itemName);
await nameInput.press('Enter'); await nameInput.press('Enter');
await expect(itemRow(page, itemName)).toBeVisible({ timeout: 5_000 });
const row = itemRow(page, itemName); 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, // Double-tap → overlay → Delete. (The swipe gesture is a toggle now,
// desktop-hover on the row, and a checked-state one). The desktop-hover // not a delete; delete lives behind the overlay.)
// button is the one with `sm:flex` — target it via class. await row.getByRole('button', { name: itemName }).dblclick();
await row.locator('button.sm\\:flex[aria-label="Delete"]').click(); 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 // Row gone from the list; toast scoped out via itemRow locator.
// removed immediately, but a toast briefly shows "Deleted <name>" — that
// substring matches `getByText(itemName)`. Scope the assertion to the
// item list (role="listitem") so the toast is ignored.
await expect(itemRow(page, itemName)).not.toBeVisible({ timeout: 5_000 }); await expect(itemRow(page, itemName)).not.toBeVisible({ timeout: 5_000 });
}); });

View File

@@ -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 ana = await loggedInListPage(browser, USERS.ana);
const borja = await loggedInListPage(browser, USERS.borja); const borja = await loggedInListPage(browser, USERS.borja);
@@ -52,36 +57,34 @@ test.describe('Realtime sync — two sessions on the same list', () => {
}); });
try { 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); const anaInput = ana.page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await anaInput.fill(itemName); await anaInput.fill(originalName);
await anaInput.press('Enter'); await anaInput.press('Enter');
await expect(ana.page.getByText(itemName)).toBeVisible({ timeout: 5_000 }); await expect(ana.page.getByText(originalName)).toBeVisible({ timeout: 5_000 });
await expect(borja.page.getByText(itemName)).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 // Wait a beat so the optimistic tempId is swapped for the real one.
// echo + dedupe time to settle; otherwise the row might still have
// the temp UUID in the DOM and the button id changes under us.
await ana.page.waitForTimeout(500); 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) // Open the overlay via the row's name button (double-click = double-tap).
await expect( const anaRowNameBtn = ana.page
ana.page
.locator('[role="listitem"]') .locator('[role="listitem"]')
.filter({ hasText: itemName }) .filter({ hasText: originalName })
.getByRole('button', { name: /uncheck item/i }) .getByRole('button', { name: originalName })
).toBeVisible({ timeout: 5_000 }); .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 // Ana's UI shows the new name, and Borja's catches up via Realtime.
await expect( await expect(ana.page.getByText(renamedName)).toBeVisible({ timeout: 5_000 });
borja.page await expect(borja.page.getByText(renamedName)).toBeVisible({ timeout: 10_000 });
.locator('[role="listitem"]')
.filter({ hasText: itemName })
.getByRole('button', { name: /uncheck item/i })
).toBeVisible({ timeout: 10_000 });
} finally { } finally {
await ana.context.close(); await ana.context.close();
await borja.context.close(); await borja.context.close();