Compare commits

...

7 Commits

Author SHA1 Message Date
Rizumu Ayaka
40780cac6f test: cover showPicker exposure and upload button branch
Lift patch coverage above the codecov threshold by adding:

- FormDropdownInput: exposed showPicker() invokes the file input's
  native showPicker, with a click() fallback branch.
- FormDropdownMenuFilter: the uploadable Upload button renders only when
  the Import (cloud) button is gated off, and clicking it emits
  show-picker.
2026-06-02 15:13:56 +08:00
Rizumu Ayaka
75863e7f9a style: restyle upload button with semantic theme tokens
Use `bg-base-foreground text-base-background` (the project's standard
inverted-button pair) so the button tracks theme changes, and share the
class string between the Import and Upload buttons.
2026-06-02 14:38:46 +08:00
Rizumu Ayaka
4d205deb09 fix: drop redundant aria-label on upload button
Visible "Upload" text already provides the button's accessible name, so
the `aria-label` was duplicating it.
2026-06-02 14:20:59 +08:00
GitHub Action
942edb069a [automated] Apply ESLint and Oxfmt fixes 2026-06-02 06:14:44 +00:00
Rizumu Ayaka
95317306db refactor: move upload button to FormDropdownMenuFilter
Restore FormDropdownMenuActions to its pre-PR state. The upload button
lives in FormDropdownMenuFilter; the show-picker event now bubbles from
filter to menu to FormDropdown. Drop the now-unused width-collapse CSS
and update menu tests to assert prop forwarding / event re-emission via
the filter stub.
2026-06-02 14:10:49 +08:00
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
7 changed files with 205 additions and 9 deletions

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,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'
@@ -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,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
@@ -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"

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>