diff --git a/frontend/src/lib/components/common/checkbox/Checkbox.svelte b/frontend/src/lib/components/common/checkbox/Checkbox.svelte index 270b260d04..980f2f0a71 100644 --- a/frontend/src/lib/components/common/checkbox/Checkbox.svelte +++ b/frontend/src/lib/components/common/checkbox/Checkbox.svelte @@ -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' : '', diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 636bd2c9d6..7e553143bb 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -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, diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index a155ca3a97..55d4b2b142 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -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, diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index 849d77e2d9..a04e6f8e11 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -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 @@ + 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. -->
-
-
- -
- { - // 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) - }} - /> -
+ + { + // 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()} {:else} - {@render rowContent(!rowSelection)} + {@render rowContent()} {/if} {#if errorHandlerMuted} @@ -266,12 +265,10 @@
-{#snippet rowContent(withIcon: boolean)} - {#if withIcon} -
- -
- {/if} +{#snippet rowContent()} +
+ +
{#if customSummary} diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 25b557690e..49e290f7fc 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -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, diff --git a/frontend/src/lib/components/common/table/rowSelection.ts b/frontend/src/lib/components/common/table/rowSelection.ts index 081f29addc..1a4220cba5 100644 --- a/frontend/src/lib/components/common/table/rowSelection.ts +++ b/frontend/src/lib/components/common/table/rowSelection.ts @@ -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) + } + ] +} diff --git a/frontend/src/lib/components/home/BulkActionsBar.svelte b/frontend/src/lib/components/home/BulkActionsBar.svelte index f36ed4848a..05370326e2 100644 --- a/frontend/src/lib/components/home/BulkActionsBar.svelte +++ b/frontend/src/lib/components/home/BulkActionsBar.svelte @@ -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` diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index a1369cdc68..1a87d88a6b 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -19,7 +19,6 @@ import type uFuzzy from '@leeoniya/ufuzzy' import { ArrowDownUp, - CheckSquare, ChevronsDownUp, ChevronsUpDown, Code2, @@ -1606,7 +1605,7 @@
{/if} {#if !loading} -
+
{#snippet trigger()} {/if} - {#if homeSelection.available && !homeSelection.active} - -
{/if}
diff --git a/frontend/src/lib/components/home/TreeView.svelte b/frontend/src/lib/components/home/TreeView.svelte index e688c5b1a2..4a56e94454 100644 --- a/frontend/src/lib/components/home/TreeView.svelte +++ b/frontend/src/lib/components/home/TreeView.svelte @@ -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() {#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} + +
+ {/if}
{#if isUser(item)} diff --git a/frontend/src/lib/components/home/homeSelection.svelte.ts b/frontend/src/lib/components/home/homeSelection.svelte.ts index fa755aabb3..da0e4fc5c6 100644 --- a/frontend/src/lib/components/home/homeSelection.svelte.ts +++ b/frontend/src/lib/components/home/homeSelection.svelte.ts @@ -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() /** 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()