Compare commits

..

2 Commits

Author SHA1 Message Date
Rizumu Ayaka
452729aa7d fix: guard showPicker call and add menu event tests
Address CodeRabbit review:

- FormDropdownInput.showPicker(): fall back to `input.click()` on
  browsers without `HTMLInputElement.showPicker` (pre-2022 Chrome/Firefox/
  Safari). Use a non-null assertion on the ref to preserve fail-fast
  behavior when invoked outside the rendered upload region.
- FormDropdownMenu.test.ts: add two behavioral tests — `uploadable` is
  forwarded to FormDropdownMenuActions, and `show-picker` re-emits when
  the actions child emits it.
2026-05-28 23:04:50 +08:00
Rizumu Ayaka
260fe4db34 feat: add upload button to dropdown menu actions bar
Expose `showPicker()` from `FormDropdownInput` and route a `show-picker`
event from the new upload button up through `FormDropdownMenuActions` →
`FormDropdownMenu` → `FormDropdown` so the menu button reuses the input's
single hidden `<input type="file">`. Calls stay in the click event's
synchronous stack to satisfy the browser's transient activation
requirement.

The button hides with a `width-collapse` transition while the user is
typing in the search box (renamed from `widthfade`, since the animation
only collapses width — no opacity fade).
2026-05-28 22:40:00 +08:00
6 changed files with 357 additions and 235 deletions

View File

@@ -29,4 +29,16 @@
position: absolute;
width: 100%;
}
.width-collapse-enter-active,
.width-collapse-leave-active {
transition: max-width 150ms ease;
overflow: hidden;
max-width: 100%;
}
.width-collapse-enter-from,
.width-collapse-leave-to {
max-width: 0;
}
}

View File

@@ -261,6 +261,11 @@ async function selectTopSearchResult() {
function handleSearchEnter() {
void selectTopSearchResult()
}
function showPicker() {
triggerRef.value!.showPicker()
closeDropdown()
}
</script>
<template>
@@ -302,6 +307,7 @@ function handleSearchEnter() {
v-model:search-query="searchQuery"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:uploadable
:filter-options
:sort-options
:show-ownership-filter
@@ -317,6 +323,7 @@ function handleSearchEnter() {
@close="closeDropdown"
@search-enter="handleSearchEnter"
@item-click="handleSelection"
@show-picker="showPicker"
/>
</Popover>
</div>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
@@ -42,12 +42,29 @@ const theButtonStyle = computed(() =>
)
const buttonRef = ref<HTMLButtonElement>()
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef')
function focus() {
buttonRef.value?.focus()
}
defineExpose({ focus })
/**
* Open the native file picker without a user click on the input itself.
* Must be invoked synchronously from a user-initiated event handler so the
* browser's transient activation requirement is satisfied. Falls back to
* `click()` on browsers that predate showPicker (Chrome <99, Firefox <101,
* Safari <16).
*/
function showPicker() {
const input = fileInputRef.value!
if (typeof input.showPicker === 'function') {
input.showPicker()
} else {
input.click()
}
}
defineExpose({ focus, showPicker })
</script>
<template>
@@ -102,6 +119,7 @@ defineExpose({ focus })
>
<i class="icon-[lucide--folder-search] size-4" aria-hidden="true" />
<input
ref="fileInputRef"
type="file"
class="absolute inset-0 -z-1 opacity-0"
:aria-label="t('g.upload')"

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import FormDropdownMenu from './FormDropdownMenu.vue'
@@ -24,6 +25,7 @@ describe('FormDropdownMenu', () => {
const defaultProps = {
items: [createItem('1', 'Item 1'), createItem('2', 'Item 2')],
isSelected: () => false,
uploadable: false,
filterOptions: [],
sortOptions: []
}
@@ -131,6 +133,50 @@ describe('FormDropdownMenu', () => {
expect(event.defaultPrevented).toBe(true)
})
/** Stub that surfaces `uploadable` as a data attribute and exposes a button
* that emits `show-picker`, so the parent's prop-forwarding and event
* re-emission can be asserted from the DOM. */
const FormDropdownMenuActionsStub = {
name: 'FormDropdownMenuActions',
props: ['uploadable'],
emits: ['show-picker', 'search-enter'],
template:
'<button data-testid="actions-stub" :data-uploadable="String(uploadable)" @click="$emit(\'show-picker\')" />'
}
it('forwards uploadable prop to FormDropdownMenuActions', () => {
render(FormDropdownMenu, {
props: { ...defaultProps, uploadable: true },
global: {
stubs: {
FormDropdownMenuFilter: true,
FormDropdownMenuActions: FormDropdownMenuActionsStub,
VirtualGrid: VirtualGridStub
},
mocks: { $t: (key: string) => key }
}
})
expect(screen.getByTestId('actions-stub').dataset.uploadable).toBe('true')
})
it('re-emits show-picker when FormDropdownMenuActions emits it', async () => {
const { emitted } = render(FormDropdownMenu, {
props: { ...defaultProps, uploadable: true },
global: {
stubs: {
FormDropdownMenuFilter: true,
FormDropdownMenuActions: FormDropdownMenuActionsStub,
VirtualGrid: VirtualGridStub
},
mocks: { $t: (key: string) => key }
}
})
await userEvent.click(screen.getByTestId('actions-stub'))
expect(emitted('show-picker')).toHaveLength(1)
})
/** Vertical scrolling must remain native so the dropdown's own scroll
* container can scroll its content. */
it('does not suppress vertical scroll', () => {

View File

@@ -19,6 +19,7 @@ import type { FormDropdownItem, LayoutMode, SortOption } from './types'
interface Props {
items: FormDropdownItem[]
isSelected: (item: FormDropdownItem, index: number) => boolean
uploadable: boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
showOwnershipFilter?: boolean
@@ -32,6 +33,7 @@ interface Props {
const {
items,
isSelected,
uploadable,
filterOptions,
sortOptions,
showOwnershipFilter,
@@ -44,6 +46,7 @@ const {
const emit = defineEmits<{
(e: 'item-click', item: FormDropdownItem, index: number): void
(e: 'search-enter'): void
(e: 'show-picker'): void
}>()
const filterSelected = defineModel<string>('filterSelected')
@@ -136,7 +139,9 @@ const onWheel = (event: WheelEvent) => {
:show-base-model-filter
:base-model-options
:candidate-label
:uploadable
@search-enter="emit('search-enter')"
@show-picker="emit('show-picker')"
/>
<div
v-if="items.length === 0"

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import { ref, useTemplateRef } from 'vue'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -16,16 +16,18 @@ import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
defineProps<{
const { uploadable } = defineProps<{
sortOptions: SortOption[]
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
baseModelOptions?: FilterOption[]
candidateLabel?: string
uploadable: boolean
}>()
const emit = defineEmits<{
(e: 'search-enter'): void
(e: 'show-picker'): void
}>()
const layoutMode = defineModel<LayoutMode>('layoutMode')
@@ -104,264 +106,296 @@ function handleSearchEnter(event: KeyboardEvent) {
event.preventDefault()
emit('search-enter')
}
/**
* Hide file upload button during search. This allows more space for the search box.
*/
const showUploadButtonArea = computed(() => {
return (
uploadable && (!searchQuery.value || searchQuery.value.trim().length === 0)
)
})
</script>
<template>
<div class="text-secondary flex gap-2 px-4">
<AsyncSearchInput
v-model="searchQuery"
autofocus
:class="
cn(
actionButtonStyle,
'hover:outline-component-node-widget-background-highlighted/80',
'focus-within:ring-0 focus-within:outline-component-node-widget-background-highlighted/80'
)
"
@enter="handleSearchEnter"
/>
<span
v-if="candidateLabel"
role="status"
aria-live="polite"
aria-atomic="true"
class="sr-only"
>
{{ t('widgets.uploadSelect.topResult', { result: candidateLabel }) }}
</span>
<Button
ref="sortTriggerRef"
:aria-label="t('assetBrowser.sortBy')"
:title="t('assetBrowser.sortBy')"
variant="textonly"
size="icon"
:class="
cn(
actionButtonStyle,
'relative w-8 hover:outline-component-node-widget-background-highlighted active:scale-95'
)
"
@click="toggleSortPopover"
>
<div
v-if="sortSelected !== 'default'"
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
/>
<i class="icon-[lucide--arrow-up-down] size-4" />
</Button>
<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
<Transition name="width-collapse">
<div v-if="showUploadButtonArea" class="text-secondary flex gap-2">
<Button
:aria-label="t('g.upload')"
:title="t('g.upload')"
variant="textonly"
size="md"
:class="
cn(
actionButtonStyle,
'relative flex items-center justify-center gap-2 p-2 hover:outline-component-node-widget-background-highlighted active:scale-95'
)
"
@click="emit('show-picker')"
>
<i class="icon-[lucide--folder-search] size-4" />
<span>{{ $t('g.upload') }}</span>
</Button>
<div class="h-6 w-px self-center bg-node-component-border" />
</div>
</Transition>
<div class="text-secondary flex flex-1 gap-2">
<AsyncSearchInput
v-model="searchQuery"
autofocus
:class="
cn(
'flex min-w-32 flex-col gap-2 p-2',
'bg-component-node-background',
'rounded-lg outline -outline-offset-1 outline-component-node-border'
actionButtonStyle,
'hover:outline-component-node-widget-background-highlighted/80',
'focus-within:ring-0 focus-within:outline-component-node-widget-background-highlighted/80'
)
"
>
<Button
v-for="item of sortOptions"
:key="item.name"
variant="textonly"
size="unset"
:class="cn('flex h-6 items-center justify-between text-left')"
@click="handleSortSelected(item)"
>
<span>{{ item.name }}</span>
<i
v-if="sortSelected === item.id"
class="icon-[lucide--check] size-4"
/>
</Button>
</div>
</Popover>
<Button
v-if="showOwnershipFilter && ownershipOptions?.length"
ref="ownershipTriggerRef"
:aria-label="t('assetBrowser.ownership')"
:title="t('assetBrowser.ownership')"
variant="textonly"
size="icon"
:class="
cn(
actionButtonStyle,
'relative w-8 hover:outline-component-node-widget-background-highlighted active:scale-95'
)
"
@click="toggleOwnershipPopover"
>
<div
v-if="ownershipSelected !== 'all'"
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
@enter="handleSearchEnter"
/>
<i class="icon-[lucide--user] size-4" />
</Button>
<Popover
ref="ownershipPopoverRef"
: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="isOwnershipPopoverOpen = false"
>
<div
:class="
cn(
'flex min-w-32 flex-col gap-2 p-2',
'bg-component-node-background',
'rounded-lg outline -outline-offset-1 outline-component-node-border'
)
"
<span
v-if="candidateLabel"
role="status"
aria-live="polite"
aria-atomic="true"
class="sr-only"
>
<Button
v-for="item of ownershipOptions"
:key="item.value"
variant="textonly"
size="unset"
:class="cn('flex h-6 items-center justify-between text-left')"
@click="handleOwnershipSelected(item)"
>
<span>{{ item.name }}</span>
<i
v-if="ownershipSelected === item.value"
class="icon-[lucide--check] size-4"
/>
</Button>
</div>
</Popover>
{{ t('widgets.uploadSelect.topResult', { result: candidateLabel }) }}
</span>
<Button
v-if="showBaseModelFilter && baseModelOptions?.length"
ref="baseModelTriggerRef"
:aria-label="t('assetBrowser.baseModel')"
:title="t('assetBrowser.baseModel')"
variant="textonly"
size="icon"
:class="
cn(
actionButtonStyle,
'relative w-8 hover:outline-component-node-widget-background-highlighted active:scale-95'
)
"
@click="toggleBaseModelPopover"
>
<div
v-if="baseModelSelected.size > 0"
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
/>
<i class="icon-[comfy--ai-model] size-4" />
</Button>
<Popover
ref="baseModelPopoverRef"
: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="isBaseModelPopoverOpen = false"
>
<div
:class="
cn(
'flex min-w-32 flex-col gap-2 p-2',
'bg-component-node-background',
'rounded-lg outline -outline-offset-1 outline-component-node-border'
)
"
>
<Button
v-for="item of baseModelOptions"
:key="item.value"
variant="textonly"
size="unset"
:class="cn('flex h-6 items-center justify-between text-left')"
@click="toggleBaseModelSelection(item)"
>
<span>{{ item.name }}</span>
<i
v-if="baseModelSelected.has(item.value)"
class="icon-[lucide--check] size-4"
/>
</Button>
<span class="h-0 w-full border-b border-border-default" />
<Button
variant="textonly"
size="unset"
:class="cn('flex h-6 items-center justify-between text-left')"
@click="baseModelSelected = new Set()"
>
{{ t('g.clearFilters') }}
</Button>
</div>
</Popover>
<div
:class="
cn(
actionButtonStyle,
'flex items-center justify-center gap-1 p-1 hover:outline-component-node-widget-background-highlighted'
)
"
>
<Button
:aria-label="t('assetBrowser.listView')"
:title="t('assetBrowser.listView')"
ref="sortTriggerRef"
:aria-label="t('assetBrowser.sortBy')"
:title="t('assetBrowser.sortBy')"
variant="textonly"
size="unset"
size="icon"
:class="
cn(
layoutSwitchItemStyle,
layoutMode === 'list' && 'bg-neutral-500/50 text-base-foreground'
actionButtonStyle,
'relative w-8 hover:outline-component-node-widget-background-highlighted active:scale-95'
)
"
@click="layoutMode = 'list'"
@click="toggleSortPopover"
>
<i class="icon-[lucide--list] size-4" />
<div
v-if="sortSelected !== 'default'"
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
/>
<i class="icon-[lucide--arrow-up-down] size-4" />
</Button>
<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 min-w-32 flex-col gap-2 p-2',
'bg-component-node-background',
'rounded-lg outline -outline-offset-1 outline-component-node-border'
)
"
>
<Button
v-for="item of sortOptions"
:key="item.name"
variant="textonly"
size="unset"
:class="cn('flex h-6 items-center justify-between text-left')"
@click="handleSortSelected(item)"
>
<span>{{ item.name }}</span>
<i
v-if="sortSelected === item.id"
class="icon-[lucide--check] size-4"
/>
</Button>
</div>
</Popover>
<Button
:aria-label="t('assetBrowser.gridView')"
:title="t('assetBrowser.gridView')"
v-if="showOwnershipFilter && ownershipOptions?.length"
ref="ownershipTriggerRef"
:aria-label="t('assetBrowser.ownership')"
:title="t('assetBrowser.ownership')"
variant="textonly"
size="unset"
size="icon"
:class="
cn(
layoutSwitchItemStyle,
layoutMode === 'grid' && 'bg-neutral-500/50 text-base-foreground'
actionButtonStyle,
'relative w-8 hover:outline-component-node-widget-background-highlighted active:scale-95'
)
"
@click="layoutMode = 'grid'"
@click="toggleOwnershipPopover"
>
<i class="icon-[lucide--layout-grid] size-4" />
<div
v-if="ownershipSelected !== 'all'"
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
/>
<i class="icon-[lucide--user] size-4" />
</Button>
<Popover
ref="ownershipPopoverRef"
: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="isOwnershipPopoverOpen = false"
>
<div
:class="
cn(
'flex min-w-32 flex-col gap-2 p-2',
'bg-component-node-background',
'rounded-lg outline -outline-offset-1 outline-component-node-border'
)
"
>
<Button
v-for="item of ownershipOptions"
:key="item.value"
variant="textonly"
size="unset"
:class="cn('flex h-6 items-center justify-between text-left')"
@click="handleOwnershipSelected(item)"
>
<span>{{ item.name }}</span>
<i
v-if="ownershipSelected === item.value"
class="icon-[lucide--check] size-4"
/>
</Button>
</div>
</Popover>
<Button
v-if="showBaseModelFilter && baseModelOptions?.length"
ref="baseModelTriggerRef"
:aria-label="t('assetBrowser.baseModel')"
:title="t('assetBrowser.baseModel')"
variant="textonly"
size="icon"
:class="
cn(
actionButtonStyle,
'relative w-8 hover:outline-component-node-widget-background-highlighted active:scale-95'
)
"
@click="toggleBaseModelPopover"
>
<div
v-if="baseModelSelected.size > 0"
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
/>
<i class="icon-[comfy--ai-model] size-4" />
</Button>
<Popover
ref="baseModelPopoverRef"
: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="isBaseModelPopoverOpen = false"
>
<div
:class="
cn(
'flex min-w-32 flex-col gap-2 p-2',
'bg-component-node-background',
'rounded-lg outline -outline-offset-1 outline-component-node-border'
)
"
>
<Button
v-for="item of baseModelOptions"
:key="item.value"
variant="textonly"
size="unset"
:class="cn('flex h-6 items-center justify-between text-left')"
@click="toggleBaseModelSelection(item)"
>
<span>{{ item.name }}</span>
<i
v-if="baseModelSelected.has(item.value)"
class="icon-[lucide--check] size-4"
/>
</Button>
<span class="h-0 w-full border-b border-border-default" />
<Button
variant="textonly"
size="unset"
:class="cn('flex h-6 items-center justify-between text-left')"
@click="baseModelSelected = new Set()"
>
{{ t('g.clearFilters') }}
</Button>
</div>
</Popover>
<div
:class="
cn(
actionButtonStyle,
'flex items-center justify-center gap-1 p-1 hover:outline-component-node-widget-background-highlighted'
)
"
>
<Button
:aria-label="t('assetBrowser.listView')"
:title="t('assetBrowser.listView')"
variant="textonly"
size="unset"
:class="
cn(
layoutSwitchItemStyle,
layoutMode === 'list' && 'bg-neutral-500/50 text-base-foreground'
)
"
@click="layoutMode = 'list'"
>
<i class="icon-[lucide--list] size-4" />
</Button>
<Button
:aria-label="t('assetBrowser.gridView')"
:title="t('assetBrowser.gridView')"
variant="textonly"
size="unset"
:class="
cn(
layoutSwitchItemStyle,
layoutMode === 'grid' && 'bg-neutral-500/50 text-base-foreground'
)
"
@click="layoutMode = 'grid'"
>
<i class="icon-[lucide--layout-grid] size-4" />
</Button>
</div>
</div>
</div>
</template>