mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 16:05:58 +00:00
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:
co-authored by
Claude Opus 5
parent
c8ba771797
commit
81dc32e48e
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user