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/apps/desktop-ui/src/components/install/HardwareOption.stories.ts b/apps/desktop-ui/src/components/install/HardwareOption.stories.ts
index d830af49f..fc0e56713 100644
--- a/apps/desktop-ui/src/components/install/HardwareOption.stories.ts
+++ b/apps/desktop-ui/src/components/install/HardwareOption.stories.ts
@@ -29,7 +29,6 @@ export const AppleMetalSelected: Story = {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
- value: 'mps',
selected: true
}
}
@@ -39,7 +38,6 @@ export const AppleMetalUnselected: Story = {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
- value: 'mps',
selected: false
}
}
@@ -48,7 +46,6 @@ export const CPUOption: Story = {
args: {
placeholderText: 'CPU',
subtitle: 'Subtitle',
- value: 'cpu',
selected: false
}
}
@@ -57,7 +54,6 @@ export const ManualInstall: Story = {
args: {
placeholderText: 'Manual Install',
subtitle: 'Subtitle',
- value: 'unsupported',
selected: false
}
}
@@ -67,7 +63,6 @@ export const NvidiaSelected: Story = {
imagePath: '/assets/images/nvidia-logo-square.jpg',
placeholderText: 'NVIDIA',
subtitle: 'NVIDIA',
- value: 'nvidia',
selected: true
}
}
diff --git a/apps/desktop-ui/src/components/install/HardwareOption.vue b/apps/desktop-ui/src/components/install/HardwareOption.vue
index ae254fd8f..9acc9e79c 100644
--- a/apps/desktop-ui/src/components/install/HardwareOption.vue
+++ b/apps/desktop-ui/src/components/install/HardwareOption.vue
@@ -36,17 +36,13 @@
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 @@
-
-
-
-
+
+ :variant="item.key === queueMode ? 'primary' : 'secondary'"
+ size="sm"
+ class="w-full justify-start"
+ >
+
+ {{ String(item.label ?? '') }}
+
-
-
-
-
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 @@
+
+
+
+ {{ label }}
+
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) }}
-
+
+
-
-
- {{ $t('g.devices') }}
-
-
-
-
-
-
-
-
+
+
+ {{ $t('g.devices') }}
+
+
+
+
+
+
+
+
+
@@ -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 @@
-
-
-
-
-
+
+ {{
+ $t('templateWorkflows.resetFilters', 'Clear Filters')
+ }}
+
@@ -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 @@
+
+
+
+ {{ promptTextReal }}
+
+
+
+
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 @@
+
+
+
+ {{ cancelTextX }}
+
+
+ {{ confirmTextX }}
+
+
+
+
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 @@
+
+
+ {{ title }}
+
+
+
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 @@
-
+
+ {{ t('g.learnMore') }}
+
-
-
+
+ {{ t('g.cancel') }}
+
+
+ {{ t('g.login') }}
+
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 @@
}}
-
-
+
+
+ {{ $t('g.cancel') }}
+
+
+
+ {{ $t('g.confirm') }}
+
+ >
+
+ {{ $t('g.delete') }}
+
+ >
+
+ {{ $t('g.overwrite') }}
+
-
-
+
+
+ {{ $t('g.no') }}
+
+
+
+ {{ $t('g.save') }}
+
+ >
+
+ {{ $t('desktopMenu.reinstall') }}
+
-
+
+
+ {{ $t('g.close') }}
+
-
-
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 @@
+
+
+
+
+
+ {{
+ isCloud
+ ? $t('missingNodes.cloud.title')
+ : $t('missingNodes.oss.title')
+ }}
+
+
+
+
+
+
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 @@
-
+
+ {{ $t('userSettings.updatePassword') }}
+
@@ -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 @@
-
-
-
(customAmount = Number(e.value))"
- @input="(e: InputNumberInputEvent) => (customAmount = Number(e.value))"
- />
- {{ amount }}
+
+
+ {{ formattedCredits }}
+
+
+ {{ description }}
+
-
-
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 @@
-
+
+
+ {{ $t('g.findIssues') }}
+
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: `
+
+
+
+
Download Queue
+
+
+
+
+
+
+
+
+ lora-style.safetensors
+
+
+
+
+
+ `
+ })
+}
+
+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: `
+
+
+
+
Download Queue
+
+
+
+
+
+
+
+
+ lora-style.safetensors
+
+
+
+
+
+ `
+ })
+}
+
+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: `
+
+
+
+
Download Queue
+
+
+
+
+
+
+
+
+ All downloads completed
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+ })
+}
+
+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: `
+
+
+
+
Download Queue
+
+
+
+
+
+
+
+
+ 1 download failed
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+ })
+}
+
+export const Hidden: Story = {
+ render: () => ({
+ components: { HoneyToast },
+ template: `
+
+
HoneyToast is hidden when visible=false. Nothing appears at the bottom.
+
+
+
+ Content
+
+
+ Footer
+
+
+
+ `
+ })
+}
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: `
+
+
+ {{ slotProps.isExpanded ? 'expanded' : 'collapsed' }}
+
+
+
+ {{ slotProps.isExpanded ? 'Collapse' : 'Expand' }}
+
+
+
+ `
+ })
+
+ 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:
-
-
- - Tab to focus the dropdown
- - Enter/Space to open dropdown
- - Arrow Up/Down to navigate options
- - Enter/Space to select options
- - Escape to close dropdown
-
-
-
-
-
-
-
- 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:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Submit Button
-
-
-
-
- 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
-
- - Keyboard Only: Navigate using only keyboard
- - Screen Reader: Test with NVDA, JAWS, or VoiceOver
- - Focus Visible: Ensure focus rings are always visible
- - Tab Order: Verify logical progression
- - Announcements: Check state changes are announced
- - Escape Behavior: Escape always closes dropdown
-
-
-
-
-
-
🎯 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') }}
+
-
+
-
+
{{ label }}
{{ selectedCount }}
@@ -75,30 +140,32 @@
-
+
-
+
-
{{ slotProps.option.name }}
+
+ {{ slotProps.option.name }}
+
@@ -107,17 +174,16 @@
diff --git a/src/components/input/SearchBox.vue b/src/components/input/SearchBox.vue
deleted file mode 100644
index 8cc5e5f36..000000000
--- a/src/components/input/SearchBox.vue
+++ /dev/null
@@ -1,112 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/src/components/input/SingleSelect.accessibility.stories.ts b/src/components/input/SingleSelect.accessibility.stories.ts
deleted file mode 100644
index b671ae59c..000000000
--- a/src/components/input/SingleSelect.accessibility.stories.ts
+++ /dev/null
@@ -1,464 +0,0 @@
-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
= {
- 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
-
-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(null)
- const selectedPriority = ref('medium')
-
- return {
- args,
- selectedSort,
- selectedPriority,
- sortOptions,
- priorityOptions
- }
- },
- template: `
-
-
-
🎯 Keyboard Navigation Test
-
- Use your keyboard to navigate these SingleSelect dropdowns:
-
-
- - Tab to focus the dropdown
- - Enter/Space to open dropdown
- - Arrow Up/Down to navigate options
- - Enter/Space to select option
- - Escape to close dropdown
-
-
-
-
-
-
-
-
- Selected: {{ selectedSort ? sortOptions.find(o => o.value === selectedSort)?.name : 'None' }}
-
-
-
-
-
-
-
-
-
-
-
- Selected: {{ selectedPriority ? priorityOptions.find(o => o.value === selectedPriority)?.name : 'None' }}
-
-
-
-
- `
- })
-}
-
-export const ScreenReaderFriendly: Story = {
- render: (args) => ({
- components: { SingleSelect },
- setup() {
- const selectedLanguage = ref('en')
- const selectedTheme = ref(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: `
-
-
-
♿ 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
- - Selected option value announced to assistive technology
-
-
-
-
-
-
-
-
- Current: {{ selectedLanguage ? languageOptions.find(o => o.value === selectedLanguage)?.name : 'None selected' }}
-
-
-
-
-
-
-
- Current: {{ selectedTheme ? themeOptions.find(o => o.value === selectedTheme)?.name : 'No theme selected' }}
-
-
-
-
-
-
🎧 Screen Reader Testing Tips
-
- - • Listen for role announcements when focusing
- - • Verify dropdown state changes are announced
- - • Check that selected values are spoken clearly
- - • Ensure option navigation is announced
-
-
-
- `
- })
-}
-
-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: `
-
-
-
📝 Form Integration Test
-
- Test keyboard navigation through a complete form with SingleSelect components.
- Tab order should be logical and all elements should be accessible.
-
-
-
-
-
-
-
Current Form Data:
-
{{ JSON.stringify(formData, null, 2) }}
-
-
- `
- })
-}
-
-export const AccessibilityChecklist: Story = {
- render: () => ({
- template: `
-
-
-
♿ SingleSelect 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
-
- -
- ✓
- Form Integration: Works properly in forms with other elements
-
-
-
-
-
-
📋 Testing Guidelines
-
- - Keyboard Only: Navigate using only keyboard
- - Screen Reader: Test with NVDA, JAWS, or VoiceOver
- - Focus Visible: Ensure focus rings are always visible
- - Tab Order: Verify logical progression in forms
- - Announcements: Check state changes are announced
- - Selection: Verify selected value is announced
-
-
-
-
-
-
🎯 Quick Test
-
- 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!
-
-
-
-
-
⚡ Performance Note
-
- 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.
-
-
-
-
- `
- })
-}
diff --git a/src/components/input/SingleSelect.stories.ts b/src/components/input/SingleSelect.stories.ts
index b130780e2..7466804eb 100644
--- a/src/components/input/SingleSelect.stories.ts
+++ b/src/components/input/SingleSelect.stories.ts
@@ -58,7 +58,7 @@ export const Default: Story = {
template: `
-
+
Selected: {{ selected ?? 'None' }}
@@ -81,7 +81,7 @@ export const WithIcon: Story = {
-
diff --git a/src/components/input/SingleSelect.vue b/src/components/input/SingleSelect.vue
index 21814b520..2a3f7cdee 100644
--- a/src/components/input/SingleSelect.vue
+++ b/src/components/input/SingleSelect.vue
@@ -13,7 +13,69 @@
option-label="name"
option-value="value"
unstyled
- :pt="pt"
+ :pt="{
+ root: ({ props }: SelectPassThroughMethodOptions
) => ({
+ class: [
+ // container
+ 'h-10 relative inline-flex cursor-pointer select-none items-center',
+ // trigger surface
+ 'rounded-lg',
+ 'bg-secondary-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) => ({
+ 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'
+ }
+ }"
:aria-label="label || t('g.singleSelectDropdown')"
role="combobox"
:aria-expanded="false"
@@ -22,15 +84,15 @@
>
-
+
{{ getLabel(slotProps.value) }}
-
+
{{ label }}
@@ -38,7 +100,7 @@
-
+
@@ -48,10 +110,7 @@
:style="optionStyle"
>
{{ option.name }}
-
+
@@ -93,7 +152,7 @@ const {
popoverMaxWidth?: string
}>()
-const selectedItem = defineModel({ required: true })
+const selectedItem = defineModel({ required: true })
const { t } = useI18n()
@@ -119,73 +178,4 @@ 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) => ({
- 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) => ({
- 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'
- }
-}))
diff --git a/src/components/load3d/Load3D.vue b/src/components/load3d/Load3D.vue
index a525481d4..f6942e7c3 100644
--- a/src/components/load3d/Load3D.vue
+++ b/src/components/load3d/Load3D.vue
@@ -1,6 +1,6 @@