additional search feedback

- improved keyboard navigation and aria
- fixed alignment of elements
- updated fonts and sizes
- more tidy + nits
- tests
This commit is contained in:
pythongosssss
2026-03-12 04:18:20 -07:00
parent 0f3b2e0455
commit 3cba424e52
13 changed files with 380 additions and 116 deletions

View File

@@ -1,9 +1,14 @@
<template>
<div
class="flex w-50 flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div ref="previewWrapperRef" class="origin-top-left scale-50">
<div
ref="previewWrapperRef"
class="origin-top-left"
:style="{ transform: `scale(${scaleFactor})` }"
>
<LGraphNodePreview :node-def="nodeDef" position="relative" />
</div>
</div>
@@ -99,17 +104,20 @@ import NodeProviderBadge from '@/components/node/NodeProviderBadge.vue'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const SCALE_FACTOR = 0.5
const BASE_WIDTH_PX = 200
const BASE_SCALE = 0.5
const PREVIEW_CONTAINER_PADDING_PX = 24
const {
nodeDef,
showInputsAndOutputs = true,
showCategoryPath = false
showCategoryPath = false,
scaleFactor = 0.5
} = defineProps<{
nodeDef: ComfyNodeDefImpl
showInputsAndOutputs?: boolean
showCategoryPath?: boolean
scaleFactor?: number
}>()
const previewContainerRef = ref<HTMLElement>()
@@ -118,7 +126,7 @@ const previewWrapperRef = ref<HTMLElement>()
useResizeObserver(previewWrapperRef, (entries) => {
const entry = entries[0]
if (entry && previewContainerRef.value) {
const scaledHeight = entry.contentRect.height * SCALE_FACTOR
const scaledHeight = entry.contentRect.height * scaleFactor
previewContainerRef.value.style.height = `${scaledHeight + PREVIEW_CONTAINER_PADDING_PX}px`
}
})

View File

@@ -7,7 +7,7 @@
:pt="{
root: {
class: useSearchBoxV2
? 'w-4/5 min-w-[32rem] max-w-[56rem] border-0 bg-transparent mt-[10vh] max-md:w-[95%] max-md:min-w-0 overflow-visible'
? 'w-4/5 min-w-[32rem] max-w-[56rem] border-0 bg-transparent max-md:w-[95%] max-md:min-w-0 overflow-visible'
: 'invisible-dialog-root'
},
mask: {
@@ -36,7 +36,9 @@
v-if="hoveredNodeDef && enableNodePreview"
:key="hoveredNodeDef.name"
:node-def="hoveredNodeDef"
:scale-factor="0.625"
show-category-path
inert
class="absolute top-0 left-full ml-3"
/>
</div>

View File

@@ -1,8 +1,11 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchCategorySidebar, {
DEFAULT_CATEGORY
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import {
createMockNodeDef,
setupTestPinia,
@@ -23,15 +26,22 @@ vi.mock('@/platform/settings/settingStore', () => ({
}))
describe('NodeSearchCategorySidebar', () => {
let wrapper: VueWrapper
beforeEach(() => {
vi.restoreAllMocks()
setupTestPinia()
})
afterEach(() => {
wrapper?.unmount()
})
async function createWrapper(props = {}) {
const wrapper = mount(NodeSearchCategorySidebar, {
props: { selectedCategory: 'most-relevant', ...props },
global: { plugins: [testI18n] }
wrapper = mount(NodeSearchCategorySidebar, {
props: { selectedCategory: DEFAULT_CATEGORY, ...props },
global: { plugins: [testI18n] },
attachTo: document.body
})
await nextTick()
return wrapper
@@ -75,7 +85,9 @@ describe('NodeSearchCategorySidebar', () => {
})
it('should mark the selected preset category as selected', async () => {
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
const wrapper = await createWrapper({
selectedCategory: DEFAULT_CATEGORY
})
const mostRelevantBtn = wrapper.find(
'[data-testid="category-most-relevant"]'
@@ -88,7 +100,9 @@ describe('NodeSearchCategorySidebar', () => {
vi.spyOn(useNodeBookmarkStore(), 'bookmarks', 'get').mockReturnValue([
'some-bookmark'
])
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
const wrapper = await createWrapper({
selectedCategory: DEFAULT_CATEGORY
})
await clickCategory(wrapper, 'Favorites')
@@ -218,7 +232,9 @@ describe('NodeSearchCategorySidebar', () => {
])
await nextTick()
const wrapper = await createWrapper({ selectedCategory: 'most-relevant' })
const wrapper = await createWrapper({
selectedCategory: DEFAULT_CATEGORY
})
// Expand and click subcategory
await clickCategory(wrapper, 'sampling', true)
@@ -283,4 +299,117 @@ describe('NodeSearchCategorySidebar', () => {
expect(wrapper.emitted('update:selectedCategory')![0][0]).toBe('sampling')
})
describe('keyboard navigation', () => {
it('should expand a collapsed tree node on ArrowRight', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
expect(wrapper.text()).not.toContain('advanced')
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
await samplingBtn.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
// Should have emitted select for sampling, expanding it
expect(wrapper.emitted('update:selectedCategory')).toBeTruthy()
expect(wrapper.emitted('update:selectedCategory')![0]).toEqual([
'sampling'
])
})
it('should collapse an expanded tree node on ArrowLeft', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
// First expand sampling by clicking
const wrapper = await createWrapper()
await clickCategory(wrapper, 'sampling', true)
expect(wrapper.text()).toContain('advanced')
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
await samplingBtn.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
// Collapse toggles internal state; children should be hidden
expect(wrapper.text()).not.toContain('advanced')
})
it('should focus first child on ArrowRight when already expanded', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
await clickCategory(wrapper, 'sampling', true)
expect(wrapper.text()).toContain('advanced')
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
await samplingBtn.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
const advancedBtn = wrapper.find(
'[data-testid="category-sampling/advanced"]'
)
expect(advancedBtn.element).toBe(document.activeElement)
})
it('should focus parent on ArrowLeft from a leaf or collapsed node', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
await clickCategory(wrapper, 'sampling', true)
const advancedBtn = wrapper.find(
'[data-testid="category-sampling/advanced"]'
)
await advancedBtn.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
const samplingBtn = wrapper.find('[data-testid="category-sampling"]')
expect(samplingBtn.element).toBe(document.activeElement)
})
it('should set aria-expanded on tree nodes with children', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({ name: 'Node1', category: 'sampling' }),
createMockNodeDef({ name: 'Node2', category: 'sampling/advanced' }),
createMockNodeDef({ name: 'Node3', category: 'loaders' })
])
await nextTick()
const wrapper = await createWrapper()
const samplingTreeItem = wrapper
.find('[data-testid="category-sampling"]')
.element.closest('[role="treeitem"]')!
expect(samplingTreeItem.getAttribute('aria-expanded')).toBe('false')
// Leaf node should not have aria-expanded
const loadersTreeItem = wrapper
.find('[data-testid="category-loaders"]')
.element.closest('[role="treeitem"]')!
expect(loadersTreeItem.getAttribute('aria-expanded')).toBeNull()
})
})
})

View File

@@ -1,43 +1,58 @@
<template>
<div class="group/categories flex min-h-0 flex-col overflow-y-auto py-2.5">
<RovingFocusGroup
as="div"
orientation="vertical"
:loop="true"
class="group/categories flex min-h-0 flex-col overflow-y-auto py-2.5 select-none"
>
<!-- Preset categories -->
<div v-if="!hidePresets" class="flex flex-col px-1">
<button
<div v-if="!hidePresets" class="flex flex-col px-3">
<RovingFocusItem
v-for="preset in topCategories"
:key="preset.id"
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
as-child
>
{{ preset.label }}
</button>
<button
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
>
{{ preset.label }}
</button>
</RovingFocusItem>
</div>
<!-- Source categories -->
<div
v-if="!hidePresets && sourceCategories.length > 0"
class="my-2 flex flex-col border-y border-border-subtle px-1 py-2"
class="my-2 flex flex-col border-y border-border-subtle px-3 py-2"
>
<button
<RovingFocusItem
v-for="preset in sourceCategories"
:key="preset.id"
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
as-child
>
{{ preset.label }}
</button>
<button
type="button"
:data-testid="`category-${preset.id}`"
:aria-current="selectedCategory === preset.id || undefined"
:class="categoryBtnClass(preset.id)"
@click="selectCategory(preset.id)"
>
{{ preset.label }}
</button>
</RovingFocusItem>
</div>
<!-- Category tree -->
<div
role="tree"
:aria-label="t('g.category')"
:class="
cn(
'flex flex-col px-1',
'flex flex-col px-3',
!hidePresets &&
!sourceCategories.length &&
'mt-2 border-t border-border-subtle pt-2'
@@ -54,12 +69,17 @@
@select="selectCategory"
/>
</div>
</div>
</RovingFocusGroup>
</template>
<script lang="ts">
export const DEFAULT_CATEGORY = 'most-relevant'
</script>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RovingFocusGroup, RovingFocusItem } from 'reka-ui'
import NodeSearchCategoryTreeNode, {
CATEGORY_SELECTED_CLASS,
@@ -102,7 +122,7 @@ const nodeDefStore = useNodeDefStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const topCategories = computed(() => {
const categories = [{ id: 'most-relevant', label: t('g.mostRelevant') }]
const categories = [{ id: DEFAULT_CATEGORY, label: t('g.mostRelevant') }]
if (nodeBookmarkStore.bookmarks.length > 0) {
categories.push({ id: 'favorites', label: t('g.favorites') })
}
@@ -171,7 +191,7 @@ watch(
function categoryBtnClass(id: string) {
return cn(
'cursor-pointer rounded-sm border-none bg-transparent py-2.5 pr-3 text-left text-sm transition-colors',
'cursor-pointer rounded-lg border-none bg-transparent py-2.5 pr-3 text-left font-inter text-sm transition-colors',
hideChevrons ? 'pl-3' : 'pl-9',
selectedCategory.value === id
? CATEGORY_SELECTED_CLASS

View File

@@ -1,55 +1,64 @@
<template>
<div
role="treeitem"
:aria-expanded="node.children?.length ? isExpanded : undefined"
:class="
cn(
selectedCategory === node.key &&
isExpanded &&
node.children?.length &&
'rounded-sm bg-secondary-background'
'rounded-lg bg-secondary-background'
)
"
>
<button
type="button"
:data-testid="`category-${node.key}`"
:aria-current="selectedCategory === node.key || undefined"
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
:class="
cn(
'flex w-full cursor-pointer items-center gap-2 rounded-sm border-none bg-transparent py-2.5 pr-3 text-left text-sm transition-colors',
selectedCategory === node.key
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
)
"
@click="$emit('select', node.key)"
>
<i
v-if="!hideChevrons"
<RovingFocusItem as-child>
<button
ref="buttonEl"
type="button"
:data-testid="`category-${node.key}`"
:aria-current="selectedCategory === node.key || undefined"
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
:class="
cn(
'size-4 shrink-0 text-muted-foreground transition-[transform,opacity] duration-150',
node.children?.length
? 'icon-[lucide--chevron-down] opacity-0 group-hover/categories:opacity-100'
: '',
node.children?.length && !isExpanded && '-rotate-90'
'flex w-full cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent py-2.5 pr-3 text-left font-inter text-sm transition-colors',
selectedCategory === node.key
? CATEGORY_SELECTED_CLASS
: CATEGORY_UNSELECTED_CLASS
)
"
/>
<span class="flex-1 truncate">{{ node.label }}</span>
</button>
<template v-if="isExpanded && node.children?.length">
@click="$emit('select', node.key)"
@keydown.right.prevent="handleRight"
@keydown.left.prevent="handleLeft"
>
<i
v-if="!hideChevrons"
:class="
cn(
'size-4 shrink-0 text-muted-foreground transition-[transform,opacity] duration-150',
node.children?.length
? 'icon-[lucide--chevron-down] opacity-0 group-focus-within/categories:opacity-100 group-hover/categories:opacity-100'
: '',
node.children?.length && !isExpanded && '-rotate-90'
)
"
/>
<span class="flex-1 truncate">{{ node.label }}</span>
</button>
</RovingFocusItem>
<div v-if="isExpanded && node.children?.length" role="group">
<NodeSearchCategoryTreeNode
v-for="child in node.children"
:key="child.key"
ref="childRefs"
:node="child"
:depth="depth + 1"
:selected-category="selectedCategory"
:selected-collapsed="selectedCollapsed"
:hide-chevrons="hideChevrons"
:focus-parent="() => buttonEl?.focus()"
@select="$emit('select', $event)"
/>
</template>
</div>
</div>
</template>
@@ -61,13 +70,14 @@ export interface CategoryNode {
}
export const CATEGORY_SELECTED_CLASS =
'bg-secondary-background-hover font-semibold text-foreground'
'bg-secondary-background-hover text-foreground'
export const CATEGORY_UNSELECTED_CLASS =
'text-muted-foreground hover:bg-secondary-background-hover hover:text-foreground'
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick, ref } from 'vue'
import { RovingFocusItem } from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
@@ -76,21 +86,47 @@ const {
depth = 0,
selectedCategory,
selectedCollapsed = false,
hideChevrons = false
hideChevrons = false,
focusParent
} = defineProps<{
node: CategoryNode
depth?: number
selectedCategory: string
selectedCollapsed?: boolean
hideChevrons?: boolean
focusParent?: () => void
}>()
defineEmits<{
const emit = defineEmits<{
select: [key: string]
}>()
const buttonEl = ref<HTMLButtonElement>()
const childRefs = ref<{ focus?: () => void }[]>([])
defineExpose({ focus: () => buttonEl.value?.focus() })
const isExpanded = computed(() => {
if (selectedCategory === node.key) return !selectedCollapsed
return selectedCategory.startsWith(node.key + '/')
})
function handleRight() {
if (!node.children?.length) return
if (!isExpanded.value) {
emit('select', node.key)
return
}
nextTick(() => {
childRefs.value[0]?.focus?.()
})
}
function handleLeft() {
if (node.children?.length && isExpanded.value) {
emit('select', node.key)
return
}
focusParent?.()
}
</script>

View File

@@ -458,6 +458,50 @@ describe('NodeSearchContent', () => {
expect(results[1].attributes('aria-selected')).toBe('true')
})
it('should navigate results with ArrowDown/ArrowUp from a focused result item', async () => {
const wrapper = await setupFavorites([
{ name: 'Node1', display_name: 'Node One' },
{ name: 'Node2', display_name: 'Node Two' },
{ name: 'Node3', display_name: 'Node Three' }
])
const results = getResultItems(wrapper)
await results[0].trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(getResultItems(wrapper)[1].attributes('aria-selected')).toBe(
'true'
)
await getResultItems(wrapper)[1].trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(getResultItems(wrapper)[2].attributes('aria-selected')).toBe(
'true'
)
await getResultItems(wrapper)[2].trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(getResultItems(wrapper)[1].attributes('aria-selected')).toBe(
'true'
)
})
it('should select node with Enter from a focused result item', async () => {
const wrapper = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }
])
await getResultItems(wrapper)[0].trigger('keydown', { key: 'Enter' })
await nextTick()
expect(wrapper.emitted('addNode')).toBeTruthy()
expect(wrapper.emitted('addNode')![0][0]).toMatchObject({
name: 'TestNode'
})
})
it('should add node on click', async () => {
const wrapper = await setupFavorites([
{ name: 'TestNode', display_name: 'Test Node' }

View File

@@ -1,7 +1,7 @@
<template>
<div
ref="dialogRef"
class="flex max-h-[50vh] min-h-[400px] w-full flex-col overflow-hidden rounded-lg border border-interface-stroke bg-base-background"
class="flex max-h-[min(80vh,750px)] min-h-[400px] w-full flex-col overflow-hidden rounded-lg border border-interface-stroke bg-base-background"
>
<!-- Search input row -->
<NodeSearchInput
@@ -20,9 +20,9 @@
class="flex-1"
:filters="filters"
:active-category="rootFilter"
:has-essential-nodes="hasEssentialNodes"
:has-blueprint-nodes="hasBlueprintNodes"
:has-partner-nodes="hasPartnerNodes"
:has-essential-nodes="nodeAvailability.essential"
:has-blueprint-nodes="nodeAvailability.blueprint"
:has-partner-nodes="nodeAvailability.partner"
@toggle-filter="onToggleFilter"
@clear-filter-group="onClearFilterGroup"
@focus-search="nextTick(() => searchInputRef?.focus())"
@@ -38,7 +38,7 @@
class="w-52 shrink-0"
:hide-chevrons="!anyTreeCategoryHasChildren"
:hide-presets="rootFilter !== null"
:has-essential-nodes="hasEssentialNodes"
:has-essential-nodes="nodeAvailability.essential"
:node-defs="rootFilteredNodeDefs"
:root-label="rootFilterLabel"
:root-key="rootFilter ?? undefined"
@@ -49,7 +49,8 @@
<div
id="results-list"
role="listbox"
class="flex-1 overflow-y-auto py-2"
tabindex="-1"
class="flex-1 overflow-y-auto py-2 pr-3 pl-1 select-none"
@pointermove="onPointerMove"
>
<div
@@ -58,14 +59,18 @@
:key="node.name"
role="option"
data-testid="result-item"
:tabindex="index === selectedIndex ? 0 : -1"
:aria-selected="index === selectedIndex"
:class="
cn(
'flex h-14 cursor-pointer items-center px-4',
'flex h-14 cursor-pointer items-center rounded-lg px-4 outline-none focus-visible:ring-2 focus-visible:ring-primary',
index === selectedIndex && 'bg-secondary-background'
)
"
@click="emit('addNode', node, $event)"
@keydown.down.prevent="navigateResults(1, true)"
@keydown.up.prevent="navigateResults(-1, true)"
@keydown.enter.prevent="selectCurrentResult"
>
<NodeSearchListItem
:node-def="node"
@@ -91,7 +96,9 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NodeSearchFilterBar from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchCategorySidebar, {
DEFAULT_CATEGORY
} from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
@@ -129,25 +136,25 @@ const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const hasEssentialNodes = computed(
() =>
flags.nodeLibraryEssentialsEnabled &&
nodeDefStore.visibleNodeDefs.some(isEssentialNode)
)
const hasBlueprintNodes = computed(() =>
nodeDefStore.visibleNodeDefs.some((n) =>
n.category.startsWith(BLUEPRINT_CATEGORY)
)
)
const hasPartnerNodes = computed(() =>
nodeDefStore.visibleNodeDefs.some((n) => n.api_node)
)
const nodeAvailability = computed(() => {
let essential = false
let blueprint = false
let partner = false
for (const n of nodeDefStore.visibleNodeDefs) {
if (!essential && flags.nodeLibraryEssentialsEnabled && isEssentialNode(n))
essential = true
if (!blueprint && n.category.startsWith(BLUEPRINT_CATEGORY))
blueprint = true
if (!partner && n.api_node) partner = true
if (essential && blueprint && partner) break
}
return { essential, blueprint, partner }
})
const dialogRef = ref<HTMLElement>()
const searchInputRef = ref<InstanceType<typeof NodeSearchInput>>()
// Freeze dialog height to prevent layout shift when switching categories
onMounted(() => {
if (dialogRef.value) {
dialogRef.value.style.height = `${dialogRef.value.offsetHeight}px`
@@ -155,7 +162,7 @@ onMounted(() => {
})
const searchQuery = ref('')
const selectedCategory = ref('most-relevant')
const selectedCategory = ref(DEFAULT_CATEGORY)
const selectedIndex = ref(0)
// Root filter from filter bar category buttons (radio toggle)
@@ -217,7 +224,7 @@ function onSelectCategory(category: string) {
} else {
rootFilter.value = category
}
selectedCategory.value = 'most-relevant'
selectedCategory.value = DEFAULT_CATEGORY
searchQuery.value = ''
nextTick(() => searchInputRef.value?.focus())
}
@@ -232,7 +239,7 @@ const searchResults = computed(() => {
})
const effectiveCategory = computed(() =>
searchQuery.value ? 'most-relevant' : selectedCategory.value
searchQuery.value ? DEFAULT_CATEGORY : selectedCategory.value
)
const sidebarCategory = computed({
@@ -278,7 +285,7 @@ const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
const baseNodes = rootFilteredNodeDefs.value
const category = effectiveCategory.value
if (category === 'most-relevant') return getMostRelevantResults(baseNodes)
if (category === DEFAULT_CATEGORY) return getMostRelevantResults(baseNodes)
const sourceFilter = sourceCategoryFilters[category]
let results: ComfyNodeDefImpl[]
@@ -305,7 +312,7 @@ watch(
{ immediate: true }
)
watch([selectedCategory, searchQuery, rootFilter, () => filters], () => {
watch([selectedCategory, searchQuery, rootFilter, () => filters.length], () => {
selectedIndex.value = 0
})
@@ -317,14 +324,16 @@ function onPointerMove(event: PointerEvent) {
selectedIndex.value = index
}
function navigateResults(direction: number) {
function navigateResults(direction: number, focusItem = false) {
const newIndex = selectedIndex.value + direction
if (newIndex >= 0 && newIndex < displayedResults.value.length) {
selectedIndex.value = newIndex
nextTick(() => {
dialogRef.value
?.querySelector(`#result-item-${newIndex}`)
?.scrollIntoView({ block: 'nearest' })
const el = dialogRef.value?.querySelector(
`#result-item-${newIndex}`
) as HTMLElement | null
el?.scrollIntoView({ block: 'nearest' })
if (focusItem) el?.focus()
})
}
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex items-center gap-2 px-2 py-1.5">
<div class="flex items-center gap-2 px-3">
<!-- Category filter buttons -->
<button
v-for="btn in categoryButtons"
@@ -129,7 +129,7 @@ const typeFilters = computed(() => [
function chipClass(isActive: boolean, hasSelections = false) {
return cn(
'flex cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 text-sm transition-colors',
'flex cursor-pointer items-center justify-center gap-1 rounded-md border border-secondary-background px-3 py-1 font-inter text-sm transition-colors',
isActive
? 'border-base-foreground bg-base-foreground text-base-background'
: hasSelections

View File

@@ -25,9 +25,9 @@
type="button"
data-testid="chip-delete"
:aria-label="$t('g.remove')"
class="ml-1 aspect-square cursor-pointer rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
class="ml-1 flex aspect-square cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-muted-foreground hover:text-base-foreground"
>
<i class="pi pi-times text-xs" />
<i class="icon-[lucide--x] size-3" />
</TagsInputItemDelete>
</TagsInputItem>
<TagsInputInput as-child>
@@ -41,7 +41,7 @@
aria-controls="results-list"
:aria-label="t('g.addNode')"
:placeholder="t('g.addNode')"
class="text-foreground h-6 min-w-[min(300px,80vw)] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
class="text-foreground h-6 min-w-[min(300px,80vw)] flex-1 border-none bg-transparent font-inter text-sm outline-none placeholder:text-muted-foreground"
@keydown.enter.prevent="emit('selectCurrent')"
@keydown.down.prevent="emit('navigateDown')"
@keydown.up.prevent="emit('navigateUp')"

View File

@@ -2,9 +2,9 @@
<div
class="option-container flex w-full cursor-pointer items-center justify-between overflow-hidden"
>
<div class="flex min-w-0 flex-1 flex-col gap-0.5 overflow-hidden">
<div class="flex min-w-0 flex-1 flex-col gap-1 overflow-hidden">
<!-- Row 1: Name (left) + badges (right) -->
<div class="text-foreground flex items-center gap-2 font-semibold">
<div class="text-foreground flex items-center gap-2 text-sm">
<span v-if="isBookmarked && !hideBookmarkIcon">
<i class="pi pi-bookmark-fill mr-1 text-sm" />
</span>
@@ -60,7 +60,7 @@
<div
v-if="showDescription"
class="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground"
class="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"
>
<span v-if="showCategory" class="max-w-2/5 shrink-0 truncate">
{{ nodeDef.category.replaceAll('/', ' / ') }}

View File

@@ -129,6 +129,18 @@ describe(NodeSearchTypeFilterPopover, () => {
expect(wrapper.emitted('clear')).toHaveLength(1)
})
it('should emit toggle when an option is clicked', async () => {
createWrapper()
await openPopover(wrapper)
const options = getOptions()
await options[0].trigger('click')
await nextTick()
expect(wrapper.emitted('toggle')).toBeTruthy()
expect(wrapper.emitted('toggle')![0][0]).toBe('IMAGE')
})
it('should filter options via search input', async () => {
createWrapper()
await openPopover(wrapper)

View File

@@ -29,7 +29,7 @@
ref="searchFilterRef"
v-model="searchQuery"
:placeholder="t('g.search')"
class="text-foreground size-full border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
class="text-foreground size-full border-none bg-transparent font-inter text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
@@ -40,7 +40,7 @@
<button
v-if="selectedValues.length > 0"
type="button"
class="cursor-pointer border-none bg-transparent text-sm text-base-foreground"
class="cursor-pointer border-none bg-transparent font-inter text-sm text-base-foreground"
@click="emit('clear')"
>
{{ t('g.clearAll') }}
@@ -61,13 +61,13 @@
:class="
cn(
'flex size-4 shrink-0 items-center justify-center rounded-sm border border-border-default',
selectedValues.includes(option) &&
selectedSet.has(option) &&
'text-primary-foreground border-primary bg-primary'
)
"
>
<i
v-if="selectedValues.includes(option)"
v-if="selectedSet.has(option)"
class="icon-[lucide--check] size-3"
/>
</span>
@@ -94,6 +94,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AcceptableValue } from 'reka-ui'
import {
ListboxContent,
ListboxFilter,
@@ -129,9 +130,11 @@ function onOpenChange(isOpen: boolean) {
if (!isOpen) searchQuery.value = ''
}
function onSelectionChange(newValues: unknown) {
if (!Array.isArray(newValues)) return
const added = newValues.find((v) => !selectedValues.includes(v))
const selectedSet = computed(() => new Set(selectedValues))
function onSelectionChange(value: AcceptableValue) {
const newValues = value as string[]
const added = newValues.find((v) => !selectedSet.value.has(v))
const removed = selectedValues.find((v) => !newValues.includes(v))
const toggled = added ?? removed
if (toggled) emit('toggle', toggled)

View File

@@ -40,6 +40,7 @@ export const testI18n = createI18n({
recents: 'Recents',
favorites: 'Favorites',
essentials: 'Essentials',
category: 'Category',
custom: 'Custom',
comfy: 'Comfy',
partner: 'Partner',