feat(fase-5.8): /lists big-button create — match /notes pattern

Remove the sticky bottom create input from /lists. The masthead now hosts
a primary "New list" button next to the Trash icon, styled identically to
the "New note" button on /notes. Click → createList with an empty name →
goto /lists/[id], where a prominent editable title input (above To buy /
Checked) autosaves on blur / input with a 500 ms debounce.

Changes
  apps/web/src/routes/(app)/lists/+page.svelte
    - remove `newListName`, `nameInput`, `handleKeydown`
    - remove the absolute-positioned sticky bottom block
    - handleCreate now creates with name = '' and navigates to the detail
    - actions snippet gains the primary New list button; Trash button
      demoted to ghost icon
  apps/web/src/routes/(app)/lists/[id]/+page.svelte
    - new listName / scheduleNameSave / flushNameSave state (mirrors the
      /notes editor pattern)
    - big editable title <input> at top of content, autosaves on blur
      via renameList
    - contextual header h1 trimmed to a secondary caption-size preview
      on mobile (so the user still sees the list name at the top)
    - flushNameSave on component destroy so unsaved renames commit
  apps/web/messages/{en,es}.json: lists_new_list = "New list"/"Nueva lista"

Tests
  apps/web/tests/e2e/lists.test.ts: createList helper now clicks the
    button, fills the title input, blurs, waits for autosave, and goes
    back to /lists to present the same "list-is-on-the-overview" state
    that C-11 / C-12 expect. C-07 picks up the reload check.
  apps/web/tests/e2e/session.test.ts: S-03 mirrors the new flow for its
    fresh-list setup.

Verification
  just test-e2e → 40 passed, 2 skipped (swipe-delete .skip remains).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 19:09:33 +02:00
parent 4cc5f694d3
commit d14d6cd5ab
6 changed files with 96 additions and 68 deletions

View File

@@ -75,7 +75,7 @@
"masthead_search_label": "Omni-Search", "masthead_search_label": "Omni-Search",
"masthead_search_title": "Find Anything.", "masthead_search_title": "Find Anything.",
"lists_title": "Shopping Lists", "lists_title": "Shopping Lists",
"lists_new_list": "Create", "lists_new_list": "New list",
"lists_no_lists": "No lists yet", "lists_no_lists": "No lists yet",
"lists_create_first": "Create your first shopping list", "lists_create_first": "Create your first shopping list",
"lists_trash": "Trash", "lists_trash": "Trash",

View File

@@ -75,7 +75,7 @@
"masthead_search_label": "Buscar", "masthead_search_label": "Buscar",
"masthead_search_title": "Encuéntralo todo.", "masthead_search_title": "Encuéntralo todo.",
"lists_title": "Listas de la compra", "lists_title": "Listas de la compra",
"lists_new_list": "Crear", "lists_new_list": "Nueva lista",
"lists_no_lists": "Sin listas", "lists_no_lists": "Sin listas",
"lists_create_first": "Crea tu primera lista de la compra", "lists_create_first": "Crea tu primera lista de la compra",
"lists_trash": "Papelera", "lists_trash": "Papelera",

View File

@@ -22,9 +22,7 @@
// ── State ────────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────────
let newListName = $state('');
let creating = $state(false); let creating = $state(false);
let nameInput: HTMLInputElement | undefined = $state();
let showTrash = $state(false); let showTrash = $state(false);
let trashedLists = $state<ShoppingList[]>([]); let trashedLists = $state<ShoppingList[]>([]);
@@ -57,19 +55,15 @@
} }
// ── Create ───────────────────────────────────────────────────────────────── // ── Create ─────────────────────────────────────────────────────────────────
// Matches the /notes flow: click the masthead button → create with an empty
// name → navigate to /lists/[id] where the user renames inline.
async function handleCreate() { async function handleCreate() {
const name = newListName.trim(); if (!$currentCollective || !$currentUser || creating) return;
if (!name || !$currentCollective || !$currentUser) return;
creating = true; creating = true;
newListName = ''; const created = await createList($currentCollective.id, '', $currentUser.id);
await createList($currentCollective.id, name, $currentUser.id);
creating = false; creating = false;
nameInput?.focus(); if (created) await goto(`/lists/${created.id}`);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') handleCreate();
} }
// ── Actions ──────────────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────────────
@@ -129,19 +123,27 @@
{#snippet actions()} {#snippet actions()}
<button <button
onclick={toggleTrash} onclick={toggleTrash}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[13px] font-medium aria-label={m.lists_trash()}
{showTrash class="rounded p-1.5 {showTrash
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900' ? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900'
: 'text-text-secondary hover:bg-black/5 dark:hover:bg-white/5'}" : 'text-text-secondary hover:bg-black/5 dark:hover:bg-white/5'}"
> >
<Trash2 size={14} strokeWidth={1.5} /> <Trash2 size={16} strokeWidth={1.5} />
{m.lists_trash()} </button>
<button
type="button"
onclick={handleCreate}
disabled={creating || !$currentCollective}
class="ml-1 inline-flex items-center gap-1 rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-60 dark:bg-slate-100 dark:text-slate-900"
>
<Plus size={14} strokeWidth={2} />
{m.lists_new_list()}
</button> </button>
{/snippet} {/snippet}
</ScreenMasthead> </ScreenMasthead>
<!-- Content --> <!-- Content -->
<div class="flex-1 overflow-y-auto px-4 pb-24 md:px-6 md:pb-28"> <div class="flex-1 overflow-y-auto px-4 pb-6 md:px-6">
{#if showTrash} {#if showTrash}
<!-- Trash view --> <!-- Trash view -->
<div class="mb-6"> <div class="mb-6">
@@ -389,32 +391,4 @@
{/if} {/if}
</div> </div>
<!-- Sticky bottom: create new list (offset for the mobile bottom tab bar on small screens) -->
<div
class="absolute inset-x-0 bottom-0 flex items-center gap-3 px-4 py-4 bg-background/80 backdrop-blur-[12px] md:left-56 md:right-0 md:px-8
mb-16 md:mb-0"
>
<div class="flex flex-1 items-center gap-2 rounded-lg bg-surface px-4 py-2.5
shadow-[0px_2px_8px_rgba(15,23,42,0.06)]">
<Plus size={16} strokeWidth={1.5} class="shrink-0 text-text-muted" />
<input
bind:this={nameInput}
bind:value={newListName}
onkeydown={handleKeydown}
type="text"
placeholder={m.list_name_placeholder()}
class="min-w-0 flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-muted
outline-none"
/>
</div>
<button
onclick={handleCreate}
disabled={!newListName.trim() || creating}
class="rounded-md bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white
hover:opacity-90 disabled:opacity-40 dark:bg-white dark:text-slate-900
transition-opacity"
>
{m.lists_new_list()}
</button>
</div>
</div> </div>

View File

@@ -13,6 +13,7 @@
checkItem, checkItem,
deleteItem, deleteItem,
reorderItems, reorderItems,
renameList,
fetchSuggestions fetchSuggestions
} from '$lib/stores/lists'; } from '$lib/stores/lists';
import { import {
@@ -46,6 +47,11 @@
let items = $state<ShoppingItem[]>([]); let items = $state<ShoppingItem[]>([]);
let loading = $state(true); let loading = $state(true);
// Inline rename of the list (replaces the old /lists sticky input flow).
let listName = $state('');
let nameSaveTimer: ReturnType<typeof setTimeout> | null = null;
const NAME_SAVE_DEBOUNCE_MS = 500;
// Add-item form // Add-item form
let newName = $state(''); let newName = $state('');
let newQty = $state<number | null>(null); let newQty = $state<number | null>(null);
@@ -103,6 +109,7 @@
} }
list = listRes.data as ShoppingList; list = listRes.data as ShoppingList;
listName = list.name ?? '';
items = itemsRes; items = itemsRes;
loading = false; loading = false;
@@ -133,6 +140,10 @@
onDestroy(async () => { onDestroy(async () => {
await realtimeSub?.unsubscribe(); await realtimeSub?.unsubscribe();
if (nameSaveTimer) {
clearTimeout(nameSaveTimer);
await flushNameSave();
}
}); });
// ── Suggestions ──────────────────────────────────────────────────────────── // ── Suggestions ────────────────────────────────────────────────────────────
@@ -154,6 +165,24 @@
nameInput?.focus(); nameInput?.focus();
} }
// ── Inline rename ──────────────────────────────────────────────────────────
function scheduleNameSave() {
if (nameSaveTimer) clearTimeout(nameSaveTimer);
nameSaveTimer = setTimeout(() => {
nameSaveTimer = null;
void flushNameSave();
}, NAME_SAVE_DEBOUNCE_MS);
}
async function flushNameSave() {
if (!list) return;
const next = listName.trim();
if (next === (list.name ?? '')) return;
list = { ...list, name: next };
await renameList(list.id, next);
}
// ── Add item ─────────────────────────────────────────────────────────────── // ── Add item ───────────────────────────────────────────────────────────────
async function handleAdd() { async function handleAdd() {
@@ -398,11 +427,9 @@
<ArrowLeft size={16} strokeWidth={1.5} /> <ArrowLeft size={16} strokeWidth={1.5} />
</a> </a>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="mb-0.5 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary"> <!-- Title moved to the content area for inline rename (see <input> below). -->
{m.list_status_active()} <h1 class="truncate text-[15px] font-medium text-text-secondary md:hidden">
</p> {list?.name || m.list_name_placeholder()}
<h1 class="truncate text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
{list?.name}
</h1> </h1>
</div> </div>
<!-- Start shopping session --> <!-- Start shopping session -->
@@ -445,6 +472,22 @@
<!-- Items --> <!-- Items -->
<div class="flex-1 overflow-y-auto pb-32"> <div class="flex-1 overflow-y-auto pb-32">
<!-- Inline rename title — matches /notes editor pattern. Autosaves after
NAME_SAVE_DEBOUNCE_MS of inactivity. -->
<div class="px-4 pt-4 pb-2 md:px-8 md:pt-6 md:pb-4">
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.list_status_active()}
</p>
<input
bind:value={listName}
oninput={scheduleNameSave}
onblur={flushNameSave}
placeholder={m.list_name_placeholder()}
class="w-full bg-transparent text-[26px] md:text-[32px] font-semibold tracking-[-0.02em] text-text-primary placeholder:text-text-secondary focus:outline-none"
aria-label={m.list_name_placeholder()}
/>
</div>
{#if uncheckedItems.length === 0 && checkedItems.length === 0} {#if uncheckedItems.length === 0 && checkedItems.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center"> <div class="flex flex-col items-center justify-center py-16 text-center">
<p class="mb-1 text-sm font-medium text-text-primary">{m.list_items_empty()}</p> <p class="mb-1 text-sm font-medium text-text-primary">{m.list_items_empty()}</p>

View File

@@ -37,14 +37,25 @@ function cardActionsButton(page: Page, name: string) {
async function gotoListsClean(page: Page) { async function gotoListsClean(page: Page) {
await page.goto('/lists'); await page.goto('/lists');
await expect(page.getByPlaceholder(PLACEHOLDER_NEW_LIST)).toBeVisible({ timeout: 15_000 }); // The masthead "New list" button is the anchor now (replaces the sticky input).
await expect(
page.getByRole('button', { name: /new list|nueva lista/i })
).toBeVisible({ timeout: 15_000 });
} }
async function createList(page: Page, name: string) { async function createList(page: Page, name: string) {
const input = page.getByPlaceholder(PLACEHOLDER_NEW_LIST); await page.getByRole('button', { name: /new list|nueva lista/i }).click();
await input.fill(name); // Lands on /lists/[id]; the editable title input carries the placeholder.
await input.press('Enter'); await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 });
await expect(listHeading(page, name)).toBeVisible({ timeout: 5_000 }); const titleInput = page.getByPlaceholder(PLACEHOLDER_NEW_LIST);
await expect(titleInput).toBeVisible({ timeout: 10_000 });
await titleInput.fill(name);
// Blur + wait for the 500 ms autosave debounce + buffer.
await titleInput.blur();
await page.waitForTimeout(900);
// Navigate back to /lists to give callers a consistent starting state.
await page.goto('/lists');
await expect(listHeading(page, name)).toBeVisible({ timeout: 10_000 });
} }
test.describe('Shopping lists — member (Borja)', () => { test.describe('Shopping lists — member (Borja)', () => {

View File

@@ -59,20 +59,20 @@ test.describe('Shopping session — full-screen mode', () => {
page page
}) => { }) => {
// Need a fresh list so completing it doesn't affect shared seed state. // Need a fresh list so completing it doesn't affect shared seed state.
// Create via the lists overview. // Use the new big-button create flow: masthead "New list" → lands on
// /lists/[id] → fill inline title → autosave → we're already on the
// detail view, no need to navigate back.
await page.goto('/lists'); await page.goto('/lists');
const listInput = page.getByPlaceholder(/weekly shop|compra semanal/i); await page
await expect(listInput).toBeVisible({ timeout: 15_000 }); .getByRole('button', { name: /new list|nueva lista/i })
.click();
await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 });
const listName = `S-03-list-${Date.now()}`; const listName = `S-03-list-${Date.now()}`;
await listInput.fill(listName); const titleInput = page.getByPlaceholder(/weekly shop|compra semanal/i);
await listInput.press('Enter'); await expect(titleInput).toBeVisible({ timeout: 10_000 });
// Wait for the new list's heading so we can click into it await titleInput.fill(listName);
const heading = page.getByRole('heading', { name: new RegExp(`^${listName}$`) }); await titleInput.blur();
await expect(heading).toBeVisible({ timeout: 5_000 }); await page.waitForTimeout(900); // autosave debounce
// Navigate via the heading (linked to /lists/[id])
await heading.click();
await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 5_000 });
// Open session mode // Open session mode
await page.getByTestId('start-session').click(); await page.getByTestId('start-session').click();