diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index 6f43623a3..000000000 --- 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/src/components/MenuHamburger.vue b/src/components/MenuHamburger.vue index 467561b54..fa99e032c 100644 --- a/src/components/MenuHamburger.vue +++ b/src/components/MenuHamburger.vue @@ -1,27 +1,27 @@ - - diff --git a/tests-ui/tests/components/TopMenuSection.test.ts b/src/components/TopMenuSection.test.ts similarity index 83% rename from tests-ui/tests/components/TopMenuSection.test.ts rename to src/components/TopMenuSection.test.ts index 5d92a0cc9..b4d11efea 100644 --- a/tests-ui/tests/components/TopMenuSection.test.ts +++ b/src/components/TopMenuSection.test.ts @@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing' import { mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { computed } from 'vue' +import { createI18n } from 'vue-i18n' import TopMenuSection from '@/components/TopMenuSection.vue' import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue' @@ -27,13 +28,32 @@ vi.mock('@/stores/firebaseAuthStore', () => ({ })) function createWrapper() { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + sideToolbar: { + queueProgressOverlay: { + viewJobHistory: 'View job history', + expandCollapsedQueue: 'Expand collapsed queue' + } + } + } + } + }) + return mount(TopMenuSection, { global: { - plugins: [createTestingPinia({ createSpy: vi.fn })], + plugins: [createTestingPinia({ createSpy: vi.fn }), i18n], stubs: { SubgraphBreadcrumb: true, + QueueProgressOverlay: true, CurrentUserButton: true, LoginButton: true + }, + directives: { + tooltip: () => {} } } }) diff --git a/src/components/TopMenuSection.vue b/src/components/TopMenuSection.vue index d170305a4..28f2411ea 100644 --- a/src/components/TopMenuSection.vue +++ b/src/components/TopMenuSection.vue @@ -1,41 +1,155 @@ - + +const openCustomNodeManager = async () => { + try { + await managerState.openManager({ + initialTab: ManagerTab.All, + showToastOnLegacyError: false + }) + } catch (error) { + try { + toastErrorHandler(error) + } catch (toastError) { + console.error(error) + console.error(toastError) + } + } +} + diff --git a/src/components/actionbar/ComfyActionbar.vue b/src/components/actionbar/ComfyActionbar.vue index dbcc492ee..1d24e4838 100644 --- a/src/components/actionbar/ComfyActionbar.vue +++ b/src/components/actionbar/ComfyActionbar.vue @@ -10,7 +10,7 @@ -
+
- - + + + +
@@ -43,17 +54,25 @@ import { watchDebounced } from '@vueuse/core' import { clamp } from 'es-toolkit/compat' +import { storeToRefs } from 'pinia' import Panel from 'primevue/panel' -import { computed, nextTick, onMounted, ref, watch } from 'vue' +import { computed, nextTick, ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' -import { t } from '@/i18n' +import Button from '@/components/ui/button/Button.vue' +import { buildTooltipConfig } from '@/composables/useTooltipConfig' import { useSettingStore } from '@/platform/settings/settingStore' import { useTelemetry } from '@/platform/telemetry' +import { useCommandStore } from '@/stores/commandStore' +import { useExecutionStore } from '@/stores/executionStore' import { cn } from '@/utils/tailwindUtil' import ComfyRunButton from './ComfyRunButton' const settingsStore = useSettingStore() +const commandStore = useCommandStore() +const { t } = useI18n() +const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore()) const position = computed(() => settingsStore.get('Comfy.UseNewMenu')) const visible = computed(() => position.value !== 'Disabled') @@ -66,12 +85,7 @@ const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', { x: 0, y: 0 }) -const { - x, - y, - style: style, - isDragging -} = useDraggable(panelRef, { +const { x, y, style, isDragging } = useDraggable(panelRef, { initialValue: { x: 0, y: 0 }, handle: dragHandleRef, containerElement: document.body, @@ -126,7 +140,14 @@ const setInitialPosition = () => { } } } -onMounted(setInitialPosition) + +//The ComfyRunButton is a dynamic import. Which means it will not be loaded onMount in this component. +//So we must use suspense resolve to ensure that is has loaded and updated the DOM before calling setInitialPosition() +async function comfyRunButtonResolved() { + await nextTick() + setInitialPosition() +} + watch(visible, async (newVisible) => { if (newVisible) { await nextTick(setInitialPosition) @@ -255,21 +276,32 @@ watch(isDragging, (dragging) => { isMouseOverDropZone.value = false } }) + +const cancelJobTooltipConfig = computed(() => + buildTooltipConfig(t('menu.interrupt')) +) + +const cancelCurrentJob = async () => { + if (isExecutionIdle.value) return + await commandStore.execute('Comfy.Interrupt') +} + const actionbarClass = computed(() => cn( - 'w-[265px] border-dashed border-blue-500 opacity-80', + 'w-[200px] border-dashed border-blue-500 opacity-80', 'm-1.5 flex items-center justify-center self-stretch', 'rounded-md before:w-50 before:-ml-50 before:h-full', + 'pointer-events-auto', isMouseOverDropZone.value && 'border-[3px] opacity-100 scale-105 shadow-[0_0_20px] shadow-blue-500' ) ) const panelClass = computed(() => cn( - 'actionbar pointer-events-auto z1000', + 'actionbar pointer-events-auto z-1300', isDragging.value && 'select-none pointer-events-none', isDocked.value - ? 'p-0 static mr-2 border-none bg-transparent' + ? 'p-0 static border-none bg-transparent' : 'fixed shadow-interface' ) ) diff --git a/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue b/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue index 8907ecf44..4c0ea84e4 100644 --- a/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue +++ b/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue @@ -2,9 +2,7 @@
- -
- - 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 e632d0d47..655df0b65 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 000000000..46ef7ac79 --- /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 1d3612aa2..5d3cd8965 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 c6a3dbe60..828d8ff45 100644 --- a/src/components/common/TreeExplorer.vue +++ b/src/components/common/TreeExplorer.vue @@ -2,7 +2,7 @@
diff --git a/src/components/common/UserCredit.test.ts b/src/components/common/UserCredit.test.ts new file mode 100644 index 000000000..7fcb42f49 --- /dev/null +++ b/src/components/common/UserCredit.test.ts @@ -0,0 +1,134 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import enMessages from '@/locales/en/main.json' with { type: 'json' } + +import UserCredit from './UserCredit.vue' + +vi.mock('firebase/app', () => ({ + initializeApp: vi.fn(), + getApp: vi.fn() +})) + +vi.mock('firebase/auth', () => ({ + getAuth: vi.fn(), + setPersistence: vi.fn(), + browserLocalPersistence: {}, + onAuthStateChanged: vi.fn(), + signInWithEmailAndPassword: vi.fn(), + signOut: vi.fn() +})) + +vi.mock('pinia') + +const mockBalance = vi.hoisted(() => ({ + value: { + amount_micros: 100_000, + effective_balance_micros: 100_000, + currency: 'usd' + } +})) + +const mockIsFetchingBalance = vi.hoisted(() => ({ value: false })) + +vi.mock('@/stores/firebaseAuthStore', () => ({ + useFirebaseAuthStore: vi.fn(() => ({ + balance: mockBalance.value, + isFetchingBalance: mockIsFetchingBalance.value + })) +})) + +describe('UserCredit', () => { + beforeEach(() => { + vi.clearAllMocks() + mockBalance.value = { + amount_micros: 100_000, + effective_balance_micros: 100_000, + currency: 'usd' + } + mockIsFetchingBalance.value = false + }) + + const mountComponent = (props = {}) => { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: enMessages } + }) + + return mount(UserCredit, { + props, + global: { + plugins: [i18n], + stubs: { + Skeleton: true, + Tag: true + } + } + }) + } + + describe('effective_balance_micros handling', () => { + it('uses effective_balance_micros when present (positive balance)', () => { + mockBalance.value = { + amount_micros: 200_000, + effective_balance_micros: 150_000, + currency: 'usd' + } + + const wrapper = mountComponent() + expect(wrapper.text()).toContain('Credits') + }) + + it('uses effective_balance_micros when zero', () => { + mockBalance.value = { + amount_micros: 100_000, + effective_balance_micros: 0, + currency: 'usd' + } + + const wrapper = mountComponent() + expect(wrapper.text()).toContain('0') + }) + + it('uses effective_balance_micros when negative', () => { + mockBalance.value = { + amount_micros: 0, + effective_balance_micros: -50_000, + currency: 'usd' + } + + const wrapper = mountComponent() + expect(wrapper.text()).toContain('-') + }) + + it('falls back to amount_micros when effective_balance_micros is missing', () => { + mockBalance.value = { + amount_micros: 100_000, + currency: 'usd' + } as typeof mockBalance.value + + const wrapper = mountComponent() + expect(wrapper.text()).toContain('Credits') + }) + + it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => { + mockBalance.value = { + currency: 'usd' + } as typeof mockBalance.value + + const wrapper = mountComponent() + expect(wrapper.text()).toContain('0') + }) + }) + + describe('loading state', () => { + it('shows skeleton when loading', () => { + mockIsFetchingBalance.value = true + + const wrapper = mountComponent() + expect(wrapper.findComponent({ name: 'Skeleton' }).exists()).toBe(true) + }) + }) +}) diff --git a/src/components/common/UserCredit.vue b/src/components/common/UserCredit.vue index aac405b90..987406ede 100644 --- a/src/components/common/UserCredit.vue +++ b/src/components/common/UserCredit.vue @@ -8,12 +8,18 @@
-
{{ formattedBalance }}
+ > + +
+
+ {{ showCreditsOnly ? formattedCreditsOnly : formattedBalance }} +
@@ -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 89de6e421..134778778 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 84e50bf62..aeb98971b 100644 --- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue +++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue @@ -22,16 +22,17 @@ @@ -92,7 +93,7 @@ class="w-62.5" > @@ -174,6 +175,7 @@ - - + @@ -364,10 +366,7 @@ -
+
{{ $t('templateWorkflows.resultsCount', { count: filteredCount, @@ -385,19 +384,18 @@ import ProgressSpinner from 'primevue/progressspinner' import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' -import IconButton from '@/components/button/IconButton.vue' -import IconTextButton from '@/components/button/IconTextButton.vue' import CardBottom from '@/components/card/CardBottom.vue' import CardContainer from '@/components/card/CardContainer.vue' import CardTop from '@/components/card/CardTop.vue' import SquareChip from '@/components/chip/SquareChip.vue' +import SearchBox from '@/components/common/SearchBox.vue' import MultiSelect from '@/components/input/MultiSelect.vue' -import SearchBox from '@/components/input/SearchBox.vue' import SingleSelect from '@/components/input/SingleSelect.vue' import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue' import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue' import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue' import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue' +import Button from '@/components/ui/button/Button.vue' import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue' import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue' import { useIntersectionObserver } from '@/composables/useIntersectionObserver' @@ -408,6 +406,8 @@ import { useTelemetry } from '@/platform/telemetry' import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows' import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore' import type { TemplateInfo } from '@/platform/workflow/templates/types/template' +import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template' +import { useSystemStatsStore } from '@/stores/systemStatsStore' import type { NavGroupData, NavItemData } from '@/types/navTypes' import { OnCloseKey } from '@/types/widgetTypes' import { createGridStyle } from '@/utils/gridUtil' @@ -426,6 +426,30 @@ onMounted(() => { sessionStartTime.value = Date.now() }) +const systemStatsStore = useSystemStatsStore() + +const distributions = computed(() => { + // eslint-disable-next-line no-undef + switch (__DISTRIBUTION__) { + case 'cloud': + return [TemplateIncludeOnDistributionEnum.Cloud] + case 'localhost': + return [TemplateIncludeOnDistributionEnum.Local] + case 'desktop': + default: + if (systemStatsStore.systemStats?.system.os === 'darwin') { + return [ + TemplateIncludeOnDistributionEnum.Desktop, + TemplateIncludeOnDistributionEnum.Mac + ] + } + return [ + TemplateIncludeOnDistributionEnum.Desktop, + TemplateIncludeOnDistributionEnum.Windows + ] + } +}) + // Wrap onClose to track session end const onClose = () => { if (isCloud) { @@ -514,6 +538,9 @@ const allTemplates = computed(() => { return workflowTemplatesStore.enhancedTemplates }) +// Navigation +const selectedNavItem = ref('all') + // Filter templates based on selected navigation item const navigationFilteredTemplates = computed(() => { if (!selectedNavItem.value) { @@ -536,9 +563,40 @@ const { availableRunsOn, filteredCount, totalCount, - resetFilters + resetFilters, + loadFuseOptions } = useTemplateFiltering(navigationFilteredTemplates) +/** + * Coordinates state between the selected navigation item and the sort order to + * create deterministic, predictable behavior. + * @param source The origin of the change ('nav' or 'sort'). + */ +const coordinateNavAndSort = (source: 'nav' | 'sort') => { + const isPopularNav = selectedNavItem.value === 'popular' + const isPopularSort = sortBy.value === 'popular' + + if (source === 'nav') { + if (isPopularNav && !isPopularSort) { + // When navigating to 'Popular' category, automatically set sort to 'Popular'. + sortBy.value = 'popular' + } else if (!isPopularNav && isPopularSort) { + // When navigating away from 'Popular' category while sort is 'Popular', reset sort to default. + sortBy.value = 'default' + } + } else if (source === 'sort') { + // When sort is changed away from 'Popular' while in the 'Popular' category, + // reset the category to 'All Templates' to avoid a confusing state. + if (isPopularNav && !isPopularSort) { + selectedNavItem.value = 'all' + } + } +} + +// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator. +watch(selectedNavItem, () => coordinateNavAndSort('nav')) +watch(sortBy, () => coordinateNavAndSort('sort')) + // Convert between string array and object array for MultiSelect component const selectedModelObjects = computed({ get() { @@ -581,9 +639,6 @@ const cardRefs = ref([]) // Force re-render key for templates when sorting changes const templateListKey = ref(0) -// Navigation -const selectedNavItem = ref('all') - // Search text for model filter const modelSearchText = ref('') @@ -648,11 +703,19 @@ const runsOnFilterLabel = computed(() => { // Sort options const sortOptions = computed(() => [ - { name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' }, { name: t('templateWorkflows.sort.default', 'Default'), value: 'default' }, + { + name: t('templateWorkflows.sort.recommended', 'Recommended'), + value: 'recommended' + }, + { + name: t('templateWorkflows.sort.popular', 'Popular'), + value: 'popular' + }, + { name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' }, { name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'), value: 'vram-low-to-high' @@ -753,10 +816,10 @@ const pageTitle = computed(() => { // Initialize templates loading with useAsyncState const { isLoading } = useAsyncState( async () => { - // Run both operations in parallel for better performance await Promise.all([ loadTemplates(), - workflowTemplatesStore.loadWorkflowTemplates() + workflowTemplatesStore.loadWorkflowTemplates(), + loadFuseOptions() ]) return true }, @@ -766,6 +829,14 @@ const { isLoading } = useAsyncState( } ) +const isTemplateVisibleOnDistribution = (template: TemplateInfo) => { + return (template.includeOnDistributions?.length ?? 0) > 0 + ? distributions.value.some((d) => + template.includeOnDistributions?.includes(d) + ) + : true +} + onBeforeUnmount(() => { cardRefs.value = [] // Release DOM refs }) diff --git a/src/components/dialog/GlobalDialog.vue b/src/components/dialog/GlobalDialog.vue index 4566b0684..2074132b5 100644 --- a/src/components/dialog/GlobalDialog.vue +++ b/src/components/dialog/GlobalDialog.vue @@ -14,6 +14,7 @@

diff --git a/src/components/dialog/confirm/ConfirmBody.vue b/src/components/dialog/confirm/ConfirmBody.vue new file mode 100644 index 000000000..9a1cd5980 --- /dev/null +++ b/src/components/dialog/confirm/ConfirmBody.vue @@ -0,0 +1,19 @@ + + diff --git a/src/components/dialog/confirm/ConfirmFooter.vue b/src/components/dialog/confirm/ConfirmFooter.vue new file mode 100644 index 000000000..9cdd6e37b --- /dev/null +++ b/src/components/dialog/confirm/ConfirmFooter.vue @@ -0,0 +1,40 @@ + + diff --git a/src/components/dialog/confirm/ConfirmHeader.vue b/src/components/dialog/confirm/ConfirmHeader.vue new file mode 100644 index 000000000..3c8337733 --- /dev/null +++ b/src/components/dialog/confirm/ConfirmHeader.vue @@ -0,0 +1,12 @@ + + diff --git a/src/components/dialog/confirm/confirmDialog.ts b/src/components/dialog/confirm/confirmDialog.ts new file mode 100644 index 000000000..c615e6475 --- /dev/null +++ b/src/components/dialog/confirm/confirmDialog.ts @@ -0,0 +1,31 @@ +import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue' +import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue' +import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue' +import { useDialogStore } from '@/stores/dialogStore' +import type { ComponentAttrs } from 'vue-component-type-helpers' + +interface ConfirmDialogOptions { + headerProps?: ComponentAttrs + props?: ComponentAttrs + footerProps?: ComponentAttrs +} + +export function showConfirmDialog(options: ConfirmDialogOptions = {}) { + const dialogStore = useDialogStore() + const { headerProps, props, footerProps } = options + return dialogStore.showDialog({ + headerComponent: ConfirmHeader, + component: ConfirmBody, + footerComponent: ConfirmFooter, + headerProps, + props, + footerProps, + dialogComponentProps: { + pt: { + header: 'py-0! px-0!', + content: 'p-0!', + footer: 'p-0!' + } + } + }) +} diff --git a/src/components/dialog/content/ApiNodesSignInContent.vue b/src/components/dialog/content/ApiNodesSignInContent.vue index 0e12f8a61..41ad903c7 100644 --- a/src/components/dialog/content/ApiNodesSignInContent.vue +++ b/src/components/dialog/content/ApiNodesSignInContent.vue @@ -11,25 +11,29 @@
-
- +

diff --git a/src/components/dialog/content/CloudMissingNodesFooter.vue b/src/components/dialog/content/CloudMissingNodesFooter.vue deleted file mode 100644 index ee00cf54b..000000000 --- a/src/components/dialog/content/CloudMissingNodesFooter.vue +++ /dev/null @@ -1,37 +0,0 @@ - - - diff --git a/src/components/dialog/content/ConfirmationDialogContent.vue b/src/components/dialog/content/ConfirmationDialogContent.vue index ee21d76ce..5dbb6cd9e 100644 --- a/src/components/dialog/content/ConfirmationDialogContent.vue +++ b/src/components/dialog/content/ConfirmationDialogContent.vue @@ -31,69 +31,64 @@ }}
- + - - - diff --git a/src/components/dialog/content/MissingNodesHeader.vue b/src/components/dialog/content/MissingNodesHeader.vue new file mode 100644 index 000000000..1e150c3d3 --- /dev/null +++ b/src/components/dialog/content/MissingNodesHeader.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/components/dialog/content/PromptDialogContent.vue b/src/components/dialog/content/PromptDialogContent.vue index 14e70e861..92c721a24 100644 --- a/src/components/dialog/content/PromptDialogContent.vue +++ b/src/components/dialog/content/PromptDialogContent.vue @@ -4,6 +4,7 @@ diff --git a/src/components/dialog/content/UpdatePasswordContent.vue b/src/components/dialog/content/UpdatePasswordContent.vue index dc116e9c2..ef99a7788 100644 --- a/src/components/dialog/content/UpdatePasswordContent.vue +++ b/src/components/dialog/content/UpdatePasswordContent.vue @@ -7,12 +7,9 @@ - @@ -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 000000000..7faf432e7 --- /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 f134aba5e..c67c273a2 100644 --- a/src/components/dialog/content/credit/CreditTopUpOption.vue +++ b/src/components/dialog/content/credit/CreditTopUpOption.vue @@ -1,81 +1,45 @@ diff --git a/src/components/dialog/content/error/FindIssueButton.vue b/src/components/dialog/content/error/FindIssueButton.vue index 20f4f6489..767202251 100644 --- a/src/components/dialog/content/error/FindIssueButton.vue +++ b/src/components/dialog/content/error/FindIssueButton.vue @@ -1,16 +1,14 @@ diff --git a/src/components/dialog/header/SettingDialogHeader.vue b/src/components/dialog/header/SettingDialogHeader.vue index 66765846f..959cfa14d 100644 --- a/src/components/dialog/header/SettingDialogHeader.vue +++ b/src/components/dialog/header/SettingDialogHeader.vue @@ -15,9 +15,7 @@ diff --git a/src/components/honeyToast/HoneyToast.stories.ts b/src/components/honeyToast/HoneyToast.stories.ts new file mode 100644 index 000000000..74331d49f --- /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 000000000..ada123053 --- /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 000000000..a7d86ba77 --- /dev/null +++ b/src/components/honeyToast/HoneyToast.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/components/input/MultiSelect.accessibility.stories.ts b/src/components/input/MultiSelect.accessibility.stories.ts deleted file mode 100644 index 3decd2a21..000000000 --- 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 e6b5d9144..d66a70653 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 851439a3a..21ba0d6a2 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') }} +
-
+