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>
96 lines
3.7 KiB
TypeScript
96 lines
3.7 KiB
TypeScript
/**
|
|
* 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 });
|
|
});
|
|
});
|