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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user