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:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { flip } from 'svelte/animate';
|
||||
@@ -14,7 +15,13 @@
|
||||
deleteItem,
|
||||
reorderItems,
|
||||
renameList,
|
||||
fetchSuggestions
|
||||
fetchSuggestions,
|
||||
bulkDeleteItems,
|
||||
bulkMoveItems,
|
||||
bulkCheckItems,
|
||||
loadLists,
|
||||
createList,
|
||||
lists as listsStore
|
||||
} from '$lib/stores/lists';
|
||||
import {
|
||||
subscribeToList,
|
||||
@@ -36,6 +43,8 @@
|
||||
MoreHorizontal,
|
||||
RotateCcw,
|
||||
Check,
|
||||
CheckSquare,
|
||||
FolderInput,
|
||||
X
|
||||
} from 'lucide-svelte';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
@@ -92,6 +101,102 @@
|
||||
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
|
||||
let showMenu = $state(false);
|
||||
|
||||
@@ -449,56 +554,117 @@
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<!-- Header — sticky so it stays in view when the list is long.
|
||||
(app)/+layout.svelte hides the global MobileTopBar on detail routes,
|
||||
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">
|
||||
<a
|
||||
href="/lists"
|
||||
class="rounded-md p-1.5 text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
|
||||
aria-label="Back"
|
||||
so this is the ONLY top chrome on mobile.
|
||||
In selection mode the header transforms into a dark action bar
|
||||
showing the selection count + Cancel (bulk action buttons live in
|
||||
a bottom bar on mobile / inline on desktop). -->
|
||||
{#if selectionMode}
|
||||
<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
|
||||
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()}
|
||||
type="button"
|
||||
onclick={exitSelection}
|
||||
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>
|
||||
{#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)]"
|
||||
<span class="flex-1 text-sm font-medium">
|
||||
{m.list_selection_count({ n: String(selectedCount) })}
|
||||
</span>
|
||||
<!-- 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
|
||||
onclick={handleReset}
|
||||
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
|
||||
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5 rounded-lg"
|
||||
<CheckSquare size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
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} />
|
||||
{m.list_reset()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
<button
|
||||
onclick={handleReset}
|
||||
class="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-slate-700
|
||||
hover:bg-black/5 dark:text-slate-300 dark:hover:bg-white/5 rounded-lg"
|
||||
>
|
||||
<RotateCcw size={14} strokeWidth={1.5} />
|
||||
{m.list_reset()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<SyncBanner />
|
||||
|
||||
@@ -544,45 +710,80 @@
|
||||
>
|
||||
{#each uncheckedItems as item (item.id)}
|
||||
{@const offset = swipeOffsets[item.id] ?? 0}
|
||||
{@const isSelected = selectedIds.has(item.id)}
|
||||
<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 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"
|
||||
>
|
||||
<Check size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<!-- Reveal zone (behind row) — hidden in selection mode -->
|
||||
{#if !selectionMode}
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<Check size={18} strokeWidth={2} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Item row — button, no checkbox -->
|
||||
<!-- Item row — button, no checkbox (except in selection mode) -->
|
||||
<div
|
||||
role="listitem"
|
||||
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)}
|
||||
onpointerup={(e) => onSwipeEnd(e, item)}
|
||||
data-selected={isSelected ? 'true' : undefined}
|
||||
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'}"
|
||||
style={selectionMode ? '' : `transform: translateX(${offset}px)`}
|
||||
onpointerdown={(e) => {
|
||||
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).
|
||||
Pointerdown enables drag for this interaction only — row
|
||||
swipes keep working when the user grabs elsewhere. -->
|
||||
<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 selectionMode}
|
||||
<!-- Selection checkbox replaces the drag handle -->
|
||||
<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}
|
||||
<!-- 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
|
||||
type="button"
|
||||
onclick={() => onRowTap(item)}
|
||||
onclick={() => {
|
||||
if (selectionMode) toggleSelection(item.id);
|
||||
else onRowTap(item);
|
||||
}}
|
||||
class="flex-1 min-w-0 text-left"
|
||||
aria-label={item.name}
|
||||
>
|
||||
@@ -591,7 +792,8 @@
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Quantity stepper — always visible -->
|
||||
<!-- Quantity stepper — hidden in selection mode -->
|
||||
{#if !selectionMode}
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -613,6 +815,7 @@
|
||||
<Plus size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -627,29 +830,63 @@
|
||||
</p>
|
||||
{#each checkedItems as item (item.id)}
|
||||
{@const offset = swipeOffsets[item.id] ?? 0}
|
||||
{@const isSelected = selectedIds.has(item.id)}
|
||||
<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"
|
||||
>
|
||||
<RotateCcw size={18} strokeWidth={2} />
|
||||
</div>
|
||||
{#if !selectionMode}
|
||||
<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"
|
||||
>
|
||||
<RotateCcw size={18} strokeWidth={2} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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)}
|
||||
data-selected={isSelected ? 'true' : undefined}
|
||||
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'}"
|
||||
style={selectionMode ? '' : `transform: translateX(${offset}px)`}
|
||||
onpointerdown={(e) => {
|
||||
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
|
||||
type="button"
|
||||
onclick={() => onRowTap(item)}
|
||||
onclick={() => {
|
||||
if (selectionMode) toggleSelection(item.id);
|
||||
else onRowTap(item);
|
||||
}}
|
||||
class="flex-1 min-w-0 text-left"
|
||||
aria-label={item.name}
|
||||
>
|
||||
@@ -657,9 +894,11 @@
|
||||
{item.name}
|
||||
</span>
|
||||
</button>
|
||||
<span class="shrink-0 text-xs tabular-nums text-text-muted">
|
||||
{item.quantity ?? 1}
|
||||
</span>
|
||||
{#if !selectionMode}
|
||||
<span class="shrink-0 text-xs tabular-nums text-text-muted">
|
||||
{item.quantity ?? 1}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -668,10 +907,47 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<!-- Mobile bottom action bar (selection mode only) — replaces the
|
||||
add-item row and sits above the BottomTabBar safe-area. -->
|
||||
{#if selectionMode}
|
||||
<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 -->
|
||||
<ItemSuggestions
|
||||
{suggestions}
|
||||
@@ -771,4 +1047,56 @@
|
||||
</div>
|
||||
</div>
|
||||
{/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}
|
||||
|
||||
Reference in New Issue
Block a user