Compare commits

..

1 Commits

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

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

@@ -1,20 +1,17 @@
import type { FullConfig } from '@playwright/test'
import { config as loadEnv } from 'dotenv'
import dotenv from 'dotenv'
import { backupPath } from './utils/backupUtils'
import { syncDevtools } from './utils/devtoolsSync'
loadEnv()
dotenv.config()
export default function globalSetup(_: FullConfig) {
export default function globalSetup(config: FullConfig) {
if (!process.env.CI) {
if (process.env.TEST_COMFYUI_DIR) {
backupPath([process.env.TEST_COMFYUI_DIR, 'user'])
backupPath([process.env.TEST_COMFYUI_DIR, 'models'], {
renameAndReplaceWithScaffolding: true
})
syncDevtools(process.env.TEST_COMFYUI_DIR)
} else {
console.warn(
'Set TEST_COMFYUI_DIR in .env to prevent user data (settings, workflows, etc.) from being overwritten'

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: 119 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: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 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

@@ -1,52 +0,0 @@
import fs from 'fs-extra'
import path from 'path'
import { fileURLToPath } from 'url'
export function syncDevtools(targetComfyDir: string): boolean {
if (!targetComfyDir) {
console.warn('syncDevtools skipped: TEST_COMFYUI_DIR not set')
return false
}
// Validate and sanitize the target directory path
const resolvedTargetDir = path.resolve(targetComfyDir)
// Basic path validation to prevent directory traversal
if (resolvedTargetDir.includes('..') || !path.isAbsolute(resolvedTargetDir)) {
console.error('syncDevtools failed: Invalid target directory path')
return false
}
const moduleDir =
typeof __dirname !== 'undefined'
? __dirname
: path.dirname(fileURLToPath(import.meta.url))
const devtoolsSrc = path.resolve(moduleDir, '..', '..', 'tools', 'devtools')
if (!fs.pathExistsSync(devtoolsSrc)) {
console.warn(
`syncDevtools skipped: source directory not found at ${devtoolsSrc}`
)
return false
}
const devtoolsDest = path.resolve(
resolvedTargetDir,
'custom_nodes',
'ComfyUI_devtools'
)
console.warn(`syncDevtools: copying ${devtoolsSrc} -> ${devtoolsDest}`)
try {
fs.removeSync(devtoolsDest)
fs.ensureDirSync(devtoolsDest)
fs.copySync(devtoolsSrc, devtoolsDest, { overwrite: true })
console.warn('syncDevtools: copy complete')
return true
} catch (error) {
console.error(`Failed to sync DevTools to ${devtoolsDest}:`, error)
return false
}
}

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

@@ -131,7 +131,6 @@
"@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

@@ -160,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);
@@ -264,13 +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-button-surface: var(--color-smoke-300);
--modal-card-placeholder-background: var(--color-smoke-600);
--modal-card-tag-background: var(--color-smoke-200);
--modal-card-tag-foreground: var(--base-foreground);
--modal-panel-background: var(--color-white);
}
.dark-theme {
@@ -292,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);
@@ -366,14 +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-button-surface: var(--color-charcoal-300);
--modal-card-placeholder-background: var(--secondary-background);
--modal-card-tag-background: var(--color-charcoal-200);
--modal-card-tag-foreground: var(--base-foreground);
--modal-panel-background: var(--color-charcoal-600);
}
@theme inline {
@@ -384,14 +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-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

3
pnpm-lock.yaml generated
View File

@@ -326,9 +326,6 @@ importers:
'@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

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

@@ -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>
@@ -46,11 +46,11 @@
</template>
<script setup lang="ts">
import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ComfyCommandImpl } from '@/stores/commandStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
const { t } = useI18n()
@@ -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

@@ -47,7 +47,6 @@
</template>
<script setup lang="ts">
import { appendJsonExt } from '@comfyorg/shared-frontend-utils/formatUtil'
import InputText from 'primevue/inputtext'
import type { MenuState } from 'primevue/menu'
import Menu from 'primevue/menu'
@@ -64,6 +63,7 @@ import {
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { appendJsonExt } from '@/utils/formatUtil'
interface Props {
item: MenuItem

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

@@ -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'
@@ -68,4 +57,19 @@ const toggle = (event: Event) => {
const hide = () => {
popover.value?.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

@@ -19,7 +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')
light: 'backdrop-blur-[2px] bg-white/50 text-zinc-900 dark-theme:text-white'
}
const chipClasses = computed(() => {

View File

@@ -12,9 +12,8 @@
</template>
<script setup lang="ts">
import { formatSize } from '@comfyorg/shared-frontend-utils/formatUtil'
import type { DeviceStats } from '@/schemas/apiSchema'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
device: DeviceStats

View File

@@ -82,7 +82,6 @@
</template>
<script setup lang="ts">
import { formatSize } from '@comfyorg/shared-frontend-utils/formatUtil'
import Button from 'primevue/button'
import ProgressBar from 'primevue/progressbar'
import { computed, ref } from 'vue'
@@ -91,6 +90,7 @@ import { useI18n } from 'vue-i18n'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
url: string

View File

@@ -43,13 +43,13 @@
</template>
<script setup lang="ts">
import { formatSize } from '@comfyorg/shared-frontend-utils/formatUtil'
import Button from 'primevue/button'
import Message from 'primevue/message'
import { computed } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
url: string

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

@@ -36,7 +36,6 @@
</template>
<script setup lang="ts">
import { formatSize } from '@comfyorg/shared-frontend-utils/formatUtil'
import Divider from 'primevue/divider'
import TabPanel from 'primevue/tabpanel'
import TabView from 'primevue/tabview'
@@ -44,6 +43,7 @@ import { computed } from 'vue'
import DeviceInfo from '@/components/common/DeviceInfo.vue'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatSize } from '@/utils/formatUtil'
const props = defineProps<{
stats: SystemStats

View File

@@ -23,12 +23,12 @@
</template>
<script setup lang="ts">
import { isValidUrl } from '@comfyorg/shared-frontend-utils/formatUtil'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { onMounted, ref, watch } from 'vue'
import { isValidUrl } from '@/utils/formatUtil'
import { checkUrlReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'

View File

@@ -18,12 +18,12 @@
</template>
<script setup lang="ts">
import { formatMetronomeCurrency } from '@comfyorg/shared-frontend-utils/formatUtil'
import Skeleton from 'primevue/skeleton'
import Tag from 'primevue/tag'
import { computed } from 'vue'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
const { textClass } = defineProps<{
textClass?: string

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

@@ -112,7 +112,6 @@
</template>
<script setup lang="ts">
import { formatMetronomeCurrency } from '@comfyorg/shared-frontend-utils/formatUtil'
import Button from 'primevue/button'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
@@ -130,6 +129,7 @@ import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
interface CreditHistoryItemData {
title: string

View File

@@ -126,7 +126,6 @@
</template>
<script setup lang="ts">
import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
import { FilterMatchMode } from '@primevue/core/api'
import Button from 'primevue/button'
import Column from 'primevue/column'
@@ -147,6 +146,7 @@ import {
KeybindingImpl,
useKeybindingStore
} from '@/stores/keybindingStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import PanelTemplate from './PanelTemplate.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'

View File

@@ -10,7 +10,6 @@
</template>
<script setup lang="ts">
import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
import { useEventListener } from '@vueuse/core'
import { nextTick, ref } from 'vue'
@@ -24,6 +23,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { app as comfyApp } from '@/scripts/app'
import { isDOMWidget } from '@/scripts/domWidget'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
let idleTimeout: number
const nodeDefStore = useNodeDefStore()

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

@@ -14,12 +14,12 @@
</template>
<script setup lang="ts">
import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
import Button from 'primevue/button'
import { st } from '@/i18n'
import type { ComfyCommand } from '@/stores/commandStore'
import { useCommandStore } from '@/stores/commandStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
defineProps<{
command: ComfyCommand

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

@@ -12,12 +12,12 @@
</template>
<script setup lang="ts">
import { linkifyHtml, nl2br } from '@comfyorg/shared-frontend-utils/formatUtil'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, ref, watch } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/litegraph'
import { useExecutionStore } from '@/stores/executionStore'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const modelValue = defineModel<string>({ required: true })
const props = defineProps<{

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

@@ -137,7 +137,6 @@
</template>
<script setup lang="ts">
import { formatVersionAnchor } from '@comfyorg/shared-frontend-utils/formatUtil'
import Button from 'primevue/button'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import type { CSSProperties, Component } from 'vue'
@@ -152,6 +151,7 @@ import type { ReleaseNote } from '@/platform/updates/common/releaseService'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { useCommandStore } from '@/stores/commandStore'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { formatVersionAnchor } from '@/utils/formatUtil'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'

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

@@ -47,10 +47,6 @@
</template>
<script setup lang="ts">
import {
formatNumberWithSuffix,
highlightQuery
} from '@comfyorg/shared-frontend-utils/formatUtil'
import Chip from 'primevue/chip'
import Tag from 'primevue/tag'
import { computed } from 'vue'
@@ -60,6 +56,7 @@ import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
const settingStore = useSettingStore()
const showCategory = computed(() =>

View File

@@ -100,7 +100,6 @@
</template>
<script setup lang="ts">
import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
import type { MenuItem } from 'primevue/menuitem'
import TieredMenu from 'primevue/tieredmenu'
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
@@ -119,6 +118,7 @@ import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'

View File

@@ -67,7 +67,7 @@
/>
</div>
<!-- Content -->
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<div v-else class="relative size-full">
<VirtualGrid
:items="mediaAssetsWithKey"
:grid-style="{
@@ -97,22 +97,32 @@
<template #footer>
<div
v-if="hasSelection"
class="flex gap-1 h-18 w-full items-center justify-between"
class="flex h-18 w-full items-center justify-between px-4"
>
<div ref="selectionCountButtonRef" class="flex-1 pl-4">
<div>
<TextButton
:label="
isHoveringSelectionCount
? $t('mediaAsset.selection.deselectAll')
: $t('mediaAsset.selection.selectedCount', {
count: totalOutputCount
})
"
v-if="isHoveringSelectionCount"
:label="$t('mediaAsset.selection.deselectAll')"
type="transparent"
@click="handleDeselectAll"
@mouseleave="isHoveringSelectionCount = false"
/>
<span
v-else
role="button"
tabindex="0"
:aria-label="$t('mediaAsset.selection.deselectAll')"
class="cursor-pointer px-3 text-sm focus:ring-2 focus:ring-primary focus:outline-none"
@mouseenter="isHoveringSelectionCount = true"
@keydown.enter="handleDeselectAll"
@keydown.space.prevent="handleDeselectAll"
>
{{
$t('mediaAsset.selection.selectedCount', { count: selectedCount })
}}
</span>
</div>
<div class="flex gap-2 pr-4">
<div class="flex gap-2">
<IconTextButton
v-if="shouldShowDeleteButton"
:label="$t('mediaAsset.selection.deleteSelected')"
@@ -145,11 +155,7 @@
</template>
<script setup lang="ts">
import {
formatDuration,
getMediaTypeFromFilename
} from '@comfyorg/shared-frontend-utils/formatUtil'
import { useDebounceFn, useElementHover } from '@vueuse/core'
import { useDebounceFn } from '@vueuse/core'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
@@ -174,6 +180,7 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
@@ -216,6 +223,7 @@ const {
isSelected,
handleAssetClick,
hasSelection,
selectedCount,
clearSelection,
getSelectedAssets,
activate: activateSelection,
@@ -224,15 +232,8 @@ const {
const { downloadMultipleAssets, deleteMultipleAssets } = useMediaAssetActions()
// Hover state for selection count button
const selectionCountButtonRef = ref<HTMLElement | null>(null)
const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
// Total output count for all selected assets
const totalOutputCount = computed(() => {
const selectedAssets = getSelectedAssets(displayAssets.value)
return selectedAssets.reduce((sum, asset) => sum + getOutputCount(asset), 0)
})
// Hover state for selection count
const isHoveringSelectionCount = ref(false)
const currentAssets = computed(() =>
activeTab.value === 'input' ? inputAssets : outputAssets
@@ -399,6 +400,7 @@ const exitFolderView = () => {
folderExecutionTime.value = undefined
folderAssets.value = []
searchQuery.value = ''
clearSelection()
}
onMounted(() => {
@@ -411,12 +413,7 @@ onUnmounted(() => {
const handleDeselectAll = () => {
clearSelection()
}
const handleEmptySpaceClick = () => {
if (hasSelection) {
clearSelection()
}
isHoveringSelectionCount.value = false
}
const copyJobId = async () => {

View File

@@ -128,7 +128,6 @@
</template>
<script setup lang="ts">
import { appendJsonExt } from '@comfyorg/shared-frontend-utils/formatUtil'
import Button from 'primevue/button'
import ConfirmDialog from 'primevue/confirmdialog'
import { computed, nextTick, onMounted, ref } from 'vue'
@@ -151,6 +150,7 @@ import {
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
import { appendJsonExt } from '@/utils/formatUtil'
import { buildTree, sortedTree } from '@/utils/treeUtil'
const settingStore = useSettingStore()

View File

@@ -4,7 +4,7 @@
outlined
rounded
severity="secondary"
class="size-8 bg-secondary-background text-base-foreground hover:bg-secondary-background-hover"
class="size-8 border-black/50 bg-transparent text-black hover:bg-interface-panel-hover-surface dark-theme:border-white/50 dark-theme:text-white"
@click="handleSignIn()"
@mouseenter="showPopover"
@mouseleave="hidePopover"

View File

@@ -219,10 +219,10 @@ const popoverPt = computed(() => ({
content: {
class: cn(
'mt-1 rounded-lg',
'bg-base-background',
'text-base-foreground',
'bg-white dark-theme:bg-zinc-800',
'text-neutral dark-theme:text-white',
'shadow-lg',
'border border-border-default'
'border border-zinc-200 dark-theme:border-zinc-700'
)
}
}))

View File

@@ -132,7 +132,8 @@ const toggleRightPanel = () => {
// Computed classes for better readability
const layoutClasses = cn(
'base-widget-layout',
'rounded-2xl overflow-hidden relative'
'rounded-2xl overflow-hidden relative',
'bg-gray-50 dark-theme:bg-smoke-800'
)
const rightPanelButtonClasses = computed(() => {
@@ -147,7 +148,10 @@ const closeButtonClasses = cn(
'transition-opacity duration-200'
)
const mainContainerClasses = cn('flex-1 flex bg-base-background')
const mainContainerClasses = cn(
'flex-1 flex',
'bg-smoke-100 dark-theme:bg-neutral-900'
)
const headerClasses = cn(
'w-full h-18 px-6',

View File

@@ -1,10 +1,10 @@
<template>
<div
class="flex cursor-pointer items-center gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
class="flex cursor-pointer items-center gap-2 rounded-md px-4 py-3 text-sm transition-colors"
:class="
active
? 'bg-interface-menu-component-surface-selected'
: 'hover:bg-interface-menu-component-surface-hovered'
? 'bg-smoke-400 dark-theme:bg-charcoal-300 text-neutral'
: 'text-neutral hover:bg-smoke-100 dark-theme:hover:bg-charcoal-300'
"
role="button"
@click="onClick"

View File

@@ -8,14 +8,16 @@
"
@click="collapsible && toggleCollapse()"
>
<h3 class="text-xs font-bold text-text-secondary uppercase">
<h3
class="text-xs font-bold text-neutral-400 uppercase dark-theme:text-neutral-400"
>
{{ title }}
</h3>
<i
v-if="collapsible"
:class="
cn(
'pi transition-transform duration-200 text-xs text-text-secondary ',
'pi transition-transform duration-200 text-xs text-neutral-400 dark-theme:text-neutral-400',
isCollapsed ? 'pi-chevron-right' : 'pi-chevron-down'
)
"

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex h-full w-full flex-col bg-modal-panel-background">
<div class="flex h-full w-full flex-col bg-white dark-theme:bg-charcoal-600">
<PanelHeader>
<template #icon>
<slot name="header-icon"></slot>

View File

@@ -1,5 +1,5 @@
<template>
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4">
<div class="h-full w-full bg-white pr-6 pb-8 pl-4 dark-theme:bg-charcoal-600">
<slot></slot>
</div>
</template>

View File

@@ -4,31 +4,13 @@ This directory contains Vue composables for the ComfyUI frontend application. Co
## Table of Contents
- [Composables](#composables)
- [Table of Contents](#table-of-contents)
- [Overview](#overview)
- [Composable Architecture](#composable-architecture)
- [Composable Categories](#composable-categories)
- [Auth](#auth)
- [Bottom Panel Tabs](#bottom-panel-tabs)
- [Element](#element)
- [Functional](#functional)
- [Manager](#manager)
- [Node Pack](#node-pack)
- [Node](#node)
- [Settings](#settings)
- [Sidebar Tabs](#sidebar-tabs)
- [Tree](#tree)
- [Widgets](#widgets)
- [Root-level Composables](#root-level-composables)
- [Usage Guidelines](#usage-guidelines)
- [VueUse Library](#vueuse-library)
- [Development Guidelines](#development-guidelines)
- [Composable Template](#composable-template)
- [Common Patterns](#common-patterns)
- [State Management](#state-management)
- [Event Handling with VueUse](#event-handling-with-vueuse)
- [Fetch \& Load with VueUse](#fetch--load-with-vueuse)
- [Overview](#overview)
- [Composable Architecture](#composable-architecture)
- [Composable Categories](#composable-categories)
- [Usage Guidelines](#usage-guidelines)
- [VueUse Library](#vueuse-library)
- [Development Guidelines](#development-guidelines)
- [Common Patterns](#common-patterns)
## Overview
@@ -157,6 +139,7 @@ Composables for node-specific functionality:
| `useNodeAnimatedImage` | Handles animated images in nodes |
| `useNodeBadge` | Handles node badge display and interaction |
| `useNodeCanvasImagePreview` | Canvas-based image preview for nodes |
| `useNodeChatHistory` | Manages chat history for nodes |
| `useNodeDragAndDrop` | Handles drag and drop for nodes |
| `useNodeFileInput` | Manages file input widgets in nodes |
| `useNodeImage` | Manages node image preview |
@@ -197,6 +180,7 @@ Composables for widget functionality:
| Composable | Description |
|------------|-------------|
| `useBooleanWidget` | Manages boolean widget interactions |
| `useChatHistoryWidget` | Handles chat history widget |
| `useComboWidget` | Manages combo box widget interactions |
| `useFloatWidget` | Manages float input widget interactions |
| `useImagePreviewWidget` | Manages image preview widget |

View File

@@ -11,7 +11,7 @@ import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { usdToMicros } from '@comfyorg/shared-frontend-utils/formatUtil'
import { usdToMicros } from '@/utils/formatUtil'
/**
* Service for Firebase Auth actions.

View File

@@ -79,8 +79,7 @@ export function useNodeArrangement() {
return
}
const newPositions = alignNodes(selectedNodes, alignOption.value)
canvasStore.canvas?.repositionNodesVueMode(newPositions)
alignNodes(selectedNodes, alignOption.value)
canvasRefresh.refreshCanvas()
}
@@ -94,8 +93,7 @@ export function useNodeArrangement() {
return
}
const newPositions = distributeNodes(selectedNodes, distributeOption.value)
canvasStore.canvas?.repositionNodesVueMode(newPositions)
distributeNodes(selectedNodes, distributeOption.value)
canvasRefresh.refreshCanvas()
}

View File

@@ -0,0 +1,58 @@
import type ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useChatHistoryWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChatHistoryWidget'
const CHAT_HISTORY_WIDGET_NAME = '$$node-chat-history'
/**
* Composable for handling node text previews
*/
export function useNodeChatHistory(
options: {
minHeight?: number
props?: Omit<InstanceType<typeof ChatHistoryWidget>['$props'], 'widget'>
} = {}
) {
const chatHistoryWidget = useChatHistoryWidget(options)
const addChatHistoryWidget = (node: LGraphNode) =>
chatHistoryWidget(node, {
name: CHAT_HISTORY_WIDGET_NAME,
type: 'chatHistory'
})
/**
* Shows chat history for a node
* @param node The graph node to show the chat history for
*/
function showChatHistory(node: LGraphNode) {
// First remove any existing widget
removeChatHistory(node)
// Then add the widget with new history
addChatHistoryWidget(node)
node.setDirtyCanvas?.(true)
}
/**
* Removes chat history from a node
* @param node The graph node to remove the chat history from
*/
function removeChatHistory(node: LGraphNode) {
if (!node.widgets) return
const widgetIdx = node.widgets.findIndex(
(w) => w.name === CHAT_HISTORY_WIDGET_NAME
)
if (widgetIdx > -1) {
node.widgets[widgetIdx].onRemove?.()
node.widgets.splice(widgetIdx, 1)
}
}
return {
showChatHistory,
removeChatHistory
}
}

View File

@@ -1,6 +1,6 @@
import QuickLRU from '@alloc/quick-lru'
import { paramsToCacheKey } from '@comfyorg/shared-frontend-utils/formatUtil'
import { paramsToCacheKey } from '@/utils/formatUtil'
const DEFAULT_MAX_SIZE = 50

View File

@@ -8,7 +8,7 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { normalizeI18nKey } from '@comfyorg/shared-frontend-utils/formatUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
/**
* Add translation for litegraph context menu.

View File

@@ -2,10 +2,7 @@ import { whenever } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import { useCivitaiModel } from '@/composables/useCivitaiModel'
import {
downloadUrlToHfRepoUrl,
isCivitaiModelUrl
} from '@comfyorg/shared-frontend-utils/formatUtil'
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
export function useDownload(url: string, fileName?: string) {
const fileSize = ref<number | null>(null)

View File

@@ -1,5 +1,5 @@
import { useExtensionService } from '@/services/extensionService'
import { processDynamicPrompt } from '@comfyorg/shared-frontend-utils/formatUtil'
import { processDynamicPrompt } from '@/utils/formatUtil'
// Allows for simple dynamic prompt replacement
// Inputs in the format {a|b} will have a random value of a or b chosen when the prompt is queued.

View File

@@ -49,7 +49,6 @@ import type {
ISlotType,
LinkNetwork,
LinkSegment,
NewNodePosition,
NullableProperties,
Point,
Positionable,
@@ -1012,8 +1011,7 @@ export class LGraphCanvas
direction: Direction,
align_to?: LGraphNode
): void {
const newPositions = alignNodes(Object.values(nodes), direction, align_to)
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
alignNodes(Object.values(nodes), direction, align_to)
LGraphCanvas.active_canvas.setDirty(true, true)
}
@@ -1033,12 +1031,11 @@ export class LGraphCanvas
})
function inner_clicked(value: string) {
const newPositions = alignNodes(
alignNodes(
Object.values(LGraphCanvas.active_canvas.selected_nodes),
value.toLowerCase() as Direction,
node
)
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
LGraphCanvas.active_canvas.setDirty(true, true)
}
}
@@ -1058,11 +1055,10 @@ export class LGraphCanvas
})
function inner_clicked(value: string) {
const newPositions = alignNodes(
alignNodes(
Object.values(LGraphCanvas.active_canvas.selected_nodes),
value.toLowerCase() as Direction
)
LGraphCanvas.active_canvas.repositionNodesVueMode(newPositions)
LGraphCanvas.active_canvas.setDirty(true, true)
}
}
@@ -1083,11 +1079,10 @@ export class LGraphCanvas
function inner_clicked(value: string) {
const canvas = LGraphCanvas.active_canvas
const newPositions = distributeNodes(
distributeNodes(
Object.values(canvas.selected_nodes),
value === 'Horizontally'
)
canvas.repositionNodesVueMode(newPositions)
canvas.setDirty(true, true)
}
}
@@ -8562,7 +8557,10 @@ export class LGraphCanvas
) {
const mutations = this.initLayoutMutations()
const nodesInMovingGroups = this.collectNodesInGroups(allItems)
const nodesToMove: NewNodePosition[] = []
const nodesToMove: Array<{
node: LGraphNode
newPos: { x: number; y: number }
}> = []
// First, collect all the moves we need to make
for (const item of allItems) {
@@ -8588,9 +8586,4 @@ export class LGraphCanvas
// Now apply all the node moves at once
this.applyNodePositionUpdates(nodesToMove, mutations)
}
repositionNodesVueMode(nodesToReposition: NewNodePosition[]) {
const mutations = this.initLayoutMutations()
this.applyNodePositionUpdates(nodesToReposition, mutations)
}
}

View File

@@ -254,13 +254,7 @@ type KeysOfType<T, Match> = Exclude<
/** The names of all (optional) methods and functions in T */
export type MethodNames<T> = KeysOfType<T, ((...args: any) => any) | undefined>
export interface NewNodePosition {
node: LGraphNode
newPos: {
x: number
y: number
}
}
export interface IBoundaryNodes {
top: LGraphNode
right: LGraphNode

View File

@@ -1,5 +1,5 @@
import type { LGraphNode } from '../LGraphNode'
import type { Direction, IBoundaryNodes, NewNodePosition } from '../interfaces'
import type { Direction, IBoundaryNodes } from '../interfaces'
/**
* Finds the nodes that are farthest in all four directions, representing the boundary of the nodes.
@@ -43,9 +43,9 @@ export function getBoundaryNodes(nodes: LGraphNode[]): IBoundaryNodes | null {
export function distributeNodes(
nodes: LGraphNode[],
horizontal?: boolean
): NewNodePosition[] {
): void {
const nodeCount = nodes?.length
if (!(nodeCount > 1)) return []
if (!(nodeCount > 1)) return
const index = horizontal ? 0 : 1
@@ -68,16 +68,6 @@ export function distributeNodes(
node.pos[index] = startAt + gap * i
startAt += node.size[index]
}
const newPositions = sorted.map(
(node): NewNodePosition => ({
node,
newPos: {
x: node.pos[0],
y: node.pos[1]
}
})
)
return newPositions
}
/**
@@ -90,56 +80,32 @@ export function alignNodes(
nodes: LGraphNode[],
direction: Direction,
align_to?: LGraphNode
): NewNodePosition[] {
if (!nodes) return []
): void {
if (!nodes) return
const boundary =
align_to === undefined
? getBoundaryNodes(nodes)
: { top: align_to, right: align_to, bottom: align_to, left: align_to }
if (boundary === null) return []
if (boundary === null) return
const nodePositions = nodes.map((node): NewNodePosition => {
for (const node of nodes) {
switch (direction) {
case 'right':
return {
node,
newPos: {
x: boundary.right.pos[0] + boundary.right.size[0] - node.size[0],
y: node.pos[1]
}
}
node.pos[0] =
boundary.right.pos[0] + boundary.right.size[0] - node.size[0]
break
case 'left':
return {
node,
newPos: {
x: boundary.left.pos[0],
y: node.pos[1]
}
}
node.pos[0] = boundary.left.pos[0]
break
case 'top':
return {
node,
newPos: {
x: node.pos[0],
y: boundary.top.pos[1]
}
}
node.pos[1] = boundary.top.pos[1]
break
case 'bottom':
return {
node,
newPos: {
x: node.pos[0],
y: boundary.bottom.pos[1] + boundary.bottom.size[1] - node.size[1]
}
}
node.pos[1] =
boundary.bottom.pos[1] + boundary.bottom.size[1] - node.size[1]
break
}
})
for (const { node, newPos } of nodePositions) {
node.pos[0] = newPos.x
node.pos[1] = newPos.y
}
return nodePositions
}

View File

@@ -1804,6 +1804,13 @@
"Bypass Group Nodes": "Bypass Group Nodes",
"Set Group Nodes to Always": "Set Group Nodes to Always"
},
"chatHistory": {
"cancelEdit": "Cancel",
"editTooltip": "Edit message",
"cancelEditTooltip": "Cancel edit",
"copiedTooltip": "Copied",
"copyTooltip": "Copy message to clipboard"
},
"widgets": {
"selectModel": "Select model",
"uploadSelect": {
@@ -2036,18 +2043,8 @@
"downloadStarted": "Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file(s)",
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
"failedToDeleteAssets": "Failed to delete selected assets",
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed"
},
"noJobIdFound": "No job ID found for this asset",
"unsupportedFileType": "Unsupported file type for loader node",
"nodeTypeNotFound": "Node type {nodeType} not found",
"failedToCreateNode": "Failed to create node",
"nodeAddedToWorkflow": "{nodeType} node added to workflow",
"noWorkflowDataFound": "No workflow data found in this asset",
"workflowOpenedInNewTab": "Workflow opened in new tab",
"failedToExportWorkflow": "Failed to export workflow",
"workflowExportedSuccessfully": "Workflow exported successfully"
"failedToDeleteAssets": "Failed to delete selected assets"
}
},
"actionbar": {
"dockToTop": "Dock to top",

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