Compare commits

...

1 Commits

Author SHA1 Message Date
Rizumu Ayaka
f822feb2e3 feat: add upload button to dropdown menu filter bar
Add a local file-upload button to the dropdown filter bar that reuses the
hidden file input in FormDropdownInput. The button is shown when `uploadable`
is set and the cloud Import button is not applicable.

FormDropdownInput exposes a `showPicker()` method (falling back to `click()`
on browsers predating HTMLInputElement.showPicker). The filter bar emits
`show-picker`, which bubbles through FormDropdownMenu to FormDropdown, which
invokes the exposed method on the trigger input — keeping the popover children
free of refs into the parent.

Claude-Session: https://claude.ai/code/session_01Cs2tvkcb3qwM8tKS1oaqJG
2026-06-24 22:39:21 +08:00
7 changed files with 204 additions and 9 deletions

View File

@@ -268,6 +268,11 @@ async function selectTopSearchResult() {
function handleSearchEnter() {
void selectTopSearchResult()
}
function showPicker() {
triggerRef.value!.showPicker()
closeDropdown()
}
</script>
<template>
@@ -310,6 +315,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
@@ -326,6 +332,7 @@ function handleSearchEnter() {
@close="closeDropdown"
@search-enter="handleSearchEnter"
@item-click="handleSelection"
@show-picker="showPicker"
@approach-end="emit('approach-end')"
/>
</Popover>

View File

@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import FormDropdownInput from './FormDropdownInput.vue'
@@ -132,4 +133,57 @@ describe('FormDropdownInput', () => {
expect(onFileChange).toHaveBeenCalledTimes(1)
})
})
describe('Exposed showPicker', () => {
/** Mount a harness that captures the FormDropdownInput instance so we can
* invoke its exposed methods, mirroring how FormDropdown drives it. */
async function mountWithRef(props: Partial<FormDropdownInputProps> = {}) {
const inputRef = ref<InstanceType<typeof FormDropdownInput> | null>(null)
const Harness = defineComponent({
components: { FormDropdownInput },
setup: () => ({
inputRef,
bindings: {
items,
selected: new Set<string>(),
maxSelectable: 1,
uploadable: true,
disabled: false,
...props
}
}),
template: '<FormDropdownInput ref="inputRef" v-bind="bindings" />'
})
render(Harness, { global: { plugins: [i18n] } })
await nextTick()
return inputRef
}
it('calls showPicker on the file input when available', async () => {
const showPickerSpy = vi.fn()
Object.defineProperty(HTMLInputElement.prototype, 'showPicker', {
value: showPickerSpy,
configurable: true,
writable: true
})
const inputRef = await mountWithRef()
inputRef.value!.showPicker()
expect(showPickerSpy).toHaveBeenCalledTimes(1)
})
it('falls back to click() when showPicker is unavailable', async () => {
// Simulate older browsers
// @ts-expect-error -- intentional removal for fallback path
delete HTMLInputElement.prototype.showPicker
const clickSpy = vi.fn()
Object.defineProperty(HTMLInputElement.prototype, 'click', {
value: clickSpy,
configurable: true,
writable: true
})
const inputRef = await mountWithRef()
inputRef.value!.showPicker()
expect(clickSpy).toHaveBeenCalledTimes(1)
})
})
})

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'
@@ -43,12 +43,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>
@@ -108,6 +125,7 @@ defineExpose({ focus })
aria-hidden="true"
/>
<input
ref="fileInputRef"
type="file"
class="absolute inset-0 -z-1 opacity-0"
:aria-label="t('g.upload')"

View File

@@ -26,6 +26,7 @@ describe('FormDropdownMenu', () => {
const defaultProps = {
items: [createItem('1', 'Item 1'), createItem('2', 'Item 2')],
isSelected: () => false,
uploadable: false,
filterOptions: [],
sortOptions: []
}
@@ -158,6 +159,58 @@ 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 FormDropdownMenuFilterStub = {
name: 'FormDropdownMenuFilter',
props: ['uploadable', 'filterOptions'],
emits: ['show-picker'],
template:
'<button data-testid="filter-stub" :data-uploadable="String(uploadable)" @click="$emit(\'show-picker\')" />'
}
it('forwards uploadable prop to FormDropdownMenuFilter', () => {
render(FormDropdownMenu, {
props: {
...defaultProps,
uploadable: true,
filterOptions: [{ name: 'All', value: 'all' }]
},
global: {
stubs: {
FormDropdownMenuFilter: FormDropdownMenuFilterStub,
FormDropdownMenuActions: true,
VirtualGrid: VirtualGridStub
},
mocks: { $t: (key: string) => key }
}
})
expect(screen.getByTestId('filter-stub').dataset.uploadable).toBe('true')
})
it('re-emits show-picker when FormDropdownMenuFilter emits it', async () => {
const { emitted } = render(FormDropdownMenu, {
props: {
...defaultProps,
uploadable: true,
filterOptions: [{ name: 'All', value: 'all' }]
},
global: {
stubs: {
FormDropdownMenuFilter: FormDropdownMenuFilterStub,
FormDropdownMenuActions: true,
VirtualGrid: VirtualGridStub
},
mocks: { $t: (key: string) => key }
}
})
await userEvent.click(screen.getByTestId('filter-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
@@ -33,6 +34,7 @@ interface Props {
const {
items,
isSelected,
uploadable,
filterOptions,
sortOptions,
showOwnershipFilter,
@@ -46,6 +48,7 @@ const {
const emit = defineEmits<{
(e: 'item-click', item: FormDropdownItem, index: number): void
(e: 'search-enter'): void
(e: 'show-picker'): void
(e: 'approach-end'): void
}>()
@@ -126,6 +129,8 @@ const onWheel = (event: WheelEvent) => {
v-if="filterOptions.length > 0"
v-model:filter-selected="filterSelected"
:filter-options
:uploadable
@show-picker="emit('show-picker')"
/>
<FormDropdownMenuActions
v-model:layout-mode="layoutMode"

View File

@@ -34,7 +34,7 @@ function getUploadMock() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { import: 'Import' } } }
messages: { en: { g: { import: 'Import', upload: 'Upload' } } }
})
const ButtonStub = {
@@ -52,14 +52,16 @@ const singleOption: FilterOption[] = [{ value: 'all', name: 'All' }]
function renderMenu(
filterOptions: FilterOption[] = options,
modelValue: string | undefined = 'all'
modelValue: string | undefined = 'all',
extraProps: { uploadable?: boolean } = {}
) {
const value = ref<string | undefined>(modelValue)
const onShowPicker = vi.fn()
const Harness = defineComponent({
components: { FormDropdownMenuFilter },
setup: () => ({ value, filterOptions }),
setup: () => ({ value, filterOptions, extraProps, onShowPicker }),
template:
'<FormDropdownMenuFilter v-model:filter-selected="value" :filter-options="filterOptions" />'
'<FormDropdownMenuFilter v-model:filter-selected="value" :filter-options="filterOptions" :uploadable="extraProps.uploadable ?? false" @show-picker="onShowPicker" />'
})
const utils = render(Harness, {
global: {
@@ -67,7 +69,7 @@ function renderMenu(
stubs: { Button: ButtonStub }
}
})
return { ...utils, value }
return { ...utils, value, onShowPicker }
}
describe('FormDropdownMenuFilter', () => {
@@ -134,4 +136,39 @@ describe('FormDropdownMenuFilter', () => {
expect(upload.showUploadDialog).toHaveBeenCalledTimes(1)
})
})
describe('Local-upload button (uploadable branch)', () => {
it('renders when uploadable is true and the Import button is disabled', () => {
getUploadMock().isUploadButtonEnabled.value = false
renderMenu(singleOption, 'all', { uploadable: true })
expect(
screen.getByRole('button', { name: /Upload/i })
).toBeInTheDocument()
})
it('does not render when uploadable is false', () => {
getUploadMock().isUploadButtonEnabled.value = false
renderMenu(singleOption, 'all', { uploadable: false })
expect(screen.queryByRole('button', { name: /Upload/i })).toBeNull()
})
it('prefers the Import button over Upload when both gates allow it', () => {
getUploadMock().isUploadButtonEnabled.value = true
renderMenu(singleOption, 'all', { uploadable: true })
expect(
screen.getByRole('button', { name: /Import/i })
).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Upload/i })).toBeNull()
})
it('emits show-picker when the upload button is clicked', async () => {
getUploadMock().isUploadButtonEnabled.value = false
const { onShowPicker } = renderMenu(singleOption, 'all', {
uploadable: true
})
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: /Upload/i }))
expect(onShowPicker).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -8,6 +8,10 @@ import { cn } from '@comfyorg/tailwind-utils'
const { filterOptions } = defineProps<{
filterOptions: FilterOption[]
uploadable: boolean
}>()
const emit = defineEmits<{
(e: 'show-picker'): void
}>()
const filterSelected = defineModel<string>('filterSelected')
@@ -15,6 +19,12 @@ const filterSelected = defineModel<string>('filterSelected')
const { isUploadButtonEnabled, showUploadDialog } = useModelUpload()
const singleFilterOption = computed(() => filterOptions.length === 1)
const uploadButtonStyle = cn(
'ml-auto h-8 rounded-lg bg-base-foreground text-base-background',
'flex items-center justify-center gap-2 p-2',
'transition-all duration-150 hover:bg-base-foreground/90 active:scale-95'
)
</script>
<template>
@@ -40,13 +50,24 @@ const singleFilterOption = computed(() => filterOptions.length === 1)
</button>
<Button
v-if="isUploadButtonEnabled && singleFilterOption"
class="ml-auto"
size="md"
variant="textonly"
size="md"
:class="uploadButtonStyle"
@click="showUploadDialog"
>
<i class="icon-[lucide--folder-input]" />
<span>{{ $t('g.import') }}</span>
</Button>
<Button
v-else-if="uploadable"
:title="$t('g.upload')"
variant="textonly"
size="md"
:class="uploadButtonStyle"
@click="emit('show-picker')"
>
<i class="icon-[lucide--folder-search] size-4" />
<span>{{ $t('g.upload') }}</span>
</Button>
</div>
</template>