mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 02:32:18 +00:00
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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user