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:
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
animate:flip={{ duration: flipDurationMs }}
|
||||||
|
class="group relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Reveal zone (behind row) — green success for check-on-swipe -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-y-0 right-0 flex w-[72px] items-center justify-center
|
class="absolute inset-y-0 left-0 flex items-center bg-emerald-500 pl-4 rounded-l-lg text-white"
|
||||||
bg-red-500 rounded-r-lg"
|
style="width: {Math.min(SWIPE_REVEAL_WIDTH, Math.abs(offset))}px; opacity: {offset > 0 ? 1 : 0};"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<button
|
<Check size={18} strokeWidth={2} />
|
||||||
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>
|
|
||||||
</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 -->
|
<button
|
||||||
<div class="flex flex-1 items-center gap-2">
|
type="button"
|
||||||
<input
|
onclick={() => onRowTap(item)}
|
||||||
type="text"
|
class="flex-1 min-w-0 text-left"
|
||||||
bind:value={editName}
|
aria-label={item.name}
|
||||||
onblur={() => commitEdit(item)}
|
>
|
||||||
onkeydown={(e) => handleEditKeydown(e, item)}
|
<span class="block truncate text-sm text-slate-800 dark:text-slate-200">
|
||||||
class="flex-1 min-w-0 bg-transparent text-sm text-text-primary outline-none
|
{item.name}
|
||||||
border-b border-slate-300 dark:border-slate-600 pb-0.5"
|
</span>
|
||||||
/>
|
</button>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 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
|
|
||||||
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) -->
|
|
||||||
<button
|
<button
|
||||||
onclick={() => handleDelete(item.id)}
|
type="button"
|
||||||
class="hidden shrink-0 rounded p-1 text-text-muted hover:text-destructive
|
onclick={() => adjustQty(item, -1)}
|
||||||
hover:bg-destructive/10 sm:flex"
|
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.action_delete()}
|
aria-label={m.list_qty_decrease()}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} strokeWidth={1.5} />
|
<Minus size={14} strokeWidth={2} />
|
||||||
</button>
|
</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>
|
||||||
</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
|
||||||
{item.name}
|
role="listitem"
|
||||||
</span>
|
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"
|
||||||
<button
|
style="transform: translateX({offset}px)"
|
||||||
onclick={() => handleDelete(item.id)}
|
onpointerdown={(e) => onSwipeStart(e, item.id)}
|
||||||
class="shrink-0 rounded p-1 text-text-muted hover:text-destructive
|
onpointermove={(e) => onSwipeMove(e, item)}
|
||||||
hover:bg-destructive/10"
|
onpointerup={(e) => onSwipeEnd(e, item)}
|
||||||
aria-label={m.action_delete()}
|
|
||||||
>
|
>
|
||||||
<Trash2 size={14} strokeWidth={1.5} />
|
<div class="shrink-0 w-4"></div>
|
||||||
</button>
|
<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>
|
</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}
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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: originalName })
|
||||||
.filter({ hasText: itemName })
|
.getByRole('button', { name: originalName })
|
||||||
.getByRole('button', { name: /uncheck item/i })
|
.first();
|
||||||
).toBeVisible({ timeout: 5_000 });
|
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user