diff --git a/.claude/commands/create-frontend-release.md b/.claude/commands/create-frontend-release.md index 38ed14651c..f16189d428 100644 --- a/.claude/commands/create-frontend-release.md +++ b/.claude/commands/create-frontend-release.md @@ -458,15 +458,15 @@ echo "Workflow triggered. Waiting for PR creation..." 3. **IMMEDIATELY CHECK**: Did release workflow trigger? ```bash sleep 10 - gh run list --workflow=release.yaml --limit=1 + gh run list --workflow=release-draft-create.yaml --limit=1 ``` -4. **For Minor/Major Version Releases**: The create-release-candidate-branch workflow will automatically: +4. **For Minor/Major Version Releases**: The release-branch-create workflow will automatically: - Create a `core/x.yy` branch for the PREVIOUS minor version - Apply branch protection rules - Document the feature freeze policy ```bash # Monitor branch creation (for minor/major releases) - gh run list --workflow=create-release-candidate-branch.yaml --limit=1 + gh run list --workflow=release-branch-create.yaml --limit=1 ``` 4. If workflow didn't trigger due to [skip ci]: ```bash @@ -477,7 +477,7 @@ echo "Workflow triggered. Waiting for PR creation..." ``` 5. If workflow triggered, monitor execution: ```bash - WORKFLOW_RUN_ID=$(gh run list --workflow=release.yaml --limit=1 --json databaseId --jq '.[0].databaseId') + WORKFLOW_RUN_ID=$(gh run list --workflow=release-draft-create.yaml --limit=1 --json databaseId --jq '.[0].databaseId') gh run watch ${WORKFLOW_RUN_ID} ``` diff --git a/.claude/commands/create-hotfix-release.md b/.claude/commands/create-hotfix-release.md index f35a8ad235..cc9c37ef5b 100644 --- a/.claude/commands/create-hotfix-release.md +++ b/.claude/commands/create-hotfix-release.md @@ -246,7 +246,7 @@ For each commit: 3. Merge the PR: `gh pr merge --merge` 4. Monitor release workflow: ```bash - gh run list --workflow=release.yaml --limit=1 + gh run list --workflow=release-draft-create.yaml --limit=1 gh run watch ``` 5. Track progress: diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index 6f43623a3d..0000000000 --- a/.cursorrules +++ /dev/null @@ -1,61 +0,0 @@ -# Vue 3 Composition API Project Rules - -## Vue 3 Composition API Best Practices -- Use setup() function for component logic -- Utilize ref and reactive for reactive state -- Implement computed properties with computed() -- Use watch and watchEffect for side effects -- Implement lifecycle hooks with onMounted, onUpdated, etc. -- Utilize provide/inject for dependency injection -- Use vue 3.5 style of default prop declaration. Example: - -```typescript -const { nodes, showTotal = true } = defineProps<{ - nodes: ApiNodeCost[] - showTotal?: boolean -}>() -``` - -- Organize vue component in + + diff --git a/tests-ui/tests/components/bottomPanel/EssentialsPanel.spec.ts b/src/components/bottomPanel/tabs/shortcuts/EssentialsPanel.test.ts similarity index 90% rename from tests-ui/tests/components/bottomPanel/EssentialsPanel.spec.ts rename to src/components/bottomPanel/tabs/shortcuts/EssentialsPanel.test.ts index d8ad4d6a05..0c4f656aba 100644 --- a/tests-ui/tests/components/bottomPanel/EssentialsPanel.spec.ts +++ b/src/components/bottomPanel/tabs/shortcuts/EssentialsPanel.test.ts @@ -61,13 +61,6 @@ describe('EssentialsPanel', () => { const shortcutsList = wrapper.findComponent(ShortcutsList) expect(shortcutsList.exists()).toBe(true) - - // Should pass only essentials commands - const commands = shortcutsList.props('commands') - expect(commands).toHaveLength(3) - commands.forEach((cmd: ComfyCommandImpl) => { - expect(cmd.category).toBe('essentials') - }) }) it('should categorize commands into subcategories', () => { diff --git a/tests-ui/tests/components/bottomPanel/ShortcutsList.spec.ts b/src/components/bottomPanel/tabs/shortcuts/ShortcutsList.test.ts similarity index 100% rename from tests-ui/tests/components/bottomPanel/ShortcutsList.spec.ts rename to src/components/bottomPanel/tabs/shortcuts/ShortcutsList.test.ts diff --git a/src/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue b/src/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue index 1481b012f7..d8992332bc 100644 --- a/src/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue +++ b/src/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue @@ -7,7 +7,7 @@ class="flex flex-col" >

{{ getSubcategoryTitle(subcategory) }}

@@ -16,7 +16,7 @@
@@ -32,7 +32,7 @@ {{ formatKey(key) }} @@ -55,7 +55,6 @@ import { normalizeI18nKey } from '@/utils/formatUtil' const { t } = useI18n() const { subcategories } = defineProps<{ - commands: ComfyCommandImpl[] subcategories: Record }>() @@ -100,21 +99,3 @@ const formatKey = (key: string): string => { return keyMap[key] || key } - - diff --git a/tests-ui/tests/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts b/src/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts similarity index 100% rename from tests-ui/tests/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts rename to src/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts diff --git a/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue b/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue index e632d0d473..655df0b650 100644 --- a/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue +++ b/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue @@ -11,9 +11,8 @@ value: tooltipText, showDelay: 300 }" - icon="pi pi-copy" - severity="secondary" - size="small" + variant="secondary" + size="sm" :class=" cn('absolute top-2 right-8 transition-opacity', { 'opacity-0 pointer-events-none select-none': !isHovered @@ -21,18 +20,20 @@ " :aria-label="tooltipText" @click="handleCopy" - /> + > + +
diff --git a/src/components/common/StatusBadge.vue b/src/components/common/StatusBadge.vue new file mode 100644 index 0000000000..46ef7ac79d --- /dev/null +++ b/src/components/common/StatusBadge.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/common/SystemStatsPanel.vue b/src/components/common/SystemStatsPanel.vue index 1d3612aa24..5d3cd89659 100644 --- a/src/components/common/SystemStatsPanel.vue +++ b/src/components/common/SystemStatsPanel.vue @@ -9,29 +9,31 @@
{{ col.header }}
-
{{ formatValue(systemInfo[col.field], col.field) }}
+
{{ getDisplayValue(col) }}
- + @@ -42,8 +44,9 @@ import TabView from 'primevue/tabview' import { computed } from 'vue' import DeviceInfo from '@/components/common/DeviceInfo.vue' +import { isCloud } from '@/platform/distribution/types' import type { SystemStats } from '@/schemas/apiSchema' -import { formatSize } from '@/utils/formatUtil' +import { formatCommitHash, formatSize } from '@/utils/formatUtil' const props = defineProps<{ stats: SystemStats @@ -54,20 +57,53 @@ const systemInfo = computed(() => ({ argv: props.stats.system.argv.join(' ') })) -const systemColumns: { field: keyof SystemStats['system']; header: string }[] = - [ - { field: 'os', header: 'OS' }, - { field: 'python_version', header: 'Python Version' }, - { field: 'embedded_python', header: 'Embedded Python' }, - { field: 'pytorch_version', header: 'Pytorch Version' }, - { field: 'argv', header: 'Arguments' }, - { field: 'ram_total', header: 'RAM Total' }, - { field: 'ram_free', header: 'RAM Free' } - ] +const hasDevices = computed(() => props.stats.devices.length > 0) -const formatValue = (value: any, field: string) => { - if (['ram_total', 'ram_free'].includes(field)) { - return formatSize(value) +type SystemInfoKey = keyof SystemStats['system'] + +type ColumnDef = { + field: SystemInfoKey + header: string + format?: (value: string) => string + formatNumber?: (value: number) => string +} + +/** Columns for local distribution */ +const localColumns: ColumnDef[] = [ + { field: 'os', header: 'OS' }, + { field: 'python_version', header: 'Python Version' }, + { field: 'embedded_python', header: 'Embedded Python' }, + { field: 'pytorch_version', header: 'Pytorch Version' }, + { field: 'argv', header: 'Arguments' }, + { field: 'ram_total', header: 'RAM Total', formatNumber: formatSize }, + { field: 'ram_free', header: 'RAM Free', formatNumber: formatSize } +] + +/** Columns for cloud distribution */ +const cloudColumns: ColumnDef[] = [ + { field: 'cloud_version', header: 'Cloud Version' }, + { + field: 'comfyui_version', + header: 'ComfyUI Version', + format: formatCommitHash + }, + { + field: 'comfyui_frontend_version', + header: 'Frontend Version', + format: formatCommitHash + }, + { field: 'workflow_templates_version', header: 'Templates Version' } +] + +const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns)) + +const getDisplayValue = (column: ColumnDef) => { + const value = systemInfo.value[column.field] + if (column.formatNumber && typeof value === 'number') { + return column.formatNumber(value) + } + if (column.format && typeof value === 'string') { + return column.format(value) } return value } diff --git a/src/components/common/TreeExplorer.vue b/src/components/common/TreeExplorer.vue index c6a3dbe60e..828d8ff45a 100644 --- a/src/components/common/TreeExplorer.vue +++ b/src/components/common/TreeExplorer.vue @@ -2,7 +2,7 @@
diff --git a/src/components/common/UserAvatar.test.ts b/src/components/common/UserAvatar.test.ts index d844277b94..0c6b26e4e0 100644 --- a/src/components/common/UserAvatar.test.ts +++ b/src/components/common/UserAvatar.test.ts @@ -56,7 +56,7 @@ describe('UserAvatar', () => { const avatar = wrapper.findComponent(Avatar) expect(avatar.exists()).toBe(true) expect(avatar.props('image')).toBeNull() - expect(avatar.props('icon')).toBe('pi pi-user') + expect(avatar.props('icon')).toBe('icon-[lucide--user]') }) it('renders with default icon when provided photo Url is null', () => { @@ -67,7 +67,7 @@ describe('UserAvatar', () => { const avatar = wrapper.findComponent(Avatar) expect(avatar.exists()).toBe(true) expect(avatar.props('image')).toBeNull() - expect(avatar.props('icon')).toBe('pi pi-user') + expect(avatar.props('icon')).toBe('icon-[lucide--user]') }) it('falls back to icon when image fails to load', async () => { @@ -82,7 +82,7 @@ describe('UserAvatar', () => { avatar.vm.$emit('error') await nextTick() - expect(avatar.props('icon')).toBe('pi pi-user') + expect(avatar.props('icon')).toBe('icon-[lucide--user]') }) it('uses provided ariaLabel', () => { diff --git a/src/components/common/UserAvatar.vue b/src/components/common/UserAvatar.vue index 8fce43d105..1eec243d60 100644 --- a/src/components/common/UserAvatar.vue +++ b/src/components/common/UserAvatar.vue @@ -1,7 +1,9 @@ @@ -21,19 +27,42 @@ import Skeleton from 'primevue/skeleton' import Tag from 'primevue/tag' import { computed } from 'vue' +import { useI18n } from 'vue-i18n' +import { formatCreditsFromCents } from '@/base/credits/comfyCredits' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' -import { formatMetronomeCurrency } from '@/utils/formatUtil' -const { textClass } = defineProps<{ +const { textClass, showCreditsOnly } = defineProps<{ textClass?: string + showCreditsOnly?: boolean }>() const authStore = useFirebaseAuthStore() const balanceLoading = computed(() => authStore.isFetchingBalance) +const { t, locale } = useI18n() const formattedBalance = computed(() => { - if (!authStore.balance) return '0.00' - return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd') + const cents = + authStore.balance?.effective_balance_micros ?? + authStore.balance?.amount_micros ?? + 0 + const amount = formatCreditsFromCents({ + cents, + locale: locale.value + }) + return `${amount} ${t('credits.credits')}` +}) + +const formattedCreditsOnly = computed(() => { + const cents = + authStore.balance?.effective_balance_micros ?? + authStore.balance?.amount_micros ?? + 0 + const amount = formatCreditsFromCents({ + cents, + locale: locale.value, + numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 } + }) + return amount }) diff --git a/src/components/common/VirtualGrid.vue b/src/components/common/VirtualGrid.vue index 89de6e421b..134778778a 100644 --- a/src/components/common/VirtualGrid.vue +++ b/src/components/common/VirtualGrid.vue @@ -117,16 +117,7 @@ onBeforeUnmount(() => { .scroll-container { height: 100%; overflow-y: auto; - - /* Firefox */ - scrollbar-width: none; - - &::-webkit-scrollbar { - width: 1px; - } - - &::-webkit-scrollbar-thumb { - background-color: transparent; - } + scrollbar-width: thin; + scrollbar-color: var(--dialog-surface) transparent; } diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue index c64a8e19d0..aeb98971ba 100644 --- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue +++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue @@ -22,67 +22,70 @@ @@ -20,10 +17,10 @@ import type { FormSubmitEvent } from '@primevue/forms' import { Form } from '@primevue/forms' import { zodResolver } from '@primevue/forms/resolvers/zod' -import Button from 'primevue/button' import { ref } from 'vue' import PasswordFields from '@/components/dialog/content/signin/PasswordFields.vue' +import Button from '@/components/ui/button/Button.vue' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { updatePasswordSchema } from '@/schemas/signInSchema' diff --git a/src/components/dialog/content/credit/CreditTopUpOption.test.ts b/src/components/dialog/content/credit/CreditTopUpOption.test.ts new file mode 100644 index 0000000000..7faf432e72 --- /dev/null +++ b/src/components/dialog/content/credit/CreditTopUpOption.test.ts @@ -0,0 +1,48 @@ +import { mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import { describe, expect, it } from 'vitest' + +import CreditTopUpOption from '@/components/dialog/content/credit/CreditTopUpOption.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: {} } +}) + +const mountOption = ( + props?: Partial<{ credits: number; description: string; selected: boolean }> +) => + mount(CreditTopUpOption, { + props: { + credits: 1000, + description: '~100 videos*', + selected: false, + ...props + }, + global: { + plugins: [i18n] + } + }) + +describe('CreditTopUpOption', () => { + it('renders credit amount and description', () => { + const wrapper = mountOption({ credits: 5000, description: '~500 videos*' }) + expect(wrapper.text()).toContain('5,000') + expect(wrapper.text()).toContain('~500 videos*') + }) + + it('applies unselected styling when not selected', () => { + const wrapper = mountOption({ selected: false }) + expect(wrapper.find('div').classes()).toContain( + 'bg-component-node-disabled' + ) + expect(wrapper.find('div').classes()).toContain('border-transparent') + }) + + it('emits select event when clicked', async () => { + const wrapper = mountOption() + await wrapper.find('div').trigger('click') + expect(wrapper.emitted('select')).toHaveLength(1) + }) +}) diff --git a/src/components/dialog/content/credit/CreditTopUpOption.vue b/src/components/dialog/content/credit/CreditTopUpOption.vue index 402df3ac04..c67c273a27 100644 --- a/src/components/dialog/content/credit/CreditTopUpOption.vue +++ b/src/components/dialog/content/credit/CreditTopUpOption.vue @@ -1,76 +1,45 @@ diff --git a/src/components/dialog/content/error/FindIssueButton.vue b/src/components/dialog/content/error/FindIssueButton.vue index e4c32b4713..767202251f 100644 --- a/src/components/dialog/content/error/FindIssueButton.vue +++ b/src/components/dialog/content/error/FindIssueButton.vue @@ -1,16 +1,16 @@ diff --git a/src/components/dialog/header/ComfyOrgHeader.vue b/src/components/dialog/header/ComfyOrgHeader.vue index 8a8a0afa2c..d4fc3a4589 100644 --- a/src/components/dialog/header/ComfyOrgHeader.vue +++ b/src/components/dialog/header/ComfyOrgHeader.vue @@ -3,7 +3,7 @@
ComfyOrg Logo diff --git a/src/components/dialog/header/SettingDialogHeader.vue b/src/components/dialog/header/SettingDialogHeader.vue index 66765846ff..959cfa14da 100644 --- a/src/components/dialog/header/SettingDialogHeader.vue +++ b/src/components/dialog/header/SettingDialogHeader.vue @@ -15,9 +15,7 @@ diff --git a/src/components/graph/selectionToolbox/BypassButton.test.ts b/src/components/graph/selectionToolbox/BypassButton.test.ts index c966e180a0..9fdcd971f8 100644 --- a/src/components/graph/selectionToolbox/BypassButton.test.ts +++ b/src/components/graph/selectionToolbox/BypassButton.test.ts @@ -84,18 +84,6 @@ 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-[#262729]' - ) - }) - it('should show bypassed styling when node is bypassed', async () => { const bypassedNode = { ...mockLGraphNode, mode: LGraphEventMode.BYPASS } canvasStore.selectedItems = [bypassedNode] as any diff --git a/src/components/graph/selectionToolbox/BypassButton.vue b/src/components/graph/selectionToolbox/BypassButton.vue index 84882a2858..f9fc191878 100644 --- a/src/components/graph/selectionToolbox/BypassButton.vue +++ b/src/components/graph/selectionToolbox/BypassButton.vue @@ -1,28 +1,23 @@ diff --git a/src/components/graph/selectionToolbox/ConfigureSubgraph.vue b/src/components/graph/selectionToolbox/ConfigureSubgraph.vue index 5c361aa73e..5535d7727d 100644 --- a/src/components/graph/selectionToolbox/ConfigureSubgraph.vue +++ b/src/components/graph/selectionToolbox/ConfigureSubgraph.vue @@ -1,17 +1,23 @@ diff --git a/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue b/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue index 21779c35e7..7352b54cf5 100644 --- a/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue +++ b/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue @@ -2,44 +2,39 @@ diff --git a/src/components/graph/selectionToolbox/Load3DViewerButton.vue b/src/components/graph/selectionToolbox/Load3DViewerButton.vue index 5187f0c023..22cc2d409c 100644 --- a/src/components/graph/selectionToolbox/Load3DViewerButton.vue +++ b/src/components/graph/selectionToolbox/Load3DViewerButton.vue @@ -1,21 +1,19 @@ diff --git a/src/components/graph/selectionToolbox/NodeOptions.vue b/src/components/graph/selectionToolbox/NodeOptions.vue deleted file mode 100644 index 9965ffa6d4..0000000000 --- a/src/components/graph/selectionToolbox/NodeOptions.vue +++ /dev/null @@ -1,324 +0,0 @@ - - - diff --git a/src/components/graph/selectionToolbox/NodeOptionsButton.vue b/src/components/graph/selectionToolbox/NodeOptionsButton.vue index 2b4e613c24..df70ad4906 100644 --- a/src/components/graph/selectionToolbox/NodeOptionsButton.vue +++ b/src/components/graph/selectionToolbox/NodeOptionsButton.vue @@ -1,33 +1,23 @@ diff --git a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue index ec0f44b2f8..5edebc9e1b 100644 --- a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue +++ b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue @@ -2,19 +2,19 @@ diff --git a/src/components/graph/widgets/DomWidget.vue b/src/components/graph/widgets/DomWidget.vue index e28920018b..f20aac3709 100644 --- a/src/components/graph/widgets/DomWidget.vue +++ b/src/components/graph/widgets/DomWidget.vue @@ -143,8 +143,8 @@ onMounted(() => { widget.options.selectOn ?? ['focus', 'click'], () => { const lgCanvas = canvasStore.canvas - lgCanvas?.selectNode(widget.node) - lgCanvas?.bringToFront(widget.node) + lgCanvas?.selectNode(widgetState.widget.node) + lgCanvas?.bringToFront(widgetState.widget.node) } ) }) diff --git a/src/components/graph/widgets/MultiSelectWidget.vue b/src/components/graph/widgets/MultiSelectWidget.vue index 6117433851..14936fca37 100644 --- a/src/components/graph/widgets/MultiSelectWidget.vue +++ b/src/components/graph/widgets/MultiSelectWidget.vue @@ -8,6 +8,9 @@ :max-selected-labels="3" :display="display" class="w-full" + :pt="{ + dropdownIcon: 'text-button-icon' + }" />
diff --git a/src/components/graph/widgets/TextPreviewWidget.vue b/src/components/graph/widgets/TextPreviewWidget.vue index 33ab748748..9e1709f2ea 100644 --- a/src/components/graph/widgets/TextPreviewWidget.vue +++ b/src/components/graph/widgets/TextPreviewWidget.vue @@ -21,13 +21,38 @@ import { linkifyHtml, nl2br } from '@/utils/formatUtil' const modelValue = defineModel({ required: true }) const props = defineProps<{ - widget?: object nodeId: NodeId }>() const executionStore = useExecutionStore() const isParentNodeExecuting = ref(true) -const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value))) +const formattedText = computed(() => { + const src = modelValue.value + // Turn [[label|url]] into placeholders to avoid interfering with linkifyHtml + const tokens: { label: string; url: string }[] = [] + const holed = src.replace( + /\[\[([^|\]]+)\|([^\]]+)\]\]/g, + (_m, label, url) => { + tokens.push({ label: String(label), url: String(url) }) + return `__LNK${tokens.length - 1}__` + } + ) + + // Keep current behavior (auto-link bare URLs + \n ->
) + let html = nl2br(linkifyHtml(holed)) + + // Restore placeholders as ... (minimal escaping + http default) + html = html.replace(/__LNK(\d+)__/g, (_m, i) => { + const { label, url } = tokens[+i] + const safeHref = url.replace(/"/g, '"') + const safeLabel = label.replace(//g, '>') + return /^https?:\/\//i.test(url) + ? `${safeLabel}` + : safeLabel + }) + + return html +}) let parentNodeId: NodeId | null = null onMounted(() => { diff --git a/src/components/graph/widgets/chatHistory/CopyButton.vue b/src/components/graph/widgets/chatHistory/CopyButton.vue deleted file mode 100644 index 933a1b921d..0000000000 --- a/src/components/graph/widgets/chatHistory/CopyButton.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/src/components/graph/widgets/chatHistory/ResponseBlurb.vue b/src/components/graph/widgets/chatHistory/ResponseBlurb.vue deleted file mode 100644 index e2326c4318..0000000000 --- a/src/components/graph/widgets/chatHistory/ResponseBlurb.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/src/components/helpcenter/HelpCenterMenuContent.vue b/src/components/helpcenter/HelpCenterMenuContent.vue index bfa1cbc963..d912a29417 100644 --- a/src/components/helpcenter/HelpCenterMenuContent.vue +++ b/src/components/helpcenter/HelpCenterMenuContent.vue @@ -1,34 +1,52 @@ diff --git a/src/components/honeyToast/HoneyToast.stories.ts b/src/components/honeyToast/HoneyToast.stories.ts new file mode 100644 index 0000000000..74331d49f9 --- /dev/null +++ b/src/components/honeyToast/HoneyToast.stories.ts @@ -0,0 +1,293 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import ProgressToastItem from '@/components/toast/ProgressToastItem.vue' +import Button from '@/components/ui/button/Button.vue' +import type { AssetDownload } from '@/stores/assetDownloadStore' +import { cn } from '@/utils/tailwindUtil' + +import HoneyToast from './HoneyToast.vue' + +function createMockJob(overrides: Partial = {}): AssetDownload { + return { + taskId: 'task-1', + assetId: 'asset-1', + assetName: 'model-v1.safetensors', + bytesTotal: 1000000, + bytesDownloaded: 0, + progress: 0, + status: 'created', + lastUpdate: Date.now(), + ...overrides + } +} + +const meta: Meta = { + title: 'Toast/HoneyToast', + component: HoneyToast, + parameters: { + layout: 'fullscreen' + }, + decorators: [ + () => ({ + template: '
' + }) + ] +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { HoneyToast, Button, ProgressToastItem }, + setup() { + const isExpanded = ref(false) + const jobs = [ + createMockJob({ + taskId: 'task-1', + assetName: 'model-v1.safetensors', + status: 'completed', + progress: 1 + }), + createMockJob({ + taskId: 'task-2', + assetName: 'lora-style.safetensors', + status: 'running', + progress: 0.45 + }), + createMockJob({ + taskId: 'task-3', + assetName: 'vae-decoder.safetensors', + status: 'created' + }) + ] + return { isExpanded, cn, jobs } + }, + template: ` + + + + + + ` + }) +} + +export const Expanded: Story = { + render: () => ({ + components: { HoneyToast, Button, ProgressToastItem }, + setup() { + const isExpanded = ref(true) + const jobs = [ + createMockJob({ + taskId: 'task-1', + assetName: 'model-v1.safetensors', + status: 'completed', + progress: 1 + }), + createMockJob({ + taskId: 'task-2', + assetName: 'lora-style.safetensors', + status: 'running', + progress: 0.45 + }), + createMockJob({ + taskId: 'task-3', + assetName: 'vae-decoder.safetensors', + status: 'created' + }) + ] + return { isExpanded, cn, jobs } + }, + template: ` + + + + + + ` + }) +} + +export const Completed: Story = { + render: () => ({ + components: { HoneyToast, Button, ProgressToastItem }, + setup() { + const isExpanded = ref(false) + const jobs = [ + createMockJob({ + taskId: 'task-1', + assetName: 'model-v1.safetensors', + bytesDownloaded: 1000000, + progress: 1, + status: 'completed' + }), + createMockJob({ + taskId: 'task-2', + assetId: 'asset-2', + assetName: 'lora-style.safetensors', + bytesTotal: 500000, + bytesDownloaded: 500000, + progress: 1, + status: 'completed' + }) + ] + return { isExpanded, cn, jobs } + }, + template: ` + + + + + + ` + }) +} + +export const WithError: Story = { + render: () => ({ + components: { HoneyToast, Button, ProgressToastItem }, + setup() { + const isExpanded = ref(true) + const jobs = [ + createMockJob({ + taskId: 'task-1', + assetName: 'model-v1.safetensors', + status: 'failed', + progress: 0.23 + }), + createMockJob({ + taskId: 'task-2', + assetName: 'lora-style.safetensors', + status: 'completed', + progress: 1 + }) + ] + return { isExpanded, cn, jobs } + }, + template: ` + + + + + + ` + }) +} + +export const Hidden: Story = { + render: () => ({ + components: { HoneyToast }, + template: ` +
+

HoneyToast is hidden when visible=false. Nothing appears at the bottom.

+ + + + + +
+ ` + }) +} diff --git a/src/components/honeyToast/HoneyToast.test.ts b/src/components/honeyToast/HoneyToast.test.ts new file mode 100644 index 0000000000..ada1230531 --- /dev/null +++ b/src/components/honeyToast/HoneyToast.test.ts @@ -0,0 +1,137 @@ +import type { VueWrapper } from '@vue/test-utils' +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, nextTick, ref } from 'vue' + +import HoneyToast from './HoneyToast.vue' + +describe('HoneyToast', () => { + beforeEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '' + }) + + function mountComponent( + props: { visible: boolean; expanded?: boolean } = { visible: true } + ): VueWrapper { + return mount(HoneyToast, { + props, + slots: { + default: (slotProps: { isExpanded: boolean }) => + h( + 'div', + { 'data-testid': 'content' }, + slotProps.isExpanded ? 'expanded' : 'collapsed' + ), + footer: (slotProps: { isExpanded: boolean; toggle: () => void }) => + h( + 'button', + { + 'data-testid': 'toggle-btn', + onClick: slotProps.toggle + }, + slotProps.isExpanded ? 'Collapse' : 'Expand' + ) + }, + attachTo: document.body + }) + } + + it('renders when visible is true', async () => { + const wrapper = mountComponent({ visible: true }) + await nextTick() + + const toast = document.body.querySelector('[role="status"]') + expect(toast).toBeTruthy() + + wrapper.unmount() + }) + + it('does not render when visible is false', async () => { + const wrapper = mountComponent({ visible: false }) + await nextTick() + + const toast = document.body.querySelector('[role="status"]') + expect(toast).toBeFalsy() + + wrapper.unmount() + }) + + it('passes is-expanded=false to slots by default', async () => { + const wrapper = mountComponent({ visible: true }) + await nextTick() + + const content = document.body.querySelector('[data-testid="content"]') + expect(content?.textContent).toBe('collapsed') + + wrapper.unmount() + }) + + it('applies collapsed max-height class when collapsed', async () => { + const wrapper = mountComponent({ visible: true, expanded: false }) + await nextTick() + + const expandableArea = document.body.querySelector( + '[role="status"] > div:first-child' + ) + expect(expandableArea?.classList.contains('max-h-0')).toBe(true) + + wrapper.unmount() + }) + + it('has aria-live="polite" for accessibility', async () => { + const wrapper = mountComponent({ visible: true }) + await nextTick() + + const toast = document.body.querySelector('[role="status"]') + expect(toast?.getAttribute('aria-live')).toBe('polite') + + wrapper.unmount() + }) + + it('supports v-model:expanded with reactive parent state', async () => { + const TestWrapper = defineComponent({ + components: { HoneyToast }, + setup() { + const expanded = ref(false) + return { expanded } + }, + template: ` + + + + + ` + }) + + const wrapper = mount(TestWrapper, { attachTo: document.body }) + await nextTick() + + const content = document.body.querySelector('[data-testid="content"]') + expect(content?.textContent).toBe('collapsed') + + const toggleBtn = document.body.querySelector( + '[data-testid="toggle-btn"]' + ) as HTMLButtonElement + expect(toggleBtn?.textContent?.trim()).toBe('Expand') + + toggleBtn?.click() + await nextTick() + + expect(content?.textContent).toBe('expanded') + expect(toggleBtn?.textContent?.trim()).toBe('Collapse') + + const expandableArea = document.body.querySelector( + '[role="status"] > div:first-child' + ) + expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true) + + wrapper.unmount() + }) +}) diff --git a/src/components/honeyToast/HoneyToast.vue b/src/components/honeyToast/HoneyToast.vue new file mode 100644 index 0000000000..a7d86ba77e --- /dev/null +++ b/src/components/honeyToast/HoneyToast.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/components/icons/ComfyLogo.vue b/src/components/icons/ComfyLogo.vue new file mode 100644 index 0000000000..493e2275de --- /dev/null +++ b/src/components/icons/ComfyLogo.vue @@ -0,0 +1,37 @@ + + + diff --git a/src/components/input/MultiSelect.accessibility.stories.ts b/src/components/input/MultiSelect.accessibility.stories.ts deleted file mode 100644 index 716bfab0fc..0000000000 --- a/src/components/input/MultiSelect.accessibility.stories.ts +++ /dev/null @@ -1,380 +0,0 @@ -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 { - // 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 = { - 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 - -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([]) - 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: ` -
-
-

🎯 Keyboard Navigation Test

-

- Use your keyboard to navigate this MultiSelect: -

-
    -
  1. Tab to focus the dropdown
  2. -
  3. Enter/Space to open dropdown
  4. -
  5. Arrow Up/Down to navigate options
  6. -
  7. Enter/Space to select options
  8. -
  9. Escape to close dropdown
  10. -
-
- -
- - -

- Selected: {{ selectedFrameworks.map(f => f.name).join(', ') || 'None' }} -

-
-
- ` - }), - args: { - label: 'Choose Frameworks', - showSearchBox: true, - showSelectedCount: true, - showClearButton: true - } -} - -export const ScreenReaderFriendly: Story = { - render: (args) => ({ - components: { MultiSelect }, - setup() { - const selectedColors = ref([]) - const selectedSizes = ref([]) - - 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: ` -
-
-

♿ Screen Reader Test

-

- These dropdowns have proper ARIA attributes and labels for screen readers: -

-
    -
  • role="combobox" identifies as dropdown
  • -
  • aria-haspopup="listbox" indicates popup type
  • -
  • aria-expanded shows open/closed state
  • -
  • aria-label provides accessible name
  • -
  • Selection count announced to assistive technology
  • -
-
- -
-
- - -

- {{ selectedColors.length }} color(s) selected -

-
- -
- - -

- {{ selectedSizes.length }} size(s) selected -

-
-
-
- ` - }) -} - -export const FocusManagement: Story = { - render: (args) => ({ - components: { MultiSelect }, - setup() { - const selectedItems = ref([]) - const focusTestOptions = [ - { name: 'Option A', value: 'a' }, - { name: 'Option B', value: 'b' }, - { name: 'Option C', value: 'c' } - ] - - return { - selectedItems, - focusTestOptions, - args - } - }, - template: ` -
-
-

🎯 Focus Management Test

-

- Test focus behavior with multiple form elements: -

-
- -
-
- - -
- -
- - -
- -
- - -
- - -
- -
- Test: Tab through all elements and verify focus rings are visible and logical. -
-
- ` - }) -} - -export const AccessibilityChecklist: Story = { - render: () => ({ - template: ` -
-
-

♿ MultiSelect Accessibility Checklist

- -
-
-

✅ Implemented Features

-
    -
  • - - Keyboard Navigation: Tab, Enter, Space, Arrow keys, Escape -
  • -
  • - - ARIA Attributes: role, aria-haspopup, aria-expanded, aria-label -
  • -
  • - - Focus Management: Visible focus rings and logical tab order -
  • -
  • - - Internationalization: Translatable aria-label fallbacks -
  • -
  • - - Screen Reader Support: Proper announcements and state -
  • -
  • - - Color Contrast: Meets WCAG AA requirements -
  • -
-
- -
-

📋 Testing Guidelines

-
    -
  1. Keyboard Only: Navigate using only keyboard
  2. -
  3. Screen Reader: Test with NVDA, JAWS, or VoiceOver
  4. -
  5. Focus Visible: Ensure focus rings are always visible
  6. -
  7. Tab Order: Verify logical progression
  8. -
  9. Announcements: Check state changes are announced
  10. -
  11. Escape Behavior: Escape always closes dropdown
  12. -
-
-
- -
-

🎯 Quick Test

-

- 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! -

-
-
-
- ` - }) -} diff --git a/src/components/input/MultiSelect.stories.ts b/src/components/input/MultiSelect.stories.ts index e6b5d91443..d66a70653d 100644 --- a/src/components/input/MultiSelect.stories.ts +++ b/src/components/input/MultiSelect.stories.ts @@ -102,7 +102,7 @@ export const Default: Story = { :showClearButton="args.showClearButton" :searchPlaceholder="args.searchPlaceholder" /> -
+

Selected: {{ selected.length > 0 ? selected.map(s => s.name).join(', ') : 'None' }}

@@ -135,7 +135,7 @@ export const WithPreselectedValues: Story = { :showClearButton="args.showClearButton" :searchPlaceholder="args.searchPlaceholder" /> -
+

Selected: {{ selected.map(s => s.name).join(', ') }}

@@ -229,7 +229,7 @@ export const MultipleSelectors: Story = { /> -
+

Current Selection:

Frameworks: {{ selectedFrameworks.length > 0 ? selectedFrameworks.map(s => s.name).join(', ') : 'None' }}

diff --git a/src/components/input/MultiSelect.vue b/src/components/input/MultiSelect.vue index 1469d0f869..21ba0d6a2e 100644 --- a/src/components/input/MultiSelect.vue +++ b/src/components/input/MultiSelect.vue @@ -13,7 +13,72 @@ option-label="name" unstyled :max-selected-labels="0" - :pt="pt" + :pt="{ + root: ({ props }: MultiSelectPassThroughMethodOptions) => ({ + class: cn( + 'h-10 relative inline-flex cursor-pointer select-none', + 'rounded-lg bg-secondary-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 cursor-pointer', + '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' } + } + }" :aria-label="label || t('g.multiSelectDropdown')" role="combobox" :aria-expanded="false" @@ -39,7 +104,7 @@ > {{ selectedCount > 0 @@ -47,27 +112,27 @@ : $t('g.itemSelected', { selectedCount }) }} - + > + {{ $t('g.clearAll') }} +
-
+