mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
fix: flow step picker layout and single hover/keyboard highlight (#10488)
* fix: keep flow step picker rows on one line and highlight only one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: restore hover on standalone picker rows and drop phantom ai slots Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop inert picker resize and align hub rows with workspace rows The step picker popover carried `!resize` but computes `overflow: visible`, so CSS `resize` never applied and the handle did nothing. Dropping it also pins the inner height at 464px, keeping `displayPath` off everywhere except the content-sized trigger picker. Hub rows there rendered summary and path inside a fixed 28px button; give them the same `h-auto min-h-7 py-1` the workspace rows got. Guard `hover:bg-transparent` on `onHover` in both pickers so all three agree, and drop the unconditional `title` on TopLevelNode, which put a native tooltip on every kind button. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: keep GenAiQuick's CSS hover when it is not wired into the shared index Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -217,6 +217,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse and keyboard share this one index, so rows report hover on mousemove rather than
|
||||
// mouseenter: arrow keys scroll the list, which slides a row under a stationary cursor and
|
||||
// fires mouseenter, which would otherwise hijack the selection mid-navigation.
|
||||
function hover(index: number) {
|
||||
selectedByKeyboard = index
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
$insertButtonOpen = true
|
||||
})
|
||||
@@ -247,9 +254,24 @@
|
||||
preFilter
|
||||
untrack(() => onPrefilterChange(preFilter))
|
||||
})
|
||||
let aiLength = $derived(
|
||||
funcDesc?.length > 0 && !disableAi && selectedKind != 'flow' && preFilter == 'all' ? 2 : 0
|
||||
// Gates the two AI rows and their slots in the index space; both must agree or arrow keys land
|
||||
// on indices that render nothing.
|
||||
let showAiRows = $derived(
|
||||
!disableAi &&
|
||||
funcDesc?.length > 0 &&
|
||||
kind != 'failure' &&
|
||||
kind != 'preprocessor' &&
|
||||
(selectedKind == 'script' || selectedKind == 'trigger') &&
|
||||
preFilter == 'all'
|
||||
)
|
||||
let aiLength = $derived(showAiRows ? 2 : 0)
|
||||
|
||||
// Every result row lives in one keyboard index space, and hovering a row moves that index, so
|
||||
// mouse and keyboard can never highlight two different rows. Offsets follow the render order.
|
||||
let inlineOffset = $derived(topLevelNodes.length)
|
||||
let aiOffset = $derived(inlineOffset + (inlineScripts?.length ?? 0))
|
||||
let workspaceOffset = $derived(aiOffset + aiLength)
|
||||
let hubOffset = $derived(workspaceOffset + (filteredWorkspaceItems?.length ?? 0))
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeyDown} />
|
||||
@@ -281,8 +303,9 @@
|
||||
icon: owner.startsWith('f/') ? Folder : User,
|
||||
props: { width: 14, height: 14 }
|
||||
}}
|
||||
title={owner.slice(2)}
|
||||
>
|
||||
{owner.slice(2)}
|
||||
<span class="truncate">{owner.slice(2)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -355,6 +378,7 @@
|
||||
}}
|
||||
{label}
|
||||
selected={selectedByKeyboard === i}
|
||||
onHover={() => hover(i)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -395,7 +419,8 @@
|
||||
{#each inlineScripts as [label, lang], i (lang)}
|
||||
<FlowScriptPickerQuick
|
||||
eeRestricted={!$enterpriseLicense && enterpriseLangs.includes(lang)}
|
||||
selected={selectedByKeyboard === i + topLevelNodes.length}
|
||||
selected={selectedByKeyboard === i + inlineOffset}
|
||||
onHover={() => hover(i + inlineOffset)}
|
||||
{enterpriseLangs}
|
||||
{label}
|
||||
lang={lang == 'docker' ? 'bash' : lang}
|
||||
@@ -419,13 +444,14 @@
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if !disableAi && funcDesc?.length > 0 && kind != 'failure' && kind != 'preprocessor' && (selectedKind == 'script' || selectedKind == 'trigger') && preFilter == 'all'}
|
||||
{#if showAiRows}
|
||||
<ul class="transition-all">
|
||||
<li
|
||||
><GenAiQuick
|
||||
{funcDesc}
|
||||
lang="TypeScript"
|
||||
selected={selectedByKeyboard === inlineScripts?.length + topLevelNodes.length}
|
||||
selected={selectedByKeyboard === aiOffset}
|
||||
onHover={() => hover(aiOffset)}
|
||||
on:click={() => {
|
||||
lang = 'bun'
|
||||
onGenerate()
|
||||
@@ -436,7 +462,8 @@
|
||||
<GenAiQuick
|
||||
{funcDesc}
|
||||
lang="Python"
|
||||
selected={selectedByKeyboard === inlineScripts?.length + topLevelNodes.length + 1}
|
||||
selected={selectedByKeyboard === aiOffset + 1}
|
||||
onHover={() => hover(aiOffset + 1)}
|
||||
on:click={() => {
|
||||
lang = 'python3'
|
||||
onGenerate()
|
||||
@@ -463,11 +490,12 @@
|
||||
bind:filteredWithOwner={filteredWorkspaceItems}
|
||||
{filter}
|
||||
kind={selectedKind}
|
||||
selected={selectedByKeyboard - inlineScripts?.length - aiLength - topLevelNodes.length}
|
||||
selected={selectedByKeyboard - workspaceOffset}
|
||||
on:pickScript
|
||||
on:pickFlow
|
||||
{displayPath}
|
||||
{refreshCount}
|
||||
onHover={(i) => hover(workspaceOffset + i)}
|
||||
/>
|
||||
{/await}
|
||||
<div class="pb-1"></div>
|
||||
@@ -511,15 +539,12 @@
|
||||
}
|
||||
appFilter={selected?.name}
|
||||
kind={selectedKind}
|
||||
selected={selectedByKeyboard -
|
||||
inlineScripts?.length -
|
||||
aiLength -
|
||||
filteredWorkspaceItems?.length -
|
||||
topLevelNodes.length}
|
||||
selected={selectedByKeyboard - hubOffset}
|
||||
on:pickScript
|
||||
bind:loading
|
||||
{displayPath}
|
||||
{refreshCount}
|
||||
onHover={(i) => hover(hubOffset + i)}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { createBubbler } from 'svelte/legacy';
|
||||
import { createBubbler } from 'svelte/legacy'
|
||||
|
||||
const bubble = createBubbler();
|
||||
const bubble = createBubbler()
|
||||
import { Wand2 } from 'lucide-svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
funcDesc: string;
|
||||
selected: boolean;
|
||||
lang: string;
|
||||
funcDesc: string
|
||||
selected: boolean
|
||||
lang: string
|
||||
onHover?: () => void
|
||||
}
|
||||
|
||||
let { funcDesc, selected, lang }: Props = $props();
|
||||
let { funcDesc, selected, lang, onHover = undefined }: Props = $props()
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -24,10 +25,13 @@
|
||||
|
||||
<svelte:window onkeydown={onKeyDown} />
|
||||
<button
|
||||
class="px-3 py-2 gap-2 w-full text-left hover:bg-surface-hover flex flex-row items-center transition-all rounded-md {selected
|
||||
class="px-3 py-2 gap-2 w-full text-left flex flex-row items-center transition-all rounded-md {selected
|
||||
? 'bg-surface-hover'
|
||||
: ''}"
|
||||
: onHover
|
||||
? ''
|
||||
: 'hover:bg-surface-hover'}"
|
||||
onclick={bubble('click')}
|
||||
onmousemove={() => onHover?.()}
|
||||
>
|
||||
<Wand2 size={14} class="text-ai" />
|
||||
|
||||
|
||||
@@ -43,10 +43,12 @@
|
||||
let loading = $state(false)
|
||||
let small = $derived(kind === 'preprocessor' || kind === 'failure')
|
||||
|
||||
let width = $state(0)
|
||||
let height = $state(0)
|
||||
let owners = $state([])
|
||||
let displayPath = $derived(width > 650 || height > 400)
|
||||
// Only the content-sized host (TriggersWrapper) grows past this. The fixed-height hosts top out
|
||||
// at 464px and must stay under the threshold, or every workspace row goes two-line in the step
|
||||
// picker.
|
||||
let displayPath = $derived(height > 480)
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -58,7 +60,6 @@
|
||||
e.stopPropagation()
|
||||
}}
|
||||
role="none"
|
||||
bind:clientWidth={width}
|
||||
bind:clientHeight={height}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
@@ -84,7 +85,7 @@
|
||||
|
||||
<div class="flex flex-row grow min-h-0 gap-2">
|
||||
{#if kind === 'script'}
|
||||
<div class="flex-none flex flex-col text-xs text-primary overflow-auto gap-1">
|
||||
<div class="flex-none w-40 flex flex-col text-xs text-primary overflow-auto gap-1">
|
||||
<TopLevelNode
|
||||
label="Action"
|
||||
selected={selectedKind === 'script'}
|
||||
@@ -188,14 +189,14 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if customUi?.aiSandbox != false}
|
||||
<TopLevelNode
|
||||
label="AI Sandbox"
|
||||
selected={selectedKind === 'aisandbox'}
|
||||
onSelect={() => {
|
||||
selectedKind = 'aisandbox'
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<TopLevelNode
|
||||
label="AI Sandbox"
|
||||
selected={selectedKind === 'aisandbox'}
|
||||
onSelect={() => {
|
||||
selectedKind = 'aisandbox'
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<Popover
|
||||
bind:this={popover}
|
||||
portal={portalTarget()}
|
||||
contentClasses="p-2 max-w-lg h-[400px] !resize bg-surface"
|
||||
contentClasses="p-2 max-w-lg h-[480px] bg-surface"
|
||||
class="inline-block"
|
||||
usePointerDownOutside
|
||||
floatingConfig={{
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
selected?: boolean
|
||||
eeRestricted: boolean
|
||||
enterpriseLangs?: string[]
|
||||
onHover?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -18,7 +19,8 @@
|
||||
lang = undefined,
|
||||
selected = false,
|
||||
eeRestricted,
|
||||
enterpriseLangs = []
|
||||
enterpriseLangs = [],
|
||||
onHover = undefined
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -44,12 +46,12 @@
|
||||
|
||||
<Button
|
||||
id={`flow-editor-new-${lang}`}
|
||||
{selected}
|
||||
onClick={click}
|
||||
onmousemove={() => onHover?.()}
|
||||
role="menuitem"
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
btnClasses="justify-start"
|
||||
btnClasses="justify-start {selected ? 'bg-surface-hover' : onHover ? 'hover:bg-transparent' : ''}"
|
||||
>
|
||||
{#if lang}
|
||||
<LanguageIcon {lang} width={13} height={13} />
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import TopLevelNode from './TopLevelNode.svelte'
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
selected?: boolean;
|
||||
label: string
|
||||
selected?: boolean
|
||||
onHover?: () => void
|
||||
}
|
||||
|
||||
let { label, selected = false }: Props = $props();
|
||||
let { label, selected = false, onHover = undefined }: Props = $props()
|
||||
const dispatch = createEventDispatcher()
|
||||
function handleKeydown(event: KeyboardEvent & { currentTarget: EventTarget & Window }) {
|
||||
if (selected && event.key === 'Enter') {
|
||||
@@ -23,4 +24,4 @@
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<TopLevelNode {label} {selected} returnIcon onSelect={click} />
|
||||
<TopLevelNode {label} {selected} returnIcon neutral onSelect={click} {onHover} />
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
displayPath?: boolean
|
||||
apps?: string[]
|
||||
refreshCount?: number
|
||||
onHover?: (index: number) => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -85,7 +86,8 @@
|
||||
items = $bindable([]),
|
||||
displayPath = false,
|
||||
apps = $bindable([]),
|
||||
refreshCount = 0
|
||||
refreshCount = 0,
|
||||
onHover = undefined
|
||||
}: Props = $props()
|
||||
|
||||
let allApps: string[] = $state([])
|
||||
@@ -199,7 +201,14 @@
|
||||
<ul class="gap-1 flex flex-col">
|
||||
{#each items as item, index (item.path)}
|
||||
<li class="w-full">
|
||||
<Popover class="w-full" placement="right" forceOpen={index === selected}>
|
||||
<!-- Only the selected row may show a tooltip: the Popover opens on its own hover too, and a
|
||||
row scrolled under a stationary cursor would otherwise open a second one. -->
|
||||
<Popover
|
||||
class="w-full"
|
||||
placement="right"
|
||||
forceOpen={index === selected}
|
||||
disablePopup={index !== selected}
|
||||
>
|
||||
{#snippet text()}
|
||||
<div class="flex flex-col">
|
||||
<div class="text-left text-xs font-normal leading-tight py-0"
|
||||
@@ -211,10 +220,14 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
<Button
|
||||
selected={selected === index}
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
btnClasses="justify-start"
|
||||
btnClasses="justify-start h-auto min-h-7 py-1 {selected === index
|
||||
? 'bg-surface-hover'
|
||||
: onHover
|
||||
? 'hover:bg-transparent'
|
||||
: ''}"
|
||||
onmousemove={() => onHover?.(index)}
|
||||
onClick={() => handlePickScript(item)}
|
||||
>
|
||||
<div class={classNames('flex justify-center items-center')}>
|
||||
@@ -229,11 +242,11 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col grow min-w-0">
|
||||
<div class="grow truncate text-left font-normal leading-tight py-0.5"
|
||||
<div class="min-w-0 truncate text-left font-normal leading-tight"
|
||||
>{item.summary ?? ''}</div
|
||||
>
|
||||
{#if displayPath && item.path}
|
||||
<div class="grow truncate text-left text-2xs font-thin">
|
||||
<div class="min-w-0 truncate text-left text-2xs font-thin leading-tight">
|
||||
{item.path}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -19,10 +19,14 @@
|
||||
label: string
|
||||
selected?: boolean
|
||||
returnIcon?: boolean
|
||||
/** Highlight with the neutral hover surface instead of the accent, for transient
|
||||
* (hover/keyboard) selection rather than the persistent category selection. */
|
||||
neutral?: boolean
|
||||
onSelect: () => void
|
||||
onHover?: () => void
|
||||
}
|
||||
|
||||
let { label, selected, returnIcon, onSelect }: Props = $props()
|
||||
let { label, selected, returnIcon, neutral = false, onSelect, onHover }: Props = $props()
|
||||
|
||||
interface IconConfig {
|
||||
icon: ComponentType
|
||||
@@ -51,21 +55,23 @@
|
||||
</script>
|
||||
|
||||
{#snippet iconWithText(icon: ComponentType, showChevron = false, iconClass = '')}
|
||||
{label}
|
||||
<span class="truncate">{label}</span>
|
||||
{#if showChevron}
|
||||
<ChevronRight size={12} class="ml-auto text-secondary" />
|
||||
<ChevronRight size={12} class="ml-auto shrink-0 text-secondary" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Button
|
||||
id={`flow-editor-flow-kind-${label.replaceAll(' ', '-').toLowerCase()}`}
|
||||
{selected}
|
||||
selected={neutral ? false : selected}
|
||||
onClick={onSelect}
|
||||
onmousemove={() => onHover?.()}
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: config?.icon }}
|
||||
btnClasses={neutral ? (selected ? 'bg-surface-hover' : 'hover:bg-transparent') : ''}
|
||||
>
|
||||
<span class="grow flex items-center gap-2">
|
||||
<span class="grow min-w-0 flex items-center gap-2">
|
||||
{#if config}
|
||||
{@render iconWithText(config.icon, config.showChevron, config.iconClass ?? '')}
|
||||
{/if}
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
| { kind: 'inline' | 'owner' | 'integrations'; name: string | undefined }
|
||||
| undefined
|
||||
refreshCount?: number
|
||||
onHover?: (index: number) => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -91,7 +92,8 @@
|
||||
filter = '',
|
||||
owners = $bindable([]),
|
||||
ownerFilter = $bindable(undefined),
|
||||
refreshCount = 0
|
||||
refreshCount = 0,
|
||||
onHover = undefined
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -163,7 +165,14 @@
|
||||
<ul class="gap-1 flex flex-col">
|
||||
{#each filteredWithOwner ?? [] as { path, hash, summary, marked }, index}
|
||||
<li class="w-full">
|
||||
<Popover class="w-full " placement="right" forceOpen={index === selected}>
|
||||
<!-- Only the selected row may show a tooltip: the Popover opens on its own hover too, and a
|
||||
row scrolled under a stationary cursor would otherwise open a second one. -->
|
||||
<Popover
|
||||
class="w-full "
|
||||
placement="right"
|
||||
forceOpen={index === selected}
|
||||
disablePopup={index !== selected}
|
||||
>
|
||||
{#snippet text()}
|
||||
<div class="flex flex-col">
|
||||
<div class="text-left text-xs font-normal leading-tight py-0">{summary ?? ''}</div>
|
||||
@@ -173,10 +182,14 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
<Button
|
||||
selected={selected === index}
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
btnClasses="justify-start transition-all"
|
||||
btnClasses="justify-start transition-all h-auto min-h-7 py-1 {selected === index
|
||||
? 'bg-surface-hover'
|
||||
: onHover
|
||||
? 'hover:bg-transparent'
|
||||
: ''}"
|
||||
onmousemove={() => onHover?.(index)}
|
||||
onClick={() => {
|
||||
if (kind == 'flow') {
|
||||
dispatch('pickFlow', { path: path })
|
||||
@@ -189,7 +202,7 @@
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col grow min-w-0">
|
||||
<div class="grow min-w-0 truncate text-left">
|
||||
<div class="min-w-0 truncate text-left leading-tight">
|
||||
{#if marked}
|
||||
{@html marked}
|
||||
{:else}
|
||||
@@ -197,7 +210,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if displayPath && path}
|
||||
<div class="grow min-w-0 truncate text-left text-2xs font-thin">
|
||||
<div class="min-w-0 truncate text-left text-2xs font-thin leading-tight">
|
||||
{path}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -49,8 +49,9 @@
|
||||
startIcon={icon}
|
||||
unifiedSize="sm"
|
||||
btnClasses="justify-start"
|
||||
title={filter}
|
||||
>
|
||||
{filter}
|
||||
<span class="truncate">{filter}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user