Files
windmill/frontend/src/lib/components/DropdownV2.svelte
Guilhem 94e5c21e25 glm/fix-prop-picker-position (#6991)
* fix button fill container

* fix popover overflow

* Update script picker to new guidelines

* fix expand sublow button

* fix scroll

* fix popover dark mode

* fix refresh button

* nit

* fix popover oferflow

* nit
2025-10-29 21:03:12 +00:00

180 lines
4.5 KiB
Svelte

<script module lang="ts">
import { writable } from 'svelte/store'
const activeDropdown = writable<{ id: string | null; close: (() => void) | null }>({
id: null,
close: null
})
</script>
<script lang="ts">
import { EllipsisVertical } from 'lucide-svelte'
import type { Placement } from '@floating-ui/core'
import type { Item } from '$lib/utils'
import DropdownV2Inner from './DropdownV2Inner.svelte'
import { pointerDownOutside } from '$lib/utils'
import { createDropdownMenu, melt, createSync } from '@melt-ui/svelte'
import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { twMerge } from 'tailwind-merge'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import { untrack } from 'svelte'
import { fly } from 'svelte/transition'
import { ButtonType } from './common/button/model'
interface Props {
aiId?: string | undefined
aiDescription?: string | undefined
items?: Item[] | (() => Item[]) | (() => Promise<Item[]>)
disabled?: boolean
placement?: Placement
usePointerDownOutside?: boolean
closeOnOtherDropdownOpen?: boolean
fixedHeight?: boolean
hidePopup?: boolean
open?: boolean
customWidth?: number | undefined
customMenu?: boolean
class?: string | undefined
enableFlyTransition?: boolean
size?: ButtonType.UnifiedSize
btnText?: string
buttonReplacement?: import('svelte').Snippet
menu?: import('svelte').Snippet
}
let {
aiId = undefined,
aiDescription = undefined,
items = [],
disabled = false,
placement = 'bottom-end',
usePointerDownOutside = false,
closeOnOtherDropdownOpen = true,
fixedHeight = true,
hidePopup = false,
open = $bindable(false),
customWidth = undefined,
customMenu = false,
class: classNames = undefined,
enableFlyTransition = false,
size = 'md',
btnText = '',
buttonReplacement,
menu
}: Props = $props()
let buttonEl: HTMLButtonElement | undefined = $state(undefined)
const {
elements: { menu: menuEl, item, trigger },
states,
ids: { menu: dropdownId }
} = createDropdownMenu({
positioning: {
placement
},
loop: true,
onOpenChange: ({ next }) => {
if (closeOnOtherDropdownOpen) {
if (next) {
// Close previous dropdown if exists
if ($activeDropdown.close && $activeDropdown.id !== $dropdownId) {
$activeDropdown.close()
}
// Set this dropdown as active
activeDropdown.set({ id: $dropdownId, close })
} else if ($activeDropdown.id === $dropdownId) {
activeDropdown.set({ id: null, close: null })
}
}
return next
}
})
const sync = createSync(states)
$effect(() => {
open
untrack(() => {
sync.open(open, (v) => (open = Boolean(v)))
})
})
export function close() {
open = false
}
async function computeItems(): Promise<Item[]> {
if (typeof items === 'function') {
const result = await items()
return Array.isArray(result) ? result.filter((item) => !item.hide) : []
} else {
return items.filter((item) => !item.hide)
}
}
async function getMenuElements(): Promise<HTMLElement[]> {
return Array.from(document.querySelectorAll('[data-menu]')) as HTMLElement[]
}
</script>
<ResolveOpen {open} on:open on:close />
<button
bind:this={buttonEl}
use:triggerableByAI={{
id: aiId,
description: aiDescription,
callback: () => buttonEl?.click()
}}
class={twMerge('flex items-center justify-end', fixedHeight && 'h-8', classNames)}
use:melt={$trigger}
{disabled}
onclick={(e) => e.stopPropagation()}
use:pointerDownOutside={{
capture: true,
stopPropagation: false,
exclude: getMenuElements,
customEventName: 'pointerdown_menu',
onClickOutside: () => {
if (usePointerDownOutside) {
close()
}
}
}}
data-menu
>
{#if buttonReplacement}
{@render buttonReplacement?.()}
{:else}
<Button
nonCaptureEvent
unifiedSize={size}
variant="subtle"
startIcon={{ icon: EllipsisVertical }}
btnClasses="bg-transparent"
iconOnly
>
{btnText}
</Button>
{/if}
</button>
{#if open && !hidePopup}
<div
use:melt={$menuEl}
data-menu
class="z-[6000] transition-all duration-100"
transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }}
>
{#if customMenu}
{@render menu?.()}
{:else}
<div
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1 max-h-[50vh]"
style={customWidth ? `width: ${customWidth}px` : ''}
>
<DropdownV2Inner {aiId} items={computeItems} meltItem={item} />
</div>
{/if}
</div>
{/if}