mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-05 12:44:23 +00:00
Compare commits
7 Commits
deepme987/
...
rizumu/fea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40780cac6f | ||
|
|
75863e7f9a | ||
|
|
4d205deb09 | ||
|
|
942edb069a | ||
|
|
95317306db | ||
|
|
452729aa7d | ||
|
|
260fe4db34 |
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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,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', () => {
|
||||
|
||||
@@ -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')
|
||||
@@ -123,6 +126,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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user