From 9dd4fa653e0d6ce87d6128d1ae461f24b6bf6059 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sat, 30 May 2026 21:22:22 -0400 Subject: [PATCH] feat(board): lane-header + add button + kebab menu shell (TASK-1671) (#672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(board): lane-header + add button + kebab menu shell (TASK-1671, folds IDEA-1159) Replace the hover-only .archive-col-btn in BoardView lane headers with two always-visible, touch-sized affordances: - `+` add-into-this-lane button → onCreateInColumn(colValue), which quick-creates an item with the lane's group field pre-filled (status or a custom board_group_by) and opens it. - `⋯` kebab → a lane menu (the new home for column actions). Shell carries "Add item here" + "Archive all (N)" with an in-menu confirm; move/tag/priority/assign land in TASK-1672. One menu open at a time, dismissed on outside click (QuickActionsMenu pattern). Both affordances are canEdit-gated. Touch targets bump to 32px ≤768px. * fix(board): stopPropagation on lane-menu clicks per Codex review (round 1) Clicking "Archive all" set confirmArchiveColumn, which re-rendered and detached the clicked button before the event bubbled to the window outside-click handler — closest() on the orphaned node returned null and slammed the menu shut, so the confirm never showed. Stop propagation on the kebab toggle and every in-menu click (the documented Svelte 5 same-click bubbling fix from console/+layout.svelte). --- .../components/collections/BoardView.svelte | 242 +++++++++++++++--- .../[workspace]/[collection]/+page.svelte | 36 +++ 2 files changed, 236 insertions(+), 42 deletions(-) diff --git a/web/src/lib/components/collections/BoardView.svelte b/web/src/lib/components/collections/BoardView.svelte index a20f91ca..81aab13a 100644 --- a/web/src/lib/components/collections/BoardView.svelte +++ b/web/src/lib/components/collections/BoardView.svelte @@ -18,6 +18,12 @@ onArchiveColumn?: (items: Item[]) => void; onGroupReorder?: (newOrder: string[]) => void; oncreate?: () => void; + /** + * Create an item directly in this lane, pre-filling the lane's + * group field with its column value (folds IDEA-1159). The `+` + * lane-header button calls this. Gated behind `canEdit`. + */ + onCreateInColumn?: (groupValue: string) => void; itemProgress?: Record; progressLabel?: string; /** @@ -37,11 +43,35 @@ preserveOrder?: boolean; } - let { items, collection, wsSlug = '', groupField = 'status', focusedItemId = null, onStatusChange, onReorder, onArchiveColumn, onGroupReorder, oncreate, itemProgress, progressLabel = 'tasks', canEdit = true, preserveOrder = false }: Props = $props(); + let { items, collection, wsSlug = '', groupField = 'status', focusedItemId = null, onStatusChange, onReorder, onArchiveColumn, onGroupReorder, oncreate, onCreateInColumn, itemProgress, progressLabel = 'tasks', canEdit = true, preserveOrder = false }: Props = $props(); let confirmArchiveColumn = $state(null); + // Which lane's ⋯ menu is open (null = none). The menu is the new home + // for the column actions (archive today; move/tag/priority/assign land + // in TASK-1672). One menu open at a time. + let openMenuColumn = $state(null); let isMobile = $state(false); + function toggleMenu(colValue: string) { + openMenuColumn = openMenuColumn === colValue ? null : colValue; + confirmArchiveColumn = null; + } + + function closeMenu() { + openMenuColumn = null; + confirmArchiveColumn = null; + } + + // Dismiss the open lane menu on any click outside it (mirrors the + // QuickActionsMenu pattern). The menu markup lives under + // `.lane-menu-wrap`, so clicks there don't close it. + function handleWindowClick(e: MouseEvent) { + if (openMenuColumn === null) return; + const target = e.target as HTMLElement | null; + if (!target) return; + if (!target.closest('.lane-menu-wrap')) closeMenu(); + } + const flipDurationMs = 200; const touchDragDelayMs = 500; @@ -221,6 +251,8 @@ } + + {#if items.length === 0} {:else} @@ -252,19 +284,68 @@ {formatLabel(colValue)}
{colItems.length} - {#if canEdit && onArchiveColumn && colItems.length > 0} - {#if confirmArchiveColumn === colValue} - - - - - {:else} + {#if canEdit} + {#if onCreateInColumn} + class="lane-btn lane-add-btn" + title="Add item to {formatLabel(colValue).toLowerCase()}" + aria-label="Add item to {formatLabel(colValue)}" + onclick={() => onCreateInColumn?.(colValue)} + >+ {/if} +
+ + {#if openMenuColumn === colValue} + + + {/if} +
{/if}
@@ -408,61 +489,138 @@ border-radius: 10px; } - .archive-col-btn { + /* Lane-header affordances (TASK-1671): a `+` add-into-lane button and + a ⋯ kebab that opens the lane menu. Unlike the old hover-only + archive button these are always visible with real (≥28px, ≥32px on + touch) tap targets. */ + .lane-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 4px; background: none; border: none; color: var(--text-muted); - font-size: 0.8em; - cursor: pointer; - padding: 2px 4px; - border-radius: var(--radius-sm); - opacity: 0; - transition: opacity 0.15s; + font-size: 1em; line-height: 1; + cursor: pointer; + border-radius: var(--radius-sm); + transition: color 0.15s, background 0.15s; } - .column-header:hover .archive-col-btn { - opacity: 1; + .lane-add-btn { + font-size: 1.15em; + font-weight: 600; } - .archive-col-btn:hover { + .lane-btn:hover { color: var(--text-primary); background: var(--bg-hover); } - .archive-confirm { - display: flex; - gap: var(--space-1); - align-items: center; + .lane-menu-wrap { + position: relative; + display: inline-flex; } - .archive-yes { + .lane-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 20; + min-width: 180px; + padding: var(--space-1); + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md, 0 4px 12px rgba(0, 0, 0, 0.15)); + display: flex; + flex-direction: column; + gap: 2px; + } + + .lane-menu-item { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: 8px 10px; background: none; border: none; - color: var(--accent-red, #ef4444); - font-size: 0.75em; - cursor: pointer; - padding: 2px 6px; border-radius: var(--radius-sm); - white-space: nowrap; + color: var(--text-primary); + font-size: 0.875em; + text-align: left; + cursor: pointer; } - .archive-yes:hover { + .lane-menu-item:hover { + background: var(--bg-hover); + } + + .lane-menu-item.lmi-danger { + color: var(--accent-red, #ef4444); + } + + .lane-menu-item.lmi-danger:hover { background: color-mix(in srgb, var(--accent-red, #ef4444) 10%, transparent); } - .archive-no { - background: none; - border: none; - color: var(--text-muted); - font-size: 0.75em; - cursor: pointer; - padding: 2px 6px; - border-radius: var(--radius-sm); + .lmi-icon { + width: 1.1em; + text-align: center; + flex-shrink: 0; } - .archive-no:hover { + .lane-menu-sep { + height: 1px; + margin: 2px 0; + background: var(--border); + } + + .lane-menu-confirm { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: 8px 10px; + font-size: 0.8125em; + color: var(--text-secondary); + } + + .lmc-actions { + display: flex; + gap: var(--space-2); + } + + .lmc-yes { + flex: 1; + padding: 6px 10px; + background: var(--accent-red, #ef4444); + border: none; + border-radius: var(--radius-sm); + color: #fff; + font-size: 0.8125em; + cursor: pointer; + } + + .lmc-no { + flex: 1; + padding: 6px 10px; + background: var(--bg-tertiary); + border: none; + border-radius: var(--radius-sm); color: var(--text-primary); + font-size: 0.8125em; + cursor: pointer; + } + + @media (max-width: 768px) { + .lane-btn { + min-width: 32px; + height: 32px; + } } .column-cards { diff --git a/web/src/routes/[username]/[workspace]/[collection]/+page.svelte b/web/src/routes/[username]/[workspace]/[collection]/+page.svelte index 00ab695b..83918bfd 100644 --- a/web/src/routes/[username]/[workspace]/[collection]/+page.svelte +++ b/web/src/routes/[username]/[workspace]/[collection]/+page.svelte @@ -955,6 +955,41 @@ } } + // Create an item directly in a board lane (TASK-1671 / IDEA-1159), + // pre-filling the lane's group field with its column value so it + // lands in that lane. Navigates to the new item to title it, like + // the top-level "+ New" → createNewItem flow. + async function quickCreateInColumn(groupValue: string) { + if (!wsSlug || !collSlug || creatingNew) return; + creatingNew = true; + try { + const schema = collection ? parseSchema(collection) : { fields: [] }; + const defaultFields: Record = {}; + const statusField = schema.fields.find((f) => f.key === 'status'); + if (statusField?.options?.length) { + defaultFields.status = statusField.options[0]; + } + // Pre-fill the lane's group field (status, or a custom + // board_group_by select) so the item opens in this lane. + defaultFields[groupField] = groupValue; + const item = await api.items.create(wsSlug, collSlug, { + title: 'Untitled', + content: '', + fields: JSON.stringify(defaultFields), + source: 'web' + }); + goto(`/${username}/${wsSlug}/${collSlug}/${itemUrlId(item)}?new=1`); + } catch (err: any) { + if (isPlanLimitError(err)) { + toastStore.show(planLimitMessage(err) + ' Upgrade to Pro', 'error', 6000, '/console/billing'); + } else { + toastStore.show(err?.message || 'Failed to create item', 'error'); + } + } finally { + creatingNew = false; + } + } + async function quickCreate() { const title = quickCreateTitle.trim(); if (!title || !wsSlug || !collSlug || creatingNew) return; @@ -1751,6 +1786,7 @@ onArchiveColumn={handleBulkArchive} onGroupReorder={handleGroupReorder} oncreate={canEditThisCollection ? openQuickCreate : undefined} + onCreateInColumn={canEditThisCollection ? quickCreateInColumn : undefined} {itemProgress} {progressLabel} canEdit={canEditThisCollection}