Files
windmill/frontend/src/lib/components/select/SelectDropdown.svelte
T

165 lines
4.7 KiB
Svelte

<script lang="ts" generics="T">
import { untrack, type Snippet } from 'svelte'
import type { ProcessedItem } from './utils.svelte'
import { twMerge } from 'tailwind-merge'
import { PlusIcon } from 'lucide-svelte'
import GenericDropdown from './GenericDropdown.svelte'
import { Debounced } from 'runed'
let {
processedItems: _processedItems,
value,
filterText,
listAutoWidth = true,
disabled,
disablePortal = false,
open,
noItemsMsg = 'No items found',
class: className = '',
ulClass = '',
itemLabelWrapperClasses = '',
itemButtonWrapperClasses = '',
maxHeight = 256,
header,
getInputRect,
onSelectValue,
startSnippet,
endSnippet,
bottomSnippet,
highlightFirstOnOpen = false
}: {
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
maxHeight?: number
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 }]>
/** When true, the first item is highlighted when the dropdown opens (even without filterText) */
highlightFirstOnOpen?: boolean
} = $props()
let processedItems = $derived(
!filterText
? _processedItems
: _processedItems?.filter(
(item) =>
item.__is_create || item?.label?.toLowerCase().includes(filterText?.toLowerCase())
)
)
let keyArrowPos = $state<number | undefined>()
$effect(() => {
;[open, processedItems]
untrack(() => (keyArrowPos = open && (filterText || highlightFirstOnOpen) ? 0 : undefined))
})
// Expose whether the dropdown is visually open for keyboard nav guard.
// We mirror the same logic GenericDropdown uses: open && !disabled.
let isVisible = $derived(open && !disabled)
// Dirty fix to prevent a rendering bug where the ul is present in the layout but
// displays as empty. It only happens with overflow-y-auto set.
let enableOverflowYAuto = new Debounced(() => isVisible, 15)
</script>
<svelte:window
onkeydown={(e) => {
if (!isVisible || !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()
}}
/>
<GenericDropdown
{listAutoWidth}
{disablePortal}
{open}
{disabled}
{maxHeight}
{getInputRect}
class={className}
>
{@render header?.()}
{#if processedItems?.length === 0}
<div class="py-8 px-4 text-center text-primary text-xs">{noItemsMsg}</div>
{/if}
<ul
class={twMerge(
'flex-1 flex flex-col',
enableOverflowYAuto.current ? 'overflow-y-auto' : '',
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 border-border-light',
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 text-xs',
itemIndex === keyArrowPos || item.value === value
? 'bg-surface-secondary dark:bg-surface-tertiary'
: 'hover:bg-surface-hover',
endSnippet || item.__is_create ? 'flex items-center justify-between gap-2' : '',
itemButtonWrapperClasses,
item.disabled ? 'cursor-not-allowed text-disabled' : ''
)}
onclick={(e) => {
e.stopImmediatePropagation()
if (!item.disabled) onSelectValue(item)
}}
>
{@render startSnippet?.({ item, close: () => (open = false) })}
<span class={itemLabelWrapperClasses}>
{item.label || '\xa0'}
</span>
{#if item.__is_create}
<PlusIcon class="inline ml-auto" size={16} />
{:else}
{@render endSnippet?.({ item, close: () => (open = false) })}
{/if}
{#if item.subtitle}
<div class="text-2xs text-secondary">{item.subtitle}</div>
{/if}
</button>
</li>
{/each}
</ul>
{@render bottomSnippet?.({ close: () => (open = false) })}
</GenericDropdown>