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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user