refactor: give home multi-select a reserved gutter and a menu entry

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-08-06 17:05:02 +02:00
co-authored by Claude Opus 5
parent c8ba771797
commit 81dc32e48e
10 changed files with 95 additions and 81 deletions
@@ -54,7 +54,11 @@
clicks++
}}
class={twMerge(
'rounded max-w-4 w-full',
// `no-default-style` opts out of the global `input` rule in tailwind.config.cjs,
// which is written for text inputs: it forces `width: 100%`, `display: block`
// and text padding onto a checkbox, inflating the 16px box to 18px and putting
// a floor under it that `w-4` can't get past.
'no-default-style rounded max-w-4 w-full',
// When disabled, grey it and let hover fall through to a wrapping trigger
// (e.g. a tooltip explaining why it can't be selected).
disabled ? 'opacity-50 cursor-not-allowed pointer-events-none' : '',
@@ -11,7 +11,7 @@
import { createEventDispatcher } from 'svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import type { RowSelection } from './rowSelection'
import { selectMenuItems, type RowSelection } from './rowSelection'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import Badge from '../badge/Badge.svelte'
import {
@@ -188,6 +188,7 @@
const canEdit = canWrite && showEditButton
if (draft_only) {
return [
...selectMenuItems(rowSelection),
{
displayName: 'Delete',
icon: Trash,
@@ -221,6 +222,7 @@
]
}
return [
...selectMenuItems(rowSelection),
{
displayName: 'Duplicate/Fork',
icon: GitFork,
@@ -14,7 +14,7 @@
import Badge from '../badge/Badge.svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import type { RowSelection } from './rowSelection'
import { selectMenuItems, type RowSelection } from './rowSelection'
import { sendUserToast } from '$lib/toast'
import { copyToClipboard, isOwner } from '$lib/utils'
import { isDeployable } from '$lib/utils_deployable'
@@ -210,6 +210,7 @@
const canEdit = flow.canWrite && showEditButton
if (draft_only) {
return [
...selectMenuItems(rowSelection),
{
displayName: 'Delete',
icon: Trash,
@@ -233,6 +234,7 @@
]
}
return [
...selectMenuItems(rowSelection),
{
displayName: 'View runs',
icon: List,
@@ -25,8 +25,10 @@
* children — checkbox, buttons, links) toggles selection. Opt-in so
* existing tables that don't want it are unaffected. */
selectOnRowClick?: boolean
/** Home-style multi-select: the kind icon doubles as the checkbox instead
* of adding a column, so an unused selection costs the row nothing. */
/** Home-style multi-select: a leading gutter the row reserves whether or
* not it is in use, revealing its checkbox on hover. Unlike `isSelectable`
* the checkbox is not shown at rest, so an unused selection costs the row
* 16px and no visual noise. */
rowSelection?: RowSelection
alignWithSelectable?: boolean
errorHandlerMuted?: boolean
@@ -167,7 +169,9 @@
<!-- Tree-view alignment: a folder header's icon sits at px-4 (16px) + its inner
padding-left of depth*16, i.e. (depth+1)*16. This row's inline padding-left
overrides px-4, so it must carry the full (depth+1)*16 for a file to line up
with its sibling folder at the same depth. -->
with its sibling folder at the same depth. The selection gutter below adds a
further 16px, which TreeView's folder header mirrors with an empty box of
the same width and margins — change one and the other has to follow. -->
<div
bind:this={rowEl}
data-row-selection-key={rowSelection?.key}
@@ -201,35 +205,30 @@
{/if}
{#if rowSelection}
<!-- The icon slot itself: the kind icon until the row is hovered (or
selection mode is on), the checkbox from then on. Both are stacked in a
fixed 16px box and swapped with visibility so nothing shifts. -->
<div class="shrink relative w-4 h-4">
<div
class={twMerge(
'absolute inset-0',
rowSelection.active ? 'invisible' : 'group-hover/row:invisible'
)}
>
<RowIcon {kind} {triggerKind} />
</div>
<Checkbox
class={twMerge(
'absolute inset-0 w-4 h-4',
rowSelection.active ? '' : 'invisible group-hover/row:visible'
)}
checked={rowSelection.selected}
title={rowSelection.selected ? 'Deselect' : 'Select (shift-click to select a range)'}
onClick={(e) => {
// Left unprevented on purpose: the browser's own toggle already lands
// on the value we are about to compute, except on a range re-select,
// which Checkbox re-asserts. Preventing it would revert the box AFTER
// the update and leave every clicked row visually unticked.
e.stopPropagation()
rowSelection?.onToggle(e)
}}
/>
</div>
<!-- A gutter the row reserves whether or not it is in use: the checkbox is
only visible from hover onwards, but the space it occupies is not
conditional, so the kind icon never moves and starting a selection
doesn't reflow the list. The negative margins halve the 32px the box
would otherwise add, by sitting it inside the row's own left padding
and closing half of `gap-4`. -->
<Checkbox
class={twMerge(
// Exactly 16px wide, which is what TreeView's folder header reserves to
// keep a file's icon aligned with a sibling folder's.
'w-4 h-4 shrink-0 -ml-2 -mr-2',
rowSelection.active ? '' : 'invisible group-hover/row:visible'
)}
checked={rowSelection.selected}
title={rowSelection.selected ? 'Deselect' : 'Select (shift-click to select a range)'}
onClick={(e) => {
// Left unprevented on purpose: the browser's own toggle already lands
// on the value we are about to compute, except on a range re-select,
// which Checkbox re-asserts. Preventing it would revert the box AFTER
// the update and leave every clicked row visually unticked.
e.stopPropagation()
rowSelection?.onToggle(e)
}}
/>
{/if}
{#if href && !inSelectionMode}
@@ -237,10 +236,10 @@
{href}
class="min-w-0 grow hover:underline decoration-gray-400 inline-flex items-center gap-4"
>
{@render rowContent(!rowSelection)}
{@render rowContent()}
</a>
{:else}
{@render rowContent(!rowSelection)}
{@render rowContent()}
{/if}
{#if errorHandlerMuted}
@@ -266,12 +265,10 @@
</div>
</div>
{#snippet rowContent(withIcon: boolean)}
{#if withIcon}
<div class="shrink">
<RowIcon {kind} {triggerKind} />
</div>
{/if}
{#snippet rowContent()}
<div class="shrink">
<RowIcon {kind} {triggerKind} />
</div>
<div class="grow min-w-0">
<div class="text-emphasis flex-wrap text-left text-xs font-semibold">
{#if customSummary}
@@ -16,7 +16,7 @@
import Badge from '../badge/Badge.svelte'
import Button from '../button/Button.svelte'
import Row from './Row.svelte'
import type { RowSelection } from './rowSelection'
import { selectMenuItems, type RowSelection } from './rowSelection'
import { sendUserToast } from '$lib/toast'
import { capitalize, copyToClipboard, isOwner } from '$lib/utils'
import { isDeployable } from '$lib/utils_deployable'
@@ -266,6 +266,7 @@
const canEdit = script.canWrite && showEditButton
if (script.draft_only) {
return [
...selectMenuItems(rowSelection),
{
displayName: 'View code',
icon: Code,
@@ -296,6 +297,7 @@
]
}
return [
...selectMenuItems(rowSelection),
{
displayName: 'View code',
icon: Code,
@@ -1,8 +1,12 @@
import { SquareCheckBig } from 'lucide-svelte'
import type { Item } from '$lib/utils'
/**
* Wiring for a row whose kind icon doubles as a selection control: the icon
* swaps to a checkbox on hover, and stays one while a selection is active.
* Distinct from `Row`'s `isSelectable`, which adds a permanent leading checkbox
* column — this variant leaves the default row untouched until it is used.
* Wiring for a row that can join a multi-selection. The control lives in a
* leading gutter the row reserves unconditionally — empty until the row is
* hovered — so the kind icon keeps its place and starting a selection never
* reflows the list. Distinct from `Row`'s `isSelectable`, which shows its
* checkbox at all times.
*/
export type RowSelection = {
/** Stable row identity; also emitted as `data-row-selection-key` so a caller
@@ -14,3 +18,19 @@ export type RowSelection = {
active: boolean
onToggle: (e: MouseEvent | KeyboardEvent) => void
}
/**
* The row menu's way into a selection, for the rows that offer one. The gutter
* checkbox is the fast path but only appears on hover; this is the one a user
* can find by looking.
*/
export function selectMenuItems(rowSelection: RowSelection | undefined): Item[] {
if (!rowSelection) return []
return [
{
displayName: rowSelection.selected ? 'Deselect' : 'Select',
icon: SquareCheckBig,
action: (e) => rowSelection.onToggle(e)
}
]
}
@@ -71,8 +71,8 @@
function actionTitle(action: BulkAction): string {
const n = targets(action).length
// Selection mode is entered from the toolbar with nothing picked yet, so this
// is the state the primary entry point lands on — it has no blocked reason.
// Reachable by unticking the last row without leaving selection mode: there is
// nothing to block, so no reason to report.
if (items.length === 0) return `Select items to ${ACTION_LABEL[action].toLowerCase()}`
if (n === 0) return `Cannot ${ACTION_LABEL[action].toLowerCase()}: ${blockedSummary(action)}`
if (n < items.length) return `${ACTION_LABEL[action]} ${n} of the ${items.length} selected`
@@ -19,7 +19,6 @@
import type uFuzzy from '@leeoniya/ufuzzy'
import {
ArrowDownUp,
CheckSquare,
ChevronsDownUp,
ChevronsUpDown,
Code2,
@@ -1606,7 +1605,7 @@
<div class="mt-10"></div>
{/if}
{#if !loading}
<div class="flex w-full flex-row-reverse gap-2 mt-2 mb-1 items-center h-6">
<div class="flex w-full flex-row-reverse gap-2 mt-2 mb-1 items-center h-7">
<Popover floatingConfig={{ placement: 'bottom-end' }}>
{#snippet trigger()}
<Button
@@ -1615,10 +1614,8 @@
}}
nonCaptureEvent
iconOnly
size="xs"
color="light"
variant="default"
spacingSize="xs2"
unifiedSize="sm"
variant="subtle"
/>
{/snippet}
{#snippet content()}
@@ -1660,10 +1657,8 @@
nonCaptureEvent
disabled={filter !== ''}
iconOnly={short === ''}
size="xs"
color="light"
variant="default"
spacingSize="xs2"
unifiedSize="sm"
variant="subtle"
startIcon={{ icon: ArrowDownUp }}
title={filter !== ''
? 'Sorting is disabled while searching (results are ranked by relevance)'
@@ -1689,21 +1684,6 @@
{/if}
</Button>
{/if}
{#if homeSelection.available && !homeSelection.active}
<!-- Last child of a flex-row-reverse row, so `mr-auto` absorbs the free
space and pins it to the far left, away from the view/sort controls. -->
<Button
wrapperClasses="mr-auto"
startIcon={{ icon: CheckSquare }}
iconOnly
size="xs"
color="light"
variant="default"
spacingSize="xs2"
title="Select items — move, archive, delete or discard several at once"
on:click={() => homeSelection.enter()}
/>
{/if}
</div>
{/if}
</div>
@@ -9,6 +9,7 @@
import { pluralize } from '$lib/utils'
import { base } from '$lib/base'
import { Button } from '$lib/components/common'
import { getHomeSelection } from './homeSelection.svelte'
interface Props {
item: ItemType | FolderItem | UserItem
@@ -243,6 +244,11 @@
onCollapseOwner?.(nodePrefix)
}
}
// A leaf row reserves a checkbox gutter whenever selection is offered, which
// would otherwise push its kind icon 16px right of a sibling folder's — the
// two must stay on the same x to read as the same level of the tree.
const homeSelection = getHomeSelection()
</script>
{#if isFolder(item) || isUser(item)}
@@ -257,6 +263,10 @@
class={twMerge('flex flex-row items-center gap-4 text-sm font-semibold')}
style={depth > 0 ? `padding-left: ${depth * 16}px;` : ''}
>
{#if homeSelection?.available}
<!-- Mirrors the leaf row's checkbox box and its margins exactly. -->
<div class="w-4 shrink-0 -ml-2 -mr-2"></div>
{/if}
<div class="flex justify-center items-center">
{#if isUser(item)}
<User size={16} class="text-secondary" />
@@ -77,8 +77,9 @@ export class HomeSelection {
/** The page offers multi-selection at all (never to an operator, and not on
* the embedded read-only variants of the list). */
available = $state(false)
/** Selection mode is on even with nothing selected yet — entered from the
* toolbar, so every row reveals its checkbox before the first pick. */
/** The user has started selecting. Kept separate from `size > 0` so that
* unticking the last row leaves them in selection mode rather than dropping
* them out of it mid-task; only `exit` clears it. */
private explicit = $state(false)
private selected = new SvelteMap<string, BulkItem>()
/** Every rendered selectable row, so a shift-click range can resolve the keys
@@ -133,10 +134,6 @@ export class HomeSelection {
this.registry.delete(key)
}
enter(): void {
this.explicit = true
}
exit(): void {
this.explicit = false
this.selected.clear()