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:
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
95
apps/web/tests/e2e/selection.test.ts
Normal file
95
apps/web/tests/e2e/selection.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user