feat(board): per-lane sort override in the kebab menu (TASK-1673) (#677)

Add a "Sort lane by" drill-down to LaneActionsMenu — an ephemeral
per-lane override on top of the page-wide sort. BoardView holds the
override map (Record<lane, SortMode>, not persisted), applies the
effective mode (override ?? page default) per lane in propColumnData,
and disables drag per-lane under a non-manual effective sort. The
submenu lists the same options as the toolbar (Priority hidden when no
priority field) plus a "Page default" entry to clear the override, with
a check on the active one.

Sort is a view preference, available to everyone — so the kebab now
shows for any non-empty lane (not just editors), and the menu's
separators are section-gated so nothing dangles when a viewer sees only
Sort.
This commit is contained in:
xarmian
2026-05-30 22:47:00 -04:00
committed by GitHub
parent 2cd8932148
commit 710c76fb66
2 changed files with 115 additions and 38 deletions
@@ -82,22 +82,18 @@
openMenuColumn = null;
}
// Any bulk verb wired (each encodes its own owner/editor permission).
// The bulk menu entries only render for a NON-empty lane, so kebab
// visibility is computed per-lane below as `onCreateInColumn ||
// (laneHasItems && hasBulkActions)` — otherwise a role-only bulk
// editor would get a ⋯ that opens an empty panel on an empty lane
// (TASK-1672 / Codex round 5).
let hasBulkActions = $derived(
!!(
onArchiveColumn ||
onMoveColumn ||
onTagColumn ||
onUntagColumn ||
onSetPriorityColumn ||
onAssignColumn
)
);
// Ephemeral per-lane sort overrides (TASK-1673): a lane sorts by its
// override when set, else the page-wide `sortMode`. Not persisted —
// cleared on reload. Available to everyone (sort is a view preference).
let laneSortOverrides = $state<Record<string, SortMode>>({});
function setLaneSort(colValue: string, mode: SortMode | null) {
if (mode === null) {
delete laneSortOverrides[colValue];
} else {
laneSortOverrides[colValue] = mode;
}
}
const laneSortFor = (colValue: string): SortMode => laneSortOverrides[colValue] ?? sortMode;
// Dismiss the open lane menu on any click outside it (mirrors the
// QuickActionsMenu pattern). The menu markup lives under
@@ -201,12 +197,12 @@
}
// `preserveOrder` opts out of the in-column sort so search rank
// from the parent isn't overridden — TASK-1367. Otherwise sort
// each lane by the page-wide sort mode (TASK-1670); 'manual'
// each lane by its effective mode — the per-lane override if set,
// else the page-wide sort (TASK-1670 / TASK-1673); 'manual'
// resolves to the stored sort_order, preserving prior behavior.
if (!preserveOrder) {
const cmp = itemComparator(sortMode, collection);
for (const col of columns) {
result[col].sort(cmp);
result[col].sort(itemComparator(laneSortFor(col), collection));
}
}
return result;
@@ -339,7 +335,9 @@
onclick={() => onCreateInColumn?.(colValue)}
>+</button>
{/if}
{#if onCreateInColumn || (colItems.length > 0 && hasBulkActions)}
<!-- Kebab shows for create OR any non-empty lane (sort is
always available, even to viewers — TASK-1673). -->
{#if onCreateInColumn || colItems.length > 0}
<div class="lane-menu-wrap">
<button
class="lane-btn lane-menu-btn"
@@ -358,6 +356,9 @@
{filtered}
{members}
{tagSuggestions}
{sortMode}
laneSort={laneSortOverrides[colValue]}
onSetLaneSort={(m) => setLaneSort(colValue, m)}
onClose={closeMenu}
onAddItem={onCreateInColumn ? () => onCreateInColumn?.(colValue) : undefined}
onArchive={onArchiveColumn ? () => onArchiveColumn?.(colItems) : undefined}
@@ -389,7 +390,7 @@
// under any non-manual page sort (TASK-1670): the
// lane is comparator-ordered, so a drag couldn't
// stick anyway.
dragDisabled: isMobile || !canEdit || preserveOrder || sortMode !== 'manual'
dragDisabled: isMobile || !canEdit || preserveOrder || laneSortFor(colValue) !== 'manual'
}}
onconsider={(e) => handleConsider(colValue, e)}
onfinalize={(e) => handleFinalize(colValue, e)}
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Item, Collection } from '$lib/types';
import { parseSchema } from '$lib/types';
import { SORT_OPTIONS, priorityField, type SortMode } from '$lib/collections/itemSort';
interface Props {
/** The lane's CURRENTLY-FILTERED items — every action operates on these. */
@@ -15,6 +16,13 @@
members: { user_id: string; user_name?: string }[];
/** Workspace tag suggestions for "Tag all". */
tagSuggestions: string[];
// Page-wide sort default + this lane's ephemeral override (TASK-1673).
// `laneSort` undefined = follow the page default. onSetLaneSort(null)
// clears the override. Sorting is a view preference, available to
// everyone (including viewers) — not gated on edit permission.
sortMode?: SortMode;
laneSort?: SortMode;
onSetLaneSort?: (mode: SortMode | null) => void;
onClose: () => void;
// Each action is optional: the caller passes only the ones the
// current user is permitted to perform (the single `+` create is
@@ -37,6 +45,9 @@
filtered,
members,
tagSuggestions,
sortMode = 'manual',
laneSort = undefined,
onSetLaneSort,
onClose,
onAddItem,
onArchive,
@@ -47,7 +58,7 @@
onAssign
}: Props = $props();
type View = 'root' | 'move' | 'tag' | 'untag' | 'priority' | 'assign';
type View = 'root' | 'move' | 'tag' | 'untag' | 'priority' | 'assign' | 'sort';
let view = $state<View>('root');
let confirmArchive = $state(false);
let tagInput = $state('');
@@ -70,6 +81,18 @@
);
let priorityOptions = $derived(priorityFieldDef?.options ?? []);
// Sort options offered in the "Sort lane by" submenu — same set the
// page toolbar uses, minus Priority when the collection has no priority
// field (TASK-1673). `effectiveSort` is the lane's current order: its
// override if set, else the page default.
let sortOptions = $derived(
priorityField(collection) ? SORT_OPTIONS : SORT_OPTIONS.filter((o) => o.value !== 'priority')
);
let effectiveSort = $derived(laneSort ?? sortMode);
let sortLabel = $derived(
SORT_OPTIONS.find((o) => o.value === effectiveSort)?.label ?? 'Manual'
);
// Untag offers only the tags actually present on the lane's items.
let laneTags = $derived.by(() => {
const set = new Set<string>();
@@ -83,6 +106,20 @@
return [...set].sort();
});
// Section presence — drives separators so none dangle when a section
// (e.g. the bulk verbs for a viewer) is empty. Declared after laneTags
// since hasVerbs references it.
let hasSort = $derived(count > 0 && !!onSetLaneSort);
let hasVerbs = $derived(
count > 0 &&
(!!(onMove && moveTargets.length) ||
!!onTag ||
!!(onUntag && laneTags.length) ||
!!(onSetPriority && priorityOptions.length) ||
!!(onAssign && members.length))
);
let hasArchive = $derived(count > 0 && !!onArchive);
function fmt(v: string): string {
return v.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
@@ -113,9 +150,16 @@
</button>
{/if}
{#if count > 0}
<div class="lane-menu-sep"></div>
{#if hasSort}
{#if onAddItem}<div class="lane-menu-sep"></div>{/if}
<button class="lane-menu-item" role="menuitem" onclick={(e) => run(e, () => (view = 'sort'))}>
<span class="lmi-icon" aria-hidden="true"></span> Sort lane by
<span class="lmi-chevron">{sortLabel} </span>
</button>
{/if}
{#if hasVerbs}
{#if onAddItem || hasSort}<div class="lane-menu-sep"></div>{/if}
{#if onMove && moveTargets.length > 0}
<button class="lane-menu-item" role="menuitem" onclick={(e) => run(e, () => (view = 'move'))}>
<span class="lmi-icon" aria-hidden="true"></span> Move all to <span class="lmi-chevron"></span>
@@ -145,22 +189,22 @@
<span class="lmi-icon" aria-hidden="true">👤</span> Assign all <span class="lmi-chevron"></span>
</button>
{/if}
{/if}
{#if onArchive}
<div class="lane-menu-sep"></div>
{#if confirmArchive}
<div class="lane-menu-confirm">
<span>Archive {count} item{count === 1 ? '' : 's'}{scopeNote}?</span>
<div class="lmc-actions">
<button class="lmc-yes" onclick={(e) => run(e, () => { onArchive?.(); onClose(); })}>Archive</button>
<button class="lmc-no" onclick={(e) => run(e, () => (confirmArchive = false))}>Cancel</button>
</div>
{#if hasArchive}
{#if onAddItem || hasSort || hasVerbs}<div class="lane-menu-sep"></div>{/if}
{#if confirmArchive}
<div class="lane-menu-confirm">
<span>Archive {count} item{count === 1 ? '' : 's'}{scopeNote}?</span>
<div class="lmc-actions">
<button class="lmc-yes" onclick={(e) => run(e, () => { onArchive?.(); onClose(); })}>Archive</button>
<button class="lmc-no" onclick={(e) => run(e, () => (confirmArchive = false))}>Cancel</button>
</div>
{:else}
<button class="lane-menu-item lmi-danger" role="menuitem" onclick={(e) => run(e, () => (confirmArchive = true))}>
<span class="lmi-icon" aria-hidden="true">🗃</span> Archive all ({count}{scopeNote})
</button>
{/if}
</div>
{:else}
<button class="lane-menu-item lmi-danger" role="menuitem" onclick={(e) => run(e, () => (confirmArchive = true))}>
<span class="lmi-icon" aria-hidden="true">🗃</span> Archive all ({count}{scopeNote})
</button>
{/if}
{/if}
{:else if view === 'move'}
@@ -211,6 +255,31 @@
</button>
{/each}
{/if}
{:else if view === 'sort'}
<button class="lane-menu-back" onclick={(e) => run(e, () => (view = 'root'))}> Sort lane by</button>
<!-- Clear the ephemeral override → follow the page-wide sort. -->
<button
class="lane-menu-item"
role="menuitemradio"
aria-checked={laneSort === undefined}
onclick={(e) => run(e, () => { onSetLaneSort?.(null); onClose(); })}
>
<span class="lmi-check" aria-hidden="true">{laneSort === undefined ? '✓' : ''}</span>
Page default
<span class="lmi-chevron">{SORT_OPTIONS.find((o) => o.value === sortMode)?.label}</span>
</button>
<div class="lane-menu-sep"></div>
{#each sortOptions as opt (opt.value)}
<button
class="lane-menu-item"
role="menuitemradio"
aria-checked={laneSort === opt.value}
onclick={(e) => run(e, () => { onSetLaneSort?.(opt.value); onClose(); })}
>
<span class="lmi-check" aria-hidden="true">{laneSort === opt.value ? '✓' : ''}</span>
{opt.label}
</button>
{/each}
{/if}
</div>
@@ -266,6 +335,13 @@
flex-shrink: 0;
}
.lmi-check {
width: 1.1em;
flex-shrink: 0;
color: var(--accent-blue);
font-size: 0.9em;
}
.lmi-chevron {
margin-left: auto;
color: var(--text-muted);