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:
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)', () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user