Style: Design System use across more components (#6705)

## Summary

Only remaining use is in `buttonTypes.ts` which @viva-jinyi is going to
be working on to consolidate our different buttons soon.

## Changes

- **What**: Replace light/dark colors with theme aware design system
tokens.

## Review Focus

Double check the chosen colors for the components

## Screenshots

| Before | After |
| ------ | ----- |
| <img width="607" height="432" alt="image"
src="https://github.com/user-attachments/assets/6c0ee6d6-819f-40b1-b775-f8b25dd18104"
/> | <img width="646" height="488" alt="image"
src="https://github.com/user-attachments/assets/9c8532de-8ac6-4b48-9021-3fd0b3e0bc63"
/> |

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6705-Style-WIP-Design-System-use-across-more-components-2ab6d73d365081619115fc5f87a46341)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Alexander Brown
2025-11-17 12:27:10 -08:00
committed by GitHub
parent 3effe714f3
commit 471ccca1dd
106 changed files with 456 additions and 2135 deletions

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

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

View File

@@ -160,7 +160,6 @@
--subscription-button-gradient: linear-gradient(315deg, rgb(105 230 255 / 0.15) 0%, rgb(99 73 233 / 0.50) 100%), radial-gradient(70.71% 70.71% at 50% 50%, rgb(62 99 222 / 0.15) 0.01%, rgb(66 0 123 / 0.50) 100%), linear-gradient(92deg, #D000FF 0.38%, #B009FE 37.07%, #3E1FFC 65.17%, #009DFF 103.86%), var(--color-button-surface, #2D2E32);
--modal-card-button-surface: var(--color-smoke-300);
/* Code styling colors for help menu*/
--code-text-color: rgb(0 122 255 / 1);
@@ -265,6 +264,13 @@
--palette-interface-panel-selected-surface: color-mix(in srgb, var(--interface-panel-surface) 87.5%, var(--contrast-mix-color));
--palette-interface-button-hover-surface: color-mix(in srgb, var(--interface-panel-surface) 82%, var(--contrast-mix-color));
--modal-card-background: var(--secondary-background);
--modal-card-button-surface: var(--color-smoke-300);
--modal-card-placeholder-background: var(--color-smoke-600);
--modal-card-tag-background: var(--color-smoke-200);
--modal-card-tag-foreground: var(--base-foreground);
--modal-panel-background: var(--color-white);
}
.dark-theme {
@@ -286,8 +292,6 @@
--subscription-button-gradient: linear-gradient(315deg, rgb(105 230 255 / 0.15) 0%, rgb(99 73 233 / 0.50) 100%), radial-gradient(70.71% 70.71% at 50% 50%, rgb(62 99 222 / 0.15) 0.01%, rgb(66 0 123 / 0.50) 100%), linear-gradient(92deg, #D000FF 0.38%, #B009FE 37.07%, #3E1FFC 65.17%, #009DFF 103.86%), var(--color-button-surface, #2D2E32);
--modal-card-button-surface: var(--color-charcoal-300);
--dialog-surface: var(--color-neutral-700);
--interface-menu-component-surface-hovered: var(--color-charcoal-400);
@@ -362,6 +366,14 @@
--component-node-widget-background-selected: var(--color-charcoal-100);
--component-node-widget-background-disabled: var(--color-alpha-charcoal-600-30);
--component-node-widget-background-highlighted: var(--color-graphite-400);
--modal-card-background: var(--secondary-background);
--modal-card-button-surface: var(--color-charcoal-300);
--modal-card-placeholder-background: var(--secondary-background);
--modal-card-tag-background: var(--color-charcoal-200);
--modal-card-tag-foreground: var(--base-foreground);
--modal-panel-background: var(--color-charcoal-600);
}
@theme inline {
@@ -372,7 +384,14 @@
--color-button-surface: var(--button-surface);
--color-button-surface-contrast: var(--button-surface-contrast);
--color-subscription-button-gradient: var(--subscription-button-gradient);
--color-modal-card-background: var(--modal-card-background);
--color-modal-card-button-surface: var(--modal-card-button-surface);
--color-modal-card-placeholder-background: var(--modal-card-placeholder-background);
--color-modal-card-tag-background: var(--modal-card-tag-background);
--color-modal-card-tag-foreground: var(--modal-card-tag-foreground);
--color-modal-panel-background: var(--modal-panel-background);
--color-dialog-surface: var(--dialog-surface);
--color-interface-menu-component-surface-hovered: var(
--interface-menu-component-surface-hovered

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ const baseClasses =
const variantStyles = {
dark: 'bg-zinc-500/40 text-white/90',
light: 'backdrop-blur-[2px] bg-white/50 text-zinc-900 dark-theme:text-white'
light: cn('backdrop-blur-[2px] bg-base-background/50 text-base-foreground')
}
const chipClasses = computed(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1804,13 +1804,6 @@
"Bypass Group Nodes": "Bypass Group Nodes",
"Set Group Nodes to Always": "Set Group Nodes to Always"
},
"chatHistory": {
"cancelEdit": "Cancel",
"editTooltip": "Edit message",
"cancelEditTooltip": "Cancel edit",
"copiedTooltip": "Copied",
"copyTooltip": "Copy message to clipboard"
},
"widgets": {
"selectModel": "Select model",
"uploadSelect": {

View File

@@ -5,8 +5,7 @@
:key="badge.label"
:class="
cn(
'px-2 py-1 rounded text-xs font-medium uppercase tracking-wider text-white',
getBadgeColor(badge.type)
'px-2 py-1 rounded text-xs font-bold uppercase tracking-wider text-modal-card-tag-foreground bg-modal-card-tag-background'
)
"
>
@@ -26,17 +25,4 @@ type AssetBadge = {
defineProps<{
badges: AssetBadge[]
}>()
function getBadgeColor(type: AssetBadge['type']): string {
switch (type) {
case 'type':
return 'bg-azure-600/90 dark-theme:bg-azure-600/80'
case 'base':
return 'bg-jade-600/90 dark-theme:bg-jade-600/80'
case 'size':
return 'bg-ash-500/90 dark-theme:bg-charcoal-700/80'
default:
return 'bg-ash-500/90 dark-theme:bg-charcoal-700/80'
}
}
</script>

View File

@@ -70,7 +70,7 @@ export const Default: Story = {
}
},
template: `
<div class="flex items-center justify-center min-h-screen bg-ash-800 dark-theme:bg-ash-800 p-4">
<div class="flex items-center justify-center min-h-screen bg-ash-800 p-4">
<AssetBrowserModal
:node-type="nodeType"
:input-name="inputName"
@@ -111,7 +111,7 @@ export const SingleAssetType: Story = {
return { ...args, onAssetSelect, onClose, assets: singleTypeAssets }
},
template: `
<div class="flex items-center justify-center min-h-screen bg-ash-800 dark-theme:bg-ash-800 p-4">
<div class="flex items-center justify-center min-h-screen bg-ash-800 p-4">
<AssetBrowserModal
:node-type="nodeType"
:input-name="inputName"
@@ -154,7 +154,7 @@ export const NoLeftPanel: Story = {
return { ...args, onAssetSelect, onClose, assets: mockAssets }
},
template: `
<div class="flex items-center justify-center min-h-screen bg-ash-800 dark-theme:bg-ash-800 p-4">
<div class="flex items-center justify-center min-h-screen bg-ash-800 p-4">
<AssetBrowserModal
:node-type="nodeType"
:input-name="inputName"

View File

@@ -32,8 +32,7 @@ const meta: Meta<typeof AssetCard> = {
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-smoke-900"><story /></div>'
template: '<div class="p-8 bg-base-background"><story /></div>'
})
]
}
@@ -48,8 +47,7 @@ export const Interactive: Story = {
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-smoke-900 max-w-96"><story /></div>'
template: '<div class="p-8 bg-base-background max-w-96"><story /></div>'
})
],
parameters: {
@@ -69,8 +67,7 @@ export const NonInteractive: Story = {
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-smoke-900 max-w-96"><story /></div>'
template: '<div class="p-8 bg-base-background max-w-96"><story /></div>'
})
],
parameters: {
@@ -92,8 +89,7 @@ export const WithPreviewImage: Story = {
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-smoke-900 max-w-96"><story /></div>'
template: '<div class="p-8 bg-base-background max-w-96"><story /></div>'
})
],
parameters: {
@@ -114,8 +110,7 @@ export const FallbackGradient: Story = {
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-smoke-900 max-w-96"><story /></div>'
template: '<div class="p-8 bg-base-background max-w-96"><story /></div>'
})
],
parameters: {
@@ -203,7 +198,7 @@ export const EdgeCases: Story = {
return { edgeCases }
},
template: `
<div class="grid grid-cols-4 gap-6 p-8 bg-gray-50 dark-theme:bg-smoke-900">
<div class="grid grid-cols-4 gap-6 p-8 bg-base-background">
<AssetCard
v-for="asset in edgeCases"
:key="asset.id"

View File

@@ -40,19 +40,14 @@
:class="
cn(
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
'text-ash-500',
'dark-theme:text-slate-100'
'text-muted-foreground'
)
"
>
{{ asset.description }}
</p>
</div>
<div
:class="
cn('flex gap-4 text-xs', 'text-stone-400', 'dark-theme:text-ash-300')
"
>
<div :class="cn('flex gap-4 text-xs text-muted-foreground')">
<span v-if="asset.stats.stars" class="flex items-center gap-1">
<i class="icon-[lucide--star] size-3" />
{{ asset.stats.stars }}
@@ -101,23 +96,19 @@ const { error } = useImage({
const shouldShowImage = computed(() => props.asset.preview_url && !error.value)
const cardClasses = computed(() => {
const base = [
'rounded-xl',
'overflow-hidden',
'transition-all',
'duration-200'
]
const base = cn(
'rounded-xl overflow-hidden transition-all duration-200 bg-modal-card-background'
)
if (!props.interactive) {
return cn(...base, 'bg-smoke-100 dark-theme:bg-charcoal-800')
return base
}
return cn(
...base,
base,
'group',
'appearance-none bg-transparent p-0 m-0',
'font-inherit text-inherit outline-none cursor-pointer text-left',
'bg-smoke-100 dark-theme:bg-charcoal-800',
'hover:bg-secondary-background',
'border-none',
'focus:outline-solid outline-azure-600 outline-4'

View File

@@ -26,11 +26,11 @@ const meta: Meta<typeof AssetFilterBar> = {
decorators: [
() => ({
template: `
<div class="min-h-screen bg-white dark-theme:bg-charcoal-900">
<div class="bg-gray-50 dark-theme:bg-charcoal-800 border-b border-smoke-200 dark-theme:border-charcoal-600">
<div class="min-h-screen bg-base-background">
<div class="bg-base-background border-b border-border-default">
<story />
</div>
<div class="p-6 text-sm text-smoke-600 dark-theme:text-smoke-400">
<div class="p-6 text-sm text-muted-foreground">
<p>Filter bar with proper chrome styling showing contextual background and borders.</p>
</div>
</div>
@@ -222,7 +222,7 @@ export const CategorySwitchingReactivity: Story = {
'px-4 py-2 rounded border',
selectedCategory === 'all'
? 'bg-blue-500 text-white border-blue-600'
: 'bg-white dark-theme:bg-charcoal-700 border-smoke-300 dark-theme:border-charcoal-600'
: 'bg-secondary-background border-border-default'
]"
>
All (.safetensors + .pt, sd15 + sdxl)
@@ -233,7 +233,7 @@ export const CategorySwitchingReactivity: Story = {
'px-4 py-2 rounded border',
selectedCategory === 'checkpoints'
? 'bg-blue-500 text-white border-blue-600'
: 'bg-white dark-theme:bg-charcoal-700 border-smoke-300 dark-theme:border-charcoal-600'
: 'bg-secondary-background border-border-default'
]"
>
Checkpoints (.safetensors, sd15 + sdxl)
@@ -244,7 +244,7 @@ export const CategorySwitchingReactivity: Story = {
'px-4 py-2 rounded border',
selectedCategory === 'loras'
? 'bg-blue-500 text-white border-blue-600'
: 'bg-white dark-theme:bg-charcoal-700 border-smoke-300 dark-theme:border-charcoal-600'
: 'bg-secondary-background border-border-default'
]"
>
LoRAs (.pt, sd15 only)

View File

@@ -8,24 +8,19 @@
:aria-colcount="-1"
:aria-setsize="assets.length"
>
<AssetCard
v-for="asset in assets"
:key="asset.id"
:asset="asset"
:interactive="true"
role="gridcell"
@select="$emit('assetSelect', $event)"
/>
<!-- Loading state -->
<div
v-if="loading"
class="col-span-full flex items-center justify-center py-20"
>
<i
class="icon-[lucide--loader] size-12 animate-spin text-muted-foreground"
/>
</div>
<!-- Empty state -->
<div
v-if="assets.length === 0"
:class="
cn(
'col-span-full flex flex-col items-center justify-center py-16',
'text-ash-300 dark-theme:text-ash-800'
)
"
v-else-if="assets.length === 0"
class="col-span-full flex flex-col items-center justify-center py-16 text-muted-foreground"
>
<i class="mb-4 icon-[lucide--search] size-10" />
<h3 class="mb-2 text-lg font-medium">
@@ -33,19 +28,16 @@
</h3>
<p class="text-sm">{{ $t('assetBrowser.tryAdjustingFilters') }}</p>
</div>
<!-- Loading state -->
<div
v-if="loading"
class="col-span-full flex items-center justify-center py-20"
>
<i
class="icon-[lucide--loader]"
:class="
cn('size-12 animate-spin', 'text-ash-300 dark-theme:text-ash-800')
"
<template v-else>
<AssetCard
v-for="asset in assets"
:key="asset.id"
:asset="asset"
:interactive="true"
role="gridcell"
@select="$emit('assetSelect', $event)"
/>
</div>
</template>
</div>
</template>
@@ -55,7 +47,6 @@ import { computed } from 'vue'
import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { createGridStyle } from '@/utils/gridUtil'
import { cn } from '@/utils/tailwindUtil'
defineProps<{
assets: AssetDisplayItem[]

View File

@@ -1,14 +1,10 @@
<template>
<div class="relative size-full overflow-hidden rounded">
<div
class="flex size-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
class="flex size-full flex-col items-center justify-center gap-2 bg-modal-card-placeholder-background text-base-foreground"
>
<i
class="icon-[lucide--box] text-3xl text-zinc-600 dark-theme:text-zinc-200"
/>
<span class="text-zinc-600 dark-theme:text-zinc-200">{{
$t('assetBrowser.media.threeDModelPlaceholder')
}}</span>
<i class="icon-[lucide--box] text-3xl" />
<span>{{ $t('assetBrowser.media.threeDModelPlaceholder') }}</span>
</div>
</div>
</template>

View File

@@ -1,4 +1,3 @@
<template>
<div class="h-[1px] bg-neutral-200 dark-theme:bg-neutral-700"></div>
<div class="h-px bg-border-default"></div>
</template>
<script setup lang="ts"></script>

View File

@@ -25,7 +25,7 @@
<!-- Loading State -->
<template v-if="loading">
<div
class="h-full w-full animate-pulse rounded-lg bg-zinc-200 dark-theme:bg-zinc-700"
class="size-full animate-pulse rounded-lg bg-modal-card-button-surface"
/>
</template>
@@ -109,10 +109,10 @@
<template v-if="loading">
<div class="flex flex-col items-center justify-between gap-1">
<div
class="h-4 w-2/3 animate-pulse rounded bg-zinc-200 dark-theme:bg-zinc-700"
class="h-4 w-2/3 animate-pulse rounded bg-modal-card-background"
/>
<div
class="h-3 w-1/2 animate-pulse rounded bg-zinc-200 dark-theme:bg-zinc-700"
class="h-3 w-1/2 animate-pulse rounded bg-modal-card-background"
/>
</div>
</template>
@@ -250,8 +250,8 @@ const containerClasses = computed(() =>
cn(
'gap-1 select-none',
selected
? 'border-3 border-zinc-900 dark-theme:border-white bg-zinc-200 dark-theme:bg-zinc-700'
: 'hover:bg-zinc-100 dark-theme:hover:bg-zinc-800'
? 'border-3 border-base-foreground bg-modal-card-background'
: 'hover:bg-modal-card-background/70'
)
)

View File

@@ -1,14 +1,10 @@
<template>
<div class="relative size-full overflow-hidden rounded">
<div
class="flex size-full flex-col items-center justify-center gap-2 bg-zinc-200 dark-theme:bg-zinc-700/50"
class="flex size-full flex-col items-center justify-center gap-2 bg-modal-card-placeholder-background text-base-foreground"
>
<i
class="icon-[lucide--music] text-3xl text-zinc-600 dark-theme:text-zinc-200"
/>
<span class="text-zinc-600 dark-theme:text-zinc-200">{{
$t('assetBrowser.media.audioPlaceholder')
}}</span>
<i class="icon-[lucide--music] text-3xl" />
<span>{{ $t('assetBrowser.media.audioPlaceholder') }}</span>
</div>
<audio
controls

View File

@@ -1,6 +1,6 @@
<template>
<div
class="relative size-full overflow-hidden rounded bg-zinc-200 dark-theme:bg-zinc-700/50"
class="relative size-full overflow-hidden rounded bg-modal-card-placeholder-background"
>
<img
v-if="!error"
@@ -10,7 +10,7 @@
/>
<div
v-else
class="flex size-full items-center justify-center bg-zinc-200 dark-theme:bg-zinc-700/50"
class="flex size-full items-center justify-center bg-modal-card-placeholder-background"
>
<i class="pi pi-image text-3xl text-smoke-400" />
</div>

View File

@@ -9,9 +9,7 @@
</p>
<!-- Troubleshooting Section -->
<div
class="mb-4 rounded bg-surface-700 px-3 py-2 text-left dark-theme:bg-surface-800"
>
<div class="mb-4 rounded bg-secondary-background px-3 py-2 text-left">
<h3 class="mb-2 text-sm font-semibold text-text-primary">
{{ $t('cloudOnboarding.authTimeout.troubleshooting') }}
</h3>
@@ -30,7 +28,7 @@
<!-- Technical Details (Collapsible) -->
<div v-if="errorMessage" class="mb-4 text-left">
<button
class="flex w-full items-center justify-between rounded bg-surface-600 px-4 py-2 text-sm text-muted transition-colors hover:bg-surface-500 dark-theme:bg-surface-700 dark-theme:hover:bg-surface-600"
class="flex w-full items-center justify-between rounded bg-secondary-background px-4 py-2 text-sm text-text-secondary transition-colors hover:bg-secondary-background-hover border-0"
@click="showTechnicalDetails = !showTechnicalDetails"
>
<span>{{ $t('cloudOnboarding.authTimeout.technicalDetails') }}</span>
@@ -43,7 +41,7 @@
</button>
<div
v-if="showTechnicalDetails"
class="mt-2 rounded bg-surface-800 p-4 font-mono text-xs text-muted break-all dark-theme:bg-surface-900"
class="mt-2 rounded border-muted-background border p-4 font-mono text-xs text-muted-foreground break-all"
>
{{ errorMessage }}
</div>

View File

@@ -90,7 +90,7 @@
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-smoke-100 dark-theme:bg-charcoal-600'
'bg-modal-panel-background'
)
"
>

View File

@@ -84,7 +84,6 @@
<template v-if="!isCollapsed">
<div class="relative mb-1">
<div :class="separatorClasses" />
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
@@ -388,7 +387,6 @@ const hasCustomContent = computed(() => {
})
// Computed classes and conditions for better reusability
const separatorClasses = 'bg-component-node-border h-px mx-0 w-full lod-toggle'
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(

View File

@@ -7,7 +7,7 @@
:class="
cn(
'lg-node-header py-2 pl-2 pr-3 text-sm rounded-t-2xl w-full min-w-50',
'text-node-component-header',
'text-node-component-header bg-node-component-header-surface',
collapsed && 'rounded-2xl'
)
"

View File

@@ -1,8 +1,6 @@
<template>
<div class="flex flex-col gap-1">
<div
class="max-h-[48rem] rounded border border-smoke-300 p-4 dark-theme:border-smoke-600"
>
<div class="max-h-[48rem] rounded border p-4">
<Chart
:type="chartType"
:data="chartData"

View File

@@ -2,7 +2,7 @@
<div class="relative">
<div class="mb-4">
<Button
class="text-secondary w-[413px] border-0 bg-zinc-500/10 dark-theme:bg-charcoal-600 dark-theme:text-white"
class="text-text-secondary w-[413px] border-0 bg-secondary-background hover:bg-secondary-background-hover"
:disabled="isRecording || readonly"
@click="handleStartRecording"
>
@@ -12,7 +12,7 @@
</div>
<div
v-if="isRecording || isPlaying || recordedURL"
class="text-secondary flex h-14 w-[413px] items-center gap-4 rounded-lg bg-zinc-500/10 px-4 dark-theme:bg-node-component-surface dark-theme:text-white"
class="flex h-14 w-[413px] items-center gap-4 rounded-lg px-4 bg-node-component-surface text-text-secondary"
>
<!-- Recording Status -->
<div class="flex min-w-30 items-center gap-2">
@@ -57,9 +57,7 @@
class="flex size-8 items-center justify-center rounded-full border-0 bg-smoke-500/33 transition-colors"
@click="handlePlayRecording"
>
<i
class="text-secondary icon-[lucide--play] size-4 dark-theme:text-white"
/>
<i class="text-text-secondary icon-[lucide--play] size-4" />
</button>
<button
@@ -68,9 +66,7 @@
class="flex size-8 items-center justify-center rounded-full border-0 bg-smoke-500/33 transition-colors"
@click="handleStopPlayback"
>
<i
class="text-secondary icon-[lucide--square] size-4 dark-theme:text-white"
/>
<i class="text-text-secondary icon-[lucide--square] size-4" />
</button>
</div>
<audio

View File

@@ -99,8 +99,10 @@
:model="menuItems"
popup
class="audio-player-menu"
pt:root:class="!bg-white dark-theme:!bg-charcoal-800 !border-sand-100 dark-theme:!border-charcoal-600"
pt:submenu:class="!bg-white dark-theme:!bg-charcoal-800"
:pt:root:class="
cn('bg-component-node-widget-background border-component-node-border')
"
:pt:submenu:class="cn('bg-component-node-widget-background')"
>
<template #item="{ item }">
<div v-if="item.key === 'volume'" class="w-48 px-4 py-2">

View File

@@ -36,7 +36,7 @@ const searchQuery = defineModel<string>('searchQuery')
<template>
<div
class="flex max-h-[640px] w-103 flex-col rounded-lg bg-node-component-surface pt-4 outline outline-offset-[-1px] outline-node-component-border"
class="flex max-h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
>
<!-- Filter -->
<FormDropdownMenuFilter
@@ -66,9 +66,7 @@ const searchQuery = defineModel<string>('searchQuery')
)
"
>
<div
class="pointer-events-none absolute inset-x-3 top-0 z-10 h-5 bg-gradient-to-b from-backdrop to-transparent"
/>
<div class="pointer-events-none absolute inset-x-3 top-0 z-10 h-5" />
<div
v-if="items.length === 0"
class="absolute inset-0 flex items-center justify-center"

View File

@@ -15,8 +15,9 @@ const layoutMode = defineModel<LayoutMode>('layoutMode')
const searchQuery = defineModel<string>('searchQuery')
const sortSelected = defineModel<OptionId>('sortSelected')
const actionButtonStyle =
'h-8 bg-zinc-500/20 rounded-lg outline outline-1 outline-offset-[-1px] outline-sand-100 dark-theme:outline-neutral-700 transition-all duration-150'
const actionButtonStyle = cn(
'h-8 bg-zinc-500/20 rounded-lg outline outline-1 outline-offset-[-1px] outline-node-component-border transition-all duration-150'
)
const resetInputStyle = 'bg-transparent border-0 outline-0 ring-0 text-left'
@@ -45,14 +46,15 @@ function handleSortSelected(item: SortOption) {
<template>
<div class="text-secondary flex gap-2 px-4">
<!-- TODO: Replace with a common Search input -->
<label
:class="
cn(
actionButtonStyle,
'flex-1 flex px-2 items-center text-base leading-none cursor-text',
searchQuery?.trim() !== '' ? 'text-base-foreground' : '',
'hover:!outline-blue-500/80',
'focus-within:!outline-blue-500/80'
'hover:outline-component-node-widget-background-highlighted/80',
'focus-within:outline-component-node-widget-background-highlighted/80'
)
"
>
@@ -77,7 +79,7 @@ function handleSortSelected(item: SortOption) {
resetInputStyle,
actionButtonStyle,
'relative w-8 flex justify-center items-center cursor-pointer',
'hover:!outline-blue-500/80',
'hover:outline-component-node-widget-background-highlighted',
'active:!scale-95'
)
"
@@ -85,7 +87,7 @@ function handleSortSelected(item: SortOption) {
>
<div
v-if="sortSelected !== 'default'"
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-blue-500"
class="absolute top-[-2px] left-[-2px] size-2 rounded-full bg-component-node-widget-background-highlighted"
/>
<i class="icon-[lucide--arrow-up-down] size-4" />
</button>
@@ -109,8 +111,8 @@ function handleSortSelected(item: SortOption) {
:class="
cn(
'flex flex-col gap-2 p-2 min-w-32',
'bg-zinc-200 dark-theme:bg-charcoal-700',
'rounded-lg outline outline-offset-[-1px] outline-sand-200 dark-theme:outline-zinc-700'
'bg-component-node-background',
'rounded-lg outline outline-offset-[-1px] outline-component-node-border'
)
"
>
@@ -140,7 +142,7 @@ function handleSortSelected(item: SortOption) {
:class="
cn(
actionButtonStyle,
'flex justify-center items-center p-1 gap-1 hover:!outline-blue-500/80'
'flex justify-center items-center p-1 gap-1 hover:outline-component-node-widget-background-highlighted'
)
"
>

View File

@@ -57,16 +57,17 @@ function handleVideoLoad(event: Event) {
<div
:class="
cn(
'flex gap-1 select-none group/item cursor-pointer',
'flex gap-1 select-none group/item cursor-pointer bg-component-node-widget-background',
'transition-all duration-150',
{
'flex-col text-center': layout === 'grid',
'flex-row text-left max-h-16 bg-interface-menu-component-surface-hovered rounded-lg hover:scale-102 active:scale-98':
'flex-row text-left max-h-16 rounded-lg hover:scale-102 active:scale-98':
layout === 'list',
'flex-row text-left hover:bg-interface-menu-component-surface-hovered rounded-lg':
'flex-row text-left hover:bg-component-node-widget-background-hovered rounded-lg':
layout === 'list-small',
// selection
'ring-2 ring-blue-500': layout === 'list' && selected
'ring-2 ring-component-node-widget-background-highlighted':
layout === 'list' && selected
}
)
"
@@ -85,7 +86,8 @@ function handleVideoLoad(event: Event) {
'rounded-sm group-hover/item:scale-108 group-active/item:scale-95':
layout === 'grid',
// selection
'ring-2 ring-blue-500': layout === 'grid' && selected
'ring-2 ring-component-node-widget-background-highlighted':
layout === 'grid' && selected
}
)
"
@@ -93,10 +95,10 @@ function handleVideoLoad(event: Event) {
<!-- Selected Icon -->
<div
v-if="selected"
class="absolute top-1 left-1 size-4 rounded-full border-1 border-white bg-blue-500"
class="absolute top-1 left-1 size-4 rounded-full border-1 border-base-foreground bg-primary-background"
>
<i
class="icon-[lucide--check] size-3 translate-y-[-0.5px] text-white"
class="icon-[lucide--check] size-3 translate-y-[-0.5px] text-base-foreground bold"
/>
</div>
<video
@@ -134,10 +136,10 @@ function handleVideoLoad(event: Event) {
v-tooltip="layout === 'grid' ? (label ?? name) : undefined"
:class="
cn(
'block text-[15px] line-clamp-2 break-words overflow-hidden',
'block text-xs line-clamp-2 break-words overflow-hidden',
'transition-colors duration-150',
// selection
!!selected && 'text-blue-500'
!!selected && 'text-base-foreground'
)
"
>

View File

@@ -1,49 +0,0 @@
import { ref } from 'vue'
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComponentWidgetStandardProps } from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
type ChatHistoryCustomProps = Omit<
InstanceType<typeof ChatHistoryWidget>['$props'],
ComponentWidgetStandardProps
>
const PADDING = 16
export const useChatHistoryWidget = (
options: {
props?: ChatHistoryCustomProps
} = {}
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
ChatHistoryCustomProps
>({
node,
name: inputSpec.name,
component: ChatHistoryWidget,
props: options.props,
inputSpec,
options: {
getValue: () => widgetValue.value,
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => 400 + PADDING
}
})
addWidget(node, widget)
return widget
}
return widgetConstructor
}

View File

@@ -109,12 +109,6 @@ const zProgressTextWsMessage = z.object({
text: z.string()
})
const zDisplayComponentWsMessage = z.object({
node_id: zNodeId,
component: z.enum(['ChatHistoryWidget']),
props: z.record(z.string(), z.any()).optional()
})
const zTerminalSize = z.object({
cols: z.number(),
row: z.number()
@@ -150,9 +144,6 @@ export type ExecutionInterruptedWsMessage = z.infer<
export type ExecutionErrorWsMessage = z.infer<typeof zExecutionErrorWsMessage>
export type LogsWsMessage = z.infer<typeof zLogsWsMessage>
export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
export type DisplayComponentWsMessage = z.infer<
typeof zDisplayComponentWsMessage
>
export type NodeProgressState = z.infer<typeof zNodeProgressState>
export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>

View File

@@ -16,7 +16,6 @@ import type {
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
DisplayComponentWsMessage,
EmbeddingsResponse,
ExecutedWsMessage,
ExecutingWsMessage,
@@ -139,7 +138,6 @@ interface BackendApiCalls {
}
progress_text: ProgressTextWsMessage
progress_state: ProgressStateWsMessage
display_component: DisplayComponentWsMessage
feature_flags: FeatureFlagsWsMessage
}

View File

@@ -101,7 +101,7 @@ export const useColorPaletteService = () => {
linkColorPalette: Colors['node_slot']
) {
if (!linkColorPalette) return
const rootStyle = document.getElementById('vue-app')?.style
const rootStyle = document.body?.style
if (!rootStyle) return
for (const dataType of nodeDefStore.nodeDataTypes) {
@@ -122,7 +122,7 @@ export const useColorPaletteService = () => {
colorPaletteId: string
) {
if (!palette) return
const rootStyle = document.getElementById('vue-app')?.style
const rootStyle = document.body?.style
if (!rootStyle) return
for (const themeVar of Object.keys(THEME_PROPERTY_MAP)) {

View File

@@ -1,8 +1,6 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
import { useNodeChatHistory } from '@/composables/node/useNodeChatHistory'
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { isCloud } from '@/platform/distribution/types'
@@ -16,7 +14,6 @@ import type {
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type {
DisplayComponentWsMessage,
ExecutedWsMessage,
ExecutionCachedWsMessage,
ExecutionErrorWsMessage,
@@ -255,7 +252,6 @@ export const useExecutionStore = defineStore('execution', () => {
api.addEventListener('status', handleStatus)
api.addEventListener('execution_error', handleExecutionError)
api.addEventListener('progress_text', handleProgressText)
api.addEventListener('display_component', handleDisplayComponent)
}
function unbindExecutionEvents() {
@@ -270,7 +266,6 @@ export const useExecutionStore = defineStore('execution', () => {
api.removeEventListener('status', handleStatus)
api.removeEventListener('execution_error', handleExecutionError)
api.removeEventListener('progress_text', handleProgressText)
api.removeEventListener('display_component', handleDisplayComponent)
}
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
@@ -407,24 +402,6 @@ export const useExecutionStore = defineStore('execution', () => {
useNodeProgressText().showTextPreview(node, text)
}
function handleDisplayComponent(e: CustomEvent<DisplayComponentWsMessage>) {
const { node_id: nodeId, component, props = {} } = e.detail
// Handle execution node IDs for subgraphs
const currentId = getNodeIdIfExecuting(nodeId)
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
if (!node) return
if (component === 'ChatHistoryWidget') {
useNodeChatHistory({
props: props as Omit<
InstanceType<typeof ChatHistoryWidget>['$props'],
'widget'
>
}).showChatHistory(node)
}
}
function storePrompt({
nodes,
id,

View File

@@ -1,3 +1,4 @@
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
export type ButtonSize = 'fit-content' | 'sm' | 'md'
@@ -25,10 +26,12 @@ export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
const baseByType = {
primary:
'bg-neutral-900 border-none text-white dark-theme:bg-white dark-theme:text-neutral-900',
secondary:
'bg-white border-none text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white',
transparent:
'bg-transparent border-none text-neutral-600 dark-theme:text-neutral-200',
secondary: cn(
'bg-secondary-background border-none text-base-foreground hover:bg-secondary-background-hover'
),
transparent: cn(
'bg-transparent border-none text-base-foreground hover:bg-secondary-background-hover'
),
accent:
'bg-primary-background hover:bg-primary-background-hover border-none text-white font-bold'
} as const
@@ -42,7 +45,9 @@ export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
'bg-neutral-900 text-white dark-theme:bg-white dark-theme:text-neutral-900',
secondary:
'bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white',
transparent: 'bg-transparent text-neutral-600 dark-theme:text-neutral-400',
transparent: cn(
'bg-transparent text-base-foreground hover:bg-secondary-background-hover'
),
accent:
'bg-primary-background hover:bg-primary-background-hover text-white font-bold'
} as const

View File

@@ -22,7 +22,7 @@
<Panel
:expanded="collapsedPanels[index] === true"
toggleable
class="shadow-elevation-1 mt-2 rounded-lg dark-theme:border-black dark-theme:bg-black"
class="shadow-elevation-1 mt-2 rounded-lg"
>
<template #header>
<div class="flex w-full items-center justify-between py-2">
@@ -67,7 +67,7 @@
<div
v-for="(logLine, logIndex) in log.logs"
:key="logIndex"
class="text-neutral-400 dark-theme:text-muted"
class="text-muted"
>
<pre class="break-words whitespace-pre-wrap">{{ logLine }}</pre>
</div>

View File

@@ -32,7 +32,7 @@
v-if="!isInProgress && !isRestartCompleted"
rounded
outlined
class="!dark-theme:bg-transparent mr-4 rounded-md border-2 border-neutral-900 px-3 text-neutral-600 hover:bg-neutral-100 dark-theme:border-white dark-theme:text-white dark-theme:hover:bg-neutral-800"
class="mr-4 rounded-md border-2 border-base-foreground px-3 text-base-foreground hover:bg-secondary-background-hover"
@click="handleRestart"
>
{{ $t('manager.applyChanges') }}

View File

@@ -20,7 +20,7 @@
:tabs="tabs"
/>
<div
class="flex-1 overflow-auto bg-gray-50 dark-theme:bg-neutral-900"
class="flex-1 overflow-auto bg-base-background"
:class="{
'transition-all duration-300': isSmallScreen
}"

View File

@@ -14,7 +14,7 @@
<!-- Import Failed List Wrapper -->
<div
v-if="importFailedConflicts.length > 0"
class="flex min-h-8 w-full flex-col rounded-lg bg-neutral-200 dark-theme:bg-black"
class="flex min-h-8 w-full flex-col rounded-lg bg-base-background"
>
<div
data-testid="conflict-dialog-panel-toggle"
@@ -22,14 +22,12 @@
@click="toggleImportFailedPanel"
>
<div class="flex flex-1">
<span
class="mr-2 text-xs font-bold text-yellow-600 dark-theme:text-yellow-400"
>{{ importFailedConflicts.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.importFailedExtensions') }}</span
>
<span class="mr-2 text-xs font-bold text-warning-background">{{
importFailedConflicts.length
}}</span>
<span class="text-xs font-bold text-base-foreground">{{
$t('manager.conflicts.importFailedExtensions')
}}</span>
</div>
<div>
<Button
@@ -39,7 +37,7 @@
: 'pi pi-chevron-right text-xs'
"
text
class="!bg-transparent text-neutral-600 dark-theme:text-neutral-300"
class="!bg-transparent text-muted"
/>
</div>
</div>
@@ -54,7 +52,7 @@
:key="i"
class="conflict-list-item flex h-6 flex-shrink-0 items-center justify-between px-4"
>
<span class="text-xs text-neutral-600 dark-theme:text-neutral-300">
<span class="text-xs text-muted">
{{ packageName }}
</span>
<span class="pi pi-info-circle text-sm"></span>
@@ -62,23 +60,19 @@
</div>
</div>
<!-- Conflict List Wrapper -->
<div
class="flex min-h-8 w-full flex-col rounded-lg bg-neutral-200 dark-theme:bg-black"
>
<div class="flex min-h-8 w-full flex-col rounded-lg bg-base-background">
<div
data-testid="conflict-dialog-panel-toggle"
class="flex h-8 w-full items-center justify-between gap-2 pl-4"
@click="toggleConflictsPanel"
>
<div class="flex flex-1">
<span
class="mr-2 text-xs font-bold text-yellow-600 dark-theme:text-yellow-400"
>{{ allConflictDetails.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.conflicts') }}</span
>
<span class="mr-2 text-xs font-bold text-warning-background">{{
allConflictDetails.length
}}</span>
<span class="text-xs font-bold text-base-foreground">{{
$t('manager.conflicts.conflicts')
}}</span>
</div>
<div>
<Button
@@ -88,7 +82,7 @@
: 'pi pi-chevron-right text-xs'
"
text
class="!bg-transparent text-neutral-600 dark-theme:text-neutral-300"
class="!bg-transparent text-muted"
/>
</div>
</div>
@@ -103,32 +97,27 @@
:key="i"
class="conflict-list-item flex h-6 flex-shrink-0 items-center justify-between px-4"
>
<span
class="text-xs text-neutral-600 dark-theme:text-neutral-300"
>{{ getConflictMessage(conflict, t) }}</span
>
<span class="text-xs text-muted">{{
getConflictMessage(conflict, t)
}}</span>
<span class="pi pi-info-circle text-sm"></span>
</div>
</div>
</div>
<!-- Extension List Wrapper -->
<div
class="flex min-h-8 w-full flex-col rounded-lg bg-neutral-200 dark-theme:bg-black"
>
<div class="flex min-h-8 w-full flex-col rounded-lg bg-base-background">
<div
data-testid="conflict-dialog-panel-toggle"
class="flex h-8 w-full items-center justify-between gap-2 pl-4"
@click="toggleExtensionsPanel"
>
<div class="flex flex-1">
<span
class="mr-2 text-xs font-bold text-yellow-600 dark-theme:text-yellow-400"
>{{ conflictData.length }}</span
>
<span
class="text-xs font-bold text-neutral-600 dark-theme:text-white"
>{{ $t('manager.conflicts.extensionAtRisk') }}</span
>
<span class="mr-2 text-xs font-bold text-warning-background">{{
conflictData.length
}}</span>
<span class="text-xs font-bold text-base-foreground">{{
$t('manager.conflicts.extensionAtRisk')
}}</span>
</div>
<div>
<Button
@@ -138,7 +127,7 @@
: 'pi pi-chevron-right text-xs'
"
text
class="!bg-transparent text-neutral-600 dark-theme:text-neutral-300"
class="!bg-transparent text-muted"
/>
</div>
</div>
@@ -153,7 +142,7 @@
:key="conflictResult.package_id"
class="conflict-list-item flex h-6 flex-shrink-0 items-center justify-between px-4"
>
<span class="text-xs text-neutral-600 dark-theme:text-neutral-300">
<span class="text-xs text-muted">
{{ conflictResult.package_name }}
</span>
<span class="pi pi-info-circle text-sm"></span>

View File

@@ -71,7 +71,7 @@
<Button
severity="secondary"
:label="$t('g.install')"
class="dark-theme:bg-unset dark-theme:text-unset rounded-lg bg-black/80 px-4 py-2.5 text-sm text-neutral-100"
class="rounded-lg bg-secondary-background px-4 py-2.5 text-sm text-base-foreground"
:disabled="isQueueing"
@click="handleSubmit"
/>

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