mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 03:16:43 +00:00
feat(board): lane-header + add button + kebab menu shell (TASK-1671) (#672)
* 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).
This commit is contained in:
@@ -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<string, { total: number; done: number }>;
|
||||
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<string | null>(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<string | null>(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 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
{#if items.length === 0}
|
||||
<EmptyState {collection} {wsSlug} {oncreate} />
|
||||
{:else}
|
||||
@@ -252,19 +284,68 @@
|
||||
<span class="column-name">{formatLabel(colValue)}</span>
|
||||
<div class="column-actions">
|
||||
<span class="column-count">{colItems.length}</span>
|
||||
{#if canEdit && onArchiveColumn && colItems.length > 0}
|
||||
{#if confirmArchiveColumn === colValue}
|
||||
<span class="archive-confirm">
|
||||
<button class="archive-yes" onclick={() => { onArchiveColumn(colItems); confirmArchiveColumn = null; }}>Archive {colItems.length}?</button>
|
||||
<button class="archive-no" onclick={() => confirmArchiveColumn = null}>Cancel</button>
|
||||
</span>
|
||||
{:else}
|
||||
{#if canEdit}
|
||||
{#if onCreateInColumn}
|
||||
<button
|
||||
class="archive-col-btn"
|
||||
title="Archive all {formatLabel(colValue).toLowerCase()} items"
|
||||
onclick={() => confirmArchiveColumn = colValue}
|
||||
>🗃</button>
|
||||
class="lane-btn lane-add-btn"
|
||||
title="Add item to {formatLabel(colValue).toLowerCase()}"
|
||||
aria-label="Add item to {formatLabel(colValue)}"
|
||||
onclick={() => onCreateInColumn?.(colValue)}
|
||||
>+</button>
|
||||
{/if}
|
||||
<div class="lane-menu-wrap">
|
||||
<button
|
||||
class="lane-btn lane-menu-btn"
|
||||
title="Lane actions"
|
||||
aria-label="{formatLabel(colValue)} lane actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={openMenuColumn === colValue}
|
||||
onclick={(e) => { e.stopPropagation(); toggleMenu(colValue); }}
|
||||
>⋯</button>
|
||||
{#if openMenuColumn === colValue}
|
||||
<!-- stopPropagation on every in-menu click: an
|
||||
onclick that mutates state (e.g. showing the
|
||||
archive confirm) re-renders and detaches the
|
||||
clicked button before the event bubbles to
|
||||
<svelte:window onclick>, where closest() on the
|
||||
now-orphaned node returns null and slams the
|
||||
menu shut. Same Svelte 5 same-click bubbling
|
||||
issue documented in console/+layout.svelte. -->
|
||||
<div class="lane-menu" role="menu">
|
||||
{#if onCreateInColumn}
|
||||
<button
|
||||
class="lane-menu-item"
|
||||
role="menuitem"
|
||||
onclick={(e) => { e.stopPropagation(); onCreateInColumn?.(colValue); closeMenu(); }}
|
||||
>
|
||||
<span class="lmi-icon" aria-hidden="true">+</span> Add item here
|
||||
</button>
|
||||
{/if}
|
||||
{#if onArchiveColumn && colItems.length > 0}
|
||||
{#if onCreateInColumn}
|
||||
<div class="lane-menu-sep"></div>
|
||||
{/if}
|
||||
{#if confirmArchiveColumn === colValue}
|
||||
<div class="lane-menu-confirm">
|
||||
<span>Archive {colItems.length} item{colItems.length === 1 ? '' : 's'}?</span>
|
||||
<div class="lmc-actions">
|
||||
<button class="lmc-yes" onclick={(e) => { e.stopPropagation(); onArchiveColumn?.(colItems); closeMenu(); }}>Archive</button>
|
||||
<button class="lmc-no" onclick={(e) => { e.stopPropagation(); confirmArchiveColumn = null; }}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="lane-menu-item lmi-danger"
|
||||
role="menuitem"
|
||||
onclick={(e) => { e.stopPropagation(); confirmArchiveColumn = colValue; }}
|
||||
>
|
||||
<span class="lmi-icon" aria-hidden="true">🗃</span> Archive all ({colItems.length})
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, any> = {};
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user