feat(fase-5.11): selection mode + bulk actions (delete / move / mark-checked)

Long-press on mobile or the new header toggle on desktop puts
/lists/[id] into multi-select mode. Rows render a checkbox on the left,
the stepper + drag handle go away, and swipes + double-tap are disabled.

Chrome during selection mode
  Mobile: header turns into a dark slate-900 action bar with "N selected"
    + Cancel on the left, and a bottom action bar (Delete / Move / Check)
    appears above the BottomTabBar. The sticky add-item form is hidden.
  Desktop: same dark header with Cancel + inline action icons on the right.

Bulk actions
  Delete — one scheduleUndoable with a batch restore (re-inserts all
    snapshots) / batch commit (bulkDeleteItems). Single undo toast.
  Move — opens a bottom sheet / dialog with the collective's other active
    lists + a "Create new list" row that creates an empty list and moves
    into it in a single gesture.
  Mark checked — flips only the selected unchecked items
    (bulkCheckItems with is_checked=false filter), no toggle.

Store helpers (apps/web/src/lib/stores/lists.ts)
  bulkDeleteItems(ids)           → DELETE .in('id', ids)
  bulkMoveItems(ids, newListId)  → UPDATE list_id .in('id', ids)
  bulkCheckItems(ids, userId)    → UPDATE is_checked=true where !is_checked

Entry / exit
  Long-press 500 ms on a row enters with that row pre-selected. Movement
    cancels the long-press intent so it never fights the swipe.
  Single-tap toggles selection while active (instead of invoking the
    no-op short-tap / dbltap-overlay path).
  Cancel button exits + clears selection.

Tests
  apps/web/tests/e2e/selection.test.ts (3 desktop tests)
    SEL-01 toggle enters, row click toggles, Cancel exits
    SEL-02 bulk delete → row gone + undo toast visible
    SEL-03 mark checked → row gains strikethrough
  Mobile long-press tests deferred until WebKit install lands (same
  reason as the swipe-toggle M-series).

i18n additions
  list_select, list_cancel_selection, list_selection_count({n}),
  list_bulk_delete/move/mark_checked, list_undo_bulk_delete({n}),
  list_move_title, list_move_create_new (en/es).

Verification
  just test-e2e → 43 passed + 2 skipped (was 40 + 2).

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

View File

@@ -170,5 +170,14 @@
"list_reorder_handle": "Reorder", "list_reorder_handle": "Reorder",
"list_edit_item": "Edit item", "list_edit_item": "Edit item",
"list_confirm": "Confirm", "list_confirm": "Confirm",
"list_close": "Close" "list_close": "Close",
"list_select": "Select",
"list_selection_count": "{n} selected",
"list_cancel_selection": "Cancel",
"list_bulk_delete": "Delete",
"list_bulk_move": "Move",
"list_bulk_mark_checked": "Mark checked",
"list_undo_bulk_delete": "Deleted {n} items",
"list_move_title": "Move to list",
"list_move_create_new": "Create new list"
} }

View File

@@ -170,5 +170,14 @@
"list_reorder_handle": "Reordenar", "list_reorder_handle": "Reordenar",
"list_edit_item": "Editar producto", "list_edit_item": "Editar producto",
"list_confirm": "Confirmar", "list_confirm": "Confirmar",
"list_close": "Cerrar" "list_close": "Cerrar",
"list_select": "Seleccionar",
"list_selection_count": "{n} seleccionados",
"list_cancel_selection": "Cancelar",
"list_bulk_delete": "Eliminar",
"list_bulk_move": "Mover",
"list_bulk_mark_checked": "Marcar",
"list_undo_bulk_delete": "{n} eliminados",
"list_move_title": "Mover a lista",
"list_move_create_new": "Crear lista nueva"
} }

View File

@@ -196,6 +196,27 @@ export async function deleteItem(id: string) {
await getSupabase().from('shopping_items').delete().eq('id', id); await getSupabase().from('shopping_items').delete().eq('id', id);
} }
// ── Bulk operations (selection mode) ──────────────────────────────────────────
export async function bulkDeleteItems(ids: string[]): Promise<void> {
if (!ids.length) return;
await getSupabase().from('shopping_items').delete().in('id', ids);
}
export async function bulkMoveItems(ids: string[], newListId: string): Promise<void> {
if (!ids.length) return;
await getSupabase().from('shopping_items').update({ list_id: newListId }).in('id', ids);
}
export async function bulkCheckItems(ids: string[], userId: string): Promise<void> {
if (!ids.length) return;
await getSupabase()
.from('shopping_items')
.update({ is_checked: true, checked_by: userId, checked_at: new Date().toISOString() })
.in('id', ids)
.eq('is_checked', false);
}
export async function reorderItems(items: Pick<ShoppingItem, 'id'>[]) { export async function reorderItems(items: Pick<ShoppingItem, 'id'>[]) {
await Promise.all( await Promise.all(
items.map((item, index) => items.map((item, index) =>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
@@ -14,7 +15,13 @@
deleteItem, deleteItem,
reorderItems, reorderItems,
renameList, renameList,
fetchSuggestions fetchSuggestions,
bulkDeleteItems,
bulkMoveItems,
bulkCheckItems,
loadLists,
createList,
lists as listsStore
} from '$lib/stores/lists'; } from '$lib/stores/lists';
import { import {
subscribeToList, subscribeToList,
@@ -36,6 +43,8 @@
MoreHorizontal, MoreHorizontal,
RotateCcw, RotateCcw,
Check, Check,
CheckSquare,
FolderInput,
X X
} from 'lucide-svelte'; } from 'lucide-svelte';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
@@ -92,6 +101,102 @@
dragEnabled = false; dragEnabled = false;
} }
// ── Selection mode ────────────────────────────────────────────────────────
// Entered via long-press on mobile or toggle button on desktop. While
// active: single tap toggles selection, swipes + double-tap are disabled.
let selectionMode = $state(false);
let selectedIds = $state(new SvelteSet<string>());
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
const LONG_PRESS_MS = 500;
let showMovePicker = $state(false);
const selectedCount = $derived(selectedIds.size);
function enterSelection(initialId?: string) {
selectionMode = true;
if (initialId) selectedIds.add(initialId);
}
function exitSelection() {
selectionMode = false;
selectedIds.clear();
showMovePicker = false;
}
function toggleSelection(id: string) {
if (selectedIds.has(id)) selectedIds.delete(id);
else selectedIds.add(id);
}
function startLongPress(item: ShoppingItem) {
if (selectionMode) return;
if (longPressTimer) clearTimeout(longPressTimer);
longPressTimer = setTimeout(() => {
longPressTimer = null;
enterSelection(item.id);
}, LONG_PRESS_MS);
}
function cancelLongPress() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
// ── Bulk actions ──────────────────────────────────────────────────────────
async function handleBulkDelete() {
if (!selectedIds.size) return;
const ids = [...selectedIds];
const snapshots = items.filter((i) => ids.includes(i.id)).map((i) => ({ ...i }));
items = items.filter((i) => !ids.includes(i.id));
exitSelection();
scheduleUndoable({
label: m.list_undo_bulk_delete({ n: String(ids.length) }),
restore: () => {
items = [...items, ...snapshots].sort((a, b) => a.sort_order - b.sort_order);
},
commit: async () => {
await bulkDeleteItems(ids);
}
});
}
async function handleBulkCheck() {
if (!$currentUser || !selectedIds.size) return;
const ids = [...selectedIds];
const now = new Date().toISOString();
items = items.map((i) =>
ids.includes(i.id) && !i.is_checked
? { ...i, is_checked: true, checked_by: $currentUser!.id, checked_at: now }
: i
);
exitSelection();
await bulkCheckItems(ids, $currentUser.id);
}
async function handleBulkMove(targetListId: string) {
if (!selectedIds.size) return;
const ids = [...selectedIds];
items = items.filter((i) => !ids.includes(i.id));
exitSelection();
await bulkMoveItems(ids, targetListId);
}
async function openMovePicker() {
if (!$currentCollective) return;
showMovePicker = true;
await loadLists($currentCollective.id);
}
async function handleMoveToNewList() {
if (!$currentCollective || !$currentUser) return;
const created = await createList($currentCollective.id, '', $currentUser.id);
if (created) await handleBulkMove(created.id);
}
// List action menu // List action menu
let showMenu = $state(false); let showMenu = $state(false);
@@ -449,56 +554,117 @@
<div class="flex flex-1 flex-col overflow-hidden"> <div class="flex flex-1 flex-col overflow-hidden">
<!-- Header — sticky so it stays in view when the list is long. <!-- Header — sticky so it stays in view when the list is long.
(app)/+layout.svelte hides the global MobileTopBar on detail routes, (app)/+layout.svelte hides the global MobileTopBar on detail routes,
so this is the ONLY top chrome on mobile. --> so this is the ONLY top chrome on mobile.
<header class="sticky top-0 z-30 flex items-center gap-3 border-b border-black/5 bg-surface/80 px-4 py-3 backdrop-blur-xl md:border-b-0 md:bg-transparent md:backdrop-blur-0 md:px-8 md:pb-4 md:pt-8 dark:border-white/5"> In selection mode the header transforms into a dark action bar
<a showing the selection count + Cancel (bulk action buttons live in
href="/lists" a bottom bar on mobile / inline on desktop). -->
class="rounded-md p-1.5 text-text-muted hover:bg-black/5 dark:hover:bg-white/5" {#if selectionMode}
aria-label="Back" <header
data-testid="selection-bar"
class="sticky top-0 z-30 flex items-center gap-3 bg-slate-900 px-4 py-3 text-white md:px-8"
> >
<ArrowLeft size={16} strokeWidth={1.5} />
</a>
<div class="flex-1 min-w-0">
<!-- Title moved to the content area for inline rename (see <input> below). -->
<h1 class="truncate text-[15px] font-medium text-text-secondary md:hidden">
{list?.name || m.list_name_placeholder()}
</h1>
</div>
<!-- Start shopping session -->
<a
href="/lists/{listId}/session"
data-testid="start-session"
class="shrink-0 rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-slate-50
hover:bg-slate-800 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-200"
>
{m.list_start_session()}
</a>
<!-- Actions menu -->
<div class="relative shrink-0">
<button <button
onclick={() => (showMenu = !showMenu)} type="button"
class="rounded-md p-1.5 text-text-muted hover:bg-black/5 dark:hover:bg-white/5" onclick={exitSelection}
aria-label={m.list_actions()} class="rounded-md px-2 py-1 text-sm font-medium hover:bg-white/10"
> >
<MoreHorizontal size={16} strokeWidth={1.5} /> {m.list_cancel_selection()}
</button> </button>
{#if showMenu} <span class="flex-1 text-sm font-medium">
<div {m.list_selection_count({ n: String(selectedCount) })}
class="absolute right-0 top-full z-10 mt-1 w-44 rounded-lg bg-surface </span>
shadow-[0px_20px_40px_rgba(15,23,42,0.1)] dark:shadow-[0px_20px_40px_rgba(0,0,0,0.3)]" <!-- Desktop inline actions (mobile gets a bottom bar further down) -->
<div class="hidden shrink-0 items-center gap-1 md:flex">
<button
type="button"
onclick={handleBulkCheck}
disabled={selectedCount === 0}
aria-label={m.list_bulk_mark_checked()}
class="rounded-md p-1.5 hover:bg-white/10 disabled:opacity-40"
> >
<button <CheckSquare size={18} strokeWidth={1.5} />
onclick={handleReset} </button>
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700 <button
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5 rounded-lg" type="button"
onclick={openMovePicker}
disabled={selectedCount === 0}
aria-label={m.list_bulk_move()}
class="rounded-md p-1.5 hover:bg-white/10 disabled:opacity-40"
>
<FolderInput size={18} strokeWidth={1.5} />
</button>
<button
type="button"
onclick={handleBulkDelete}
disabled={selectedCount === 0}
aria-label={m.list_bulk_delete()}
class="rounded-md p-1.5 text-red-300 hover:bg-white/10 disabled:opacity-40"
>
<Trash2 size={18} strokeWidth={1.5} />
</button>
</div>
</header>
{:else}
<header class="sticky top-0 z-30 flex items-center gap-3 border-b border-black/5 bg-surface/80 px-4 py-3 backdrop-blur-xl md:border-b-0 md:bg-transparent md:backdrop-blur-0 md:px-8 md:pb-4 md:pt-8 dark:border-white/5">
<a
href="/lists"
class="rounded-md p-1.5 text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
aria-label="Back"
>
<ArrowLeft size={16} strokeWidth={1.5} />
</a>
<div class="flex-1 min-w-0">
<!-- Title moved to the content area for inline rename (see <input> below). -->
<h1 class="truncate text-[15px] font-medium text-text-secondary md:hidden">
{list?.name || m.list_name_placeholder()}
</h1>
</div>
<!-- Start shopping session -->
<a
href="/lists/{listId}/session"
data-testid="start-session"
class="shrink-0 rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-slate-50
hover:bg-slate-800 dark:bg-slate-50 dark:text-slate-900 dark:hover:bg-slate-200"
>
{m.list_start_session()}
</a>
<!-- Selection toggle (desktop, next to 3-dots) -->
<button
type="button"
onclick={() => enterSelection()}
aria-label={m.list_select()}
class="hidden shrink-0 rounded-md p-1.5 text-text-muted hover:bg-black/5 md:flex dark:hover:bg-white/5"
data-testid="selection-toggle"
>
<CheckSquare size={16} strokeWidth={1.5} />
</button>
<!-- Actions menu -->
<div class="relative shrink-0">
<button
onclick={() => (showMenu = !showMenu)}
class="rounded-md p-1.5 text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
aria-label={m.list_actions()}
>
<MoreHorizontal size={16} strokeWidth={1.5} />
</button>
{#if showMenu}
<div
class="absolute right-0 top-full z-10 mt-1 w-44 rounded-lg bg-surface
shadow-[0px_20px_40px_rgba(15,23,42,0.1)] dark:shadow-[0px_20px_40px_rgba(0,0,0,0.3)]"
> >
<RotateCcw size={14} strokeWidth={1.5} /> <button
{m.list_reset()} onclick={handleReset}
</button> class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
</div> hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5 rounded-lg"
{/if} >
</div> <RotateCcw size={14} strokeWidth={1.5} />
</header> {m.list_reset()}
</button>
</div>
{/if}
</div>
</header>
{/if}
<SyncBanner /> <SyncBanner />
@@ -544,45 +710,80 @@
> >
{#each uncheckedItems as item (item.id)} {#each uncheckedItems as item (item.id)}
{@const offset = swipeOffsets[item.id] ?? 0} {@const offset = swipeOffsets[item.id] ?? 0}
{@const isSelected = selectedIds.has(item.id)}
<div <div
animate:flip={{ duration: flipDurationMs }} animate:flip={{ duration: flipDurationMs }}
class="group relative overflow-hidden" class="group relative overflow-hidden"
> >
<!-- Reveal zone (behind row) — green success for check-on-swipe --> <!-- Reveal zone (behind row) — hidden in selection mode -->
<div {#if !selectionMode}
class="absolute inset-y-0 left-0 flex items-center bg-emerald-500 pl-4 rounded-l-lg text-white" <div
style="width: {Math.min(SWIPE_REVEAL_WIDTH, Math.abs(offset))}px; opacity: {offset > 0 ? 1 : 0};" class="absolute inset-y-0 left-0 flex items-center bg-emerald-500 pl-4 rounded-l-lg text-white"
aria-hidden="true" style="width: {Math.min(SWIPE_REVEAL_WIDTH, Math.abs(offset))}px; opacity: {offset > 0 ? 1 : 0};"
> aria-hidden="true"
<Check size={18} strokeWidth={2} /> >
</div> <Check size={18} strokeWidth={2} />
</div>
{/if}
<!-- Item row — button, no checkbox --> <!-- Item row — button, no checkbox (except in selection mode) -->
<div <div
role="listitem" role="listitem"
data-testid="item-row" data-testid="item-row"
class="relative flex items-center gap-2 py-3 bg-background touch-pan-y select-none transition-transform duration-150" data-selected={isSelected ? 'true' : undefined}
style="transform: translateX({offset}px)" class="relative flex items-center gap-2 py-3 select-none transition-transform duration-150 {isSelected ? 'bg-slate-100 dark:bg-slate-800' : 'bg-background'} {selectionMode ? '' : 'touch-pan-y'}"
onpointerdown={(e) => onSwipeStart(e, item.id)} style={selectionMode ? '' : `transform: translateX(${offset}px)`}
onpointermove={(e) => onSwipeMove(e, item)} onpointerdown={(e) => {
onpointerup={(e) => onSwipeEnd(e, item)} if (selectionMode) return;
onSwipeStart(e, item.id);
startLongPress(item);
}}
onpointermove={(e) => {
if (selectionMode) return;
onSwipeMove(e, item);
// Any move cancels the long-press intent
cancelLongPress();
}}
onpointerup={(e) => {
if (selectionMode) return;
cancelLongPress();
void onSwipeEnd(e, item);
}}
onpointerleave={cancelLongPress}
onpointercancel={cancelLongPress}
> >
<!-- Drag handle (always visible on mobile, reveal on hover on desktop). {#if selectionMode}
Pointerdown enables drag for this interaction only — row <!-- Selection checkbox replaces the drag handle -->
swipes keep working when the user grabs elsewhere. --> <button
<button type="button"
type="button" onclick={() => toggleSelection(item.id)}
aria-label={m.list_reorder_handle()} aria-label={item.name}
onpointerdown={enableDragFromHandle} aria-pressed={isSelected}
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" class="shrink-0 flex h-5 w-5 items-center justify-center rounded border-2 {isSelected ? 'bg-slate-900 border-slate-900 dark:bg-slate-100 dark:border-slate-100' : 'border-slate-300 dark:border-slate-600'}"
> >
<GripVertical size={16} strokeWidth={1.5} /> {#if isSelected}
</button> <Check size={12} strokeWidth={2.5} class="text-white dark:text-slate-900" />
{/if}
</button>
{:else}
<!-- Drag handle (always visible on mobile, reveal on hover on desktop). -->
<button
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}
<!-- Name as button; single tap does nothing, double-tap opens overlay --> <!-- Name as button; tap=nothing/toggle-selection, dbltap=overlay -->
<button <button
type="button" type="button"
onclick={() => onRowTap(item)} onclick={() => {
if (selectionMode) toggleSelection(item.id);
else onRowTap(item);
}}
class="flex-1 min-w-0 text-left" class="flex-1 min-w-0 text-left"
aria-label={item.name} aria-label={item.name}
> >
@@ -591,7 +792,8 @@
</span> </span>
</button> </button>
<!-- Quantity stepper — always visible --> <!-- Quantity stepper — hidden in selection mode -->
{#if !selectionMode}
<div class="flex shrink-0 items-center gap-1"> <div class="flex shrink-0 items-center gap-1">
<button <button
type="button" type="button"
@@ -613,6 +815,7 @@
<Plus size={14} strokeWidth={2} /> <Plus size={14} strokeWidth={2} />
</button> </button>
</div> </div>
{/if}
</div> </div>
</div> </div>
{/each} {/each}
@@ -627,29 +830,63 @@
</p> </p>
{#each checkedItems as item (item.id)} {#each checkedItems as item (item.id)}
{@const offset = swipeOffsets[item.id] ?? 0} {@const offset = swipeOffsets[item.id] ?? 0}
{@const isSelected = selectedIds.has(item.id)}
<div class="relative overflow-hidden"> <div class="relative overflow-hidden">
<!-- Reveal zone on the right for checked items (swipe left to reveal) --> {#if !selectionMode}
<div <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" 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};" style="width: {Math.min(SWIPE_REVEAL_WIDTH, Math.abs(offset))}px; opacity: {offset < 0 ? 1 : 0};"
aria-hidden="true" aria-hidden="true"
> >
<RotateCcw size={18} strokeWidth={2} /> <RotateCcw size={18} strokeWidth={2} />
</div> </div>
{/if}
<div <div
role="listitem" role="listitem"
data-testid="item-row" 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" data-selected={isSelected ? 'true' : undefined}
style="transform: translateX({offset}px)" class="relative flex items-center gap-2 py-3 select-none transition-transform duration-150 {isSelected ? 'bg-slate-100 dark:bg-slate-800' : 'bg-background opacity-70'} {selectionMode ? '' : 'touch-pan-y'}"
onpointerdown={(e) => onSwipeStart(e, item.id)} style={selectionMode ? '' : `transform: translateX(${offset}px)`}
onpointermove={(e) => onSwipeMove(e, item)} onpointerdown={(e) => {
onpointerup={(e) => onSwipeEnd(e, item)} if (selectionMode) return;
onSwipeStart(e, item.id);
startLongPress(item);
}}
onpointermove={(e) => {
if (selectionMode) return;
onSwipeMove(e, item);
cancelLongPress();
}}
onpointerup={(e) => {
if (selectionMode) return;
cancelLongPress();
void onSwipeEnd(e, item);
}}
onpointerleave={cancelLongPress}
onpointercancel={cancelLongPress}
> >
<div class="shrink-0 w-4"></div> {#if selectionMode}
<button
type="button"
onclick={() => toggleSelection(item.id)}
aria-label={item.name}
aria-pressed={isSelected}
class="shrink-0 flex h-5 w-5 items-center justify-center rounded border-2 {isSelected ? 'bg-slate-900 border-slate-900 dark:bg-slate-100 dark:border-slate-100' : 'border-slate-300 dark:border-slate-600'}"
>
{#if isSelected}
<Check size={12} strokeWidth={2.5} class="text-white dark:text-slate-900" />
{/if}
</button>
{:else}
<div class="shrink-0 w-4"></div>
{/if}
<button <button
type="button" type="button"
onclick={() => onRowTap(item)} onclick={() => {
if (selectionMode) toggleSelection(item.id);
else onRowTap(item);
}}
class="flex-1 min-w-0 text-left" class="flex-1 min-w-0 text-left"
aria-label={item.name} aria-label={item.name}
> >
@@ -657,9 +894,11 @@
{item.name} {item.name}
</span> </span>
</button> </button>
<span class="shrink-0 text-xs tabular-nums text-text-muted"> {#if !selectionMode}
{item.quantity ?? 1} <span class="shrink-0 text-xs tabular-nums text-text-muted">
</span> {item.quantity ?? 1}
</span>
{/if}
</div> </div>
</div> </div>
{/each} {/each}
@@ -668,10 +907,47 @@
{/if} {/if}
</div> </div>
<!-- Sticky bottom: suggestions + add form. <!-- Mobile bottom action bar (selection mode only) — replaces the
Mobile: inset-x-0 mb-16 (bottom tab bar lives at 0). Desktop: left-56 add-item row and sits above the BottomTabBar safe-area. -->
respects the DesktopSidebar width. --> {#if selectionMode}
<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"> <div
data-testid="selection-action-bar"
class="absolute inset-x-0 bottom-0 z-30 flex items-center justify-around border-t border-black/5 bg-surface/95 px-4 py-3 backdrop-blur-xl mb-16 md:hidden dark:border-white/5"
>
<button
type="button"
onclick={handleBulkCheck}
disabled={selectedCount === 0}
aria-label={m.list_bulk_mark_checked()}
class="flex flex-col items-center gap-0.5 p-2 text-text-secondary disabled:opacity-40"
>
<CheckSquare size={20} strokeWidth={1.5} />
</button>
<button
type="button"
onclick={openMovePicker}
disabled={selectedCount === 0}
aria-label={m.list_bulk_move()}
class="flex flex-col items-center gap-0.5 p-2 text-text-secondary disabled:opacity-40"
>
<FolderInput size={20} strokeWidth={1.5} />
</button>
<button
type="button"
onclick={handleBulkDelete}
disabled={selectedCount === 0}
aria-label={m.list_bulk_delete()}
class="flex flex-col items-center gap-0.5 p-2 text-red-500 disabled:opacity-40"
>
<Trash2 size={20} strokeWidth={1.5} />
</button>
</div>
{/if}
<!-- Sticky bottom: suggestions + add form (hidden during selection). -->
<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 {selectionMode ? 'hidden' : ''}"
>
<!-- Suggestions --> <!-- Suggestions -->
<ItemSuggestions <ItemSuggestions
{suggestions} {suggestions}
@@ -771,4 +1047,56 @@
</div> </div>
</div> </div>
{/if} {/if}
<!-- Move-to-other-list picker (selection mode) -->
{#if showMovePicker}
<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_move_title()}
onclick={(e) => {
if (e.target === e.currentTarget) showMovePicker = false;
}}
onkeydown={(e) => e.key === 'Escape' && (showMovePicker = false)}
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="move-picker"
>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-base font-semibold text-text-primary">{m.list_move_title()}</h2>
<button
type="button"
onclick={() => (showMovePicker = false)}
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>
<div class="max-h-72 overflow-y-auto">
{#each $listsStore.filter((l) => l.id !== listId && l.status === 'active' && !l.deleted_at) as other (other.id)}
<button
type="button"
onclick={() => handleBulkMove(other.id)}
class="flex w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-sm hover:bg-black/5 dark:hover:bg-white/5"
>
<Check size={14} strokeWidth={1.5} class="text-text-muted" />
<span class="truncate">{other.name || m.list_name_placeholder()}</span>
</button>
{/each}
</div>
<button
type="button"
onclick={handleMoveToNewList}
class="mt-2 flex w-full items-center gap-2 rounded-md border border-dashed border-black/10 px-3 py-2.5 text-sm text-text-secondary hover:bg-black/5 dark:border-white/10 dark:hover:bg-white/5"
>
<Plus size={14} strokeWidth={1.5} />
{m.list_move_create_new()}
</button>
</div>
</div>
{/if}
{/if} {/if}

View File

@@ -0,0 +1,95 @@
/**
* SEL-series (desktop selection mode) — Fase 5.11
*
* The long-press entry is gestural and Chromium touch emulation is too flaky
* to drive reliably, so mobile-only tests stay deferred. The desktop toggle
* button exercises the same state machine end-to-end, so these three tests
* give us confidence on the full selection → bulk-action flow.
*/
import { test, expect } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
const SEED_LIST = '/lists/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const ADD_ITEM = /add item|añadir producto/i;
test.describe('Selection mode — desktop', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, USERS.borja);
});
test('SEL-01: toggle enters selection, row click toggles selection, Cancel exits', async ({
page
}) => {
await page.goto(SEED_LIST);
// Seed data guarantees at least one row (Milk, Bread, Eggs…).
await expect(page.getByTestId('item-row').first()).toBeVisible({ timeout: 10_000 });
// Enter selection mode via the header toggle.
await page.getByTestId('selection-toggle').click();
await expect(page.getByTestId('selection-bar')).toBeVisible({ timeout: 3_000 });
// Click a row — it becomes selected (data-selected="true").
const firstRow = page.getByTestId('item-row').first();
await firstRow.click();
await expect(firstRow).toHaveAttribute('data-selected', 'true');
// Selection count label updates.
await expect(page.getByTestId('selection-bar')).toContainText(/1 selected|1 seleccionad/i);
// Cancel exits and clears selection.
await page.getByTestId('selection-bar').getByRole('button', { name: /^cancel$|^cancelar$/i }).click();
await expect(page.getByTestId('selection-bar')).not.toBeVisible();
await expect(firstRow).not.toHaveAttribute('data-selected', 'true');
});
test('SEL-02: bulk delete removes selected rows with an Undo toast', async ({ page }) => {
await page.goto(SEED_LIST);
// Seed a fresh row we can safely delete.
const input = page.getByPlaceholder(ADD_ITEM);
await expect(input).toBeVisible({ timeout: 10_000 });
const name = `SEL-02-${Date.now()}`;
await input.fill(name);
await input.press('Enter');
const row = page.getByTestId('item-row').filter({ hasText: name }).first();
await expect(row).toBeVisible({ timeout: 5_000 });
// Enter selection, select only our seeded row, then delete.
await page.getByTestId('selection-toggle').click();
await row.click();
await expect(row).toHaveAttribute('data-selected', 'true');
await page
.getByTestId('selection-bar')
.getByRole('button', { name: /^delete$|^eliminar$/i })
.click();
// Row vanishes from the list; undo toast confirms the batch label.
await expect(
page.getByTestId('item-row').filter({ hasText: name })
).toHaveCount(0, { timeout: 5_000 });
await expect(page.getByTestId('undo-toast')).toBeVisible({ timeout: 3_000 });
});
test('SEL-03: mark-checked flips all selected unchecked items to checked', async ({ page }) => {
await page.goto(SEED_LIST);
const input = page.getByPlaceholder(ADD_ITEM);
await expect(input).toBeVisible({ timeout: 10_000 });
const name = `SEL-03-${Date.now()}`;
await input.fill(name);
await input.press('Enter');
const row = page.getByTestId('item-row').filter({ hasText: name }).first();
await expect(row).toBeVisible({ timeout: 5_000 });
await page.getByTestId('selection-toggle').click();
await row.click();
await page
.getByTestId('selection-bar')
.getByRole('button', { name: /mark checked|marcar/i })
.click();
// After bulk check the row should render with strikethrough (muted text).
await expect(
page.getByTestId('item-row').filter({ hasText: name }).locator('.line-through')
).toBeVisible({ timeout: 5_000 });
});
});