feat: dropdown widgets vue node ui (#5624)

- Load media dropdown widgets
- Load models dropdown widgets

I added a lot of feedback effects during interactions.
I tried my best to break the Dropdown into small components.
To make it more flexible, I provided many configurable props and
v-model.

<img width="1000" alt="CleanShot 2025-09-18 at 01 54 38"
src="https://github.com/user-attachments/assets/1a413078-1547-44b8-8b48-1ce8f8e764b5"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5624-feat-dropdown-widgets-vue-node-ui-2716d73d36508115a52bc1fb6d6376d0)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
Rizumu Ayaka
2025-09-27 03:04:39 +08:00
committed by GitHub
parent 9f19d8fb4b
commit c96f719f91
19 changed files with 1270 additions and 51 deletions

View File

@@ -0,0 +1,233 @@
<script setup lang="ts">
import { refDebounced } from '@vueuse/core'
import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import FormDropdownInput from './FormDropdownInput.vue'
import FormDropdownMenu from './FormDropdownMenu.vue'
import { defaultSearcher, getDefaultSortOptions } from './shared'
import type {
DropdownItem,
FilterOption,
LayoutMode,
OptionId,
SelectedKey,
SortOption
} from './types'
interface Props {
items: DropdownItem[]
placeholder?: string
/**
* If true, allows multiple selections. If a number is provided,
* it specifies the maximum number of selections allowed.
*/
multiple?: boolean | number
uploadable?: boolean
disabled?: boolean
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
isSelected?: (
selected: Set<SelectedKey>,
item: DropdownItem,
index: number
) => boolean
searcher?: (
query: string,
items: DropdownItem[],
onCleanup: (cleanupFn: () => void) => void
) => Promise<DropdownItem[]>
}
const props = withDefaults(defineProps<Props>(), {
placeholder: t('widgets.uploadSelect.placeholder'),
multiple: false,
uploadable: false,
disabled: false,
filterOptions: () => [],
sortOptions: () => getDefaultSortOptions(),
isSelected: (selected, item, _index) => selected.has(item.id),
searcher: defaultSearcher
})
const selected = defineModel<Set<SelectedKey>>('selected', {
default: new Set()
})
const filterSelected = defineModel<OptionId>('filterSelected', { default: '' })
const sortSelected = defineModel<OptionId>('sortSelected', {
default: 'default'
})
const layoutMode = defineModel<LayoutMode>('layoutMode', {
default: 'grid'
})
const files = defineModel<File[]>('files', { default: [] })
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const debouncedSearchQuery = refDebounced(searchQuery, 700, {
maxWait: 700
})
const isQuerying = ref(false)
const toastStore = useToastStore()
const popoverRef = ref<InstanceType<typeof Popover>>()
const triggerRef = useTemplateRef('triggerRef')
const isOpen = ref(false)
const maxSelectable = computed(() => {
if (props.multiple === true) return Infinity
if (typeof props.multiple === 'number') return props.multiple
return 1
})
const filteredItems = ref<DropdownItem[]>([])
watch(searchQuery, (value) => {
isQuerying.value = value !== debouncedSearchQuery.value
})
watch(
debouncedSearchQuery,
(_, __, onCleanup) => {
let isCleanup = false
let cleanupFn: undefined | (() => void)
onCleanup(() => {
isCleanup = true
cleanupFn?.()
})
void props
.searcher(
debouncedSearchQuery.value,
props.items,
(cb) => (cleanupFn = cb)
)
.then((result) => {
if (!isCleanup) filteredItems.value = result
})
.finally(() => {
if (!isCleanup) isQuerying.value = false
})
},
{ immediate: true }
)
const defaultSorter = computed<SortOption['sorter']>(() => {
const sorter = props.sortOptions.find(
(option) => option.id === 'default'
)?.sorter
return sorter || (({ items }) => items.slice())
})
const selectedSorter = computed<SortOption['sorter']>(() => {
if (sortSelected.value === 'default') return defaultSorter.value
const sorter = props.sortOptions.find(
(option) => option.id === sortSelected.value
)?.sorter
return sorter || defaultSorter.value
})
const sortedItems = computed(() => {
return selectedSorter.value({ items: filteredItems.value }) || []
})
function internalIsSelected(item: DropdownItem, index: number): boolean {
return props.isSelected?.(selected.value, item, index) ?? false
}
const toggleDropdown = (event: Event) => {
if (props.disabled) return
if (popoverRef.value && triggerRef.value) {
popoverRef.value.toggle(event, triggerRef.value)
isOpen.value = !isOpen.value
}
}
const closeDropdown = () => {
if (popoverRef.value) {
popoverRef.value.hide()
isOpen.value = false
}
}
function handleFileChange(event: Event) {
if (props.disabled) return
const input = event.target as HTMLInputElement
if (input.files) {
files.value = Array.from(input.files)
}
// Clear the input value to allow re-selecting the same file
input.value = ''
}
function handleSelection(item: DropdownItem, index: number) {
if (props.disabled) return
const sel = selected.value
if (internalIsSelected(item, index)) {
sel.delete(item.id)
} else {
if (sel.size < maxSelectable.value) {
sel.add(item.id)
} else if (maxSelectable.value === 1) {
sel.clear()
sel.add(item.id)
} else {
toastStore.addAlert(`Maximum selection limit reached`)
return
}
}
selected.value = new Set(sel)
if (maxSelectable.value === 1) {
closeDropdown()
}
}
</script>
<template>
<div ref="triggerRef">
<FormDropdownInput
:files="files"
:is-open="isOpen"
:placeholder="placeholder"
:items="items"
:max-selectable="maxSelectable"
:selected="selected"
:uploadable="uploadable"
:disabled="disabled"
@select-click="toggleDropdown"
@file-change="handleFileChange"
/>
<Popover
ref="popoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class: 'absolute z-50'
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
}
}"
@hide="isOpen = false"
>
<FormDropdownMenu
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
:filter-options="filterOptions"
:sort-options="sortOptions"
:disabled="disabled"
:is-querying="isQuerying"
:items="sortedItems"
:is-selected="internalIsSelected"
:max-selectable="maxSelectable"
@close="closeDropdown"
@item-click="handleSelection"
/>
</Popover>
</div>
</template>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { WidgetInputBaseClass } from '../../layout'
import type { DropdownItem, SelectedKey } from './types'
interface Props {
isOpen?: boolean
placeholder?: string
files: File[]
items: DropdownItem[]
selected: Set<SelectedKey>
maxSelectable: number
uploadable: boolean
disabled: boolean
}
const props = withDefaults(defineProps<Props>(), {
isOpen: false,
placeholder: 'Select...'
})
const emit = defineEmits<{
(e: 'select-click', event: MouseEvent): void
(e: 'file-change', event: Event): void
}>()
const selectedItems = computed(() => {
return props.items.filter((item) => props.selected.has(item.id))
})
const chevronClass = computed(() =>
cn('mr-2 size-4 transition-transform duration-200 flex-shrink-0', {
'rotate-180': props.isOpen
})
)
const theButtonStyle = computed(() => [
'bg-transparent border-0 outline-none text-zinc-400',
{
'hover:bg-zinc-500/30 hover:text-black hover:dark-theme:text-white cursor-pointer':
!props.disabled,
'cursor-not-allowed': props.disabled
}
])
</script>
<template>
<div
:class="
cn(WidgetInputBaseClass, 'flex text-base leading-none', {
'opacity-50 cursor-not-allowed !outline-zinc-300/10': disabled
})
"
>
<!-- Dropdown -->
<button
:class="
cn(theButtonStyle, 'flex justify-between items-center flex-1 h-8', {
'rounded-l-lg': uploadable,
'rounded-lg': !uploadable
})
"
@click="emit('select-click', $event)"
>
<span class="px-4 py-2 min-w-0 text-left">
<span v-if="!selectedItems.length" class="min-w-0">
{{ props.placeholder }}
</span>
<span v-else class="line-clamp-1 min-w-0 break-all">
{{ selectedItems.map((item) => (item as any)?.name).join(', ') }}
</span>
</span>
<i-lucide:chevron-down :class="chevronClass" />
</button>
<!-- Open File -->
<label
v-if="uploadable"
:class="
cn(
theButtonStyle,
'relative',
'size-8 flex justify-center items-center border-l rounded-r-lg border-zinc-300/10'
)
"
>
<i-lucide:folder-search class="size-4" />
<input
type="file"
class="opacity-0 absolute inset-0 -z-1"
:multiple="maxSelectable > 1"
:disabled="disabled"
@change="emit('file-change', $event)"
/>
</label>
</div>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import FormDropdownMenuActions from './FormDropdownMenuActions.vue'
import FormDropdownMenuFilter from './FormDropdownMenuFilter.vue'
import FormDropdownMenuItem from './FormDropdownMenuItem.vue'
import type {
DropdownItem,
FilterOption,
LayoutMode,
OptionId,
SortOption
} from './types'
interface Props {
items: DropdownItem[]
isSelected: (item: DropdownItem, index: number) => boolean
isQuerying: boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
}
defineProps<Props>()
const emit = defineEmits<{
(e: 'item-click', item: DropdownItem, index: number): void
}>()
// Define models for two-way binding
const filterSelected = defineModel<OptionId>('filterSelected')
const layoutMode = defineModel<LayoutMode>('layoutMode')
const sortSelected = defineModel<OptionId>('sortSelected')
const searchQuery = defineModel<string>('searchQuery')
// Handle item selection
</script>
<template>
<div
class="w-103 h-[640px] pt-4 bg-white dark-theme:bg-charcoal-800 rounded-lg outline outline-offset-[-1px] outline-sand-100 dark-theme:outline-zinc-800 flex flex-col"
>
<!-- Filter -->
<FormDropdownMenuFilter
v-if="filterOptions.length > 0"
v-model:filter-selected="filterSelected"
:filter-options="filterOptions"
/>
<!-- Actions -->
<FormDropdownMenuActions
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
:sort-options="sortOptions"
:is-querying="isQuerying"
/>
<!-- List -->
<div class="flex overflow-hidden relative h-full">
<div
:class="
cn(
'h-full max-h-full grid gap-x-2 gap-y-4 overflow-y-auto px-4 pt-4 pb-4 w-full',
{
'grid-cols-4': layoutMode === 'grid',
'grid-cols-1 gap-y-2': layoutMode === 'list',
'grid-cols-1 gap-y-1': layoutMode === 'list-small'
}
)
"
>
<div
class="absolute top-0 inset-x-3 h-5 bg-gradient-to-b from-white dark-theme:from-neutral-900 to-transparent pointer-events-none z-10"
/>
<div
v-if="items.length === 0"
class="flex justify-center items-center absolute inset-0"
>
<i-lucide:circle-off
title="No items"
class="size-30 text-zinc-500/20"
/>
</div>
<!-- Item -->
<FormDropdownMenuItem
v-for="(item, index) in items"
:key="item.id"
:index="index"
:selected="isSelected(item, index)"
:image-src="item.imageSrc"
:name="item.name"
:metadata="item.metadata"
:layout="layoutMode"
@click="emit('item-click', item, index)"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import { ref, useTemplateRef } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import type { LayoutMode, OptionId, SortOption } from './types'
defineProps<{
isQuerying: boolean
sortOptions: SortOption[]
}>()
const layoutMode = defineModel<LayoutMode>('layoutMode')
const searchQuery = defineModel<string>('searchQuery')
const sortSelected = defineModel<OptionId>('sortSelected')
const actionButtonStyle =
'h-8 bg-zinc-500/20 rounded-lg outline outline-1 outline-offset-[-1px] outline-sand-100 dark-theme:outline-neutral-700 transition-all duration-150'
const resetInputStyle = 'bg-transparent border-0 outline-0 ring-0 text-left'
const layoutSwitchItemStyle =
'size-6 flex justify-center items-center rounded-sm cursor-pointer transition-all duration-150 hover:scale-108 hover:text-black hover:dark-theme:text-white active:scale-95'
const sortPopoverRef = useTemplateRef('sortPopoverRef')
const sortTriggerRef = useTemplateRef('sortTriggerRef')
const isSortPopoverOpen = ref(false)
function toggleSortPopover(event: Event) {
if (!sortPopoverRef.value || !sortTriggerRef.value) return
isSortPopoverOpen.value = !isSortPopoverOpen.value
sortPopoverRef.value.toggle(event, sortTriggerRef.value)
}
function closeSortPopover() {
isSortPopoverOpen.value = false
sortPopoverRef.value?.hide()
}
function handleSortSelected(item: SortOption) {
sortSelected.value = item.id
closeSortPopover()
}
</script>
<template>
<div class="flex gap-2 text-zinc-400 px-4">
<label
:class="
cn(
actionButtonStyle,
'flex-1 flex px-2 items-center text-base leading-none cursor-text',
searchQuery?.trim() !== '' ? 'text-black dark-theme:text-white' : '',
'hover:!outline-blue-500/80',
'focus-within:!outline-blue-500/80'
)
"
>
<i-lucide:loader-circle
v-if="isQuerying"
class="mr-2 size-4 animate-spin"
/>
<i-lucide:search v-else class="mr-2 size-4" />
<input
v-model="searchQuery"
type="text"
:class="resetInputStyle"
placeholder="Search"
/>
</label>
<!-- Sort Select -->
<button
ref="sortTriggerRef"
:class="
cn(
resetInputStyle,
actionButtonStyle,
'relative w-8 flex justify-center items-center cursor-pointer',
'hover:!outline-blue-500/80',
'active:!scale-95'
)
"
@click="toggleSortPopover"
>
<div
v-if="sortSelected !== 'default'"
class="size-2 absolute top-[-2px] left-[-2px] bg-blue-500 rounded-full"
/>
<i-lucide:arrow-up-down class="size-4" />
</button>
<!-- Sort Popover -->
<Popover
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class: 'absolute z-50'
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
}
}"
@hide="isSortPopoverOpen = false"
>
<div
:class="
cn(
'flex flex-col gap-2 p-2 min-w-32',
'bg-zinc-200 dark-theme:bg-charcoal-700',
'rounded-lg outline outline-offset-[-1px] outline-sand-200 dark-theme:outline-zinc-700'
)
"
>
<button
v-for="item of sortOptions"
:key="item.name"
:class="
cn(
resetInputStyle,
'flex justify-between items-center h-6 cursor-pointer',
'hover:!text-blue-500'
)
"
@click="handleSortSelected(item)"
>
<span>{{ item.name }}</span>
<i-lucide:check v-if="sortSelected === item.id" class="size-4" />
</button>
</div>
</Popover>
<!-- Layout Switch -->
<div
:class="
cn(
actionButtonStyle,
'flex justify-center items-center p-1 gap-1 hover:!outline-blue-500/80'
)
"
>
<button
:class="
cn(
resetInputStyle,
layoutSwitchItemStyle,
layoutMode === 'list'
? 'bg-neutral-500/50 text-black dark-theme:text-white'
: ''
)
"
@click="layoutMode = 'list'"
>
<i-lucide:list class="size-4" />
</button>
<button
:class="
cn(
resetInputStyle,
layoutSwitchItemStyle,
layoutMode === 'grid'
? 'bg-neutral-500/50 text-black dark-theme:text-white'
: ''
)
"
@click="layoutMode = 'grid'"
>
<i-lucide:layout-grid class="size-4" />
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import type { FilterOption, OptionId } from './types'
defineProps<{
filterOptions: FilterOption[]
}>()
const filterSelected = defineModel<OptionId>('filterSelected')
</script>
<template>
<div class="flex gap-1 text-zinc-400 px-4 mb-4">
<div
v-for="option in filterOptions"
:key="option.id"
:class="
cn(
'px-4 py-2 rounded-md inline-flex justify-center items-center cursor-pointer select-none',
'transition-all duration-150',
'hover:text-black hover:dark-theme:text-white hover:bg-zinc-500/10',
'active:scale-95',
filterSelected === option.id
? '!bg-zinc-500/20 text-black dark-theme:text-white'
: 'bg-transparent'
)
"
@click="filterSelected = option.id"
>
{{ option.name }}
</div>
</div>
</template>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import type { LayoutMode } from './types'
interface Props {
index: number
selected: boolean
imageSrc: string
name: string
metadata?: string
layout?: LayoutMode
}
const props = defineProps<Props>()
const emit = defineEmits<{
click: [index: number]
imageLoad: [event: Event]
}>()
const actualDimensions = ref<string | null>(null)
function handleClick() {
emit('click', props.index)
}
function handleImageLoad(event: Event) {
emit('imageLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
const img = event.target
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
}
</script>
<template>
<div
:class="
cn(
'flex gap-1 select-none group/item cursor-pointer',
'transition-all duration-150',
{
'flex-col text-center': layout === 'grid',
'flex-row text-left max-h-16 bg-zinc-500/20 rounded-lg hover:scale-102 active:scale-98':
layout === 'list',
'flex-row text-left hover:bg-zinc-500/20 rounded-lg':
layout === 'list-small',
// selection
'ring-2 ring-blue-500': layout === 'list' && selected
}
)
"
@click="handleClick"
>
<!-- Image -->
<div
v-if="layout !== 'list-small'"
:class="
cn(
'relative',
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-zinc-300/10',
'transition-all duration-150',
{
'min-w-16 max-w-16 rounded-l-lg': layout === 'list',
'rounded-sm group-hover/item:scale-108 group-active/item:scale-95':
layout === 'grid',
// selection
'ring-2 ring-blue-500': layout === 'grid' && selected
}
)
"
>
<!-- Selected Icon -->
<div
v-if="selected"
class="rounded-full bg-blue-500 border-1 border-white size-4 absolute top-1 left-1"
>
<i-lucide:check class="size-3 text-white -translate-y-[0.5px]" />
</div>
<img
v-if="imageSrc"
:src="imageSrc"
class="size-full object-cover"
@load="handleImageLoad"
/>
<div
v-else
class="size-full bg-gradient-to-tr from-blue-400 via-teal-500 to-green-400"
/>
</div>
<!-- Name -->
<div
:class="
cn('flex gap-1', {
'flex-col': layout === 'grid',
'flex-col px-4 py-1 w-full justify-center': layout === 'list',
'flex-row p-2 items-center justify-between w-full':
layout === 'list-small'
})
"
>
<span
:class="
cn(
'block text-[15px] line-clamp-2 wrap-break-word',
'transition-colors duration-150',
// selection
!!selected && 'text-blue-500'
)
"
>
{{ name }}
</span>
<!-- Meta Data -->
<span class="block text-xs text-slate-400">{{
metadata || actualDimensions
}}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,28 @@
import type { DropdownItem, SortOption } from './types'
export async function defaultSearcher(query: string, items: DropdownItem[]) {
if (query.trim() === '') return items
const words = query.trim().toLowerCase().split(' ')
return items.filter((item) => {
const name = item.name.toLowerCase()
return words.every((word) => name.includes(word))
})
}
export function getDefaultSortOptions(): SortOption[] {
return [
{
name: 'Default',
id: 'default',
sorter: ({ items }) => items.slice()
},
{
name: 'A-Z',
id: 'a-z',
sorter: ({ items }) =>
items.slice().sort((a, b) => {
return a.name.localeCompare(b.name)
})
}
]
}

View File

@@ -0,0 +1,21 @@
export type OptionId = string | number | symbol
export type SelectedKey = OptionId
export interface DropdownItem {
id: SelectedKey
imageSrc: string
name: string
metadata: string
}
export interface SortOption {
id: OptionId
name: string
sorter: (ctx: { items: readonly DropdownItem[] }) => DropdownItem[]
}
export interface FilterOption {
id: OptionId
name: string
}
export type LayoutMode = 'list' | 'grid' | 'list-small'