Compare commits

...

2 Commits

Author SHA1 Message Date
shrimbly
f55a0d42d9 fix: address media asset review feedback 2026-07-11 04:10:45 +12:00
shrimbly
a81e20228c feat: revamp media assets sidebar filtering, search and view options
Ports the media-assets sidebar UX from the prototype:
- Cascading filter menu with hover flyout submenus, flat cross-facet value
  search, and full keyboard nav (Media type + Date facets)
- Applied-filter chips with clear-all
- Filled search field
- List / Grid (small) / Grid (large) view modes + A-Z / Z-A sort
- Card meta line (FORMAT + dimensions/size), overlay actions
- Cmd/Ctrl+A select-all scoped to the asset pane, plus "Unselect N"

Replaces the old filter button with the new flyout menu. No sharing behavior.
2026-07-10 14:20:37 +12:00
22 changed files with 1099 additions and 278 deletions

View File

@@ -1,7 +1,10 @@
<template>
<div class="flex h-full flex-col">
<!-- Assets Grid -->
<!-- key on gridMode remounts the virtualizer so it re-measures cell size
when switching density (it caches item height/width otherwise). -->
<VirtualGrid
:key="gridMode"
class="flex-1"
:items="assetItems"
:grid-style="gridStyle"
@@ -28,13 +31,22 @@ import { computed } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import { gridColumnsForMode } from '@/platform/assets/components/mediaAssetViewOptions'
import type { MediaAssetViewMode } from '@/platform/assets/components/mediaAssetViewOptions'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const { assets, isSelected, showOutputCount, getOutputCount } = defineProps<{
const {
assets,
isSelected,
showOutputCount,
getOutputCount,
gridMode = 'grid-small'
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
gridMode?: MediaAssetViewMode
}>()
const emit = defineEmits<{
@@ -54,10 +66,10 @@ const assetItems = computed<AssetGridItem[]>(() =>
}))
)
const gridStyle = {
const gridStyle = computed(() => ({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(min(200px, 30vw), 1fr))',
gridTemplateColumns: gridColumnsForMode(gridMode),
padding: '0 0.5rem',
gap: '0.5rem'
}
}))
</script>

View File

@@ -39,6 +39,7 @@
v-model:sort-by="sortBy"
v-model:view-mode="viewMode"
v-model:media-type-filters="mediaTypeFilters"
v-model:date-filter="dateFilter"
bottom-divider
:show-generation-time-sort="activeTab === 'output'"
/>
@@ -85,6 +86,7 @@
</div>
<div
v-else
ref="assetPaneRef"
class="relative size-full py-2"
@click="handleEmptySpaceClick"
>
@@ -106,6 +108,7 @@
:is-selected="isSelected"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
:grid-mode="viewMode"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
@@ -180,9 +183,13 @@ import Button from '@/components/ui/button/Button.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
import type { MediaAssetViewMode } from '@/platform/assets/components/mediaAssetViewOptions'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import {
shouldInterceptSelectAll,
useAssetSelection
} from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
@@ -215,11 +222,16 @@ const folderJobId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const expectedFolderCount = ref(0)
const isInFolderView = computed(() => folderJobId.value !== null)
const viewMode = useStorage<'list' | 'grid'>(
const viewMode = useStorage<MediaAssetViewMode>(
'Comfy.Assets.Sidebar.ViewMode',
'grid'
'grid-small'
)
// Migrate the pre-split legacy 'grid' value to the dense grid.
if (!['list', 'grid-small', 'grid-large'].includes(viewMode.value)) {
viewMode.value = 'grid-small'
}
const isListView = computed(() => viewMode.value === 'list')
const assetPaneRef = ref<HTMLElement>()
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const contextMenuAsset = ref<AssetItem | null>(null)
@@ -260,6 +272,7 @@ const outputAssets = useAssetsApi('output')
const {
isSelected,
handleAssetClick,
selectAll,
hasSelection,
clearSelection,
getSelectedAssets,
@@ -316,7 +329,7 @@ const baseAssets = computed(() => {
})
// Use media asset filtering composable
const { searchQuery, sortBy, mediaTypeFilters, filteredAssets } =
const { searchQuery, sortBy, mediaTypeFilters, dateFilter, filteredAssets } =
useMediaAssetFiltering(baseAssets)
const displayAssets = computed(() => {
@@ -418,8 +431,10 @@ watch(
activeTab,
() => {
clearSelection()
// Clear search when switching tabs
// Clear search + filters when switching tabs so no stale filter hides results.
searchQuery.value = ''
mediaTypeFilters.value = []
dateFilter.value = ''
// Reset pagination state when tab changes
void refreshAssets()
},
@@ -562,12 +577,27 @@ const exitFolderView = () => {
searchQuery.value = ''
}
function handleSelectAllKeydown(event: KeyboardEvent) {
if (!shouldInterceptSelectAll(event, assetPaneRef.value)) return
// Stop the event before the canvas keybinding handler runs: it is a
// bubble-phase window listener whose Comfy.Canvas.SelectAll is scoped to
// graph-canvas-container, which the asset pane lives inside — so without this
// it would also select every node. Capture phase beats the bubble listener.
event.preventDefault()
event.stopImmediatePropagation()
selectAll(visibleAssets.value)
}
onMounted(() => {
activateSelection()
window.addEventListener('keydown', handleSelectAllKeydown, { capture: true })
})
onUnmounted(() => {
deactivateSelection()
window.removeEventListener('keydown', handleSelectAllKeydown, {
capture: true
})
})
const handleDeselectAll = () => {

View File

@@ -893,6 +893,8 @@
"title": "Media Assets",
"sortNewestFirst": "Newest first",
"sortOldestFirst": "Oldest first",
"sortAToZ": "Name (A → Z)",
"sortZToA": "Name (Z → A)",
"sortLongestFirst": "Generation time (longest first)",
"sortFastestFirst": "Generation time (fastest first)",
"filterImage": "Image",
@@ -900,6 +902,20 @@
"filterAudio": "Audio",
"filter3D": "3D",
"filterText": "Text",
"filterBy": "Filter by...",
"filterNoMatches": "No matches",
"filterMediaType": "Media type",
"filterDate": "Date",
"filterGroupAttribute": "Attribute",
"dateAll": "All time",
"dateToday": "Today",
"datePastWeek": "Past 7 days",
"datePastMonth": "Past 30 days",
"dateThisYear": "This year",
"clearFilters": "Clear all",
"removeFilter": "Remove {label} filter",
"viewGridSmall": "Grid (small)",
"viewGridLarge": "Grid (large)",
"viewSettings": "View settings"
},
"backToAssets": "Back to all assets",
@@ -984,7 +1000,6 @@
"currentNode": "Current node:",
"viewAllJobs": "View all jobs",
"viewList": "List view",
"viewGrid": "Grid view",
"running": "running",
"preview": "Preview",
"interruptAll": "Interrupt all running jobs",
@@ -3482,8 +3497,8 @@
},
"selection": {
"selectedCount": "{count} selected",
"unselectCount": "Unselect {count}",
"multipleSelectedAssets": "Multiple assets selected",
"deselectAll": "Deselect all",
"downloadSelected": "Download",
"downloadSelectedAll": "Download all",
"deleteSelected": "Delete",

View File

@@ -11,6 +11,7 @@
: $t('assetBrowser.ariaLabel.loadingAsset')
"
:tabindex="loading ? -1 : 0"
:aria-pressed="selected"
:class="
cn(
'flex cursor-pointer flex-col overflow-hidden rounded-lg p-2 transition-colors duration-200',
@@ -54,20 +55,19 @@
<i class="icon-[lucide--trash-2] size-5" />
</LoadingOverlay>
<!-- Action buttons overlay (top-left) -->
<!-- Action buttons overlay (top-right) -->
<div
v-if="showActionsOverlay"
class="absolute top-2 left-2 flex flex-wrap justify-start gap-2"
class="absolute top-2 right-2 flex flex-wrap justify-end gap-2"
>
<IconGroup background-class="bg-white">
<Button
v-if="canInspect"
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.zoom')"
@click.stop="handleZoomClick"
:aria-label="$t('mediaAsset.actions.download')"
@click.stop="asset && actions.downloadAssets([asset])"
>
<i class="icon-[lucide--zoom-in] size-4" />
<i class="icon-[lucide--download] size-4" />
</Button>
<Button
variant="overlay-white"
@@ -81,6 +81,15 @@
</Button>
</IconGroup>
</div>
<!-- Selected check (top-left) -->
<div
v-if="selected"
class="absolute top-2 left-2 flex size-6 items-center justify-center rounded-full bg-white text-black shadow-sm"
aria-hidden="true"
>
<i class="icon-[lucide--check] size-4" />
</div>
</div>
<!-- Bottom Area: Media Info -->
@@ -101,34 +110,30 @@
</div>
<!-- Content -->
<div
v-else-if="asset && adaptedAsset"
class="flex items-end justify-between gap-1.5"
>
<!-- Left side: Media name and metadata -->
<div class="flex flex-col gap-1">
<!-- Title -->
<MediaTitle :file-name="fileName" />
<!-- Metadata -->
<div class="flex gap-1.5 text-xs text-muted-foreground">
<span v-if="formattedDuration">{{ formattedDuration }}</span>
<span v-if="metaInfo">{{ metaInfo }}</span>
<div v-else-if="asset && adaptedAsset" class="flex flex-col gap-1">
<!-- Title + output count -->
<div class="flex items-center justify-between gap-1.5">
<MediaTitle :file-name="fileName" class="min-w-0" />
<!-- Output count -->
<div v-if="showOutputCount" class="shrink-0">
<Button
v-tooltip.top.pt:pointer-events-none="
$t('mediaAsset.actions.seeMoreOutputs')
"
:aria-label="$t('mediaAsset.actions.seeMoreOutputs')"
variant="secondary"
size="sm"
@click.stop="handleOutputCountClick"
>
<i class="icon-[lucide--layers] size-4" />
<span>{{ outputCount }}</span>
</Button>
</div>
</div>
<!-- Right side: Output count -->
<div v-if="showOutputCount" class="shrink-0">
<Button
v-tooltip.top.pt:pointer-events-none="
$t('mediaAsset.actions.seeMoreOutputs')
"
:aria-label="$t('mediaAsset.actions.seeMoreOutputs')"
variant="secondary"
@click.stop="handleOutputCountClick"
>
<i class="icon-[lucide--layers] size-4" />
<span>{{ outputCount }}</span>
</Button>
<!-- File details -->
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
<span v-if="formattedDuration">{{ formattedDuration }}</span>
<span v-if="metaInfo">{{ metaInfo }}</span>
</div>
</div>
</div>
@@ -285,16 +290,24 @@ const displayImageDimensions = computed(() =>
resolveDisplayImageDimensions(asset, imageDimensions.value)
)
// Get metadata info based on file kind
const format = computed(() => {
const suffix = getFilenameDetails(asset?.name ?? '').suffix
return suffix ? suffix.toUpperCase() : ''
})
// Meta line: "FORMAT dimensions" for images, otherwise "FORMAT size".
const metaInfo = computed(() => {
if (!asset) return ''
const parts: string[] = []
if (format.value) parts.push(format.value)
if (fileKind.value === 'image' && displayImageDimensions.value) {
return `${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
parts.push(
`${displayImageDimensions.value.width}x${displayImageDimensions.value.height}`
)
} else if (asset.size) {
parts.push(formatSize(asset.size))
}
if (asset.size && ['video', 'audio', '3D'].includes(fileKind.value)) {
return formatSize(asset.size)
}
return ''
return parts.join(' ')
})
const showActionsOverlay = computed(() => {

View File

@@ -1,72 +1,118 @@
<template>
<SidebarTopArea :bottom-divider>
<SearchInput
:model-value="searchQuery"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('sideToolbar.labels.assets') })
"
@update:model-value="handleSearchChange"
<div>
<SidebarTopArea>
<MediaAssetSearchField
v-model="searchQuery"
:placeholder="
$t('g.searchPlaceholder', {
subject: $t('sideToolbar.labels.assets')
})
"
/>
<template #actions>
<MediaAssetFilterMenu
v-if="isCloud"
v-model:media-type-filters="mediaTypeFilters"
v-model:date-filter="dateFilter"
:active="hasActiveFilters"
/>
<MediaAssetSettingsButton
v-tooltip.top="{ value: $t('sideToolbar.mediaAssets.viewSettings') }"
>
<template #default>
<MediaAssetSettingsMenu
v-model:view-mode="viewMode"
v-model:sort-by="sortBy"
:show-sort-options="isCloud"
:show-generation-time-sort
/>
</template>
</MediaAssetSettingsButton>
</template>
</SidebarTopArea>
<MediaAssetFilterChips
v-if="filterChips.length"
:chips="filterChips"
class="px-2 pb-2 2xl:px-4"
@remove="removeChip"
@clear="clearAllFilters"
/>
<template #actions>
<MediaAssetFilterButton
v-if="isCloud"
v-tooltip.top="{ value: $t('assetBrowser.filterBy') }"
>
<template #default="{ close }">
<MediaAssetFilterMenu
:media-type-filters
:close
@update:media-type-filters="handleMediaTypeFiltersChange"
/>
</template>
</MediaAssetFilterButton>
<MediaAssetSettingsButton
v-tooltip.top="{ value: $t('sideToolbar.mediaAssets.viewSettings') }"
>
<template #default>
<MediaAssetSettingsMenu
v-model:view-mode="viewMode"
v-model:sort-by="sortBy"
:show-sort-options="isCloud"
:show-generation-time-sort
/>
</template>
</MediaAssetSettingsButton>
</template>
</SidebarTopArea>
<div
v-if="bottomDivider"
class="border-t border-dashed border-comfy-input"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { isCloud } from '@/platform/distribution/types'
import MediaAssetFilterButton from './MediaAssetFilterButton.vue'
import {
DATE_VALUES,
MEDIA_TYPE_VALUES,
labelKeyForValue
} from './mediaAssetFilterFacets'
import type { MediaAssetViewMode } from './mediaAssetViewOptions'
import type { FilterChipDescriptor } from './MediaAssetFilterChips.vue'
import MediaAssetFilterChips from './MediaAssetFilterChips.vue'
import MediaAssetFilterMenu from './MediaAssetFilterMenu.vue'
import MediaAssetSearchField from './MediaAssetSearchField.vue'
import MediaAssetSettingsButton from './MediaAssetSettingsButton.vue'
import MediaAssetSettingsMenu from './MediaAssetSettingsMenu.vue'
import type { SortBy } from './MediaAssetSettingsMenu.vue'
const { showGenerationTimeSort = false, bottomDivider = false } = defineProps<{
searchQuery: string
showGenerationTimeSort?: boolean
mediaTypeFilters: string[]
bottomDivider?: boolean
}>()
const emit = defineEmits<{
'update:searchQuery': [value: string]
'update:mediaTypeFilters': [value: string[]]
}>()
const searchQuery = defineModel<string>('searchQuery', { required: true })
const sortBy = defineModel<SortBy>('sortBy', { required: true })
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
const viewMode = defineModel<MediaAssetViewMode>('viewMode', { required: true })
const dateFilter = defineModel<string>('dateFilter', { required: true })
const mediaTypeFilters = defineModel<string[]>('mediaTypeFilters', {
required: true
})
const handleSearchChange = (value: string | undefined) => {
emit('update:searchQuery', value ?? '')
const { t } = useI18n()
const filterChips = computed<FilterChipDescriptor[]>(() => {
const chips: FilterChipDescriptor[] = mediaTypeFilters.value.map((type) => ({
key: `media:${type}`,
label: t(labelKeyForValue(MEDIA_TYPE_VALUES, type) ?? type)
}))
if (dateFilter.value) {
chips.push({
key: 'date',
label: t(
labelKeyForValue(DATE_VALUES, dateFilter.value) ?? dateFilter.value
)
})
}
return chips
})
const hasActiveFilters = computed(() => filterChips.value.length > 0)
function removeChip(key: string) {
if (key === 'date') {
dateFilter.value = ''
return
}
const type = key.slice('media:'.length)
mediaTypeFilters.value = mediaTypeFilters.value.filter(
(value) => value !== type
)
}
const handleMediaTypeFiltersChange = (value: string[]) => {
emit('update:mediaTypeFilters', value)
function clearAllFilters() {
dateFilter.value = ''
mediaTypeFilters.value = []
}
</script>

View File

@@ -1,23 +0,0 @@
<template>
<div class="inline-flex items-center">
<Popover>
<template #button>
<Button
variant="secondary"
size="icon"
:aria-label="$t('assetBrowser.filterBy')"
>
<i class="icon-[lucide--list-filter]" />
</Button>
</template>
<template #default="{ close }">
<slot :close />
</template>
</Popover>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
</script>

View File

@@ -0,0 +1,24 @@
<template>
<span
class="inline-flex items-center gap-1 rounded-md bg-secondary-background py-1 pr-1 pl-2 text-xs whitespace-nowrap"
>
<span>{{ label }}</span>
<Button
variant="textonly"
size="icon"
class="size-4 rounded-sm p-0 hover:bg-secondary-background-hover"
:aria-label="$t('sideToolbar.mediaAssets.removeFilter', { label })"
@click="emit('remove')"
>
<i class="icon-[lucide--x] size-3" />
</Button>
</span>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
defineProps<{ label: string }>()
const emit = defineEmits<{ remove: [] }>()
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div class="flex flex-wrap items-center gap-1.5">
<MediaAssetFilterChip
v-for="chip in chips"
:key="chip.key"
:label="chip.label"
@remove="emit('remove', chip.key)"
/>
<Button
variant="textonly"
class="h-6 px-1.5 text-xs text-muted-foreground"
@click="emit('clear')"
>
{{ $t('sideToolbar.mediaAssets.clearFilters') }}
</Button>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import MediaAssetFilterChip from './MediaAssetFilterChip.vue'
export interface FilterChipDescriptor {
key: string
label: string
}
defineProps<{ chips: FilterChipDescriptor[] }>()
const emit = defineEmits<{
remove: [key: string]
clear: []
}>()
</script>

View File

@@ -1,94 +1,122 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import MediaAssetFilterMenu from '@/platform/assets/components/MediaAssetFilterMenu.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderMenu(mediaTypeFilters: string[] = []) {
const onUpdate = vi.fn()
interface Overrides {
mediaTypeFilters?: string[]
dateFilter?: string
}
function renderMenu(overrides: Overrides = {}) {
const onMedia = vi.fn()
const onDate = vi.fn()
const utils = render(MediaAssetFilterMenu, {
props: {
mediaTypeFilters,
'onUpdate:mediaTypeFilters': onUpdate
mediaTypeFilters: overrides.mediaTypeFilters ?? [],
dateFilter: overrides.dateFilter ?? '',
'onUpdate:mediaTypeFilters': onMedia,
'onUpdate:dateFilter': onDate
},
global: {
mocks: {
$t: (key: string) => key
}
}
global: { plugins: [i18n] }
})
return { ...utils, onUpdate, user: userEvent.setup() }
return { ...utils, onMedia, onDate, user: userEvent.setup() }
}
const labelByType: Record<string, string> = {
image: 'sideToolbar.mediaAssets.filterImage',
video: 'sideToolbar.mediaAssets.filterVideo',
audio: 'sideToolbar.mediaAssets.filterAudio',
'3d': 'sideToolbar.mediaAssets.filter3D',
text: 'sideToolbar.mediaAssets.filterText'
const CAT = {
media: 'Media type',
date: 'Date'
}
function getCheckbox(type: keyof typeof labelByType): HTMLElement {
return screen.getByRole('checkbox', { name: labelByType[type] })
async function openMenu(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Filter by' }))
}
function categoryItem(label: string) {
return screen.getByRole('menuitem', { name: new RegExp(label) })
}
describe('MediaAssetFilterMenu', () => {
it('renders all media-type checkboxes', () => {
renderMenu()
const checkboxes = screen.getAllByRole('checkbox')
expect(checkboxes).toHaveLength(5)
for (const type of Object.keys(labelByType)) {
expect(getCheckbox(type)).toBeTruthy()
}
it('lists every filter category', async () => {
const { user } = renderMenu()
await openMenu(user)
expect(categoryItem(CAT.media)).toBeTruthy()
expect(categoryItem(CAT.date)).toBeTruthy()
})
it('reflects checked state from the prop via aria-checked', () => {
renderMenu(['image', '3d'])
it('surfaces a matching value via flat search and applies it', async () => {
const { onMedia, user } = renderMenu()
await openMenu(user)
await user.type(screen.getByRole('textbox'), 'video')
expect(getCheckbox('image').getAttribute('aria-checked')).toBe('true')
expect(getCheckbox('3d').getAttribute('aria-checked')).toBe('true')
expect(getCheckbox('video').getAttribute('aria-checked')).toBe('false')
expect(getCheckbox('audio').getAttribute('aria-checked')).toBe('false')
await user.click(screen.getByRole('menuitemcheckbox', { name: 'Video' }))
expect(onMedia).toHaveBeenCalledWith(['video'])
})
it('emits an array containing the new type when an unchecked box is clicked', async () => {
const { onUpdate, user } = renderMenu([])
await user.click(getCheckbox('video'))
it('applies a date preset via flat search', async () => {
const { onDate, user } = renderMenu()
await openMenu(user)
await user.type(screen.getByRole('textbox'), 'today')
expect(onUpdate).toHaveBeenCalledTimes(1)
expect(onUpdate).toHaveBeenCalledWith(['video'])
await user.click(screen.getByRole('menuitemcheckbox', { name: 'Today' }))
expect(onDate).toHaveBeenCalledWith('today')
})
it('emits an array without the type when a checked box is clicked again', async () => {
const { onUpdate, user } = renderMenu(['image', 'audio'])
await user.click(getCheckbox('audio'))
it('toggles a date preset off when it is already applied', async () => {
const { onDate, user } = renderMenu({ dateFilter: 'today' })
await openMenu(user)
await user.type(screen.getByRole('textbox'), 'today')
expect(onUpdate).toHaveBeenCalledWith(['image'])
const row = screen.getByRole('menuitemcheckbox', { name: 'Today' })
expect(row).toHaveAttribute('aria-checked', 'true')
await user.click(row)
expect(onDate).toHaveBeenCalledWith('')
})
it('appends to the existing filter list rather than replacing it', async () => {
const { onUpdate, user } = renderMenu(['image'])
await user.click(getCheckbox('video'))
it('moves focus into the results with arrow keys after searching', async () => {
const { user } = renderMenu()
await openMenu(user)
await user.type(screen.getByRole('textbox'), 'a')
expect(onUpdate).toHaveBeenCalledWith(['image', 'video'])
const results = screen.getAllByRole('menuitemcheckbox')
await user.keyboard('{ArrowDown}')
expect(results[0]).toHaveFocus()
screen.getByRole('textbox').focus()
await user.keyboard('{ArrowUp}')
expect(results[results.length - 1]).toHaveFocus()
})
it('toggles via keyboard (Enter and Space)', async () => {
const { onUpdate, user } = renderMenu([])
it('toggles a media value off when it is already applied', async () => {
const { onMedia, user } = renderMenu({ mediaTypeFilters: ['video'] })
await openMenu(user)
await user.type(screen.getByRole('textbox'), 'video')
getCheckbox('image').focus()
await user.keyboard('{Enter}')
expect(onUpdate).toHaveBeenLastCalledWith(['image'])
const row = screen.getByRole('menuitemcheckbox', { name: 'Video' })
expect(row).toHaveAttribute('aria-checked', 'true')
getCheckbox('audio').focus()
await user.keyboard(' ')
expect(onUpdate).toHaveBeenLastCalledWith(['audio'])
await user.click(row)
expect(onMedia).toHaveBeenCalledWith([])
})
it('shows Clear all and resets every facet when a filter is applied', async () => {
const { onMedia, onDate, user } = renderMenu({
mediaTypeFilters: ['image'],
dateFilter: 'today'
})
await openMenu(user)
await user.click(screen.getByRole('menuitem', { name: 'Clear all' }))
expect(onMedia).toHaveBeenCalledWith([])
expect(onDate).toHaveBeenCalledWith('')
})
})

View File

@@ -1,72 +1,327 @@
<!--
TODO: Extract checkbox pattern into reusable Checkbox component
- Create src/components/input/Checkbox.vue with:
- Hidden native <input type="checkbox"> for accessibility
- Custom visual styling matching this implementation
- Semantic tokens (--primary-background, --input-surface, etc.)
- Use this Checkbox component in:
- MediaAssetFilterMenu.vue (this file)
- MultiSelect.vue option template
- SingleSelect.vue if needed
- Benefits: Consistent checkbox UI, better maintainability, reusable design system component
-->
<template>
<div class="m-0 flex flex-col gap-0 p-0">
<div
v-for="filter in filters"
:key="filter.type"
class="flex h-10 min-w-32 cursor-pointer items-center gap-2 rounded-lg px-2 hover:bg-secondary-background-hover"
tabindex="0"
role="checkbox"
:aria-checked="mediaTypeFilters.includes(filter.type)"
@click="toggleMediaType(filter.type)"
@keydown.enter.prevent="toggleMediaType(filter.type)"
@keydown.space.prevent="toggleMediaType(filter.type)"
>
<div
class="flex size-4 shrink-0 items-center justify-center rounded-sm p-0.5 transition-all duration-200"
:class="
mediaTypeFilters.includes(filter.type)
? 'border-primary-background bg-primary-background'
: 'bg-secondary-background'
"
<DropdownMenuRoot v-model:open="open">
<DropdownMenuTrigger as-child>
<Button
variant="secondary"
size="icon"
class="relative"
:aria-label="$t('assetBrowser.filterBy')"
>
<i
v-if="mediaTypeFilters.includes(filter.type)"
class="icon-[lucide--check] text-xs font-bold text-white"
<i class="icon-[lucide--list-filter]" />
<span
v-if="active"
class="absolute top-1 right-1 size-1.5 rounded-full bg-primary-background"
/>
</div>
<span class="text-sm">{{ $t(filter.label) }}</span>
</div>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="bottom"
align="start"
:side-offset="6"
:collision-padding="10"
:style="contentStyle"
:class="menuClass"
@open-auto-focus.prevent="focusSearch"
>
<div class="flex h-10 items-center gap-2 px-3">
<i
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<input
ref="searchRef"
v-model="query"
type="text"
:placeholder="$t('sideToolbar.mediaAssets.filterBy')"
class="min-w-0 flex-1 border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
@keydown="onSearchKeydown"
/>
</div>
<DropdownMenuSeparator class="h-px bg-border-subtle" />
<div class="max-h-80 overflow-y-auto p-1">
<template v-if="query.trim()">
<template v-for="section in searchSections" :key="section.facet">
<DropdownMenuLabel :class="groupLabelClass">
{{ catLabel(section.facet) }}
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
v-for="row in section.rows"
:key="row.value"
:model-value="isApplied(section.facet, row.value)"
:class="rowClass"
@select.prevent="toggleFacetValue(section.facet, row.value)"
>
<i :class="cn(CAT_ICON[section.facet], iconClass)" />
<span :class="labelClass">{{ row.text }}</span>
<i
v-if="isApplied(section.facet, row.value)"
:class="checkClass"
/>
</DropdownMenuCheckboxItem>
</template>
<div
v-if="searchSections.length === 0"
class="px-2 py-1.5 text-sm text-muted-foreground"
>
{{ $t('sideToolbar.mediaAssets.filterNoMatches') }}
</div>
</template>
<template v-else>
<template v-for="group in groups" :key="group.key">
<DropdownMenuLabel :class="groupLabelClass">
{{ $t(group.label) }}
</DropdownMenuLabel>
<DropdownMenuSub v-for="cat in group.cats" :key="cat">
<DropdownMenuSubTrigger
:class="
cn(
rowClass,
'data-[state=open]:bg-secondary-background-hover'
)
"
>
<i :class="cn(CAT_ICON[cat], iconClass)" />
<span :class="labelClass">{{ catLabel(cat) }}</span>
<span v-if="appliedCount(cat) > 0" :class="countClass">
{{ appliedCount(cat) }}
</span>
<i
class="icon-[lucide--chevron-right] size-4 shrink-0 text-muted-foreground"
/>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent
:side-offset="2"
:collision-padding="10"
:style="contentStyle"
:class="menuClass"
>
<div class="p-1">
<DropdownMenuCheckboxItem
v-for="opt in valuesFor(cat)"
:key="opt.value"
:model-value="isApplied(cat, opt.value)"
:class="rowClass"
@select.prevent="toggleFacetValue(cat, opt.value)"
>
<span :class="labelClass">{{ opt.text }}</span>
<i
v-if="isApplied(cat, opt.value)"
:class="checkClass"
/>
</DropdownMenuCheckboxItem>
</div>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</template>
</template>
</div>
<template v-if="anyApplied">
<DropdownMenuSeparator class="h-px bg-border-subtle" />
<DropdownMenuItem
class="flex h-10 items-center px-3 text-sm text-muted-foreground outline-none data-highlighted:text-base-foreground"
@select.prevent="clearAll"
>
{{ $t('sideToolbar.mediaAssets.clearFilters') }}
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>
<script setup lang="ts">
const { mediaTypeFilters } = defineProps<{
mediaTypeFilters: string[]
import { cn } from '@comfyorg/tailwind-utils'
import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
import { DATE_VALUES, MEDIA_TYPE_VALUES } from './mediaAssetFilterFacets'
import type { FacetValue } from './mediaAssetFilterFacets'
type FacetKey = 'media' | 'date'
interface ValueOption {
value: string
text: string
}
const { active = false } = defineProps<{
active?: boolean
}>()
const emit = defineEmits<{
'update:mediaTypeFilters': [value: string[]]
}>()
const mediaTypeFilters = defineModel<string[]>('mediaTypeFilters', {
required: true
})
const dateFilter = defineModel<string>('dateFilter', { required: true })
const filters = [
{ type: 'image', label: 'sideToolbar.mediaAssets.filterImage' },
{ type: 'video', label: 'sideToolbar.mediaAssets.filterVideo' },
{ type: 'audio', label: 'sideToolbar.mediaAssets.filterAudio' },
{ type: '3d', label: 'sideToolbar.mediaAssets.filter3D' },
{ type: 'text', label: 'sideToolbar.mediaAssets.filterText' }
const { t } = useI18n()
const open = ref(false)
const contentStyle = useModalLiftedZIndex(open)
const searchRef = ref<HTMLInputElement>()
const query = ref('')
const menuClass =
'data-[side=top]:animate-slideDownAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade z-1700 flex w-56 flex-col rounded-lg border border-border-subtle bg-base-background shadow-sm will-change-[opacity,transform]'
const groupLabelClass =
'px-2 pt-2 pb-1 text-xs font-semibold tracking-wide text-muted-foreground uppercase'
const rowClass =
'flex h-8 w-full cursor-pointer items-center gap-2 rounded-sm px-2 text-sm outline-none data-highlighted:bg-secondary-background-hover'
const iconClass = 'size-4 shrink-0 text-muted-foreground'
const labelClass = 'min-w-0 flex-1 truncate text-left text-base-foreground'
const checkClass = 'icon-[lucide--check] size-4 shrink-0 text-base-foreground'
const countClass =
'flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-secondary-background px-1 text-xs text-muted-foreground tabular-nums'
const CAT_ICON: Record<FacetKey, string> = {
media: 'icon-[lucide--image]',
date: 'icon-[lucide--calendar]'
}
const CAT_LABEL: Record<FacetKey, string> = {
media: 'sideToolbar.mediaAssets.filterMediaType',
date: 'sideToolbar.mediaAssets.filterDate'
}
const catLabel = (cat: FacetKey) => t(CAT_LABEL[cat])
const groups: { key: string; label: string; cats: FacetKey[] }[] = [
{
key: 'attribute',
label: 'sideToolbar.mediaAssets.filterGroupAttribute',
cats: ['media', 'date']
}
]
const toggleMediaType = (type: string) => {
const isCurrentlySelected = mediaTypeFilters.includes(type)
if (isCurrentlySelected) {
emit(
'update:mediaTypeFilters',
mediaTypeFilters.filter((t) => t !== type)
)
} else {
emit('update:mediaTypeFilters', [...mediaTypeFilters, type])
// Values that clear a single-select facet — excluded from flat search results.
const CLEAR_VALUES = new Set(['', 'all'])
function toOptions(values: FacetValue[]): ValueOption[] {
return values.map((facetValue) => ({
value: facetValue.value,
text: t(facetValue.labelKey)
}))
}
function valuesFor(cat: FacetKey): ValueOption[] {
switch (cat) {
case 'media':
return toOptions(MEDIA_TYPE_VALUES)
case 'date':
return toOptions(DATE_VALUES)
}
}
const searchSections = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return []
return groups
.flatMap((g) => g.cats)
.map((facet) => ({
facet,
rows: valuesFor(facet).filter(
(o) => !CLEAR_VALUES.has(o.value) && o.text.toLowerCase().includes(q)
)
}))
.filter((s) => s.rows.length)
})
function isApplied(cat: FacetKey, value: string): boolean {
switch (cat) {
case 'media':
return mediaTypeFilters.value.includes(value)
case 'date':
return dateFilter.value === value
}
}
function appliedCount(cat: FacetKey): number {
switch (cat) {
case 'media':
return mediaTypeFilters.value.length
case 'date':
return dateFilter.value ? 1 : 0
}
}
function toggleFacetValue(cat: FacetKey, value: string) {
switch (cat) {
case 'date':
dateFilter.value = dateFilter.value === value ? '' : value
return
case 'media':
mediaTypeFilters.value = mediaTypeFilters.value.includes(value)
? mediaTypeFilters.value.filter((v) => v !== value)
: [...mediaTypeFilters.value, value]
}
}
const anyApplied = computed(
() => mediaTypeFilters.value.length > 0 || !!dateFilter.value
)
function clearAll() {
mediaTypeFilters.value = []
dateFilter.value = ''
}
function menuItems(fromEl: HTMLElement): HTMLElement[] {
const menu = fromEl.closest('[role="menu"]')
return menu
? Array.from(
menu.querySelectorAll<HTMLElement>(
'[role^="menuitem"]:not([aria-disabled="true"])'
)
)
: []
}
// The search box owns its keys: Down/Up hand focus into the results (reka drives
// nav from there), and printable keys stay in the box so reka's menu typeahead
// can't steal focus. Stopping propagation keeps global canvas keybindings out.
// Escape / Enter still reach the menu.
function onSearchKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
const items = menuItems(event.currentTarget as HTMLElement)
if (!items.length) return
event.preventDefault()
event.stopPropagation()
const target =
event.key === 'ArrowDown' ? items[0] : items[items.length - 1]
target.focus()
return
}
if (
event.key.length === 1 &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
) {
event.stopPropagation()
}
}
function focusSearch() {
query.value = ''
void nextTick(() => searchRef.value?.focus())
}
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div
class="focus-within:ring-secondary-foreground flex h-8 min-w-0 flex-1 items-center gap-2 rounded-lg bg-secondary-background px-3 focus-within:ring-1"
>
<i
class="icon-[lucide--search] size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<input
v-model="model"
type="text"
:placeholder="placeholder"
class="min-w-0 flex-1 border-none bg-transparent text-sm text-base-foreground outline-none placeholder:text-muted-foreground"
/>
<Button
v-if="model"
variant="textonly"
size="icon-sm"
:aria-label="$t('g.clear')"
@click="model = ''"
>
<i class="icon-[lucide--x] size-3.5" />
</Button>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { placeholder = '' } = defineProps<{ placeholder?: string }>()
const model = defineModel<string>({ required: true })
</script>

View File

@@ -12,7 +12,7 @@ const i18n = createI18n({
en: {
mediaAsset: {
selection: {
deselectAll: 'Deselect all',
unselectCount: 'Unselect {count}',
downloadSelected: 'Download',
deleteSelected: 'Delete',
selectedCount: '{count} selected'
@@ -39,7 +39,7 @@ describe('MediaAssetSelectionBar', () => {
it('emits deselect when the close button is clicked', async () => {
const { emitted } = renderBar({ count: 2 })
await userEvent.click(screen.getByRole('button', { name: 'Deselect all' }))
await userEvent.click(screen.getByRole('button', { name: 'Unselect 2' }))
expect(emitted().deselect).toHaveLength(1)
})

View File

@@ -6,14 +6,14 @@
>
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.deselectAll'),
value: $t('mediaAsset.selection.unselectCount', { count }),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-deselect-selected"
:aria-label="$t('mediaAsset.selection.deselectAll')"
:aria-label="$t('mediaAsset.selection.unselectCount', { count })"
class="rounded-lg hover:bg-base-background/10"
@click="emit('deselect')"
>

View File

@@ -2,28 +2,41 @@ import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import MediaAssetSettingsMenu from '@/platform/assets/components/MediaAssetSettingsMenu.vue'
import type { SortBy } from '@/platform/assets/components/MediaAssetSettingsMenu.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const KEYS = {
list: 'sideToolbar.queueProgressOverlay.viewList',
grid: 'sideToolbar.queueProgressOverlay.viewGrid',
newest: 'sideToolbar.mediaAssets.sortNewestFirst',
oldest: 'sideToolbar.mediaAssets.sortOldestFirst',
longest: 'sideToolbar.mediaAssets.sortLongestFirst',
fastest: 'sideToolbar.mediaAssets.sortFastestFirst'
list: 'List view',
gridSmall: 'Grid (small)',
gridLarge: 'Grid (large)',
newest: 'Newest first',
oldest: 'Oldest first',
longest: 'Generation time (longest first)',
fastest: 'Generation time (fastest first)',
az: 'Name (A → Z)',
za: 'Name (Z → A)'
} as const
type ViewMode = 'list' | 'grid-small' | 'grid-large'
interface MountOptions {
viewMode?: 'list' | 'grid'
viewMode?: ViewMode
sortBy?: SortBy
showSortOptions?: boolean
showGenerationTimeSort?: boolean
}
function mountWithModels(options: MountOptions = {}) {
const viewMode = ref<'list' | 'grid'>(options.viewMode ?? 'list')
const viewMode = ref<ViewMode>(options.viewMode ?? 'list')
const sortBy = ref<SortBy>(options.sortBy ?? 'newest')
const Host = defineComponent({
@@ -48,9 +61,7 @@ function mountWithModels(options: MountOptions = {}) {
const utils = render(Host, {
global: {
mocks: {
$t: (key: string) => key
}
plugins: [i18n]
}
})
return { ...utils, viewMode, sortBy, user: userEvent.setup() }
@@ -62,17 +73,24 @@ function getButton(label: string): HTMLElement {
describe('MediaAssetSettingsMenu', () => {
describe('view-mode options (always visible)', () => {
it('renders both list and grid view options', () => {
it('renders list and both grid view options', () => {
mountWithModels()
expect(getButton(KEYS.list)).toBeTruthy()
expect(getButton(KEYS.grid)).toBeTruthy()
expect(getButton(KEYS.gridSmall)).toBeTruthy()
expect(getButton(KEYS.gridLarge)).toBeTruthy()
})
it('updates the v-model:viewMode when an option is clicked', async () => {
const { viewMode, user } = mountWithModels({ viewMode: 'list' })
await user.click(getButton(KEYS.grid))
expect(viewMode.value).toBe('grid')
})
it.for([
{ key: KEYS.gridSmall, expected: 'grid-small' },
{ key: KEYS.gridLarge, expected: 'grid-large' }
] as const)(
'updates the v-model:viewMode to $expected when an option is clicked',
async ({ key, expected }) => {
const { viewMode, user } = mountWithModels({ viewMode: 'list' })
await user.click(getButton(key))
expect(viewMode.value).toBe(expected)
}
)
})
describe('sort options (gated by showSortOptions)', () => {
@@ -112,7 +130,9 @@ describe('MediaAssetSettingsMenu', () => {
{ key: 'newest', expected: 'newest' },
{ key: 'oldest', expected: 'oldest' },
{ key: 'longest', expected: 'longest' },
{ key: 'fastest', expected: 'fastest' }
{ key: 'fastest', expected: 'fastest' },
{ key: 'az', expected: 'az' },
{ key: 'za', expected: 'za' }
]
for (const { key, expected } of cases) {

View File

@@ -18,15 +18,30 @@
<Button
variant="textonly"
class="w-full"
@click="handleViewModeChange('grid')"
@click="handleViewModeChange('grid-small')"
>
<span class="flex items-center gap-2">
<i class="icon-[lucide--layout-grid] size-4" />
<span>{{ $t('sideToolbar.queueProgressOverlay.viewGrid') }}</span>
<i class="icon-[lucide--grid-3x3] size-4" />
<span>{{ $t('sideToolbar.mediaAssets.viewGridSmall') }}</span>
</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="viewMode !== 'grid' && 'opacity-0'"
:class="viewMode !== 'grid-small' && 'opacity-0'"
/>
</Button>
<Button
variant="textonly"
class="w-full"
@click="handleViewModeChange('grid-large')"
>
<span class="flex items-center gap-2">
<i class="icon-[lucide--layout-grid] size-4" />
<span>{{ $t('sideToolbar.mediaAssets.viewGridLarge') }}</span>
</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="viewMode !== 'grid-large' && 'opacity-0'"
/>
</Button>
@@ -57,6 +72,22 @@
/>
</Button>
<Button variant="textonly" class="w-full" @click="handleSortChange('az')">
<span>{{ $t('sideToolbar.mediaAssets.sortAToZ') }}</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="sortBy !== 'az' && 'opacity-0'"
/>
</Button>
<Button variant="textonly" class="w-full" @click="handleSortChange('za')">
<span>{{ $t('sideToolbar.mediaAssets.sortZToA') }}</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="sortBy !== 'za' && 'opacity-0'"
/>
</Button>
<template v-if="showGenerationTimeSort">
<Button
variant="textonly"
@@ -89,7 +120,9 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
export type SortBy = 'newest' | 'oldest' | 'longest' | 'fastest'
import type { MediaAssetViewMode } from './mediaAssetViewOptions'
export type SortBy = 'newest' | 'oldest' | 'longest' | 'fastest' | 'az' | 'za'
const { showSortOptions = false, showGenerationTimeSort = false } =
defineProps<{
@@ -97,10 +130,10 @@ const { showSortOptions = false, showGenerationTimeSort = false } =
showGenerationTimeSort?: boolean
}>()
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
const viewMode = defineModel<MediaAssetViewMode>('viewMode', { required: true })
const sortBy = defineModel<SortBy>('sortBy', { required: true })
function handleViewModeChange(value: 'list' | 'grid') {
function handleViewModeChange(value: MediaAssetViewMode) {
viewMode.value = value
}

View File

@@ -1,8 +1,5 @@
<template>
<p
class="m-0 line-clamp-2 text-sm/tight break-all text-base-foreground"
:title="fileName"
>
<p class="m-0 truncate text-sm/tight text-base-foreground" :title="fileName">
{{ fileName }}
</p>
</template>

View File

@@ -0,0 +1,33 @@
/**
* Single source of the media-assets filter facet values and their i18n label
* keys, shared by the filter menu (which renders the option rows) and the
* filter bar (which labels the applied-filter chips) so the two never drift.
*/
export interface FacetValue {
value: string
labelKey: string
}
export const MEDIA_TYPE_VALUES: FacetValue[] = [
{ value: 'image', labelKey: 'sideToolbar.mediaAssets.filterImage' },
{ value: 'video', labelKey: 'sideToolbar.mediaAssets.filterVideo' },
{ value: 'audio', labelKey: 'sideToolbar.mediaAssets.filterAudio' },
{ value: '3d', labelKey: 'sideToolbar.mediaAssets.filter3D' },
{ value: 'text', labelKey: 'sideToolbar.mediaAssets.filterText' }
]
export const DATE_VALUES: FacetValue[] = [
{ value: '', labelKey: 'sideToolbar.mediaAssets.dateAll' },
{ value: 'today', labelKey: 'sideToolbar.mediaAssets.dateToday' },
{ value: 'week', labelKey: 'sideToolbar.mediaAssets.datePastWeek' },
{ value: 'month', labelKey: 'sideToolbar.mediaAssets.datePastMonth' },
{ value: 'year', labelKey: 'sideToolbar.mediaAssets.dateThisYear' }
]
export function labelKeyForValue(
values: FacetValue[],
value: string
): string | undefined {
return values.find((facetValue) => facetValue.value === value)?.labelKey
}

View File

@@ -0,0 +1,8 @@
/** Sidebar view mode: a list, or one of two grid tile sizes. */
export type MediaAssetViewMode = 'list' | 'grid-small' | 'grid-large'
/** `grid-template-columns` for a grid view mode ('list' falls back to the dense grid). */
export function gridColumnsForMode(mode: MediaAssetViewMode): string {
const minWidth = mode === 'grid-large' ? 240 : 128
return `repeat(auto-fill, minmax(min(${minWidth}px, 30vw), 1fr))`
}

View File

@@ -22,7 +22,10 @@ vi.mock('@vueuse/core', async (importOriginal) => {
}
})
import { useAssetSelection } from './useAssetSelection'
import {
shouldInterceptSelectAll,
useAssetSelection
} from './useAssetSelection'
import { useAssetSelectionStore } from './useAssetSelectionStore'
function createMockAssets(count: number): AssetItem[] {
@@ -132,6 +135,51 @@ describe('useAssetSelection', () => {
expect(isSelected('asset-1')).toBe(true)
expect(selectedCount.value).toBe(1)
})
it('deselects when clicking the only selected asset again', () => {
const { handleAssetClick, isSelected, selectedCount } =
useAssetSelection()
const assets = createMockAssets(3)
handleAssetClick(assets[0], 0, assets)
expect(selectedCount.value).toBe(1)
handleAssetClick(assets[0], 0, assets)
expect(isSelected('asset-0')).toBe(false)
expect(selectedCount.value).toBe(0)
})
it('starts a new selection after deselecting the anchor', () => {
const { handleAssetClick, isSelected, selectedCount } =
useAssetSelection()
const assets = createMockAssets(3)
handleAssetClick(assets[0], 0, assets)
handleAssetClick(assets[0], 0, assets)
mockShiftKey.value = true
handleAssetClick(assets[2], 2, assets)
expect(isSelected('asset-0')).toBe(false)
expect(isSelected('asset-2')).toBe(true)
expect(selectedCount.value).toBe(1)
})
it('collapses a multi-selection to the clicked asset rather than deselecting', () => {
const { handleAssetClick, isSelected, selectedCount } =
useAssetSelection()
const assets = createMockAssets(3)
handleAssetClick(assets[0], 0, assets)
mockCtrlKey.value = true
handleAssetClick(assets[1], 1, assets)
mockCtrlKey.value = false
expect(selectedCount.value).toBe(2)
handleAssetClick(assets[0], 0, assets)
expect(isSelected('asset-0')).toBe(true)
expect(isSelected('asset-1')).toBe(false)
expect(selectedCount.value).toBe(1)
})
})
describe('handleAssetClick - shift+click', () => {
@@ -273,3 +321,61 @@ describe('useAssetSelection', () => {
})
})
})
describe('shouldInterceptSelectAll', () => {
function interceptsOn(
focusedTag: string,
init: KeyboardEventInit = { key: 'a', metaKey: true },
{ insidePane = true, contentEditable = false } = {}
): boolean {
const pane = document.createElement('div')
document.body.append(pane)
const focused = document.createElement(focusedTag)
if (contentEditable) {
Object.defineProperty(focused, 'isContentEditable', { value: true })
}
;(insidePane ? pane : document.body).append(focused)
let result = false
const record = (event: Event) => {
result = shouldInterceptSelectAll(event as KeyboardEvent, pane)
}
window.addEventListener('keydown', record, { capture: true })
focused.dispatchEvent(
new KeyboardEvent('keydown', { ...init, bubbles: true })
)
window.removeEventListener('keydown', record, { capture: true })
pane.remove()
focused.remove()
return result
}
it('intercepts Cmd+A and Ctrl+A on non-text elements inside the pane', () => {
expect(interceptsOn('div', { key: 'a', metaKey: true })).toBe(true)
expect(interceptsOn('div', { key: 'A', ctrlKey: true })).toBe(true)
})
it('requires the select-all chord', () => {
expect(interceptsOn('div', { key: 'a' })).toBe(false)
expect(interceptsOn('div', { key: 'b', metaKey: true })).toBe(false)
})
it('leaves native select-all to text-entry elements', () => {
for (const tag of ['input', 'textarea', 'select']) {
expect(interceptsOn(tag, { key: 'a', metaKey: true })).toBe(false)
}
expect(
interceptsOn(
'div',
{ key: 'a', metaKey: true },
{ contentEditable: true }
)
).toBe(false)
})
it('ignores the chord when focus is outside the pane', () => {
expect(
interceptsOn('div', { key: 'a', metaKey: true }, { insidePane: false })
).toBe(false)
})
})

View File

@@ -8,6 +8,25 @@ import {
getTotalAssetOutputCount
} from '@/platform/assets/utils/outputAssetUtil'
/**
* True when a Cmd/Ctrl+A keydown should become "select all assets": the chord
* matches, focus is inside the asset pane, and the target is not a text-entry
* element (those keep native select-all).
*/
export function shouldInterceptSelectAll(
event: KeyboardEvent,
pane: HTMLElement | undefined
): boolean {
if (event.key !== 'a' && event.key !== 'A') return false
if (!event.metaKey && !event.ctrlKey) return false
const target = event.target
if (!(target instanceof HTMLElement) || !pane?.contains(target)) return false
return !(
target.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
)
}
export function useAssetSelection() {
const selectionStore = useAssetSelectionStore()
@@ -83,7 +102,16 @@ export function useAssetSelection() {
return
}
// Normal Click: Single selection
// Normal Click: deselect when it is already the sole selection, otherwise
// collapse to a single selection.
if (
selectionStore.isSelected(assetId) &&
selectionStore.selectedCount === 1
) {
selectionStore.clearSelection()
setAnchor(-1, null)
return
}
selectionStore.clearSelection()
selectionStore.addToSelection(assetId)
setAnchor(index, assetId)

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
@@ -169,6 +169,92 @@ describe('useMediaAssetFiltering', () => {
})
})
describe('alphabetical sort', () => {
it('sorts A→Z by display name (case-insensitive)', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'b', name: 'banana.png' }),
makeAsset({ id: 'a', name: 'Apple.png' }),
makeAsset({ id: 'c', name: 'cherry.png' })
])
const { sortBy, filteredAssets } = useMediaAssetFiltering(assets)
sortBy.value = 'az'
expect(ids(filteredAssets.value)).toEqual(['a', 'b', 'c'])
})
it('sorts Z→A by display name (case-insensitive)', () => {
const assets = ref<AssetItem[]>([
makeAsset({ id: 'b', name: 'banana.png' }),
makeAsset({ id: 'a', name: 'Apple.png' }),
makeAsset({ id: 'c', name: 'cherry.png' })
])
const { sortBy, filteredAssets } = useMediaAssetFiltering(assets)
sortBy.value = 'za'
expect(ids(filteredAssets.value)).toEqual(['c', 'b', 'a'])
})
})
describe('date filter', () => {
afterEach(() => {
vi.useRealTimers()
})
function datedAssets() {
const now = Date.now()
return ref<AssetItem[]>([
makeAsset({ id: 'recent', name: 'a.png', createTime: now }),
makeAsset({
id: 'stale',
name: 'b.png',
createTime: now - 40 * 86_400_000
})
])
}
it('returns every asset when no date preset is set', () => {
const { filteredAssets } = useMediaAssetFiltering(datedAssets())
expect(ids(filteredAssets.value).sort()).toEqual(['recent', 'stale'])
})
it('keeps only assets inside the selected preset window', () => {
const { dateFilter, filteredAssets } =
useMediaAssetFiltering(datedAssets())
dateFilter.value = 'month'
expect(ids(filteredAssets.value)).toEqual(['recent'])
})
it('today cuts off at local midnight', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 5, 15, 12, 0, 0))
const midnight = new Date(2026, 5, 15, 0, 0, 0).getTime()
const assets = ref<AssetItem[]>([
makeAsset({ id: 'this-morning', name: 'a.png', createTime: midnight }),
makeAsset({ id: 'yesterday', name: 'b.png', createTime: midnight - 1 })
])
const { dateFilter, filteredAssets } = useMediaAssetFiltering(assets)
dateFilter.value = 'today'
expect(ids(filteredAssets.value)).toEqual(['this-morning'])
})
it('year cuts off at January 1', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 5, 15))
const janFirst = new Date(2026, 0, 1).getTime()
const assets = ref<AssetItem[]>([
makeAsset({ id: 'this-year', name: 'a.png', createTime: janFirst }),
makeAsset({ id: 'last-year', name: 'b.png', createTime: janFirst - 1 })
])
const { dateFilter, filteredAssets } = useMediaAssetFiltering(assets)
dateFilter.value = 'year'
expect(ids(filteredAssets.value)).toEqual(['this-year'])
})
})
describe('composition', () => {
it('applies media-type filter then sort', () => {
const t1 = 1_000_000

View File

@@ -7,7 +7,7 @@ import type { Ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
type SortOption = 'newest' | 'oldest' | 'longest' | 'fastest'
type SortOption = 'newest' | 'oldest' | 'longest' | 'fastest' | 'az' | 'za'
/**
* Get timestamp from asset (either create_time or created_at)
@@ -26,6 +26,30 @@ const getAssetExecutionTime = (asset: AssetItem): number => {
return (asset.user_metadata?.executionTimeInSeconds as number) ?? 0
}
/** Case-insensitive display name used for alphabetical sorting. */
const getAssetSortName = (asset: AssetItem): string => {
return (asset.display_name || asset.name || '').toLowerCase()
}
/** Inclusive lower bound (ms) for a date preset. */
const datePresetStart = (preset: string): number => {
const now = Date.now()
const start = new Date(now)
switch (preset) {
case 'today':
start.setHours(0, 0, 0, 0)
return start.getTime()
case 'week':
return now - 7 * 86_400_000
case 'month':
return now - 30 * 86_400_000
case 'year':
return new Date(start.getFullYear(), 0, 1).getTime()
default:
return 0
}
}
/**
* Media Asset Filtering composable
* Manages search, filter, and sort for media assets
@@ -35,6 +59,7 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
const debouncedSearchQuery = refDebounced(searchQuery, 50)
const sortBy = ref<SortOption>('newest')
const mediaTypeFilters = ref<string[]>([])
const dateFilter = ref('')
const fuseOptions = {
keys: ['display_name', 'name'],
@@ -67,24 +92,40 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
})
})
const dateFiltered = computed(() => {
if (!dateFilter.value) {
return typeFiltered.value
}
const start = datePresetStart(dateFilter.value)
return typeFiltered.value.filter((asset) => getAssetTime(asset) >= start)
})
const filteredAssets = computed(() => {
// Sort by create_time (output assets) or created_at (input assets)
switch (sortBy.value) {
case 'oldest':
// Ascending order (oldest first)
return sortByUtil(typeFiltered.value, [getAssetTime])
return sortByUtil(dateFiltered.value, [getAssetTime])
case 'longest':
// Descending order (longest execution time first)
return sortByUtil(typeFiltered.value, [
return sortByUtil(dateFiltered.value, [
(asset) => -getAssetExecutionTime(asset)
])
case 'fastest':
// Ascending order (fastest execution time first)
return sortByUtil(typeFiltered.value, [getAssetExecutionTime])
return sortByUtil(dateFiltered.value, [getAssetExecutionTime])
case 'az':
return [...dateFiltered.value].sort((a, b) =>
getAssetSortName(a).localeCompare(getAssetSortName(b))
)
case 'za':
return [...dateFiltered.value].sort((a, b) =>
getAssetSortName(b).localeCompare(getAssetSortName(a))
)
case 'newest':
default:
// Descending order (newest first) - negate for descending
return sortByUtil(typeFiltered.value, [(asset) => -getAssetTime(asset)])
return sortByUtil(dateFiltered.value, [(asset) => -getAssetTime(asset)])
}
})
@@ -92,6 +133,7 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
searchQuery,
sortBy,
mediaTypeFilters,
dateFilter,
filteredAssets
}
}