mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 00:01:49 +00:00
173 lines
5.2 KiB
Svelte
173 lines
5.2 KiB
Svelte
<script lang="ts" generics="T">
|
|
import { deepEqual } from 'fast-equals'
|
|
import ConditionalPortal from '../common/drawer/ConditionalPortal.svelte'
|
|
import { untrack, type Snippet } from 'svelte'
|
|
import type { ProcessedItem } from './utils.svelte'
|
|
import { twMerge } from 'tailwind-merge'
|
|
import { slide } from 'svelte/transition'
|
|
|
|
let {
|
|
processedItems: _processedItems,
|
|
value,
|
|
filterText,
|
|
listAutoWidth = true,
|
|
disabled,
|
|
disablePortal = false,
|
|
open,
|
|
noItemsMsg = 'No items found',
|
|
class: className = '',
|
|
ulClass = '',
|
|
itemLabelWrapperClasses = '',
|
|
itemButtonWrapperClasses = '',
|
|
header,
|
|
getInputRect,
|
|
onSelectValue,
|
|
startSnippet,
|
|
endSnippet,
|
|
bottomSnippet
|
|
}: {
|
|
processedItems?: ProcessedItem<T>[]
|
|
value: T | undefined
|
|
filterText?: string
|
|
listAutoWidth?: Boolean
|
|
disabled?: boolean
|
|
disablePortal?: boolean
|
|
open: boolean
|
|
noItemsMsg?: string
|
|
class?: string
|
|
ulClass?: string
|
|
itemLabelWrapperClasses?: string
|
|
itemButtonWrapperClasses?: string
|
|
header?: Snippet
|
|
getInputRect?: () => DOMRect
|
|
onSelectValue: (item: ProcessedItem<T>) => void
|
|
startSnippet?: Snippet<[{ item: ProcessedItem<T>; close: () => void }]>
|
|
endSnippet?: Snippet<[{ item: ProcessedItem<T>; close: () => void }]>
|
|
bottomSnippet?: Snippet<[{ close: () => void }]>
|
|
} = $props()
|
|
|
|
let processedItems = $derived(
|
|
!filterText
|
|
? _processedItems
|
|
: _processedItems?.filter(
|
|
(item) =>
|
|
item.__is_create || item?.label?.toLowerCase().includes(filterText?.toLowerCase())
|
|
)
|
|
)
|
|
|
|
let listEl: HTMLDivElement | undefined = $state()
|
|
let dropdownPos = $state(computeDropdownPos())
|
|
let keyArrowPos = $state<number | undefined>()
|
|
|
|
function computeDropdownPos(): { width: number; x: number; y: number } {
|
|
if (!getInputRect || !listEl) return { width: 0, x: 0, y: 0 }
|
|
let inputR = getInputRect()
|
|
const listR = listEl.getBoundingClientRect()
|
|
const openBelow = inputR.y + inputR.height + listR.height <= window.innerHeight
|
|
let [x, y] = disablePortal ? [0, 0] : [inputR.x, inputR.y]
|
|
if (openBelow) return { width: inputR.width, x: x, y: y + inputR.height }
|
|
else {
|
|
return { width: inputR.width, x: x, y: y - listR.height }
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
function updateDropdownPos() {
|
|
let nPos = computeDropdownPos()
|
|
if (!deepEqual(nPos, dropdownPos)) dropdownPos = nPos
|
|
if (open) requestAnimationFrame(updateDropdownPos)
|
|
}
|
|
if (open) untrack(() => updateDropdownPos())
|
|
})
|
|
|
|
$effect(() => {
|
|
;[open, processedItems]
|
|
untrack(() => (keyArrowPos = open && filterText ? 0 : undefined))
|
|
})
|
|
</script>
|
|
|
|
<svelte:window
|
|
on:keydown={(e) => {
|
|
if (!open || !processedItems?.length) return
|
|
if (e.key === 'ArrowUp' && keyArrowPos !== undefined && processedItems.length > 0) {
|
|
keyArrowPos = keyArrowPos <= 0 ? undefined : keyArrowPos - 1
|
|
} else if (e.key === 'ArrowDown') {
|
|
if (keyArrowPos === undefined) {
|
|
keyArrowPos = 0
|
|
} else {
|
|
keyArrowPos = Math.min(processedItems.length - 1, keyArrowPos + 1)
|
|
}
|
|
} else if (e.key === 'Enter' && keyArrowPos !== undefined && processedItems?.[keyArrowPos]) {
|
|
onSelectValue(processedItems[keyArrowPos])
|
|
} else {
|
|
keyArrowPos = undefined
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
}}
|
|
/>
|
|
|
|
<ConditionalPortal condition={!disablePortal} name="select-dropdown-portal">
|
|
{#if open && !disabled}
|
|
<div
|
|
transition:slide={{ duration: 150 }}
|
|
class={twMerge(
|
|
disablePortal ? 'absolute' : 'fixed',
|
|
'z-[5001] bg-surface-secondary text-tertiary text-sm select-none',
|
|
className
|
|
)}
|
|
style="{`top: ${dropdownPos.y}px; left: ${dropdownPos.x}px;`} {listAutoWidth
|
|
? `min-width: ${dropdownPos.width}px;`
|
|
: ''}"
|
|
>
|
|
<div
|
|
bind:this={listEl}
|
|
class="flex flex-col max-h-64 border rounded-md shadow-lg overflow-clip"
|
|
>
|
|
{@render header?.()}
|
|
{#if processedItems?.length === 0}
|
|
<div class="py-8 px-4 text-center text-primary">{noItemsMsg}</div>
|
|
{/if}
|
|
<ul class={twMerge('flex-1 overflow-y-auto flex flex-col', ulClass)}>
|
|
{#each processedItems ?? [] as item, itemIndex}
|
|
{#if (item.__select_group && itemIndex === 0) || processedItems?.[itemIndex - 1]?.__select_group !== item.__select_group}
|
|
<li
|
|
class={twMerge(
|
|
'mx-4 pb-1 mb-2 text-xs font-semibold text-primary border-b',
|
|
itemIndex === 0 ? 'mt-3' : 'mt-6'
|
|
)}
|
|
>
|
|
{item.__select_group}
|
|
</li>
|
|
{/if}
|
|
<li>
|
|
<button
|
|
class={twMerge(
|
|
'py-2 px-4 w-full font-normal text-left text-primary',
|
|
itemIndex === keyArrowPos ? 'bg-surface-hover' : '',
|
|
item.value === value ? 'bg-surface-selected' : 'hover:bg-surface-hover',
|
|
itemButtonWrapperClasses
|
|
)}
|
|
onclick={(e) => {
|
|
e.stopImmediatePropagation()
|
|
onSelectValue(item)
|
|
}}
|
|
>
|
|
{@render startSnippet?.({ item, close: () => (open = false) })}
|
|
<span class={itemLabelWrapperClasses}>
|
|
{item.label || '\xa0'}
|
|
</span>
|
|
{@render endSnippet?.({ item, close: () => (open = false) })}
|
|
{#if item.subtitle}
|
|
<div class="text-xs text-tertiary">{item.subtitle}</div>
|
|
{/if}
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{@render bottomSnippet?.({ close: () => (open = false) })}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</ConditionalPortal>
|