Compare commits

..

1 Commits

Author SHA1 Message Date
bymyself
6cb4a318c1 remove type asertion 2025-11-15 13:51:04 -08:00
332 changed files with 9838 additions and 12590 deletions

View File

@@ -78,7 +78,8 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
LABELS=$(gh pr view ${{ inputs.pr_number }} --json labels | jq -r '.labels[].name')
else
LABELS=$(jq -r '.pull_request.labels[].name' "$GITHUB_EVENT_PATH")
LABELS='${{ toJSON(github.event.pull_request.labels) }}'
LABELS=$(echo "$LABELS" | jq -r '.[].name')
fi
add_target() {

View File

@@ -59,6 +59,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'pnpm'
- name: Bump version
id: bump-version

View File

@@ -243,7 +243,7 @@ pnpm format
### Styling
- Use Tailwind CSS classes instead of custom CSS
- NEVER use `dark:` or `dark-theme:` tailwind variants. Instead use a semantic value from the `style.css` theme, e.g. `bg-node-component-surface`
- Follow the existing dark theme pattern: `dark-theme:` prefix (not `dark:`)
### Internationalization
- All user-facing strings must use vue-i18n

View File

@@ -91,7 +91,7 @@
"build-storybook": "storybook build -o dist/storybook"
},
"dependencies": {
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/comfyui-electron-types": "0.4.73-0",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@primevue/core": "catalog:",
"@primevue/themes": "catalog:",

View File

@@ -115,18 +115,19 @@ import Button from 'primevue/button'
import Divider from 'primevue/divider'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { computed, onMounted, ref } from 'vue'
import type { ModelRef } from 'vue'
import { type ModelRef, computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/uvMirrors'
import type { UVMirror } from '@/constants/uvMirrors'
import MigrationPicker from '@/components/install/MigrationPicker.vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import {
PYPI_MIRROR,
PYTHON_MIRROR,
type UVMirror
} from '@/constants/uvMirrors'
import { electronAPI } from '@/utils/envUtil'
import { ValidationState } from '@/utils/validationUtil'
import MigrationPicker from './MigrationPicker.vue'
import MirrorItem from './mirror/MirrorItem.vue'
const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true })
@@ -228,10 +229,6 @@ const validatePath = async (path: string | undefined) => {
}
if (validation.parentMissing) errors.push(t('install.parentMissing'))
if (validation.isOneDrive) errors.push(t('install.isOneDrive'))
if (validation.isInsideAppInstallDir)
errors.push(t('install.insideAppInstallDir'))
if (validation.isInsideUpdaterCache)
errors.push(t('install.insideUpdaterCache'))
if (validation.error)
errors.push(`${t('install.unhandledError')}: ${validation.error}`)

View File

@@ -16,8 +16,7 @@ export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
execute: async () => await electron.setBasePath(),
name: 'Base path',
shortDescription: 'Change the application base path.',
errorDescription:
'The current base path is invalid or unsafe. Please select a new location.',
errorDescription: 'Unable to open the base path. Please select a new one.',
description:
'The base path is the default location where ComfyUI stores data. It is the location for the python environment, and may also contain models, custom nodes, and other extensions.',
isInstallationFix: true,

View File

@@ -85,7 +85,6 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
const electron = electronAPI()
// Reactive state
const lastUpdate = ref<InstallValidation | null>(null)
const isRefreshing = ref(false)
const isRunningTerminalCommand = computed(() =>
tasks.value
@@ -98,13 +97,6 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
.some((task) => getRunner(task)?.executing)
)
const unsafeBasePath = computed(
() => lastUpdate.value?.unsafeBasePath === true
)
const unsafeBasePathReason = computed(
() => lastUpdate.value?.unsafeBasePathReason
)
// Task list
const tasks = ref(DESKTOP_MAINTENANCE_TASKS)
@@ -131,7 +123,6 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
* @param validationUpdate Update details passed in by electron
*/
const processUpdate = (validationUpdate: InstallValidation) => {
lastUpdate.value = validationUpdate
const update = validationUpdate as IndexedUpdate
isRefreshing.value = true
@@ -164,11 +155,7 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
}
const execute = async (task: MaintenanceTask) => {
const success = await getRunner(task).execute(task)
if (success && task.isInstallationFix) {
await refreshDesktopTasks()
}
return success
return getRunner(task).execute(task)
}
return {
@@ -176,8 +163,6 @@ export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
isRefreshing,
isRunningTerminalCommand,
isRunningInstallationFix,
unsafeBasePath,
unsafeBasePathReason,
execute,
getRunner,
processUpdate,

View File

@@ -1,159 +0,0 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { defineAsyncComponent } from 'vue'
type UnsafeReason = 'appInstallDir' | 'updaterCache' | 'oneDrive' | null
type ValidationIssueState = 'OK' | 'warning' | 'error' | 'skipped'
type ValidationState = {
inProgress: boolean
installState: string
basePath?: ValidationIssueState
unsafeBasePath: boolean
unsafeBasePathReason: UnsafeReason
venvDirectory?: ValidationIssueState
pythonInterpreter?: ValidationIssueState
pythonPackages?: ValidationIssueState
uv?: ValidationIssueState
git?: ValidationIssueState
vcRedist?: ValidationIssueState
upgradePackages?: ValidationIssueState
}
const validationState: ValidationState = {
inProgress: false,
installState: 'installed',
basePath: 'OK',
unsafeBasePath: false,
unsafeBasePathReason: null,
venvDirectory: 'OK',
pythonInterpreter: 'OK',
pythonPackages: 'OK',
uv: 'OK',
git: 'OK',
vcRedist: 'OK',
upgradePackages: 'OK'
}
const createMockElectronAPI = () => {
const logListeners: Array<(message: string) => void> = []
const getValidationUpdate = () => ({
...validationState
})
return {
getPlatform: () => 'darwin',
changeTheme: (_theme: unknown) => {},
onLogMessage: (listener: (message: string) => void) => {
logListeners.push(listener)
},
showContextMenu: (_options: unknown) => {},
Events: {
trackEvent: (_eventName: string, _data?: unknown) => {}
},
Validation: {
onUpdate: (_callback: (update: unknown) => void) => {},
async getStatus() {
return getValidationUpdate()
},
async validateInstallation(callback: (update: unknown) => void) {
callback(getValidationUpdate())
},
async complete() {
// Only allow completion when the base path is safe
return !validationState.unsafeBasePath
},
dispose: () => {}
},
setBasePath: () => Promise.resolve(true),
reinstall: () => Promise.resolve(),
uv: {
installRequirements: () => Promise.resolve(),
clearCache: () => Promise.resolve(),
resetVenv: () => Promise.resolve()
}
}
}
const ensureElectronAPI = () => {
const globalWindow = window as unknown as { electronAPI?: unknown }
if (!globalWindow.electronAPI) {
globalWindow.electronAPI = createMockElectronAPI()
}
return globalWindow.electronAPI
}
const MaintenanceView = defineAsyncComponent(async () => {
ensureElectronAPI()
const module = await import('./MaintenanceView.vue')
return module.default
})
const meta: Meta<typeof MaintenanceView> = {
title: 'Desktop/Views/MaintenanceView',
component: MaintenanceView,
parameters: {
layout: 'fullscreen',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' },
{ name: 'neutral-950', value: '#0a0a0a' }
]
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
name: 'All tasks OK',
render: () => ({
components: { MaintenanceView },
setup() {
validationState.inProgress = false
validationState.installState = 'installed'
validationState.basePath = 'OK'
validationState.unsafeBasePath = false
validationState.unsafeBasePathReason = null
validationState.venvDirectory = 'OK'
validationState.pythonInterpreter = 'OK'
validationState.pythonPackages = 'OK'
validationState.uv = 'OK'
validationState.git = 'OK'
validationState.vcRedist = 'OK'
validationState.upgradePackages = 'OK'
ensureElectronAPI()
return {}
},
template: '<MaintenanceView />'
})
}
export const UnsafeBasePathOneDrive: Story = {
name: 'Unsafe base path (OneDrive)',
render: () => ({
components: { MaintenanceView },
setup() {
validationState.inProgress = false
validationState.installState = 'installed'
validationState.basePath = 'error'
validationState.unsafeBasePath = true
validationState.unsafeBasePathReason = 'oneDrive'
validationState.venvDirectory = 'OK'
validationState.pythonInterpreter = 'OK'
validationState.pythonPackages = 'OK'
validationState.uv = 'OK'
validationState.git = 'OK'
validationState.vcRedist = 'OK'
validationState.upgradePackages = 'OK'
ensureElectronAPI()
return {}
},
template: '<MaintenanceView />'
})
}

View File

@@ -47,28 +47,6 @@
</div>
</div>
<!-- Unsafe migration warning -->
<div v-if="taskStore.unsafeBasePath" class="my-4">
<p class="flex items-start gap-3 text-neutral-300">
<Tag
icon="pi pi-exclamation-triangle"
severity="warn"
:value="t('icon.exclamation-triangle')"
/>
<span>
<strong class="block mb-1">
{{ t('maintenance.unsafeMigration.title') }}
</strong>
<span class="block mb-1">
{{ unsafeReasonText }}
</span>
<span class="block text-sm text-neutral-400">
{{ t('maintenance.unsafeMigration.action') }}
</span>
</span>
</p>
</div>
<!-- Tasks -->
<TaskListPanel
class="border-neutral-700 border-solid border-x-0 border-y"
@@ -111,10 +89,10 @@
import { PrimeIcons } from '@primevue/core/api'
import Button from 'primevue/button'
import SelectButton from 'primevue/selectbutton'
import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { watch } from 'vue'
import RefreshButton from '@/components/common/RefreshButton.vue'
import StatusTag from '@/components/maintenance/StatusTag.vue'
@@ -161,27 +139,6 @@ const filterOptions = ref([
/** Filter binding; can be set to show all tasks, or only errors. */
const filter = ref<MaintenanceFilter>(filterOptions.value[0])
const unsafeReasonText = computed(() => {
const reason = taskStore.unsafeBasePathReason
if (!reason) {
return t('maintenance.unsafeMigration.generic')
}
if (reason === 'appInstallDir') {
return t('maintenance.unsafeMigration.appInstallDir')
}
if (reason === 'updaterCache') {
return t('maintenance.unsafeMigration.updaterCache')
}
if (reason === 'oneDrive') {
return t('maintenance.unsafeMigration.oneDrive')
}
return t('maintenance.unsafeMigration.generic')
})
/** If valid, leave the validation window. */
const completeValidation = async () => {
const isValid = await electron.Validation.complete()

View File

@@ -85,10 +85,11 @@
</template>
<script setup lang="ts">
import { InstallStage, ProgressStatus } from '@comfyorg/comfyui-electron-types'
import type {
InstallStageInfo,
InstallStageName
import {
InstallStage,
type InstallStageInfo,
type InstallStageName,
ProgressStatus
} from '@comfyorg/comfyui-electron-types'
import type { Terminal } from '@xterm/xterm'
import Button from 'primevue/button'

View File

@@ -16,6 +16,7 @@ import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import { SettingDialog } from './components/SettingDialog'
import {
NodeLibrarySidebarTab,
QueueSidebarTab,
WorkflowsSidebarTab
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
@@ -30,6 +31,7 @@ type WorkspaceStore = ReturnType<typeof useWorkspaceStore>
class ComfyMenu {
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
private _workflowsTab: WorkflowsSidebarTab | null = null
private _queueTab: QueueSidebarTab | null = null
private _topbar: Topbar | null = null
public readonly sideToolbar: Locator
@@ -58,6 +60,11 @@ class ComfyMenu {
return this._workflowsTab
}
get queueTab() {
this._queueTab ??= new QueueSidebarTab(this.page)
return this._queueTab
}
get topbar() {
this._topbar ??= new Topbar(this.page)
return this._topbar
@@ -557,7 +564,7 @@ export class ComfyPage {
async dragAndDrop(source: Position, target: Position) {
await this.page.mouse.move(source.x, source.y)
await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y, { steps: 100 })
await this.page.mouse.move(target.x, target.y)
await this.page.mouse.up()
await this.nextFrame()
}

View File

@@ -65,9 +65,7 @@ export class VueNodeHelpers {
* Select a specific Vue node by ID
*/
async selectNode(nodeId: string): Promise<void> {
await this.page
.locator(`[data-node-id="${nodeId}"] .lg-node-header`)
.click()
await this.page.locator(`[data-node-id="${nodeId}"]`).click()
}
/**
@@ -79,13 +77,11 @@ export class VueNodeHelpers {
// Select first node normally
await this.selectNode(nodeIds[0])
// Add additional nodes with Ctrl+click on header
// Add additional nodes with Ctrl+click
for (let i = 1; i < nodeIds.length; i++) {
await this.page
.locator(`[data-node-id="${nodeIds[i]}"] .lg-node-header`)
.click({
modifiers: ['Control']
})
await this.page.locator(`[data-node-id="${nodeIds[i]}"]`).click({
modifiers: ['Control']
})
}
}

View File

@@ -148,3 +148,124 @@ export class WorkflowsSidebarTab extends SidebarTab {
.click()
}
}
export class QueueSidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
super(page, 'queue')
}
get root() {
return this.page.locator('.sidebar-content-container', { hasText: 'Queue' })
}
get tasks() {
return this.root.locator('[data-virtual-grid-item]')
}
get visibleTasks() {
return this.tasks.locator('visible=true')
}
get clearButton() {
return this.root.locator('.clear-all-button')
}
get collapseTasksButton() {
return this.getToggleExpandButton(false)
}
get expandTasksButton() {
return this.getToggleExpandButton(true)
}
get noResultsPlaceholder() {
return this.root.locator('.no-results-placeholder')
}
get galleryImage() {
return this.page.locator('.galleria-image')
}
private getToggleExpandButton(isExpanded: boolean) {
const iconSelector = isExpanded ? '.pi-image' : '.pi-images'
return this.root.locator(`.toggle-expanded-button ${iconSelector}`)
}
async open() {
await super.open()
return this.root.waitFor({ state: 'visible' })
}
async close() {
await super.close()
await this.root.waitFor({ state: 'hidden' })
}
async expandTasks() {
await this.expandTasksButton.click()
await this.collapseTasksButton.waitFor({ state: 'visible' })
}
async collapseTasks() {
await this.collapseTasksButton.click()
await this.expandTasksButton.waitFor({ state: 'visible' })
}
async waitForTasks() {
return Promise.all([
this.tasks.first().waitFor({ state: 'visible' }),
this.tasks.last().waitFor({ state: 'visible' })
])
}
async scrollTasks(direction: 'up' | 'down') {
const scrollToEl =
direction === 'up' ? this.tasks.last() : this.tasks.first()
await scrollToEl.scrollIntoViewIfNeeded()
await this.waitForTasks()
}
async clearTasks() {
await this.clearButton.click()
const confirmButton = this.page.getByLabel('Delete')
await confirmButton.click()
await this.noResultsPlaceholder.waitFor({ state: 'visible' })
}
/** Set the width of the tab (out of 100). Must call before opening the tab */
async setTabWidth(width: number) {
if (width < 0 || width > 100) {
throw new Error('Width must be between 0 and 100')
}
return this.page.evaluate((width) => {
localStorage.setItem('queue', JSON.stringify([width, 100 - width]))
}, width)
}
getTaskPreviewButton(taskIndex: number) {
return this.tasks.nth(taskIndex).getByRole('button')
}
async openTaskPreview(taskIndex: number) {
const previewButton = this.getTaskPreviewButton(taskIndex)
await previewButton.click()
return this.galleryImage.waitFor({ state: 'visible' })
}
getGalleryImage(imageFilename: string) {
return this.galleryImage.and(this.page.getByAltText(imageFilename))
}
getTaskImage(imageFilename: string) {
return this.tasks.getByAltText(imageFilename)
}
/** Trigger the queue store and tasks to update */
async triggerTasksUpdate() {
await this.page.evaluate(() => {
window['app']['api'].dispatchCustomEvent('status', {
exec_info: { queue_remaining: 0 }
})
})
}
}

View File

@@ -0,0 +1,144 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
interface ChatHistoryEntry {
prompt: string
response: string
response_id: string
}
async function renderChatHistory(page: Page, history: ChatHistoryEntry[]) {
const nodeId = await page.evaluate(() => window['app'].graph.nodes[0]?.id)
// Simulate API sending display_component message
await page.evaluate(
({ nodeId, history }) => {
const event = new CustomEvent('display_component', {
detail: {
node_id: nodeId,
component: 'ChatHistoryWidget',
props: {
history: JSON.stringify(history)
}
}
})
window['app'].api.dispatchEvent(event)
return true
},
{ nodeId, history }
)
return nodeId
}
test.describe('Chat History Widget', () => {
let nodeId: string
test.beforeEach(async ({ comfyPage }) => {
nodeId = await renderChatHistory(comfyPage.page, [
{ prompt: 'Hello', response: 'World', response_id: '123' }
])
// Wait for chat history to be rendered
await comfyPage.page.waitForSelector('.pi-pencil')
})
test('displays chat history when receiving display_component message', async ({
comfyPage
}) => {
// Verify the chat history is displayed correctly
await expect(comfyPage.page.getByText('Hello')).toBeVisible()
await expect(comfyPage.page.getByText('World')).toBeVisible()
})
test('handles message editing interaction', async ({ comfyPage }) => {
// Get first node's ID
nodeId = await comfyPage.page.evaluate(() => {
const node = window['app'].graph.nodes[0]
// Make sure the node has a prompt widget (for editing functionality)
if (!node.widgets) {
node.widgets = []
}
// Add a prompt widget if it doesn't exist
if (!node.widgets.find((w) => w.name === 'prompt')) {
node.widgets.push({
name: 'prompt',
type: 'text',
value: 'Original prompt'
})
}
return node.id
})
await renderChatHistory(comfyPage.page, [
{
prompt: 'Message 1',
response: 'Response 1',
response_id: '123'
},
{
prompt: 'Message 2',
response: 'Response 2',
response_id: '456'
}
])
await comfyPage.page.waitForSelector('.pi-pencil')
const originalTextAreaInput = await comfyPage.page
.getByPlaceholder('text')
.nth(1)
.inputValue()
// Click edit button on first message
await comfyPage.page.getByLabel('Edit').first().click()
await comfyPage.nextFrame()
// Verify cancel button appears
await expect(comfyPage.page.getByLabel('Cancel')).toBeVisible()
// Click cancel edit
await comfyPage.page.getByLabel('Cancel').click()
// Verify prompt input is restored
await expect(comfyPage.page.getByPlaceholder('text').nth(1)).toHaveValue(
originalTextAreaInput
)
})
test('handles real-time updates to chat history', async ({ comfyPage }) => {
// Send initial history
await renderChatHistory(comfyPage.page, [
{
prompt: 'Initial message',
response: 'Initial response',
response_id: '123'
}
])
await comfyPage.page.waitForSelector('.pi-pencil')
// Update history with additional messages
await renderChatHistory(comfyPage.page, [
{
prompt: 'Follow-up',
response: 'New response',
response_id: '456'
}
])
await comfyPage.page.waitForSelector('.pi-pencil')
// Move mouse over the canvas to force update
await comfyPage.page.mouse.move(100, 100)
await comfyPage.nextFrame()
// Verify new messages appear
await expect(comfyPage.page.getByText('Follow-up')).toBeVisible()
await expect(comfyPage.page.getByText('New response')).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -2,7 +2,6 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import { fitToViewInstant } from '../helpers/fitToView'
// TODO: there might be a better solution for this
// Helper function to pan canvas and select node
@@ -517,7 +516,6 @@ This is English documentation.
)
await comfyPage.loadWorkflow('default')
await fitToViewInstant(comfyPage)
// Select KSampler first
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -0,0 +1,210 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe.skip('Queue sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test('can display tasks', async ({ comfyPage }) => {
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
})
test('can display tasks after closing then opening', async ({
comfyPage
}) => {
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.close()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
})
test.describe('Virtual scroll', () => {
const layouts = [
{ description: 'Five columns layout', width: 95, rows: 3, cols: 5 },
{ description: 'Three columns layout', width: 55, rows: 3, cols: 3 },
{ description: 'Two columns layout', width: 40, rows: 3, cols: 2 }
]
test.beforeEach(async ({ comfyPage }) => {
await comfyPage
.setupHistory()
.withTask(['example.webp'])
.repeat(50)
.setupRoutes()
})
layouts.forEach(({ description, width, rows, cols }) => {
const preRenderedRows = 1
const preRenderedTasks = preRenderedRows * cols * 2
const visibleTasks = rows * cols
const expectRenderLimit = visibleTasks + preRenderedTasks
test.describe(description, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.menu.queueTab.setTabWidth(width)
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
})
test('should not render items outside of view', async ({
comfyPage
}) => {
const renderedCount =
await comfyPage.menu.queueTab.visibleTasks.count()
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
})
test('should teardown items after scrolling away', async ({
comfyPage
}) => {
await comfyPage.menu.queueTab.scrollTasks('down')
const renderedCount =
await comfyPage.menu.queueTab.visibleTasks.count()
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
})
test('should re-render items after scrolling away then back', async ({
comfyPage
}) => {
await comfyPage.menu.queueTab.scrollTasks('down')
await comfyPage.menu.queueTab.scrollTasks('up')
const renderedCount =
await comfyPage.menu.queueTab.visibleTasks.count()
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
})
})
})
})
test.describe('Expand tasks', () => {
test.beforeEach(async ({ comfyPage }) => {
// 2-item batch and 3-item batch -> 3 additional items when expanded
await comfyPage
.setupHistory()
.withTask(['example.webp', 'example.webp', 'example.webp'])
.withTask(['example.webp', 'example.webp'])
.setupRoutes()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
})
test('can expand tasks with multiple outputs', async ({ comfyPage }) => {
const initialCount = await comfyPage.menu.queueTab.visibleTasks.count()
await comfyPage.menu.queueTab.expandTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(
initialCount + 3
)
})
test('can collapse flat tasks', async ({ comfyPage }) => {
const initialCount = await comfyPage.menu.queueTab.visibleTasks.count()
await comfyPage.menu.queueTab.expandTasks()
await comfyPage.menu.queueTab.collapseTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(
initialCount
)
})
})
test.describe('Clear tasks', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage
.setupHistory()
.withTask(['example.webp'])
.repeat(6)
.setupRoutes()
await comfyPage.menu.queueTab.open()
})
test('can clear all tasks', async ({ comfyPage }) => {
await comfyPage.menu.queueTab.clearTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(0)
expect(
await comfyPage.menu.queueTab.noResultsPlaceholder.isVisible()
).toBe(true)
})
test('can load new tasks after clearing all', async ({ comfyPage }) => {
await comfyPage.menu.queueTab.clearTasks()
await comfyPage.menu.queueTab.close()
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
})
})
test.describe('Gallery', () => {
const firstImage = 'example.webp'
const secondImage = 'image32x32.webp'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage
.setupHistory()
.withTask([secondImage])
.withTask([firstImage])
.setupRoutes()
await comfyPage.menu.queueTab.open()
await comfyPage.menu.queueTab.waitForTasks()
await comfyPage.menu.queueTab.openTaskPreview(0)
})
test('displays gallery image after opening task preview', async ({
comfyPage
}) => {
await comfyPage.nextFrame()
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
})
test('maintains active gallery item when new tasks are added', async ({
comfyPage
}) => {
// Add a new task while the gallery is still open
const newImage = 'image64x64.webp'
comfyPage.setupHistory().withTask([newImage])
await comfyPage.menu.queueTab.triggerTasksUpdate()
await comfyPage.page.waitForTimeout(500)
const newTask = comfyPage.menu.queueTab.tasks.getByAltText(newImage)
await newTask.waitFor({ state: 'visible' })
// The active gallery item should still be the initial image
await expect(
comfyPage.menu.queueTab.getGalleryImage(firstImage)
).toBeVisible()
})
test.describe('Gallery navigation', () => {
const paths: {
description: string
path: ('Right' | 'Left')[]
end: string
}[] = [
{ description: 'Right', path: ['Right'], end: secondImage },
{ description: 'Left', path: ['Right', 'Left'], end: firstImage },
{ description: 'Left wrap', path: ['Left'], end: secondImage },
{ description: 'Right wrap', path: ['Right', 'Right'], end: firstImage }
]
paths.forEach(({ description, path, end }) => {
test(`can navigate gallery ${description}`, async ({ comfyPage }) => {
for (const direction of path)
await comfyPage.page.keyboard.press(`Arrow${direction}`, {
delay: 256
})
await comfyPage.nextFrame()
await expect(
comfyPage.menu.queueTab.getGalleryImage(end)
).toBeVisible()
})
})
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -6,7 +6,6 @@ import {
test.describe('Vue Nodes Zoom', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
await comfyPage.vueNodes.waitForNodes()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 129 KiB

View File

@@ -9,7 +9,6 @@ test.beforeEach(async ({ comfyPage }) => {
test.describe('Vue Nodes - LOD', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
await comfyPage.setup()
await comfyPage.loadWorkflow('default')
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -140,8 +140,8 @@ export default defineConfig([
'unused-imports/no-unused-imports': 'error',
'no-console': ['error', { allow: ['warn', 'error'] }],
'vue/no-v-html': 'off',
// Prohibit dark-theme: and dark: prefixes
'vue/no-restricted-class': ['error', '/^dark(-theme)?:/'],
// Enforce dark-theme: instead of dark: prefix
'vue/no-restricted-class': ['error', '/^dark:/'],
'vue/multi-word-component-names': 'off', // TODO: fix
'vue/no-template-shadow': 'off', // TODO: fix
'vue/match-component-import-name': 'error',

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.33.5",
"version": "1.32.5",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -128,10 +128,9 @@
"dependencies": {
"@alloc/quick-lru": "catalog:",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/comfyui-electron-types": "0.4.73-0",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@iconify/json": "catalog:",
"@primeuix/forms": "catalog:",

View File

@@ -98,9 +98,6 @@
--color-comfy-menu-secondary: var(--comfy-menu-secondary-bg);
--color-interface-panel-job-progress-primary: var(--color-azure-300);
--color-interface-panel-job-progress-secondary: var(--color-alpha-azure-600-30);
--color-blue-selection: rgb(from var(--color-azure-600) r g b / 0.3);
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b/ 0.15);
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
@@ -163,6 +160,7 @@
--subscription-button-gradient: linear-gradient(315deg, rgb(105 230 255 / 0.15) 0%, rgb(99 73 233 / 0.50) 100%), radial-gradient(70.71% 70.71% at 50% 50%, rgb(62 99 222 / 0.15) 0.01%, rgb(66 0 123 / 0.50) 100%), linear-gradient(92deg, #D000FF 0.38%, #B009FE 37.07%, #3E1FFC 65.17%, #009DFF 103.86%), var(--color-button-surface, #2D2E32);
--modal-card-button-surface: var(--color-smoke-300);
/* Code styling colors for help menu*/
--code-text-color: rgb(0 122 255 / 1);
@@ -267,14 +265,6 @@
--palette-interface-panel-selected-surface: color-mix(in srgb, var(--interface-panel-surface) 87.5%, var(--contrast-mix-color));
--palette-interface-button-hover-surface: color-mix(in srgb, var(--interface-panel-surface) 82%, var(--contrast-mix-color));
--modal-card-background: var(--secondary-background);
--modal-card-background-hovered: var(--secondary-background-hover);
--modal-card-border-highlighted: var(--secondary-background-selected);
--modal-card-button-surface: var(--color-smoke-300);
--modal-card-placeholder-background: var(--color-smoke-600);
--modal-card-tag-background: var(--color-smoke-400);
--modal-card-tag-foreground: var(--base-foreground);
--modal-panel-background: var(--color-white);
}
.dark-theme {
@@ -296,6 +286,8 @@
--subscription-button-gradient: linear-gradient(315deg, rgb(105 230 255 / 0.15) 0%, rgb(99 73 233 / 0.50) 100%), radial-gradient(70.71% 70.71% at 50% 50%, rgb(62 99 222 / 0.15) 0.01%, rgb(66 0 123 / 0.50) 100%), linear-gradient(92deg, #D000FF 0.38%, #B009FE 37.07%, #3E1FFC 65.17%, #009DFF 103.86%), var(--color-button-surface, #2D2E32);
--modal-card-button-surface: var(--color-charcoal-300);
--dialog-surface: var(--color-neutral-700);
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
@@ -334,12 +326,6 @@
--node-stroke-error: var(--color-error);
--node-stroke-executing: var(--color-azure-600);
/* Queue progress (dark theme) */
--color-interface-panel-job-progress-primary: var(--color-cobalt-800);
--color-interface-panel-job-progress-secondary: var(
--color-alpha-azure-600-30
);
--text-secondary: var(--color-slate-100);
--text-primary: var(--color-white);
@@ -376,16 +362,6 @@
--component-node-widget-background-selected: var(--color-charcoal-100);
--component-node-widget-background-disabled: var(--color-alpha-charcoal-600-30);
--component-node-widget-background-highlighted: var(--color-graphite-400);
--modal-card-background: var(--secondary-background);
--modal-card-background-hovered: var(--secondary-background-hover);
--modal-card-border-highlighted: var(--color-ash-400);
--modal-card-button-surface: var(--color-charcoal-300);
--modal-card-placeholder-background: var(--secondary-background);
--modal-card-tag-background: var(--color-ash-800);
--modal-card-tag-foreground: var(--base-foreground);
--modal-panel-background: var(--color-charcoal-600);
}
@theme inline {
@@ -396,16 +372,7 @@
--color-button-surface: var(--button-surface);
--color-button-surface-contrast: var(--button-surface-contrast);
--color-subscription-button-gradient: var(--subscription-button-gradient);
--color-modal-card-background: var(--modal-card-background);
--color-modal-card-background-hovered: var(--modal-card-background-hovered);
--color-modal-card-border-highlighted: var(--modal-card-border-highlighted);
--color-modal-card-button-surface: var(--modal-card-button-surface);
--color-modal-card-placeholder-background: var(--modal-card-placeholder-background);
--color-modal-card-tag-background: var(--modal-card-tag-background);
--color-modal-card-tag-foreground: var(--modal-card-tag-foreground);
--color-modal-panel-background: var(--modal-panel-background);
--color-dialog-surface: var(--dialog-surface);
--color-interface-menu-component-surface-hovered: var(
--interface-menu-component-surface-hovered
@@ -1841,4 +1808,4 @@ audio.comfy-audio.empty-audio-widget {
.maskEditor_sidePanelLayerCheckbox {
margin-left: 15px;
}
/* ===================== End of Mask Editor Styles ===================== */
/* ===================== End of Mask Editor Styles ===================== */

View File

@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.33398 1.33337V4.00004C9.33398 4.35366 9.47446 4.6928 9.72451 4.94285C9.97456 5.1929 10.3137 5.33337 10.6673 5.33337H13.334M2.66732 4.66671V2.66671C2.66732 2.31309 2.80779 1.97395 3.05784 1.7239C3.30789 1.47385 3.64703 1.33337 4.00065 1.33337H10.0006L13.334 4.66671V13.3334C13.334 13.687 13.1935 14.0261 12.9435 14.2762C12.6934 14.5262 12.3543 14.6667 12.0006 14.6667L4.04264 14.666C3.77927 14.7004 3.51166 14.6552 3.2741 14.5365C3.03655 14.4177 2.83988 14.2307 2.70931 13.9994M3.33398 7.33337L1.33398 9.33337M1.33398 9.33337L3.33398 11.3334M1.33398 9.33337H8.00065" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 771 B

58
pnpm-lock.yaml generated
View File

@@ -9,9 +9,6 @@ catalogs:
'@alloc/quick-lru':
specifier: ^5.2.0
version: 5.2.0
'@comfyorg/comfyui-electron-types':
specifier: 0.5.5
version: 0.5.5
'@eslint/js':
specifier: ^9.35.0
version: 9.35.0
@@ -321,17 +318,14 @@ importers:
specifier: ^1.3.1
version: 1.3.1
'@comfyorg/comfyui-electron-types':
specifier: 'catalog:'
version: 0.5.5
specifier: 0.4.73-0
version: 0.4.73-0
'@comfyorg/design-system':
specifier: workspace:*
version: link:packages/design-system
'@comfyorg/registry-types':
specifier: workspace:*
version: link:packages/registry-types
'@comfyorg/shared-frontend-utils':
specifier: workspace:*
version: link:packages/shared-frontend-utils
'@comfyorg/tailwind-utils':
specifier: workspace:*
version: link:packages/tailwind-utils
@@ -712,8 +706,8 @@ importers:
apps/desktop-ui:
dependencies:
'@comfyorg/comfyui-electron-types':
specifier: 'catalog:'
version: 0.5.5
specifier: 0.4.73-0
version: 0.4.73-0
'@comfyorg/shared-frontend-utils':
specifier: workspace:*
version: link:../../packages/shared-frontend-utils
@@ -1456,8 +1450,8 @@ packages:
'@cacheable/utils@2.0.3':
resolution: {integrity: sha512-m7Rce68cMHlAUjvWBy9Ru1Nmw5gU0SjGGtQDdhpe6E0xnbcvrIY0Epy//JU1VYYBUTzrG9jvgmTauULGKzOkWA==}
'@comfyorg/comfyui-electron-types@0.5.5':
resolution: {integrity: sha512-f3XOXpMsALIwHakz7FekVPm4/Fh2pvJPEi8tRe8jYGBt8edsd4Mkkq31Yjs2Weem3BP7yNwbdNuSiQdP/pxJyg==}
'@comfyorg/comfyui-electron-types@0.4.73-0':
resolution: {integrity: sha512-WlItGJQx9ZWShNG9wypx3kq+19pSig/U+s5sD2SAeEcMph4u8A/TS+lnRgdKhT58VT1uD7cMcj2SJpfdBPNWvw==}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
@@ -4416,9 +4410,6 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@@ -7006,11 +6997,6 @@ packages:
engines: {node: '>= 0.4'}
hasBin: true
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
hasBin: true
restore-cursor@3.1.0:
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
engines: {node: '>=8'}
@@ -7106,11 +7092,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -7831,8 +7812,8 @@ packages:
vue-component-type-helpers@3.1.1:
resolution: {integrity: sha512-B0kHv7qX6E7+kdc5nsaqjdGZ1KwNKSUQDWGy7XkTYT7wFsOpkEyaJ1Vq79TjwrrtuLRgizrTV7PPuC4rRQo+vw==}
vue-component-type-helpers@3.1.4:
resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==}
vue-component-type-helpers@3.1.3:
resolution: {integrity: sha512-V1dOD8XYfstOKCnXbWyEJIrhTBMwSyNjv271L1Jlx9ExpNlCSuqOs3OdWrGJ0V544zXufKbcYabi/o+gK8lyfQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -9008,7 +8989,7 @@ snapshots:
'@cacheable/utils@2.0.3': {}
'@comfyorg/comfyui-electron-types@0.5.5': {}
'@comfyorg/comfyui-electron-types@0.4.73-0': {}
'@csstools/color-helpers@5.1.0': {}
@@ -10633,7 +10614,7 @@ snapshots:
storybook: 9.1.6(@testing-library/dom@10.4.1)(prettier@3.6.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.2)
vue-component-type-helpers: 3.1.4
vue-component-type-helpers: 3.1.3
'@swc/helpers@0.5.17':
dependencies:
@@ -11005,7 +10986,7 @@ snapshots:
'@types/react@19.1.9':
dependencies:
csstype: 3.2.3
csstype: 3.1.3
'@types/semver@7.7.0': {}
@@ -12184,8 +12165,6 @@ snapshots:
csstype@3.1.3: {}
csstype@3.2.3: {}
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0
@@ -12612,7 +12591,7 @@ snapshots:
dependencies:
debug: 3.2.7
is-core-module: 2.16.1
resolve: 1.22.11
resolve: 1.22.10
transitivePeerDependencies:
- supports-color
optional: true
@@ -13758,7 +13737,7 @@ snapshots:
acorn: 8.15.0
eslint-visitor-keys: 3.4.3
espree: 9.6.1
semver: 7.7.3
semver: 7.7.2
jsonc-parser@3.2.0: {}
@@ -15363,13 +15342,6 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
optional: true
restore-cursor@3.1.0:
dependencies:
onetime: 5.1.2
@@ -15474,8 +15446,6 @@ snapshots:
semver@7.7.2: {}
semver@7.7.3: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -16370,7 +16340,7 @@ snapshots:
vue-component-type-helpers@3.1.1: {}
vue-component-type-helpers@3.1.4: {}
vue-component-type-helpers@3.1.3: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.2)):
dependencies:

View File

@@ -4,7 +4,6 @@ packages:
catalog:
'@alloc/quick-lru': ^5.2.0
'@comfyorg/comfyui-electron-types': 0.5.5
'@eslint/js': ^9.35.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380

View File

@@ -30,7 +30,7 @@
## Styling
- Use Tailwind CSS only (no custom CSS)
- Use the correct tokens from style.css in the design system package
- Dark theme: use "dark-theme:" prefix
- For common operations, try to use existing VueUse composables that automatically handle effect scope
- Example: Use `useElementHover` instead of manually managing mouseover/mouseout event listeners
- Example: Use `useIntersectionObserver` for visibility detection instead of custom scroll handlers

View File

@@ -4,80 +4,38 @@
<SubgraphBreadcrumb />
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-interface-stroke px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<IconButton
v-tooltip.bottom="queueHistoryTooltipConfig"
type="transparent"
size="sm"
class="queue-history-toggle relative mr-2 transition-colors duration-200 ease-in-out hover:bg-secondary-background-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:class="queueHistoryButtonBackgroundClass"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
@click="toggleQueueOverlay"
>
<i
class="icon-[lucide--history] block size-4 text-muted-foreground"
/>
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-white"
>
{{ queuedCount }}
</span>
</IconButton>
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
</div>
<QueueProgressOverlay v-model:expanded="isQueueOverlayExpanded" />
ref="legacyCommandsContainerRef"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar />
<CurrentUserButton v-if="isLoggedIn" class="shrink-0" />
<LoginButton v-else-if="isDesktop" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { onMounted, ref } from 'vue'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import IconButton from '@/components/button/IconButton.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { app } from '@/scripts/app'
import { useQueueStore } from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
const workspaceStore = useWorkspaceStore()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t } = useI18n()
const isQueueOverlayExpanded = ref(false)
const queueStore = useQueueStore()
const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const queueHistoryButtonBackgroundClass = computed(() =>
isQueueOverlayExpanded.value
? 'bg-secondary-background-selected'
: 'bg-secondary-background'
)
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
@@ -87,10 +45,6 @@ onMounted(() => {
legacyCommandsContainerRef.value.appendChild(app.menu.element)
}
})
const toggleQueueOverlay = () => {
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
}
</script>
<style scoped>

View File

@@ -10,7 +10,7 @@
</div>
<Panel
class="pointer-events-auto z-1010"
class="pointer-events-auto z-1000"
:style="style"
:class="panelClass"
:pt="{
@@ -260,7 +260,6 @@ const actionbarClass = computed(() =>
'w-[265px] border-dashed border-blue-500 opacity-80',
'm-1.5 flex items-center justify-center self-stretch',
'rounded-md before:w-50 before:-ml-50 before:h-full',
'pointer-events-auto',
isMouseOverDropZone.value &&
'border-[3px] opacity-100 scale-105 shadow-[0_0_20px] shadow-blue-500'
)

View File

@@ -2,7 +2,9 @@
<div class="queue-button-group flex">
<SplitButton
v-tooltip.bottom="{
value: queueButtonTooltip,
value: workspaceStore.shiftDown
? $t('menu.runWorkflowFront')
: $t('menu.runWorkflow'),
showDelay: 600
}"
class="comfyui-queue-button"
@@ -14,7 +16,16 @@
@click="queuePrompt"
>
<template #icon>
<i :class="iconClass" />
<i v-if="workspaceStore.shiftDown" class="icon-[lucide--list-start]" />
<i v-else-if="queueMode === 'disabled'" class="icon-[lucide--play]" />
<i
v-else-if="queueMode === 'instant'"
class="icon-[lucide--fast-forward]"
/>
<i
v-else-if="queueMode === 'change'"
class="icon-[lucide--step-forward]"
/>
</template>
<template #item="{ item }">
<Button
@@ -31,12 +42,46 @@
</template>
</SplitButton>
<BatchCountEdit />
<ButtonGroup class="execution-actions flex flex-nowrap">
<Button
v-tooltip.bottom="{
value: $t('menu.interrupt'),
showDelay: 600
}"
icon="pi pi-times"
:severity="executingPrompt ? 'danger' : 'secondary'"
:disabled="!executingPrompt"
text
:aria-label="$t('menu.interrupt')"
@click="() => commandStore.execute('Comfy.Interrupt')"
/>
<Button
v-tooltip.bottom="{
value: $t('sideToolbar.queueTab.clearPendingTasks'),
showDelay: 600
}"
icon="pi pi-stop"
:severity="hasPendingTasks ? 'danger' : 'secondary'"
:disabled="!hasPendingTasks"
text
:aria-label="$t('sideToolbar.queueTab.clearPendingTasks')"
@click="
() => {
if (queueCountStore.count.value > 1) {
commandStore.execute('Comfy.ClearPendingTasks')
}
queueMode = 'disabled'
}
"
/>
</ButtonGroup>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Button from 'primevue/button'
import ButtonGroup from 'primevue/buttongroup'
import type { MenuItem } from 'primevue/menuitem'
import SplitButton from 'primevue/splitbutton'
import { computed } from 'vue'
@@ -45,17 +90,18 @@ import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import {
useQueuePendingTaskCountStore,
useQueueSettingsStore
} from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import BatchCountEdit from '../BatchCountEdit.vue'
const workspaceStore = useWorkspaceStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
const { hasMissingNodes } = useMissingNodes()
const { t } = useI18n()
const queueModeMenuItemLookup = computed(() => {
const items: Record<string, MenuItem> = {
@@ -106,34 +152,10 @@ const queueModeMenuItems = computed(() =>
Object.values(queueModeMenuItemLookup.value)
)
const iconClass = computed(() => {
if (hasMissingNodes.value) {
return 'icon-[lucide--triangle-alert]'
}
if (workspaceStore.shiftDown) {
return 'icon-[lucide--list-start]'
}
if (queueMode.value === 'disabled') {
return 'icon-[lucide--play]'
}
if (queueMode.value === 'instant') {
return 'icon-[lucide--fast-forward]'
}
if (queueMode.value === 'change') {
return 'icon-[lucide--step-forward]'
}
return 'icon-[lucide--play]'
})
const queueButtonTooltip = computed(() => {
if (hasMissingNodes.value) {
return t('menu.runWorkflowDisabled')
}
if (workspaceStore.shiftDown) {
return t('menu.runWorkflowFront')
}
return t('menu.runWorkflow')
})
const executingPrompt = computed(() => !!queueCountStore.count.value)
const hasPendingTasks = computed(
() => queueCountStore.count.value > 1 || queueMode.value !== 'disabled'
)
const commandStore = useCommandStore()
const queuePrompt = async (e: Event) => {

View File

@@ -7,7 +7,7 @@
class="flex flex-col"
>
<h3
class="subcategory-title mb-4 text-xs font-bold tracking-wide text-text-secondary uppercase"
class="subcategory-title mb-4 text-xs font-bold tracking-wide text-surface-600 uppercase dark-theme:text-surface-400"
>
{{ getSubcategoryTitle(subcategory) }}
</h3>
@@ -16,7 +16,7 @@
<div
v-for="command in subcategoryCommands"
:key="command.id"
class="shortcut-item flex items-center justify-between rounded py-2 transition-colors duration-200"
class="shortcut-item flex items-center justify-between rounded py-2 transition-colors duration-200 hover:bg-surface-100 dark-theme:hover:bg-surface-700"
>
<div class="shortcut-info grow pr-4">
<div class="shortcut-name text-sm font-medium">
@@ -32,7 +32,7 @@
<span
v-for="key in command.keybinding!.combo.getKeySequences()"
:key="key"
class="key-badge min-w-6 rounded bg-muted-background px-2 py-1 text-center font-mono text-xs"
class="key-badge min-w-6 rounded border bg-surface-200 px-2 py-1 text-center font-mono text-xs dark-theme:bg-surface-600"
>
{{ formatKey(key) }}
</span>
@@ -100,3 +100,21 @@ const formatKey = (key: string): string => {
return keyMap[key] || key
}
</script>
<style scoped>
.subcategory-title {
color: var(--p-text-muted-color);
}
.key-badge {
background-color: var(--p-surface-200);
border: 1px solid var(--p-surface-300);
min-width: 1.5rem;
text-align: center;
}
.dark-theme .key-badge {
background-color: var(--p-surface-600);
border-color: var(--p-surface-500);
}
</style>

View File

@@ -2,7 +2,7 @@
<a
ref="wrapperRef"
v-tooltip.bottom="{
value: tooltipText,
value: item.label,
showDelay: 512
}"
draggable="false"
@@ -16,10 +16,6 @@
}"
@click="handleClick"
>
<i
v-if="hasMissingNodes && isRoot"
class="icon-[lucide--triangle-alert] text-warning-background"
/>
<span class="p-breadcrumb-item-label px-2">{{ item.label }}</span>
<Tag v-if="item.isBlueprint" :value="'Blueprint'" severity="primary" />
<i v-if="isActive" class="pi pi-angle-down text-[10px]"></i>
@@ -68,7 +64,6 @@ import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { appendJsonExt } from '@/utils/formatUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
interface Props {
item: MenuItem
@@ -79,8 +74,6 @@ const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const { hasMissingNodes } = useMissingNodes()
const { t } = useI18n()
const menu = ref<InstanceType<typeof Menu> & MenuState>()
const dialogService = useDialogService()
@@ -122,14 +115,6 @@ const rename = async (
}
const isRoot = props.item.key === 'root'
const tooltipText = computed(() => {
if (hasMissingNodes.value && isRoot) {
return t('breadcrumbsMenu.missingNodesWarning')
}
return props.item.label
})
const menuItems = computed<MenuItem[]>(() => {
return [
{

View File

@@ -24,7 +24,7 @@ import {
import { cn } from '@/utils/tailwindUtil'
interface IconButtonProps extends BaseButtonProps {
onClick?: (event: MouseEvent) => void
onClick: (event: Event) => void
}
defineOptions({

View File

@@ -10,7 +10,8 @@ import { cn } from '@/utils/tailwindUtil'
const iconGroupClasses = cn(
'flex justify-center items-center shrink-0',
'outline-hidden border-none p-0 rounded-lg',
'bg-secondary-background shadow-sm',
'bg-white dark-theme:bg-zinc-700',
'text-neutral-950 dark-theme:text-white',
'transition-all duration-200',
'cursor-pointer'
)

View File

@@ -47,7 +47,7 @@ const {
} = defineProps<IconTextButtonProps>()
const buttonStyle = computed(() => {
const baseClasses = `${getBaseButtonClasses()} justify-start gap-2`
const baseClasses = `${getBaseButtonClasses()} justify-start! gap-2`
const sizeClasses = getButtonSizeClasses(size)
const typeClasses = border
? getBorderButtonTypeClasses(type)

View File

@@ -7,24 +7,13 @@
<Popover
ref="popover"
append-to="body"
auto-z-index
dismissable
close-on-escape
unstyled
:append-to="'body'"
:auto-z-index="true"
:base-z-index="1000"
:pt="{
root: {
class: cn('absolute z-50')
},
content: {
class: cn(
'mt-1 rounded-lg',
'bg-secondary-background text-base-foreground',
'shadow-lg'
)
}
}"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="pt"
@show="$emit('menuOpened')"
@hide="$emit('menuClosed')"
>
@@ -37,7 +26,7 @@
<script setup lang="ts">
import Popover from 'primevue/popover'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import type { BaseButtonProps } from '@/types/buttonTypes'
import { cn } from '@/utils/tailwindUtil'
@@ -69,7 +58,18 @@ const hide = () => {
popover.value?.hide()
}
defineExpose({
hide
})
const pt = computed(() => ({
root: {
class: cn('absolute z-50')
},
content: {
class: cn(
'mt-1 rounded-lg',
'bg-white dark-theme:bg-zinc-800',
'text-neutral dark-theme:text-white',
'shadow-lg',
'border border-zinc-200 dark-theme:border-zinc-700'
)
}
}))
</script>

View File

@@ -47,8 +47,8 @@ const containerClasses = computed(() => {
// Variant styles
const variantClasses = {
default: cn(
hasBackground && 'bg-modal-card-background',
hasBorder && 'border border-border-default',
hasBackground && 'bg-white dark-theme:bg-zinc-800',
hasBorder && 'border border-zinc-200 dark-theme:border-zinc-700',
hasShadow && 'shadow-sm',
hasCursor && 'cursor-pointer'
),
@@ -57,9 +57,9 @@ const containerClasses = computed(() => {
'p-2 transition-colors duration-200'
),
outline: cn(
hasBorder && 'border-2 border-border-subtle',
hasBorder && 'border-2 border-zinc-300 dark-theme:border-zinc-600',
hasCursor && 'cursor-pointer',
'hover:border-border-subtle/50 transition-colors'
'hover:border-zinc-400 dark-theme:hover:border-zinc-500 transition-colors'
)
}

View File

@@ -1,5 +1,7 @@
<template>
<div class="line-clamp-2 h-7 text-xs text-muted-foreground">
<div class="line-clamp-2 h-7 text-xs text-zinc-500 dark-theme:text-zinc-400">
<slot></slot>
</div>
</template>
<script setup lang="ts"></script>

View File

@@ -11,7 +11,7 @@ import { cn } from '@/utils/tailwindUtil'
const { label, variant = 'dark' } = defineProps<{
label: string
variant?: 'dark' | 'light' | 'gray'
variant?: 'dark' | 'light'
}>()
const baseClasses =
@@ -19,10 +19,7 @@ const baseClasses =
const variantStyles = {
dark: 'bg-zinc-500/40 text-white/90',
light: cn('backdrop-blur-[2px] bg-base-background/50 text-base-foreground'),
gray: cn(
'backdrop-blur-[2px] bg-modal-card-tag-background text-base-foreground'
)
light: 'backdrop-blur-[2px] bg-white/50 text-zinc-900 dark-theme:text-white'
}
const chipClasses = computed(() => {

View File

@@ -3,7 +3,7 @@
<div class="flex items-center gap-2">
<div
class="preview-box flex h-16 w-16 items-center justify-center rounded border p-2"
:class="{ 'bg-base-background': !modelValue }"
:class="{ 'bg-smoke-100 dark-theme:bg-smoke-800': !modelValue }"
>
<img
v-if="modelValue"

View File

@@ -23,7 +23,7 @@
/>
<div
v-if="hasError"
class="absolute inset-0 flex items-center justify-center"
class="absolute inset-0 flex items-center justify-center bg-surface-50 text-muted dark-theme:bg-surface-800"
>
<img
src="/assets/images/default-template.png"

View File

@@ -364,7 +364,10 @@
</div>
<!-- Results Summary -->
<div v-if="!isLoading" class="mt-6 px-6 text-sm text-muted">
<div
v-if="!isLoading"
class="mt-6 px-6 text-sm text-neutral-600 dark-theme:text-neutral-400"
>
{{
$t('templateWorkflows.resultsCount', {
count: filteredCount,

View File

@@ -24,14 +24,16 @@
:key="version"
class="ml-4"
>
<div class="text-sm font-medium text-surface-600">
<div
class="text-sm font-medium text-surface-600 dark-theme:text-surface-400"
>
{{
$t('loadWorkflowWarning.coreNodesFromVersion', {
version: version || 'unknown'
})
}}
</div>
<div class="ml-4 text-sm text-surface-500">
<div class="ml-4 text-sm text-surface-500 dark-theme:text-surface-500">
{{ getUniqueNodeNames(nodes).join(', ') }}
</div>
</div>

View File

@@ -1,7 +1,6 @@
<template>
<div
class="flex w-[490px] flex-col border-t-1 border-border-default"
:class="isCloud ? 'border-b-1' : ''"
class="flex w-[490px] flex-col border-t-1 border-b-1 border-border-default"
>
<div class="flex h-full w-full flex-col gap-4 p-4">
<!-- Description -->

View File

@@ -4,7 +4,6 @@
<InputText
ref="inputRef"
v-model="inputValue"
:placeholder
autofocus
@keyup.enter="onConfirm"
@focus="selectAllText"
@@ -29,7 +28,6 @@ const props = defineProps<{
message: string
defaultValue: string
onConfirm: (value: string) => void
placeholder?: string
}>()
const inputValue = ref<string>(props.defaultValue)

View File

@@ -84,6 +84,18 @@ describe('BypassButton', () => {
)
})
it('should show normal styling when node is not bypassed', () => {
const normalNode = { ...mockLGraphNode, mode: LGraphEventMode.ALWAYS }
canvasStore.selectedItems = [normalNode] as any
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.classes()).not.toContain(
'dark-theme:[&:not(:active)]:!bg-charcoal-600'
)
})
it('should show bypassed styling when node is bypassed', async () => {
const bypassedNode = { ...mockLGraphNode, mode: LGraphEventMode.BYPASS }
canvasStore.selectedItems = [bypassedNode] as any

View File

@@ -4,13 +4,13 @@
value: t('selectionToolbox.executeButton.tooltip'),
showDelay: 1000
}"
class="size-8 bg-primary-background text-white p-0"
class="size-8 bg-azure-400 !p-0 dark-theme:bg-azure-600"
text
@mouseenter="() => handleMouseEnter()"
@mouseleave="() => handleMouseLeave()"
@click="handleClick"
>
<i class="icon-[lucide--play] size-4" />
<i class="icon-[lucide--play] size-4 text-white" />
</Button>
</template>

View File

@@ -1,5 +1,8 @@
<template>
<div v-if="option.type === 'divider'" class="my-1 h-px bg-border-default" />
<div
v-if="option.type === 'divider'"
class="my-1 h-px bg-smoke-200 dark-theme:bg-zinc-700"
/>
<div
v-else
role="button"
@@ -23,21 +26,18 @@
v-if="option.badge"
:severity="option.badge === 'new' ? 'info' : 'secondary'"
:value="t(option.badge)"
:class="
cn(
'h-4 gap-2.5 px-1 text-[9px] text-base-foreground uppercase rounded-4xl',
{
'bg-primary-background': option.badge === 'new',
'bg-secondary-background': option.badge === 'deprecated'
}
)
"
:class="{
'rounded-4xl bg-azure-400 dark-theme:bg-azure-600':
option.badge === 'new',
'rounded-4xl bg-slate-100 dark-theme:bg-black':
option.badge === 'deprecated',
'h-4 gap-2.5 px-1 text-[9px] text-white uppercase': true
}"
/>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Badge from 'primevue/badge'
import { useI18n } from 'vue-i18n'

View File

@@ -8,18 +8,7 @@
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class: 'absolute z-50 w-[300px]'
},
content: {
class: [
'mt-2 text-base-foreground rounded-lg',
'shadow-lg border border-border-default',
'bg-interface-panel-surface'
]
}
}"
:pt="pt"
@show="onPopoverShow"
@hide="onPopoverHide"
@wheel="canvasInteractions.forwardEventToCanvas"
@@ -47,7 +36,7 @@
<script setup lang="ts">
import { useRafFn } from '@vueuse/core'
import Popover from 'primevue/popover'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import {
forceCloseMoreOptionsSignal,
@@ -264,6 +253,19 @@ const setSubmenuRef = (key: string, el: any) => {
}
}
const pt = computed(() => ({
root: {
class: 'absolute z-50 w-[300px] px-[12]'
},
content: {
class: [
'mt-2 text-neutral dark-theme:text-white rounded-lg',
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700',
'bg-interface-panel-surface'
]
}
}))
// Distinguish outside click (PrimeVue dismiss) from programmatic hides.
const onPopoverShow = () => {
overlayElCache = resolveOverlayEl()

View File

@@ -6,18 +6,7 @@
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: {
class: 'absolute z-[60]'
},
content: {
class: [
'text-base-foreground rounded-lg',
'shadow-lg border border-base-background',
'bg-interface-panel-surface'
]
}
}"
:pt="submenuPt"
>
<div
:class="
@@ -30,25 +19,22 @@
v-for="subOption in option.submenu"
:key="subOption.label"
:class="
cn(
'hover:bg-secondary-background-hover rounded cursor-pointer',
isColorSubmenu
? 'w-7 h-7 flex items-center justify-center'
: 'flex items-center gap-2 px-3 py-1.5 text-sm'
)
isColorSubmenu
? 'w-7 h-7 flex items-center justify-center hover:bg-smoke-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer'
: 'flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-smoke-100 dark-theme:hover:bg-zinc-700 rounded cursor-pointer'
"
:title="subOption.label"
@click="handleSubmenuClick(subOption)"
>
<div
v-if="subOption.color"
class="size-5 rounded-full border border-border-default"
class="h-5 w-5 rounded-full border border-smoke-300 dark-theme:border-zinc-600"
:style="{ backgroundColor: subOption.color }"
/>
<template v-else-if="!subOption.color">
<i
v-if="isShapeSelected(subOption)"
class="icon-[lucide--check] size-4 flex-shrink-0"
class="icon-[lucide--check] h-4 w-4 flex-shrink-0"
/>
<div v-else class="w-4 flex-shrink-0" />
<span>{{ subOption.label }}</span>
@@ -59,7 +45,6 @@
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
@@ -117,4 +102,17 @@ const isColorSubmenu = computed(() => {
props.option.submenu.every((item) => item.color && !item.icon)
)
})
const submenuPt = computed(() => ({
root: {
class: 'absolute z-[60]'
},
content: {
class: [
'text-neutral dark-theme:text-white rounded-lg',
'shadow-lg border border-zinc-200 dark-theme:border-zinc-700',
'bg-interface-panel-surface'
]
}
}))
</script>

View File

@@ -1,3 +1,5 @@
<template>
<div class="h-6 w-px self-center bg-border-default" />
<div
class="h-6 w-px self-center bg-smoke-300/10 dark-theme:bg-smoke-600/10"
/>
</template>

View File

@@ -0,0 +1,95 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { edit: 'Edit' },
chatHistory: {
cancelEdit: 'Cancel edit',
cancelEditTooltip: 'Cancel edit'
}
}
}
})
vi.mock('@/components/graph/widgets/chatHistory/CopyButton.vue', () => ({
default: {
name: 'CopyButton',
template: '<div class="mock-copy-button"></div>',
props: ['text']
}
}))
vi.mock('@/components/graph/widgets/chatHistory/ResponseBlurb.vue', () => ({
default: {
name: 'ResponseBlurb',
template: '<div class="mock-response-blurb"><slot /></div>',
props: ['text']
}
}))
describe('ChatHistoryWidget.vue', () => {
const mockHistory = JSON.stringify([
{ prompt: 'Test prompt', response: 'Test response', response_id: '123' }
])
const mountWidget = (props: { history: string; widget?: any }) => {
return mount(ChatHistoryWidget, {
props,
global: {
plugins: [i18n],
stubs: {
Button: {
template: '<button><slot /></button>',
props: ['icon', 'aria-label']
},
ScrollPanel: { template: '<div><slot /></div>' }
}
}
})
}
it('renders chat history correctly', () => {
const wrapper = mountWidget({ history: mockHistory })
expect(wrapper.text()).toContain('Test prompt')
expect(wrapper.text()).toContain('Test response')
})
it('handles empty history', () => {
const wrapper = mountWidget({ history: '[]' })
expect(wrapper.find('.mb-4').exists()).toBe(false)
})
it('edits previous prompts', () => {
const mockWidget = {
node: { widgets: [{ name: 'prompt', value: '' }] }
}
const wrapper = mountWidget({ history: mockHistory, widget: mockWidget })
const vm = wrapper.vm as any
vm.handleEdit(0)
expect(mockWidget.node.widgets[0].value).toContain('Test prompt')
expect(mockWidget.node.widgets[0].value).toContain('starting_point_id')
})
it('cancels editing correctly', () => {
const mockWidget = {
node: { widgets: [{ name: 'prompt', value: 'Original value' }] }
}
const wrapper = mountWidget({ history: mockHistory, widget: mockWidget })
const vm = wrapper.vm as any
vm.handleEdit(0)
vm.handleCancelEdit()
expect(mockWidget.node.widgets[0].value).toBe('Original value')
})
})

View File

@@ -0,0 +1,134 @@
<template>
<ScrollPanel
ref="scrollPanelRef"
class="min-h-[400px] w-full rounded-lg px-2 py-2 text-xs"
:pt="{ content: { id: 'chat-scroll-content' } }"
>
<div v-for="(item, i) in parsedHistory" :key="i" class="mb-4">
<!-- Prompt (user, right) -->
<span
:class="{
'pointer-events-none opacity-40': editIndex !== null && i > editIndex
}"
>
<div class="mb-1 flex justify-end">
<div
class="max-w-[80%] rounded-xl bg-smoke-300 px-4 py-1 text-right dark-theme:bg-smoke-800"
>
<div class="text-[12px] break-words">{{ item.prompt }}</div>
</div>
</div>
<div class="mr-1 mb-2 flex justify-end">
<CopyButton :text="item.prompt" />
<Button
v-tooltip="
editIndex === i ? $t('chatHistory.cancelEditTooltip') : null
"
text
rounded
class="h-4! w-4! p-1! text-smoke-400 transition hover:text-smoke-600 hover:dark-theme:text-smoke-200"
pt:icon:class="text-xs!"
:icon="editIndex === i ? 'pi pi-times' : 'pi pi-pencil'"
:aria-label="
editIndex === i ? $t('chatHistory.cancelEdit') : $t('g.edit')
"
@click="editIndex === i ? handleCancelEdit() : handleEdit(i)"
/>
</div>
</span>
<!-- Response (LLM, left) -->
<ResponseBlurb
:text="item.response"
:class="{
'pointer-events-none opacity-25': editIndex !== null && i >= editIndex
}"
>
<div v-html="nl2br(linkifyHtml(item.response))" />
</ResponseBlurb>
</div>
</ScrollPanel>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ScrollPanel from 'primevue/scrollpanel'
import { computed, nextTick, ref, watch } from 'vue'
import CopyButton from '@/components/graph/widgets/chatHistory/CopyButton.vue'
import ResponseBlurb from '@/components/graph/widgets/chatHistory/ResponseBlurb.vue'
import type { ComponentWidget } from '@/scripts/domWidget'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const { widget, history } = defineProps<{
widget?: ComponentWidget<string>
history: string
}>()
const editIndex = ref<number | null>(null)
const scrollPanelRef = ref<InstanceType<typeof ScrollPanel> | null>(null)
const parsedHistory = computed(() => JSON.parse(history || '[]'))
const findPromptInput = () =>
widget?.node.widgets?.find((w) => w.name === 'prompt')
let promptInput = findPromptInput()
const previousPromptInput = ref<string | null>(null)
const getPreviousResponseId = (index: number) =>
index > 0 ? (parsedHistory.value[index - 1]?.response_id ?? '') : ''
const storePromptInput = () => {
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
if (!promptInput) return
previousPromptInput.value = String(promptInput.value)
}
const setPromptInput = (text: string, previousResponseId?: string | null) => {
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
if (!promptInput) return
if (previousResponseId !== null) {
promptInput.value = `<starting_point_id:${previousResponseId}>\n\n${text}`
} else {
promptInput.value = text
}
}
const handleEdit = (index: number) => {
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
editIndex.value = index
const prevResponseId = index === 0 ? 'start' : getPreviousResponseId(index)
const promptText = parsedHistory.value[index]?.prompt ?? ''
storePromptInput()
setPromptInput(promptText, prevResponseId)
}
const resetEditingState = () => {
editIndex.value = null
}
const handleCancelEdit = () => {
resetEditingState()
if (promptInput) {
promptInput.value = previousPromptInput.value ?? ''
}
}
const scrollChatToBottom = () => {
const content = document.getElementById('chat-scroll-content')
if (content) {
content.scrollTo({ top: content.scrollHeight, behavior: 'smooth' })
}
}
const onHistoryChanged = () => {
resetEditingState()
void nextTick(() => scrollChatToBottom())
}
watch(() => parsedHistory.value, onHistoryChanged, {
immediate: true,
deep: true
})
</script>

View File

@@ -0,0 +1,36 @@
<template>
<Button
v-tooltip="
copied ? $t('chatHistory.copiedTooltip') : $t('chatHistory.copyTooltip')
"
text
rounded
class="h-4! w-6! p-1! text-smoke-400 transition hover:text-smoke-600 hover:dark-theme:text-smoke-200"
pt:icon:class="text-xs!"
:icon="copied ? 'pi pi-check' : 'pi pi-copy'"
:aria-label="
copied ? $t('chatHistory.copiedTooltip') : $t('chatHistory.copyTooltip')
"
@click="handleCopy"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'
const { text } = defineProps<{
text: string
}>()
const copied = ref(false)
const handleCopy = async () => {
if (!text) return
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 1024)
}
</script>

View File

@@ -0,0 +1,22 @@
<template>
<span>
<div class="mb-1 flex justify-start">
<div class="max-w-[80%] rounded-xl px-4 py-1">
<div class="text-[12px] break-words">
<slot />
</div>
</div>
</div>
<div class="ml-1 flex justify-start">
<CopyButton :text="text" />
</div>
</span>
</template>
<script setup lang="ts">
import CopyButton from '@/components/graph/widgets/chatHistory/CopyButton.vue'
defineProps<{
text: string
}>()
</script>

View File

@@ -0,0 +1,380 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { MultiSelectProps } from 'primevue/multiselect'
import { ref } from 'vue'
import MultiSelect from './MultiSelect.vue'
import type { SelectOption } from './types'
// Combine our component props with PrimeVue MultiSelect props
interface ExtendedProps extends Partial<MultiSelectProps> {
// Our custom props
label?: string
showSearchBox?: boolean
showSelectedCount?: boolean
showClearButton?: boolean
searchPlaceholder?: string
listMaxHeight?: string
popoverMinWidth?: string
popoverMaxWidth?: string
// Override modelValue type to match our Option type
modelValue?: SelectOption[]
}
const meta: Meta<ExtendedProps> = {
title: 'Components/Input/MultiSelect/Accessibility',
component: MultiSelect,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: `
# MultiSelect Accessibility Guide
This MultiSelect component provides full keyboard accessibility and screen reader support following WCAG 2.1 AA guidelines.
## Keyboard Navigation
- **Tab** - Focus the trigger button
- **Enter/Space** - Open/close dropdown when focused
- **Arrow Up/Down** - Navigate through options when dropdown is open
- **Enter/Space** - Select/deselect options when navigating
- **Escape** - Close dropdown
## Screen Reader Support
- Uses \`role="combobox"\` to identify as dropdown
- \`aria-haspopup="listbox"\` indicates popup contains list
- \`aria-expanded\` shows dropdown state
- \`aria-label\` provides accessible name with i18n fallback
- Selected count announced to screen readers
## Testing Instructions
1. **Tab Navigation**: Use Tab key to focus the component
2. **Keyboard Opening**: Press Enter or Space to open dropdown
3. **Option Navigation**: Use Arrow keys to navigate options
4. **Selection**: Press Enter/Space to select options
5. **Closing**: Press Escape to close dropdown
6. **Screen Reader**: Test with screen reader software
Try these stories with keyboard-only navigation!
`
}
}
},
argTypes: {
label: {
control: 'text',
description: 'Label for the trigger button'
},
showSearchBox: {
control: 'boolean',
description: 'Show search box in dropdown header'
},
showSelectedCount: {
control: 'boolean',
description: 'Show selected count in dropdown header'
},
showClearButton: {
control: 'boolean',
description: 'Show clear all button in dropdown header'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
const frameworkOptions = [
{ name: 'React', value: 'react' },
{ name: 'Vue', value: 'vue' },
{ name: 'Angular', value: 'angular' },
{ name: 'Svelte', value: 'svelte' },
{ name: 'TypeScript', value: 'typescript' },
{ name: 'JavaScript', value: 'javascript' }
]
export const KeyboardNavigationDemo: Story = {
render: (args) => ({
components: { MultiSelect },
setup() {
const selectedFrameworks = ref<SelectOption[]>([])
const searchQuery = ref('')
return {
args: {
...args,
options: frameworkOptions,
modelValue: selectedFrameworks,
'onUpdate:modelValue': (value: SelectOption[]) => {
selectedFrameworks.value = value
},
'onUpdate:searchQuery': (value: string) => {
searchQuery.value = value
}
},
selectedFrameworks,
searchQuery
}
},
template: `
<div class="space-y-4 p-4">
<div class="bg-blue-50 dark-theme:bg-blue-900/20 border border-azure-400 dark-theme:border-blue-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">🎯 Keyboard Navigation Test</h3>
<p class="text-sm text-smoke-600 dark-theme:text-smoke-300 mb-4">
Use your keyboard to navigate this MultiSelect:
</p>
<ol class="text-sm text-smoke-600 list-decimal list-inside space-y-1">
<li><strong>Tab</strong> to focus the dropdown</li>
<li><strong>Enter/Space</strong> to open dropdown</li>
<li><strong>Arrow Up/Down</strong> to navigate options</li>
<li><strong>Enter/Space</strong> to select options</li>
<li><strong>Escape</strong> to close dropdown</li>
</ol>
</div>
<div class="space-y-2">
<label class="block text-sm font-medium text-smoke-700">
Select Frameworks (Keyboard Navigation Test)
</label>
<MultiSelect v-bind="args" class="w-80" />
<p class="text-xs text-smoke-500">
Selected: {{ selectedFrameworks.map(f => f.name).join(', ') || 'None' }}
</p>
</div>
</div>
`
}),
args: {
label: 'Choose Frameworks',
showSearchBox: true,
showSelectedCount: true,
showClearButton: true
}
}
export const ScreenReaderFriendly: Story = {
render: (args) => ({
components: { MultiSelect },
setup() {
const selectedColors = ref<SelectOption[]>([])
const selectedSizes = ref<SelectOption[]>([])
const colorOptions = [
{ name: 'Red', value: 'red' },
{ name: 'Blue', value: 'blue' },
{ name: 'Green', value: 'green' },
{ name: 'Yellow', value: 'yellow' }
]
const sizeOptions = [
{ name: 'Small', value: 'sm' },
{ name: 'Medium', value: 'md' },
{ name: 'Large', value: 'lg' },
{ name: 'Extra Large', value: 'xl' }
]
return {
selectedColors,
selectedSizes,
colorOptions,
sizeOptions,
args
}
},
template: `
<div class="space-y-6 p-4">
<div class="bg-green-50 dark-theme:bg-green-900/20 border border-green-200 dark-theme:border-green-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">♿ Screen Reader Test</h3>
<p class="text-sm text-smoke-600 mb-2">
These dropdowns have proper ARIA attributes and labels for screen readers:
</p>
<ul class="text-sm text-smoke-600 list-disc list-inside space-y-1">
<li><code>role="combobox"</code> identifies as dropdown</li>
<li><code>aria-haspopup="listbox"</code> indicates popup type</li>
<li><code>aria-expanded</code> shows open/closed state</li>
<li><code>aria-label</code> provides accessible name</li>
<li>Selection count announced to assistive technology</li>
</ul>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="block text-sm font-medium text-smoke-700">
Color Preferences
</label>
<MultiSelect
v-model="selectedColors"
:options="colorOptions"
label="Select colors"
:show-selected-count="true"
:show-clear-button="true"
class="w-full"
/>
<p class="text-xs text-smoke-500" aria-live="polite">
{{ selectedColors.length }} color(s) selected
</p>
</div>
<div class="space-y-2">
<label class="block text-sm font-medium text-smoke-700">
Size Preferences
</label>
<MultiSelect
v-model="selectedSizes"
:options="sizeOptions"
label="Select sizes"
:show-selected-count="true"
:show-search-box="true"
class="w-full"
/>
<p class="text-xs text-smoke-500" aria-live="polite">
{{ selectedSizes.length }} size(s) selected
</p>
</div>
</div>
</div>
`
})
}
export const FocusManagement: Story = {
render: (args) => ({
components: { MultiSelect },
setup() {
const selectedItems = ref<SelectOption[]>([])
const focusTestOptions = [
{ name: 'Option A', value: 'a' },
{ name: 'Option B', value: 'b' },
{ name: 'Option C', value: 'c' }
]
return {
selectedItems,
focusTestOptions,
args
}
},
template: `
<div class="space-y-4 p-4">
<div class="bg-purple-50 dark-theme:bg-purple-900/20 border border-purple-200 dark-theme:border-purple-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">🎯 Focus Management Test</h3>
<p class="text-sm text-smoke-600 dark-theme:text-smoke-300 mb-4">
Test focus behavior with multiple form elements:
</p>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-smoke-700 mb-1">
Before MultiSelect
</label>
<input
type="text"
placeholder="Previous field"
class="block w-64 px-3 py-2 border border-smoke-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-smoke-700 mb-1">
MultiSelect (Test Focus Ring)
</label>
<MultiSelect
v-model="selectedItems"
:options="focusTestOptions"
label="Focus test dropdown"
:show-selected-count="true"
class="w-64"
/>
</div>
<div>
<label class="block text-sm font-medium text-smoke-700 mb-1">
After MultiSelect
</label>
<input
type="text"
placeholder="Next field"
class="block w-64 px-3 py-2 border border-smoke-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<button
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Submit Button
</button>
</div>
<div class="text-sm text-smoke-600 mt-4">
<strong>Test:</strong> Tab through all elements and verify focus rings are visible and logical.
</div>
</div>
`
})
}
export const AccessibilityChecklist: Story = {
render: () => ({
template: `
<div class="max-w-4xl mx-auto p-6 space-y-6">
<div class="bg-gray-50 dark-theme:bg-zinc-800 border border-smoke-200 dark-theme:border-zinc-700 rounded-lg p-6">
<h2 class="text-2xl font-bold mb-4">♿ MultiSelect Accessibility Checklist</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-semibold mb-3 text-green-700">✅ Implemented Features</h3>
<ul class="space-y-2 text-sm">
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Keyboard Navigation:</strong> Tab, Enter, Space, Arrow keys, Escape</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>ARIA Attributes:</strong> role, aria-haspopup, aria-expanded, aria-label</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Focus Management:</strong> Visible focus rings and logical tab order</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Internationalization:</strong> Translatable aria-label fallbacks</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Screen Reader Support:</strong> Proper announcements and state</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Color Contrast:</strong> Meets WCAG AA requirements</span>
</li>
</ul>
</div>
<div>
<h3 class="text-lg font-semibold mb-3 text-blue-700">📋 Testing Guidelines</h3>
<ol class="space-y-2 text-sm list-decimal list-inside">
<li><strong>Keyboard Only:</strong> Navigate using only keyboard</li>
<li><strong>Screen Reader:</strong> Test with NVDA, JAWS, or VoiceOver</li>
<li><strong>Focus Visible:</strong> Ensure focus rings are always visible</li>
<li><strong>Tab Order:</strong> Verify logical progression</li>
<li><strong>Announcements:</strong> Check state changes are announced</li>
<li><strong>Escape Behavior:</strong> Escape always closes dropdown</li>
</ol>
</div>
</div>
<div class="mt-6 p-4 bg-blue-50 dark-theme:bg-blue-900/20 border border-azure-400 dark-theme:border-blue-700 rounded-lg">
<h4 class="font-semibold mb-2">🎯 Quick Test</h4>
<p class="text-sm text-smoke-700 dark-theme:text-smoke-300">
Close your eyes, use only the keyboard, and try to select multiple options from any dropdown above.
If you can successfully navigate and make selections, the accessibility implementation is working!
</p>
</div>
</div>
</div>
`
})
}

View File

@@ -102,7 +102,7 @@ export const Default: Story = {
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<div class="mt-4 p-3 bg-base-background rounded">
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}</p>
</div>
</div>
@@ -135,7 +135,7 @@ export const WithPreselectedValues: Story = {
:showClearButton="args.showClearButton"
:searchPlaceholder="args.searchPlaceholder"
/>
<div class="mt-4 p-3 bg-base-background rounded">
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected.map(s => s.name).join(', ') }}</p>
</div>
</div>
@@ -229,7 +229,7 @@ export const MultipleSelectors: Story = {
/>
</div>
<div class="p-4 bg-base-background rounded">
<div class="p-4 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<h4 class="font-medium mt-0">Current Selection:</h4>
<div class="flex flex-col text-sm">
<p>Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}</p>

View File

@@ -13,77 +13,12 @@
option-label="name"
unstyled
:max-selected-labels="0"
:pt="{
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'h-10 relative inline-flex cursor-pointer select-none',
'rounded-lg bg-base-background text-base-foreground',
'transition-all duration-200 ease-in-out',
'border-[2.5px] border-solid',
selectedCount > 0
? 'border-node-component-border'
: 'border-transparent',
'focus-within:border-node-component-border',
{ 'opacity-60 cursor-default': props.disabled }
)
}),
labelContainer: {
class:
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 '
},
label: {
class: 'p-0'
},
dropdown: {
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
},
header: () => ({
class:
showSearchBox || showSelectedCount || showClearButton
? 'block'
: 'hidden'
}),
// Overlay & list visuals unchanged
overlay: {
class: cn(
'mt-2 rounded-lg py-2 px-2',
'bg-base-background',
'text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: { maxHeight: `min(${listMaxHeight}, 50vh)` },
class: 'scrollbar-custom'
}),
list: {
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
// Option row hover and focus tone
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'flex gap-2 items-center h-10 px-2 rounded-lg',
'hover:bg-secondary-background-hover',
// Add focus/highlight state for keyboard navigation
context?.focused &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
// Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
pcOptionCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
}
}"
:pt="pt"
:aria-label="label || t('g.multiSelectDropdown')"
role="combobox"
:aria-expanded="false"
aria-haspopup="listbox"
tabindex="0"
:tabindex="0"
>
<template
v-if="showSearchBox || showSelectedCount || showClearButton"
@@ -104,7 +39,7 @@
>
<span
v-if="showSelectedCount"
class="px-1 text-sm text-base-foreground"
class="px-1 text-sm text-neutral-400 dark-theme:text-zinc-500"
>
{{
selectedCount > 0
@@ -117,22 +52,22 @@
:label="$t('g.clearAll')"
type="transparent"
size="fit-content"
class="text-sm text-text-primary"
class="text-sm text-blue-500 dark-theme:text-blue-600"
@click.stop="selectedItems = []"
/>
</div>
<div class="my-4 h-px bg-border-default"></div>
<div class="my-4 h-px bg-zinc-200 dark-theme:bg-zinc-700"></div>
</div>
</template>
<!-- Trigger value (keep text scale identical) -->
<template #value>
<span class="text-sm text-muted-foreground">
<span class="text-sm text-zinc-700 dark-theme:text-smoke-200">
{{ label }}
</span>
<span
v-if="selectedCount > 0"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-primary-background text-xs font-semibold text-base-foreground"
class="pointer-events-none absolute -top-2 -right-2 z-10 flex h-5 w-5 items-center justify-center rounded-full bg-blue-400 text-xs font-semibold text-white dark-theme:bg-blue-500"
>
{{ selectedCount }}
</span>
@@ -150,8 +85,8 @@
class="flex h-4 w-4 shrink-0 items-center justify-center rounded p-0.5 transition-all duration-200"
:class="
slotProps.selected
? 'bg-primary-background'
: 'bg-secondary-background'
? 'bg-blue-400 dark-theme:border-blue-500 dark-theme:bg-blue-500'
: 'bg-neutral-100 dark-theme:bg-zinc-700'
"
>
<i
@@ -268,4 +203,70 @@ const filteredOptions = computed(() => {
return [...selectedButNotInResults, ...searchResults]
})
const pt = computed(() => ({
root: ({ props }: MultiSelectPassThroughMethodOptions) => ({
class: cn(
'h-10 relative inline-flex cursor-pointer select-none',
'rounded-lg bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
'transition-all duration-200 ease-in-out',
'border-[2.5px] border-solid',
selectedCount.value > 0
? 'border-blue-400 dark-theme:border-blue-500'
: 'border-transparent',
'focus-within:border-blue-400 dark-theme:focus-within:border-blue-500',
{ 'opacity-60 cursor-default': props.disabled }
)
}),
labelContainer: {
class:
'flex-1 flex items-center overflow-hidden whitespace-nowrap pl-4 py-2 '
},
label: {
class: 'p-0'
},
dropdown: {
class: 'flex shrink-0 cursor-pointer items-center justify-center px-3'
},
header: () => ({
class:
showSearchBox || showSelectedCount || showClearButton ? 'block' : 'hidden'
}),
// Overlay & list visuals unchanged
overlay: {
class: cn(
'mt-2 rounded-lg py-2 px-2',
'bg-white dark-theme:bg-zinc-800',
'text-neutral dark-theme:text-white',
'border border-solid border-neutral-200 dark-theme:border-zinc-700'
)
},
listContainer: () => ({
style: { maxHeight: `min(${listMaxHeight}, 50vh)` },
class: 'scrollbar-custom'
}),
list: {
class: 'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
// Option row hover and focus tone
option: ({ context }: MultiSelectPassThroughMethodOptions) => ({
class: [
'flex gap-2 items-center h-10 px-2 rounded-lg',
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Add focus/highlight state for keyboard navigation
{
'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context?.focused
}
]
}),
// Hide built-in checkboxes entirely via PT (no :deep)
pcHeaderCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
},
pcOptionCheckbox: {
root: { class: 'hidden' },
style: { display: 'none' }
}
}))
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div :class="wrapperStyle" @click="focusInput">
<i class="icon-[lucide--search] text-muted" />
<i class="icon-[lucide--search]" :class="iconColorStyle" />
<InputText
ref="input"
v-model="internalSearchQuery"
@@ -12,7 +12,7 @@
"
type="text"
unstyled
class="absolute inset-0 size-full pl-11 border-none outline-none bg-transparent text-sm text-base-foreground"
:class="inputStyle"
/>
</div>
</template>
@@ -72,13 +72,18 @@ const focusInput = () => {
onMounted(() => autofocus && focusInput())
const wrapperStyle = computed(() => {
const baseClasses =
'relative flex w-full items-center gap-2 bg-base-background cursor-text'
const baseClasses = [
'relative flex w-full items-center gap-2',
'bg-white dark-theme:bg-zinc-800',
'cursor-text'
]
if (showBorder) {
return cn(
baseClasses,
'rounded p-2 border border-solid border-border-default'
...baseClasses,
'rounded p-2',
'border border-solid',
'border-zinc-200 dark-theme:border-zinc-700'
)
}
@@ -88,6 +93,20 @@ const wrapperStyle = computed(() => {
lg: 'h-10 px-4 py-2' // Matches button md size
}[size]
return cn(baseClasses, 'rounded-lg', sizeClasses)
return cn(...baseClasses, 'rounded-lg', sizeClasses)
})
const inputStyle = computed(() => {
return cn(
'absolute inset-0 w-full h-full pl-11',
'border-none outline-none bg-transparent',
'text-sm text-neutral dark-theme:text-white'
)
})
const iconColorStyle = computed(() => {
return cn(
!showBorder ? 'text-neutral' : ['text-zinc-300', 'dark-theme:text-zinc-700']
)
})
</script>

View File

@@ -0,0 +1,464 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import SingleSelect from './SingleSelect.vue'
interface SingleSelectProps {
label?: string
options?: Array<{ name: string; value: string }>
listMaxHeight?: string
popoverMinWidth?: string
popoverMaxWidth?: string
modelValue?: string | null
}
const meta: Meta<SingleSelectProps> = {
title: 'Components/Input/SingleSelect/Accessibility',
component: SingleSelect,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: `
# SingleSelect Accessibility Guide
This SingleSelect component provides full keyboard accessibility and screen reader support following WCAG 2.1 AA guidelines.
## Keyboard Navigation
- **Tab** - Focus the trigger button
- **Enter/Space** - Open/close dropdown when focused
- **Arrow Up/Down** - Navigate through options when dropdown is open
- **Enter/Space** - Select option when navigating
- **Escape** - Close dropdown
## Screen Reader Support
- Uses \`role="combobox"\` to identify as dropdown
- \`aria-haspopup="listbox"\` indicates popup contains list
- \`aria-expanded\` shows dropdown state
- \`aria-label\` provides accessible name with i18n fallback
- Selected option announced to screen readers
## Testing Instructions
1. **Tab Navigation**: Use Tab key to focus the component
2. **Keyboard Opening**: Press Enter or Space to open dropdown
3. **Option Navigation**: Use Arrow keys to navigate options
4. **Selection**: Press Enter/Space to select an option
5. **Closing**: Press Escape to close dropdown
6. **Screen Reader**: Test with screen reader software
Try these stories with keyboard-only navigation!
`
}
}
},
argTypes: {
label: {
control: 'text',
description: 'Label for the trigger button'
},
listMaxHeight: {
control: 'text',
description: 'Maximum height of dropdown list'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
const sortOptions = [
{ name: 'Name A → Z', value: 'name-asc' },
{ name: 'Name Z → A', value: 'name-desc' },
{ name: 'Most Popular', value: 'popular' },
{ name: 'Most Recent', value: 'recent' },
{ name: 'File Size', value: 'size' }
]
const priorityOptions = [
{ name: 'High Priority', value: 'high' },
{ name: 'Medium Priority', value: 'medium' },
{ name: 'Low Priority', value: 'low' },
{ name: 'No Priority', value: 'none' }
]
export const KeyboardNavigationDemo: Story = {
render: (args) => ({
components: { SingleSelect },
setup() {
const selectedSort = ref<string | null>(null)
const selectedPriority = ref<string | null>('medium')
return {
args,
selectedSort,
selectedPriority,
sortOptions,
priorityOptions
}
},
template: `
<div class="space-y-6 p-4">
<div class="bg-blue-50 dark-theme:bg-blue-900/20 border border-azure-400 dark-theme:border-blue-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">🎯 Keyboard Navigation Test</h3>
<p class="text-sm text-smoke-600 dark-theme:text-smoke-300 mb-4">
Use your keyboard to navigate these SingleSelect dropdowns:
</p>
<ol class="text-sm text-smoke-600 dark-theme:text-smoke-300 list-decimal list-inside space-y-1">
<li><strong>Tab</strong> to focus the dropdown</li>
<li><strong>Enter/Space</strong> to open dropdown</li>
<li><strong>Arrow Up/Down</strong> to navigate options</li>
<li><strong>Enter/Space</strong> to select option</li>
<li><strong>Escape</strong> to close dropdown</li>
</ol>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200">
Sort Order
</label>
<SingleSelect
v-model="selectedSort"
:options="sortOptions"
label="Choose sort order"
class="w-full"
/>
<p class="text-xs text-smoke-500">
Selected: {{ selectedSort ? sortOptions.find(o => o.value === selectedSort)?.name : 'None' }}
</p>
</div>
<div class="space-y-2">
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200">
Task Priority (With Icon)
</label>
<SingleSelect
v-model="selectedPriority"
:options="priorityOptions"
label="Set priority level"
class="w-full"
>
<template #icon>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd" />
</svg>
</template>
</SingleSelect>
<p class="text-xs text-smoke-500">
Selected: {{ selectedPriority ? priorityOptions.find(o => o.value === selectedPriority)?.name : 'None' }}
</p>
</div>
</div>
</div>
`
})
}
export const ScreenReaderFriendly: Story = {
render: (args) => ({
components: { SingleSelect },
setup() {
const selectedLanguage = ref<string | null>('en')
const selectedTheme = ref<string | null>(null)
const languageOptions = [
{ name: 'English', value: 'en' },
{ name: 'Spanish', value: 'es' },
{ name: 'French', value: 'fr' },
{ name: 'German', value: 'de' },
{ name: 'Japanese', value: 'ja' }
]
const themeOptions = [
{ name: 'Light Theme', value: 'light' },
{ name: 'Dark Theme', value: 'dark' },
{ name: 'Auto (System)', value: 'auto' },
{ name: 'High Contrast', value: 'contrast' }
]
return {
selectedLanguage,
selectedTheme,
languageOptions,
themeOptions,
args
}
},
template: `
<div class="space-y-6 p-4">
<div class="bg-green-50 dark-theme:bg-green-900/20 border border-green-200 dark-theme:border-green-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">♿ Screen Reader Test</h3>
<p class="text-sm text-smoke-600 dark-theme:text-smoke-300 mb-2">
These dropdowns have proper ARIA attributes and labels for screen readers:
</p>
<ul class="text-sm text-smoke-600 dark-theme:text-smoke-300 list-disc list-inside space-y-1">
<li><code>role="combobox"</code> identifies as dropdown</li>
<li><code>aria-haspopup="listbox"</code> indicates popup type</li>
<li><code>aria-expanded</code> shows open/closed state</li>
<li><code>aria-label</code> provides accessible name</li>
<li>Selected option value announced to assistive technology</li>
</ul>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200" id="language-label">
Preferred Language
</label>
<SingleSelect
v-model="selectedLanguage"
:options="languageOptions"
label="Select language"
class="w-full"
aria-labelledby="language-label"
/>
<p class="text-xs text-smoke-500" aria-live="polite">
Current: {{ selectedLanguage ? languageOptions.find(o => o.value === selectedLanguage)?.name : 'None selected' }}
</p>
</div>
<div class="space-y-2">
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200" id="theme-label">
Interface Theme
</label>
<SingleSelect
v-model="selectedTheme"
:options="themeOptions"
label="Select theme"
class="w-full"
aria-labelledby="theme-label"
/>
<p class="text-xs text-smoke-500" aria-live="polite">
Current: {{ selectedTheme ? themeOptions.find(o => o.value === selectedTheme)?.name : 'No theme selected' }}
</p>
</div>
</div>
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h4 class="font-semibold mb-2">🎧 Screen Reader Testing Tips</h4>
<ul class="text-sm text-smoke-600 dark-theme:text-smoke-300 space-y-1">
<li>• Listen for role announcements when focusing</li>
<li>• Verify dropdown state changes are announced</li>
<li>• Check that selected values are spoken clearly</li>
<li>• Ensure option navigation is announced</li>
</ul>
</div>
</div>
`
})
}
export const FormIntegration: Story = {
render: (args) => ({
components: { SingleSelect },
setup() {
const formData = ref({
category: null as string | null,
status: 'draft' as string | null,
assignee: null as string | null
})
const categoryOptions = [
{ name: 'Bug Report', value: 'bug' },
{ name: 'Feature Request', value: 'feature' },
{ name: 'Documentation', value: 'docs' },
{ name: 'Question', value: 'question' }
]
const statusOptions = [
{ name: 'Draft', value: 'draft' },
{ name: 'Review', value: 'review' },
{ name: 'Approved', value: 'approved' },
{ name: 'Published', value: 'published' }
]
const assigneeOptions = [
{ name: 'Alice Johnson', value: 'alice' },
{ name: 'Bob Smith', value: 'bob' },
{ name: 'Carol Davis', value: 'carol' },
{ name: 'David Wilson', value: 'david' }
]
const handleSubmit = () => {
alert('Form submitted with: ' + JSON.stringify(formData.value, null, 2))
}
return {
formData,
categoryOptions,
statusOptions,
assigneeOptions,
handleSubmit,
args
}
},
template: `
<div class="max-w-2xl mx-auto p-6">
<div class="bg-purple-50 dark-theme:bg-purple-900/20 border border-purple-200 dark-theme:border-purple-700 rounded-lg p-4 mb-6">
<h3 class="text-lg font-semibold mb-2">📝 Form Integration Test</h3>
<p class="text-sm text-smoke-600 dark-theme:text-smoke-300">
Test keyboard navigation through a complete form with SingleSelect components.
Tab order should be logical and all elements should be accessible.
</p>
</div>
<form @submit.prevent="handleSubmit" class="space-y-6">
<div>
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200 mb-1">
Title *
</label>
<input
type="text"
required
placeholder="Enter a title"
class="block w-full px-3 py-2 border border-smoke-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200 mb-1">
Category *
</label>
<SingleSelect
v-model="formData.category"
:options="categoryOptions"
label="Select category"
required
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200 mb-1">
Status
</label>
<SingleSelect
v-model="formData.status"
:options="statusOptions"
label="Select status"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200 mb-1">
Assignee
</label>
<SingleSelect
v-model="formData.assignee"
:options="assigneeOptions"
label="Select assignee"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-smoke-700 dark-theme:text-smoke-200 mb-1">
Description
</label>
<textarea
rows="4"
placeholder="Enter description"
class="block w-full px-3 py-2 border border-smoke-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div class="flex gap-3">
<button
type="submit"
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Submit
</button>
<button
type="button"
class="px-4 py-2 bg-smoke-300 dark-theme:bg-smoke-600 text-smoke-700 dark-theme:text-smoke-200 rounded-md hover:bg-smoke-400 dark-theme:hover:bg-smoke-500 focus:ring-2 focus:ring-smoke-500 focus:ring-offset-2"
>
Cancel
</button>
</div>
</form>
<div class="mt-6 p-4 bg-gray-50 dark-theme:bg-zinc-800 border border-smoke-200 dark-theme:border-zinc-700 rounded-lg">
<h4 class="font-semibold mb-2">Current Form Data:</h4>
<pre class="text-xs text-smoke-600 dark-theme:text-smoke-300">{{ JSON.stringify(formData, null, 2) }}</pre>
</div>
</div>
`
})
}
export const AccessibilityChecklist: Story = {
render: () => ({
template: `
<div class="max-w-4xl mx-auto p-6 space-y-6">
<div class="bg-gray-50 dark-theme:bg-zinc-800 border border-smoke-200 dark-theme:border-zinc-700 rounded-lg p-6">
<h2 class="text-2xl font-bold mb-4">♿ SingleSelect Accessibility Checklist</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-semibold mb-3 text-green-700">✅ Implemented Features</h3>
<ul class="space-y-2 text-sm">
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Keyboard Navigation:</strong> Tab, Enter, Space, Arrow keys, Escape</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>ARIA Attributes:</strong> role, aria-haspopup, aria-expanded, aria-label</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Focus Management:</strong> Visible focus rings and logical tab order</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Internationalization:</strong> Translatable aria-label fallbacks</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Screen Reader Support:</strong> Proper announcements and state</span>
</li>
<li class="flex items-start">
<span class="text-green-500 mr-2">✓</span>
<span><strong>Form Integration:</strong> Works properly in forms with other elements</span>
</li>
</ul>
</div>
<div>
<h3 class="text-lg font-semibold mb-3 text-blue-700">📋 Testing Guidelines</h3>
<ol class="space-y-2 text-sm list-decimal list-inside">
<li><strong>Keyboard Only:</strong> Navigate using only keyboard</li>
<li><strong>Screen Reader:</strong> Test with NVDA, JAWS, or VoiceOver</li>
<li><strong>Focus Visible:</strong> Ensure focus rings are always visible</li>
<li><strong>Tab Order:</strong> Verify logical progression in forms</li>
<li><strong>Announcements:</strong> Check state changes are announced</li>
<li><strong>Selection:</strong> Verify selected value is announced</li>
</ol>
</div>
</div>
<div class="mt-6 p-4 bg-blue-50 dark-theme:bg-blue-900/20 border border-azure-400 dark-theme:border-blue-700 rounded-lg">
<h4 class="font-semibold mb-2">🎯 Quick Test</h4>
<p class="text-sm text-smoke-700 dark-theme:text-smoke-200">
Close your eyes, use only the keyboard, and try to select different options from any dropdown above.
If you can successfully navigate and make selections, the accessibility implementation is working!
</p>
</div>
<div class="mt-4 p-4 bg-orange-50 border border-orange-200 rounded-lg">
<h4 class="font-semibold mb-2">⚡ Performance Note</h4>
<p class="text-sm text-smoke-700 dark-theme:text-smoke-200">
These accessibility features are built into the component with minimal performance impact.
The ARIA attributes and keyboard handlers add less than 1KB to the bundle size.
</p>
</div>
</div>
</div>
`
})
}

View File

@@ -58,7 +58,7 @@ export const Default: Story = {
template: `
<div>
<SingleSelect v-model="selected" :options="options" :label="args.label" />
<div class="mt-4 p-3 bg-base-background rounded">
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected ?? 'None' }}</p>
</div>
</div>
@@ -81,7 +81,7 @@ export const WithIcon: Story = {
<i class="icon-[lucide--arrow-up-down] w-3.5 h-3.5" />
</template>
</SingleSelect>
<div class="mt-4 p-3 bg-base-background rounded">
<div class="mt-4 p-3 bg-gray-50 dark-theme:bg-zinc-800 rounded">
<p class="text-sm">Selected: {{ selected }}</p>
</div>
</div>

View File

@@ -13,69 +13,7 @@
option-label="name"
option-value="value"
unstyled
:pt="{
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: [
// container
'h-10 relative inline-flex cursor-pointer select-none items-center',
// trigger surface
'rounded-lg',
'bg-base-background text-base-foreground',
'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out',
'focus-within:border-node-component-border',
// disabled
{ 'opacity-60 cursor-default': props.disabled }
]
}),
label: {
class:
// Align with MultiSelect labelContainer spacing
'flex-1 flex items-center whitespace-nowrap pl-4 py-2 outline-hidden'
},
dropdown: {
class:
// Right chevron touch area
'flex shrink-0 items-center justify-center px-3 py-2'
},
overlay: {
class: cn(
'mt-2 p-2 rounded-lg',
'bg-base-background text-base-foreground',
'border border-solid border-border-default'
)
},
listContainer: () => ({
style: `max-height: min(${listMaxHeight}, 50vh)`,
class: 'scrollbar-custom'
}),
list: {
class:
// Same list tone/size as MultiSelect
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
option: ({ context }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: cn(
// Row layout
'flex items-center justify-between gap-3 px-2 py-3 rounded',
'hover:bg-secondary-background-hover',
// Add focus state for keyboard navigation
context.focused && 'bg-secondary-background-hover',
// Selected state + check icon
context.selected &&
'bg-secondary-background-selected hover:bg-secondary-background-selected'
)
}),
optionLabel: {
class: 'truncate'
},
optionGroupLabel: {
class: 'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground'
},
emptyMessage: {
class: 'px-3 py-2 text-sm text-muted-foreground'
}
}"
:pt="pt"
:aria-label="label || t('g.singleSelectDropdown')"
role="combobox"
:aria-expanded="false"
@@ -88,11 +26,11 @@
<slot name="icon" />
<span
v-if="slotProps.value !== null && slotProps.value !== undefined"
class="text-base-foreground"
class="text-zinc-700 dark-theme:text-smoke-200"
>
{{ getLabel(slotProps.value) }}
</span>
<span v-else class="text-base-foreground">
<span v-else class="text-zinc-700 dark-theme:text-smoke-200">
{{ label }}
</span>
</div>
@@ -110,7 +48,10 @@
:style="optionStyle"
>
<span class="truncate">{{ option.name }}</span>
<i v-if="selected" class="icon-[lucide--check] text-base-foreground" />
<i
v-if="selected"
class="icon-[lucide--check] text-neutral-600 dark-theme:text-white"
/>
</div>
</template>
</Select>
@@ -178,4 +119,73 @@ const optionStyle = computed(() => {
return styles.join('; ')
})
/**
* Unstyled + PT API only
* - No background/border (same as page background)
* - Text/icon scale: compact size matching MultiSelect
*/
const pt = computed(() => ({
root: ({ props }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: [
// container
'h-10 relative inline-flex cursor-pointer select-none items-center',
// trigger surface
'rounded-lg',
'bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out',
'focus-within:border-blue-400 dark-theme:focus-within:border-blue-500',
// disabled
{ 'opacity-60 cursor-default': props.disabled }
]
}),
label: {
class:
// Align with MultiSelect labelContainer spacing
'flex-1 flex items-center whitespace-nowrap pl-4 py-2 outline-hidden'
},
dropdown: {
class:
// Right chevron touch area
'flex shrink-0 items-center justify-center px-3 py-2'
},
overlay: {
class: cn(
'mt-2 p-2 rounded-lg',
'bg-white dark-theme:bg-zinc-800 text-neutral dark-theme:text-white',
'border border-solid border-neutral-200 dark-theme:border-zinc-700'
)
},
listContainer: () => ({
style: `max-height: min(${listMaxHeight}, 50vh)`,
class: 'scrollbar-custom'
}),
list: {
class:
// Same list tone/size as MultiSelect
'flex flex-col gap-0 p-0 m-0 list-none border-none text-sm'
},
option: ({ context }: SelectPassThroughMethodOptions<SelectOption>) => ({
class: [
// Row layout
'flex items-center justify-between gap-3 px-2 py-3 rounded',
'hover:bg-neutral-100/50 dark-theme:hover:bg-zinc-700/50',
// Selected state + check icon
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.selected },
// Add focus state for keyboard navigation
{ 'bg-neutral-100/50 dark-theme:bg-zinc-700/50': context.focused }
]
}),
optionLabel: {
class: 'truncate'
},
optionGroupLabel: {
class:
'px-3 py-2 text-xs uppercase tracking-wide text-zinc-500 dark-theme:text-zinc-400'
},
emptyMessage: {
class: 'px-3 py-2 text-sm text-zinc-500 dark-theme:text-zinc-400'
}
}))
</script>

View File

@@ -18,10 +18,10 @@
t('maskEditor.brushShape')
}}</span>
<div
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
>
<div
class="maskEditor_sidePanelBrushShapeCircle bg-transparent hover:bg-comfy-menu-bg"
class="maskEditor_sidePanelBrushShapeCircle bg-transparent hover:bg-[var(--comfy-menu-bg)] dark-theme:hover:bg-[var(--p-surface-900)]"
:class="{ active: store.brushSettings.type === BrushShape.Arc }"
:style="{
background:
@@ -32,7 +32,7 @@
@click="setBrushShape(BrushShape.Arc)"
></div>
<div
class="maskEditor_sidePanelBrushShapeSquare bg-transparent hover:bg-comfy-menu-bg"
class="maskEditor_sidePanelBrushShapeSquare bg-transparent hover:bg-[var(--comfy-menu-bg)] dark-theme:hover:bg-[var(--p-surface-900)]"
:class="{ active: store.brushSettings.type === BrushShape.Rect }"
:style="{
background:

View File

@@ -37,7 +37,7 @@
t('maskEditor.maskLayer')
}}</span>
<div
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
:style="{
border: store.activeLayer === 'mask' ? '2px solid #007acc' : 'none'
}"
@@ -70,7 +70,7 @@
t('maskEditor.paintLayer')
}}</span>
<div
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
:style="{
border: store.activeLayer === 'rgb' ? '2px solid #007acc' : 'none'
}"
@@ -110,7 +110,7 @@
t('maskEditor.baseImageLayer')
}}</span>
<div
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
>
<input
type="checkbox"

View File

@@ -1,11 +1,13 @@
<template>
<div class="h-full z-[8888] flex flex-col justify-between bg-comfy-menu-bg">
<div
class="h-full z-[8888] flex flex-col justify-between bg-[var(--comfy-menu-bg)]"
>
<div class="flex flex-col">
<div
v-for="tool in allTools"
:key="tool"
:class="[
'maskEditor_toolPanelContainer hover:bg-secondary-background-hover',
'maskEditor_toolPanelContainer hover:bg-[var(--p-surface-300)] dark-theme:hover:bg-[var(--p-surface-800)]',
{ maskEditor_toolPanelContainerSelected: currentTool === tool }
]"
@click="onToolSelect(tool)"
@@ -19,12 +21,16 @@
</div>
<div
class="flex flex-col items-center cursor-pointer rounded-md mb-2 transition-colors duration-200 hover:bg-secondary-background-hover"
class="flex flex-col items-center cursor-pointer rounded-md mb-2 transition-colors duration-200 hover:bg-[var(--p-surface-300)] dark-theme:hover:bg-[var(--p-surface-800)]"
:title="t('maskEditor.clickToResetZoom')"
@click="onResetZoom"
>
<span class="text-sm text-text-secondary">{{ zoomText }}</span>
<span class="text-xs text-text-secondary">{{ dimensionsText }}</span>
<span class="text-sm text-[var(--p-button-text-secondary-color)]">{{
zoomText
}}</span>
<span class="text-xs text-[var(--p-button-text-secondary-color)]">{{
dimensionsText
}}</span>
</div>
</div>
</template>

View File

@@ -4,7 +4,7 @@
label
}}</span>
<select
class="absolute right-0 h-6 px-1.5 rounded-md border border-border-default transition-colors duration-100 bg-secondary-background focus:outline focus:outline-node-component-border"
class="absolute right-0 h-6 px-1.5 rounded-md border border-[var(--p-form-field-border-color)] transition-colors duration-100 bg-[var(--comfy-menu-bg)] focus:outline focus:outline-1 focus:outline-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-900)] dark-theme:focus:outline-[var(--p-button-text-primary-color)]"
:value="modelValue"
@change="onChange"
>

View File

@@ -83,10 +83,10 @@ const saveButtonText = ref(t('g.save'))
const saveEnabled = ref(true)
const iconButtonClass =
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-[var(--p-form-field-border-color)] pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-[var(--p-form-field-border-color)] pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)] dark-theme:hover:bg-[var(--p-surface-900)]'
const textButtonClass =
'h-7.5 w-15 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
'h-7.5 w-15 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)] dark-theme:hover:bg-[var(--p-surface-900)]'
const onUndo = () => {
store.canvasHistory.undo()

View File

@@ -3,7 +3,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
-->
<template>
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
<div v-else class="_sb_node_preview bg-component-node-background">
<div v-else class="_sb_node_preview">
<div class="_sb_table">
<div
class="node_header mr-4 text-ellipsis"
@@ -200,6 +200,7 @@ const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
}
._sb_node_preview {
background-color: var(--comfy-menu-bg);
font-family: 'Open Sans', sans-serif;
color: var(--descrip-text);
border: 1px solid var(--descrip-text);

View File

@@ -1,73 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
const meta: Meta<typeof CompletionSummaryBanner> = {
title: 'Queue/CompletionSummaryBanner',
component: CompletionSummaryBanner,
parameters: {
layout: 'padded'
}
}
export default meta
type Story = StoryObj<typeof meta>
const thumb = (hex: string) =>
`data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><rect width='24' height='24' fill='%23${hex}'/></svg>`
const thumbs = [thumb('ff6b6b'), thumb('4dabf7'), thumb('51cf66')]
export const AllSuccessSingle: Story = {
args: {
mode: 'allSuccess',
completedCount: 1,
failedCount: 0,
thumbnailUrls: [thumbs[0]]
}
}
export const AllSuccessPlural: Story = {
args: {
mode: 'allSuccess',
completedCount: 3,
failedCount: 0,
thumbnailUrls: thumbs
}
}
export const MixedSingleSingle: Story = {
args: {
mode: 'mixed',
completedCount: 1,
failedCount: 1,
thumbnailUrls: thumbs.slice(0, 2)
}
}
export const MixedPluralPlural: Story = {
args: {
mode: 'mixed',
completedCount: 2,
failedCount: 3,
thumbnailUrls: thumbs
}
}
export const AllFailedSingle: Story = {
args: {
mode: 'allFailed',
completedCount: 0,
failedCount: 1,
thumbnailUrls: []
}
}
export const AllFailedPlural: Story = {
args: {
mode: 'allFailed',
completedCount: 0,
failedCount: 4,
thumbnailUrls: []
}
}

View File

@@ -1,91 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import CompletionSummaryBanner from './CompletionSummaryBanner.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
jobsCompleted: '{count} job completed | {count} jobs completed',
jobsFailed: '{count} job failed | {count} jobs failed'
}
}
}
}
})
const mountComponent = (props: Record<string, unknown>) =>
mount(CompletionSummaryBanner, {
props: {
mode: 'allSuccess',
completedCount: 0,
failedCount: 0,
...props
},
global: {
plugins: [i18n]
}
})
describe('CompletionSummaryBanner', () => {
it('renders success mode text, thumbnails, and aria label', () => {
const wrapper = mountComponent({
mode: 'allSuccess',
completedCount: 3,
failedCount: 0,
thumbnailUrls: [
'https://example.com/thumb-a.png',
'https://example.com/thumb-b.png'
],
ariaLabel: 'Open queue summary'
})
const button = wrapper.get('button')
expect(button.attributes('aria-label')).toBe('Open queue summary')
expect(wrapper.text()).toContain('3 jobs completed')
const thumbnailImages = wrapper.findAll('img')
expect(thumbnailImages).toHaveLength(2)
expect(thumbnailImages[0].attributes('src')).toBe(
'https://example.com/thumb-a.png'
)
expect(thumbnailImages[1].attributes('src')).toBe(
'https://example.com/thumb-b.png'
)
const thumbnailContainers = wrapper.findAll('.inline-block.h-6.w-6')
expect(thumbnailContainers[1].attributes('style')).toContain(
'margin-left: -12px'
)
expect(wrapper.html()).not.toContain('icon-[lucide--circle-alert]')
})
it('renders mixed mode with success and failure counts', () => {
const wrapper = mountComponent({
mode: 'mixed',
completedCount: 2,
failedCount: 1
})
const summaryText = wrapper.text().replace(/\s+/g, ' ').trim()
expect(summaryText).toContain('2 jobs completed, 1 job failed')
})
it('renders failure mode icon without thumbnails', () => {
const wrapper = mountComponent({
mode: 'allFailed',
completedCount: 0,
failedCount: 4
})
expect(wrapper.text()).toContain('4 jobs failed')
expect(wrapper.html()).toContain('icon-[lucide--circle-alert]')
expect(wrapper.findAll('img')).toHaveLength(0)
})
})

View File

@@ -1,106 +0,0 @@
<template>
<IconButton
type="secondary"
size="fit-content"
class="group w-full justify-between gap-3 rounded-lg p-1 text-left font-normal hover:cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-background"
:aria-label="props.ariaLabel"
@click="emit('click', $event)"
>
<span class="inline-flex items-center gap-2">
<span v-if="props.mode === 'allFailed'" class="inline-flex items-center">
<i
class="ml-1 icon-[lucide--circle-alert] block size-4 leading-none"
:class="'text-destructive-background'"
/>
</span>
<span class="inline-flex items-center gap-2">
<span
v-if="props.mode !== 'allFailed'"
class="relative inline-flex h-6 items-center"
>
<span
v-for="(url, idx) in props.thumbnailUrls"
:key="url + idx"
class="inline-block h-6 w-6 overflow-hidden rounded-[6px] border-0 bg-secondary-background"
:style="{ marginLeft: idx === 0 ? '0' : '-12px' }"
>
<img :src="url" alt="preview" class="h-full w-full object-cover" />
</span>
</span>
<span class="text-[14px] font-normal text-text-primary">
<template v-if="props.mode === 'allSuccess'">
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsCompleted"
:plural="props.completedCount"
>
<template #count>
<span class="font-bold">{{ props.completedCount }}</span>
</template>
</i18n-t>
</template>
<template v-else-if="props.mode === 'mixed'">
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsCompleted"
:plural="props.completedCount"
>
<template #count>
<span class="font-bold">{{ props.completedCount }}</span>
</template>
</i18n-t>
<span>, </span>
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsFailed"
:plural="props.failedCount"
>
<template #count>
<span class="font-bold">{{ props.failedCount }}</span>
</template>
</i18n-t>
</template>
<template v-else>
<i18n-t
keypath="sideToolbar.queueProgressOverlay.jobsFailed"
:plural="props.failedCount"
>
<template #count>
<span class="font-bold">{{ props.failedCount }}</span>
</template>
</i18n-t>
</template>
</span>
</span>
</span>
<span
class="flex items-center justify-center rounded p-1 text-text-secondary transition-colors duration-200 ease-in-out"
>
<i class="icon-[lucide--chevron-down] block size-4 leading-none" />
</span>
</IconButton>
</template>
<script setup lang="ts">
import IconButton from '@/components/button/IconButton.vue'
import type {
CompletionSummary,
CompletionSummaryMode
} from '@/composables/queue/useCompletionSummary'
type Props = {
mode: CompletionSummaryMode
completedCount: CompletionSummary['completedCount']
failedCount: CompletionSummary['failedCount']
thumbnailUrls?: CompletionSummary['thumbnailUrls']
ariaLabel?: string
}
const props = withDefaults(defineProps<Props>(), {
thumbnailUrls: () => []
})
const emit = defineEmits<{
(e: 'click', event: MouseEvent): void
}>()
</script>

View File

@@ -1,125 +0,0 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import QueueOverlayActive from './QueueOverlayActive.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
queueProgressOverlay: {
total: 'Total: {percent}',
currentNode: 'Current node:',
running: 'running',
interruptAll: 'Interrupt all running jobs',
queuedSuffix: 'queued',
clearQueued: 'Clear queued',
viewAllJobs: 'View all jobs',
cancelJobTooltip: 'Cancel job',
clearQueueTooltip: 'Clear queue'
}
}
}
}
})
const tooltipDirectiveStub = {
mounted: vi.fn(),
updated: vi.fn()
}
const SELECTORS = {
interruptAllButton: 'button[aria-label="Interrupt all running jobs"]',
clearQueuedButton: 'button[aria-label="Clear queued"]',
summaryRow: '.flex.items-center.gap-2',
currentNodeRow: '.flex.items-center.gap-1.text-text-secondary'
}
const COPY = {
viewAllJobs: 'View all jobs'
}
const mountComponent = (props: Record<string, unknown> = {}) =>
mount(QueueOverlayActive, {
props: {
totalProgressStyle: { width: '65%' },
currentNodeProgressStyle: { width: '40%' },
totalPercentFormatted: '65%',
currentNodePercentFormatted: '40%',
currentNodeName: 'Sampler',
runningCount: 1,
queuedCount: 2,
bottomRowClass: 'flex custom-bottom-row',
...props
},
global: {
plugins: [i18n],
directives: {
tooltip: tooltipDirectiveStub
}
}
})
describe('QueueOverlayActive', () => {
it('renders progress metrics and emits actions when buttons clicked', async () => {
const wrapper = mountComponent({ runningCount: 2, queuedCount: 3 })
const progressBars = wrapper.findAll('.absolute.inset-0')
expect(progressBars[0].attributes('style')).toContain('width: 65%')
expect(progressBars[1].attributes('style')).toContain('width: 40%')
const content = wrapper.text().replace(/\s+/g, ' ')
expect(content).toContain('Total: 65%')
const [runningSection, queuedSection] = wrapper.findAll(
SELECTORS.summaryRow
)
expect(runningSection.text()).toContain('2')
expect(runningSection.text()).toContain('running')
expect(queuedSection.text()).toContain('3')
expect(queuedSection.text()).toContain('queued')
const currentNodeSection = wrapper.find(SELECTORS.currentNodeRow)
expect(currentNodeSection.text()).toContain('Current node:')
expect(currentNodeSection.text()).toContain('Sampler')
expect(currentNodeSection.text()).toContain('40%')
const interruptButton = wrapper.get(SELECTORS.interruptAllButton)
await interruptButton.trigger('click')
expect(wrapper.emitted('interruptAll')).toHaveLength(1)
const clearQueuedButton = wrapper.get(SELECTORS.clearQueuedButton)
await clearQueuedButton.trigger('click')
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
const buttons = wrapper.findAll('button')
const viewAllButton = buttons.find((btn) =>
btn.text().includes(COPY.viewAllJobs)
)
expect(viewAllButton).toBeDefined()
await viewAllButton!.trigger('click')
expect(wrapper.emitted('viewAllJobs')).toHaveLength(1)
expect(wrapper.find('.custom-bottom-row').exists()).toBe(true)
})
it('hides action buttons when counts are zero', () => {
const wrapper = mountComponent({ runningCount: 0, queuedCount: 0 })
expect(wrapper.find(SELECTORS.interruptAllButton).exists()).toBe(false)
expect(wrapper.find(SELECTORS.clearQueuedButton).exists()).toBe(false)
})
it('builds tooltip configs with translated strings', () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
mountComponent()
expect(spy).toHaveBeenCalledWith('Cancel job')
expect(spy).toHaveBeenCalledWith('Clear queue')
})
})

Some files were not shown because too many files have changed in this diff Show More