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

@@ -22,9 +22,7 @@
// ── State ──────────────────────────────────────────────────────────────────
let newListName = $state('');
let creating = $state(false);
let nameInput: HTMLInputElement | undefined = $state();
let showTrash = $state(false);
let trashedLists = $state<ShoppingList[]>([]);
@@ -57,19 +55,15 @@
}
// ── 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() {
const name = newListName.trim();
if (!name || !$currentCollective || !$currentUser) return;
if (!$currentCollective || !$currentUser || creating) return;
creating = true;
newListName = '';
await createList($currentCollective.id, name, $currentUser.id);
const created = await createList($currentCollective.id, '', $currentUser.id);
creating = false;
nameInput?.focus();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') handleCreate();
if (created) await goto(`/lists/${created.id}`);
}
// ── Actions ────────────────────────────────────────────────────────────────
@@ -129,19 +123,27 @@
{#snippet actions()}
<button
onclick={toggleTrash}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[13px] font-medium
{showTrash
aria-label={m.lists_trash()}
class="rounded p-1.5 {showTrash
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900'
: 'text-text-secondary hover:bg-black/5 dark:hover:bg-white/5'}"
>
<Trash2 size={14} strokeWidth={1.5} />
{m.lists_trash()}
<Trash2 size={16} strokeWidth={1.5} />
</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>
{/snippet}
</ScreenMasthead>
<!-- 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}
<!-- Trash view -->
<div class="mb-6">
@@ -389,32 +391,4 @@
{/if}
</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>

View File

@@ -13,6 +13,7 @@
checkItem,
deleteItem,
reorderItems,
renameList,
fetchSuggestions
} from '$lib/stores/lists';
import {
@@ -46,6 +47,11 @@
let items = $state<ShoppingItem[]>([]);
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
let newName = $state('');
let newQty = $state<number | null>(null);
@@ -103,6 +109,7 @@
}
list = listRes.data as ShoppingList;
listName = list.name ?? '';
items = itemsRes;
loading = false;
@@ -133,6 +140,10 @@
onDestroy(async () => {
await realtimeSub?.unsubscribe();
if (nameSaveTimer) {
clearTimeout(nameSaveTimer);
await flushNameSave();
}
});
// ── Suggestions ────────────────────────────────────────────────────────────
@@ -154,6 +165,24 @@
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 ───────────────────────────────────────────────────────────────
async function handleAdd() {
@@ -398,11 +427,9 @@
<ArrowLeft size={16} strokeWidth={1.5} />
</a>
<div class="flex-1 min-w-0">
<p class="mb-0.5 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
{m.list_status_active()}
</p>
<h1 class="truncate text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
{list?.name}
<!-- 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 -->
@@ -445,6 +472,22 @@
<!-- Items -->
<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}
<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>