mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
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:
@@ -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`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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('/', ' / ') }}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -40,6 +40,7 @@ export const testI18n = createI18n({
|
||||
recents: 'Recents',
|
||||
favorites: 'Favorites',
|
||||
essentials: 'Essentials',
|
||||
category: 'Category',
|
||||
custom: 'Custom',
|
||||
comfy: 'Comfy',
|
||||
partner: 'Partner',
|
||||
|
||||
Reference in New Issue
Block a user