Compare commits

...

3 Commits

Author SHA1 Message Date
jaeone94
9b4d8df259 test: cover primevue reka overlay z-index 2026-05-07 13:34:41 +09:00
jaeone94
c3fe2a5939 test: cover PrimeVue overlay child style 2026-05-07 13:08:15 +09:00
jaeone94
82e3068181 fix: keep reka overlays above primevue overlays 2026-05-07 11:52:44 +09:00
14 changed files with 784 additions and 18 deletions

View File

@@ -0,0 +1,575 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
import type {
AssetMetadata,
AssetItem,
ModelFolder
} from '@/platform/assets/schemas/assetSchema'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
import type { components as RegistryComponents } from '@comfyorg/registry-types'
import {
STABLE_CHECKPOINT,
STABLE_LORA,
STABLE_VAE
} from '@e2e/fixtures/data/assetFixtures'
import {
makeTemplate,
mockTemplateIndex
} from '@e2e/fixtures/data/templateFixtures'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
type InstalledPacksResponse =
ManagerComponents['schemas']['InstalledPacksResponse']
type RegistryNodePack = RegistryComponents['schemas']['Node']
interface AlgoliaSearchResult {
hits: Array<Partial<AlgoliaNodePack> | AlgoliaSuggestionHit>
nbHits: number
page: number
nbPages: number
hitsPerPage: number
}
interface AlgoliaSearchResponse {
results: AlgoliaSearchResult[]
}
interface AlgoliaSuggestionHit {
query: string
popularity: number
}
const RAISED_DIALOG_Z_INDEX = 4200
const MODEL_ASSETS: AssetItem[] = [
{
...STABLE_CHECKPOINT,
id: 'fe-569-checkpoint',
tags: STABLE_CHECKPOINT.tags ?? [],
is_immutable: false
},
{
...STABLE_LORA,
id: 'fe-569-lora',
tags: STABLE_LORA.tags ?? [],
is_immutable: false
},
{
...STABLE_VAE,
id: 'fe-569-vae',
tags: STABLE_VAE.tags ?? [],
is_immutable: false
}
]
const MODEL_FOLDERS: ModelFolder[] = [
{ name: 'checkpoints', folders: [] },
{ name: 'loras', folders: [] },
{ name: 'vae', folders: [] }
]
const MOCK_PACK_A: RegistryNodePack = {
id: 'test-pack-a',
name: 'Test Pack A',
description: 'A test custom node pack',
downloads: 5000,
status: 'NodeStatusActive',
publisher: { id: 'test-publisher', name: 'Test Publisher' },
latest_version: { version: '1.0.0', status: 'NodeVersionStatusActive' },
repository: 'https://github.com/test/pack-a',
tags: ['image', 'processing']
}
const MOCK_PACK_B: RegistryNodePack = {
id: 'test-pack-b',
name: 'Test Pack B',
description: 'Another test custom node pack for testing search',
downloads: 3000,
status: 'NodeStatusActive',
publisher: { id: 'another-publisher', name: 'Another Publisher' },
latest_version: { version: '2.1.0', status: 'NodeVersionStatusActive' },
repository: 'https://github.com/test/pack-b',
tags: ['video', 'generation']
}
const MOCK_INSTALLED_PACKS: InstalledPacksResponse = {
'test-pack-a': {
ver: '1.0.0',
cnr_id: 'test-pack-a',
enabled: true
}
}
const MOCK_HIT_A: Partial<AlgoliaNodePack> = {
objectID: 'test-pack-a',
id: 'test-pack-a',
name: 'Test Pack A',
description: 'A test custom node pack',
total_install: 5000,
status: 'NodeStatusActive',
publisher_id: 'test-publisher',
latest_version: '1.0.0',
latest_version_status: 'NodeVersionStatusActive',
repository_url: 'https://github.com/test/pack-a',
comfy_nodes: ['TestNodeA'],
create_time: '2024-01-01T00:00:00Z',
update_time: '2024-06-01T00:00:00Z',
license: 'MIT',
tags: ['image', 'processing']
}
const MOCK_HIT_B: Partial<AlgoliaNodePack> = {
objectID: 'test-pack-b',
id: 'test-pack-b',
name: 'Test Pack B',
description: 'Another test custom node pack',
total_install: 3000,
status: 'NodeStatusActive',
publisher_id: 'another-publisher',
latest_version: '2.1.0',
latest_version_status: 'NodeVersionStatusActive',
repository_url: 'https://github.com/test/pack-b',
comfy_nodes: ['TestNodeB'],
create_time: '2024-02-01T00:00:00Z',
update_time: '2024-07-01T00:00:00Z',
license: 'Apache-2.0',
tags: ['video', 'generation']
}
const MOCK_ALGOLIA_RESPONSE: AlgoliaSearchResponse = {
results: [
{
hits: [MOCK_HIT_A, MOCK_HIT_B],
nbHits: 2,
page: 0,
nbPages: 1,
hitsPerPage: 20
},
{
hits: [{ query: 'Test Pack A', popularity: 100 }],
nbHits: 1,
page: 0,
nbPages: 1,
hitsPerPage: 20
}
]
}
test.use({
initialFeatureFlags: {
model_upload_button_enabled: true,
private_models_enabled: true
}
})
test.describe('PrimeVue dialog child overlays', () => {
test('keeps workflow template filters above the template dialog', async ({
comfyPage
}) => {
await mockTemplateLibrary(comfyPage)
await forcePrimeVueDialogZIndex(comfyPage.page)
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
const dialog = comfyPage.templatesDialog.root
await expect(comfyPage.templates.content).toBeVisible()
await dialog.getByRole('button', { name: /Model Filter/ }).click()
await expectOverlayItemAboveRaisedDialog(
comfyPage.page.getByRole('option', { name: 'Flux' })
)
await comfyPage.page.keyboard.press('Escape')
await dialog.getByRole('combobox', { name: /Sort by/ }).click()
await expectOverlayItemAboveRaisedDialog(
comfyPage.page.getByRole('option', { name: 'Popular' })
)
})
test('keeps manager header controls above the manager dialog', async ({
comfyPage
}) => {
await setupManagerDialog(comfyPage)
await forcePrimeVueDialogZIndex(comfyPage.page)
await comfyPage.command.executeCommand('Comfy.OpenManagerDialog')
const dialog = comfyPage.page.getByRole('dialog').last()
await expect(dialog).toBeVisible()
await dialog.getByText('Node Pack').first().click()
await expectOverlayItemAboveRaisedDialog(
comfyPage.page.getByRole('option', { name: 'Nodes' })
)
await comfyPage.page.getByRole('option', { name: 'Nodes' }).click()
await dialog.getByRole('combobox', { name: 'Sort' }).click()
await expectOverlayItemAboveRaisedDialog(
comfyPage.page.getByRole('option', { name: 'Name' })
)
})
test('keeps asset browser filters above the asset browser dialog', async ({
comfyPage
}) => {
const dialog = await openAssetBrowser(comfyPage)
await dialog.getByRole('button', { name: 'File formats' }).click()
await expectOverlayItemAboveRaisedDialog(
comfyPage.page.getByRole('option', { name: '.safetensors' })
)
await comfyPage.page.keyboard.press('Escape')
await dialog.getByRole('combobox', { name: 'Sort by' }).click()
await expectOverlayItemAboveRaisedDialog(
comfyPage.page.getByRole('option', { name: 'A-Z' })
)
})
test('keeps the model info selector above the asset browser dialog', async ({
comfyPage
}) => {
const dialog = await openAssetBrowser(comfyPage)
const card = dialog
.locator('[data-component-id="AssetCard"]')
.filter({ hasText: STABLE_CHECKPOINT.name })
.first()
await card.hover()
await card.getByRole('button', { name: 'Model Info' }).click()
const modelInfoPanel = dialog.locator(
'[data-component-id="ModelInfoPanel"]'
)
await expect(modelInfoPanel).toBeVisible()
await modelInfoPanel.getByRole('combobox').click()
await expectOverlayItemAboveRaisedDialog(
comfyPage.page.getByRole('option', { name: 'LoRA' })
)
})
test('keeps the upload model selector above the upload dialog', async ({
comfyPage
}) => {
const assetBrowserDialog = await openAssetBrowser(comfyPage)
await mockRemoteModelMetadata(comfyPage.page)
await assetBrowserDialog
.locator('[data-attr="upload-model-button"]')
.click()
const uploadDialog = comfyPage.page.getByRole('dialog').last()
await expect(uploadDialog).toContainText('Import a model')
await uploadDialog
.locator('[data-attr="upload-model-step1-url-input"]')
.fill('https://civitai.com/models/123/fe-569-test')
await uploadDialog
.locator('[data-attr="upload-model-step1-continue-button"]')
.click()
const trigger = uploadDialog.locator(
'[data-attr="upload-model-step2-type-selector"]'
)
await expect(trigger).toBeVisible()
await trigger.click()
await expectOverlayItemAboveRaisedDialog(
comfyPage.page.getByRole('option', { name: 'LoRA' })
)
})
test('keeps keybinding controls above the settings dialog', async ({
comfyPage
}) => {
await registerNoBindingCommand(comfyPage)
await forcePrimeVueDialogZIndex(comfyPage.page)
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
const dialog = comfyPage.settingDialog.root
await expect(
comfyPage.page.getByPlaceholder('Search Keybindings...')
).toBeVisible()
await dialog
.locator('#keybinding-panel-actions')
.locator('button[role="combobox"]')
.click()
await expectOverlayItemAboveRaisedDialog(
comfyPage.page.getByRole('option', { name: 'Default Preset' })
)
await comfyPage.page.getByRole('option', { name: 'Default Preset' }).click()
await searchKeybindings(comfyPage.page, 'Comfy.SaveWorkflow')
const saveWorkflowCommand = /^Comfy\.SaveWorkflow$/
await getCommandRow(comfyPage.page, saveWorkflowCommand)
.getByTitle(saveWorkflowCommand)
.click({ button: 'right' })
await expectOverlayItemAboveRaisedDialog(
comfyPage.page.getByRole('menuitem', { name: /Change keybinding/i })
)
await comfyPage.page.getByTestId(TestIds.keybindings.presetMenu).click()
await expectOverlayItemAboveRaisedDialog(
comfyPage.page.getByRole('menuitem', { name: 'Import preset' })
)
})
})
async function forcePrimeVueDialogZIndex(page: Page) {
await page.addStyleTag({
content: `
.p-dialog-mask {
z-index: ${RAISED_DIALOG_Z_INDEX} !important;
}
`
})
}
async function expectOverlayItemAboveRaisedDialog(item: Locator) {
await expect(item).toBeVisible()
const overlay = item.locator(
'xpath=ancestor-or-self::*[contains(@style, "z-index")][1]'
)
await expect(overlay).toHaveCount(1)
const overlayZIndex = await readZIndex(overlay)
expect(overlayZIndex).toBeGreaterThan(RAISED_DIALOG_Z_INDEX)
}
async function readZIndex(locator: Locator): Promise<number> {
const rawZIndex = await locator.evaluate(
(element) => getComputedStyle(element).zIndex
)
const zIndex = Number.parseInt(rawZIndex, 10)
expect(Number.isFinite(zIndex)).toBe(true)
return zIndex
}
async function mockTemplateLibrary(comfyPage: ComfyPage) {
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
await comfyPage.settings.setSetting('Comfy.Templates.SelectedUseCases', [])
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
await comfyPage.page.route('**/templates/**.webp', async (route) => {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.page.route('**/templates/index.json', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(
mockTemplateIndex([
makeTemplate({
name: 'flux-template',
title: 'Flux Template',
models: ['Flux'],
tags: ['Image']
}),
makeTemplate({
name: 'wan-template',
title: 'Wan Template',
models: ['Wan 2.2'],
tags: ['Video'],
openSource: false
})
])
),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
}
async function setupManagerDialog(comfyPage: ComfyPage) {
const statsWithManager = {
...mockSystemStats,
system: {
...mockSystemStats.system,
argv: ['main.py', '--enable-manager']
}
}
await comfyPage.page.route('**/system_stats**', async (route) => {
await route.fulfill({ json: statsWithManager })
})
await comfyPage.page.route('**/v2/customnode/installed**', async (route) => {
await route.fulfill({ json: MOCK_INSTALLED_PACKS })
})
await comfyPage.page.route('**/v2/manager/queue/status**', async (route) => {
await route.fulfill({
json: {
history: {},
running_queue: [],
pending_queue: [],
installed_packs: {}
}
})
})
await comfyPage.page.route('**/v2/manager/queue/history**', async (route) => {
await route.fulfill({ json: {} })
})
await comfyPage.page.route('**/*.algolia.net/**', async (route) => {
await route.fulfill({ json: MOCK_ALGOLIA_RESPONSE })
})
await comfyPage.page.route('**/*.algolianet.com/**', async (route) => {
await route.fulfill({ json: MOCK_ALGOLIA_RESPONSE })
})
const registryListResponse = {
total: 2,
nodes: [MOCK_PACK_A, MOCK_PACK_B],
page: 1,
limit: 64,
totalPages: 1
}
await comfyPage.page.route(
'**/api.comfy.org/nodes/search**',
async (route) => {
await route.fulfill({ json: registryListResponse })
}
)
await comfyPage.page.route(
(url) => url.hostname === 'api.comfy.org' && url.pathname === '/nodes',
async (route) => {
await route.fulfill({ json: registryListResponse })
}
)
await comfyPage.page.route(
'**/v2/customnode/getmappings**',
async (route) => {
await route.fulfill({ json: {} })
}
)
await comfyPage.page.route(
'**/v2/customnode/import_fail_info**',
async (route) => {
await route.fulfill({ json: {} })
}
)
await comfyPage.setup()
await comfyPage.page.evaluate(() => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
extension: {
manager: {
supports_v4: true,
supports_csrf_post: true
}
}
}
})
}
async function openAssetBrowser(comfyPage: ComfyPage): Promise<Locator> {
await mockModelFolders(comfyPage.page)
await forcePrimeVueDialogZIndex(comfyPage.page)
await showAssetBrowserModal(comfyPage.page)
const assetBrowser = comfyPage.page.locator(
'[data-component-id="AssetBrowserModal"]'
)
await expect(assetBrowser).toBeVisible()
await expect(
assetBrowser.getByRole('heading', { name: STABLE_CHECKPOINT.name })
).toBeVisible()
return assetBrowser.locator('xpath=ancestor::*[@role="dialog"][1]')
}
async function showAssetBrowserModal(page: Page) {
await page.evaluate((assets) => {
Object.assign(window, { __fe569ModelAssets: assets })
}, MODEL_ASSETS)
await page.addScriptTag({
type: 'module',
content: `
const { useDialogService } = await import('/src/services/dialogService.ts')
const AssetBrowserModal = (await import('/src/platform/assets/components/AssetBrowserModal.vue')).default
useDialogService().showLayoutDialog({
key: 'fe-569-asset-browser',
component: AssetBrowserModal,
props: {
showLeftPanel: true,
assetType: 'models',
title: 'Model Library',
overrideAssets: window.__fe569ModelAssets,
onClose: () => {}
}
})
`
})
}
async function mockModelFolders(page: Page) {
await page.route('**/experiment/models', async (route) => {
await route.fulfill({ json: MODEL_FOLDERS })
})
}
async function mockRemoteModelMetadata(page: Page) {
const metadata: AssetMetadata = {
content_length: 1234,
final_url: 'https://civitai.com/api/download/models/123',
filename: 'fe-569-upload-test.safetensors',
tags: []
}
await page.route('**/assets/remote-metadata**', async (route) => {
await route.fulfill({ json: metadata })
})
}
async function registerNoBindingCommand(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
const app = window.app!
app.registerExtension({
name: 'TestExtension.PrimeVueRekaOverlayZIndex',
commands: [
{ id: 'TestCommand.PrimeVueRekaOverlay.NoBinding', function: () => {} }
]
})
})
}
async function searchKeybindings(page: Page, query: string) {
await page.getByPlaceholder('Search Keybindings...').fill(query)
}
function getCommandRow(page: Page, commandTitle: RegExp): Locator {
return page
.locator('.keybinding-panel tr')
.filter({ has: page.getByTitle(commandTitle) })
}

View File

@@ -40,7 +40,10 @@
<template #contentFilter>
<div class="relative flex flex-wrap justify-between gap-2 px-6 pb-4">
<div class="flex flex-wrap gap-2">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex flex-wrap gap-2"
>
<!-- Model Filter -->
<MultiSelect
v-model="selectedModelObjects"
@@ -48,6 +51,7 @@
class="w-[250px]"
:label="modelFilterLabel"
:options="modelOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -62,6 +66,7 @@
v-model="selectedUseCaseObjects"
:label="useCaseFilterLabel"
:options="useCaseOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -76,6 +81,7 @@
v-model="selectedRunsOnObjects"
:label="runsOnFilterLabel"
:options="runsOnOptions"
:content-style="selectContentStyle"
:show-search-box="true"
:show-selected-count="true"
:show-clear-button="true"
@@ -92,6 +98,7 @@
v-model="sortBy"
:label="$t('templateWorkflows.sorting', 'Sort by')"
:options="sortOptions"
:content-style="selectContentStyle"
class="w-62.5"
>
<template #icon>
@@ -416,6 +423,7 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -632,6 +640,8 @@ const selectedRunsOnObjects = computed({
const loadingTemplate = ref<string | null>(null)
const hoveredTemplate = ref<string | null>(null)
const cardRefs = ref<HTMLElement[]>([])
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
// Force re-render key for templates when sorting changes
const templateListKey = ref(0)

View File

@@ -1,5 +1,8 @@
<template>
<div class="keybinding-panel flex flex-col gap-2">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="keybinding-panel flex flex-col gap-2"
>
<Teleport defer to="#keybinding-panel-header">
<SearchInput
v-model="filters['global'].value"
@@ -15,10 +18,12 @@
<div class="flex items-center gap-2">
<KeybindingPresetToolbar
:preset-names="presetNames"
:content-style="keybindingOverlayContentStyle"
@presets-changed="refreshPresetList"
/>
<DropdownMenu
:entries="menuEntries"
:style="keybindingOverlayContentStyle"
icon="icon-[lucide--ellipsis]"
item-class="text-sm gap-2"
button-size="unset"
@@ -238,6 +243,7 @@
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
:style="keybindingOverlayContentStyle"
class="z-1200 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>
<ContextMenuItem
@@ -314,6 +320,7 @@ import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import Button from '@/components/ui/button/Button.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
@@ -337,6 +344,8 @@ const settingStore = useSettingStore()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const keybindingOverlayContentStyle = primeVueOverlay.contentStyle
const presetNames = ref<string[]>([])

View File

@@ -9,7 +9,10 @@
{{ displayLabel }}
</SelectValue>
</SelectTrigger>
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
<SelectContent
:style="contentStyle"
class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1"
>
<div class="max-w-60">
<SelectItem
value="default"
@@ -46,6 +49,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -57,8 +61,9 @@ import SelectValue from '@/components/ui/select/SelectValue.vue'
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
const { presetNames } = defineProps<{
const { presetNames, contentStyle } = defineProps<{
presetNames: string[]
contentStyle?: StyleValue
}>()
const emit = defineEmits<{

View File

@@ -50,7 +50,7 @@
position="popper"
:side-offset="8"
align="start"
:style="popoverStyle"
:style="[popoverStyle, contentStyle]"
:class="selectContentClass"
@keydown="onContentKeydown"
@focus-outside="preventFocusDismiss"
@@ -152,6 +152,7 @@ import {
ComboboxViewport
} from 'reka-ui'
import { computed, ref } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -183,7 +184,8 @@ const {
searchPlaceholder,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
popoverMaxWidth,
contentStyle
} = defineProps<{
/** Input label shown on the trigger button */
label?: string
@@ -207,6 +209,7 @@ const {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
contentStyle?: StyleValue
}>()
const selectedItems = defineModel<SelectOption[]>({

View File

@@ -70,6 +70,7 @@
v-if="suggestions.length > 0"
position="popper"
:side-offset="4"
:style="contentStyle"
:class="
cn(
'z-3000 max-h-60 w-(--reka-combobox-trigger-width) overflow-y-auto',
@@ -99,7 +100,7 @@
</template>
<script setup lang="ts" generic="T">
import type { HTMLAttributes } from 'vue'
import type { HTMLAttributes, StyleValue } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import {
@@ -132,7 +133,8 @@ const {
suggestions = [],
optionLabel,
optionKey,
class: className
class: className,
contentStyle
} = defineProps<{
placeholder?: string
icon?: string
@@ -144,6 +146,7 @@ const {
optionLabel?: keyof T & string
optionKey?: keyof T & string
class?: HTMLAttributes['class']
contentStyle?: StyleValue
}>()
const emit = defineEmits<{

View File

@@ -37,7 +37,7 @@
position="popper"
:side-offset="8"
align="start"
:style="optionStyle"
:style="[optionStyle, contentStyle]"
:class="cn(selectContentClass, 'min-w-(--reka-select-trigger-width)')"
@keydown="onContentKeydown"
>
@@ -82,6 +82,7 @@ import {
SelectViewport
} from 'reka-ui'
import { ref } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import {
@@ -108,7 +109,8 @@ const {
disabled = false,
listMaxHeight = '28rem',
popoverMinWidth,
popoverMaxWidth
popoverMaxWidth,
contentStyle
} = defineProps<{
label?: string
options?: SelectOption[]
@@ -126,6 +128,7 @@ const {
popoverMinWidth?: string
/** Maximum width of the popover (default: auto) */
popoverMaxWidth?: string
contentStyle?: StyleValue
}>()
const selectedItem = defineModel<string | undefined>({ required: true })

View File

@@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { effectScope } from 'vue'
import type { EffectScope } from 'vue'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
describe('usePrimeVueOverlayChildStyle', () => {
let scope: EffectScope | undefined
function mountComposable() {
scope = effectScope()
let composable: ReturnType<typeof usePrimeVueOverlayChildStyle> | undefined
scope.run(() => {
composable = usePrimeVueOverlayChildStyle()
})
if (!composable) {
throw new Error('Failed to mount composable')
}
return composable
}
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
scope?.stop()
scope = undefined
document.body.innerHTML = ''
})
it('preserves existing stacking when there is no PrimeVue parent overlay', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = document.createElement('div')
expect(contentStyle.value).toEqual({})
})
it('renders above the closest PrimeVue dialog mask', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-dialog-mask', 5000)
expect(contentStyle.value).toEqual({ zIndex: 5001 })
})
it('renders above the closest PrimeVue overlay mask', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-overlay-mask', 4200)
expect(contentStyle.value).toEqual({ zIndex: 4201 })
})
it('does not drop below the Reka select overlay z-index floor', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-dialog-mask', 1200)
expect(contentStyle.value).toEqual({ zIndex: 3000 })
})
it('preserves existing stacking when the PrimeVue overlay z-index is not numeric', () => {
const { overlayScopeRef, contentStyle } = mountComposable()
overlayScopeRef.value = appendPrimeVueOverlay('p-dialog-mask')
expect(contentStyle.value).toEqual({})
})
})
function appendPrimeVueOverlay(
className: string,
zIndex?: number
): HTMLElement {
const overlay = document.createElement('div')
overlay.className = className
if (zIndex !== undefined) {
overlay.style.zIndex = String(zIndex)
}
const anchor = document.createElement('div')
overlay.append(anchor)
document.body.append(overlay)
return anchor
}

View File

@@ -1,11 +1,13 @@
import { computed } from 'vue'
import type { CSSProperties, ComputedRef } from 'vue'
import { computed, ref } from 'vue'
import type { CSSProperties, ComputedRef, Ref } from 'vue'
interface PopoverSizeOptions {
minWidth?: string
maxWidth?: string
}
const PRIMEVUE_DIALOG_CHILD_Z_INDEX_FLOOR = 3000
/**
* Composable for managing popover sizing styles
* @param options Popover size configuration
@@ -29,3 +31,30 @@ export function usePopoverSizing(
return style
})
}
/**
* Keeps portaled Reka popovers above their containing PrimeVue dialog.
*
* This is a temporary bridge while PrimeVue dialogs and controls are
* incrementally migrated to Reka UI. Once the affected PrimeVue parents are
* migrated, this helper should be removed with the compatibility patch.
*/
export function usePrimeVueOverlayChildStyle(): {
overlayScopeRef: Ref<HTMLElement | null>
contentStyle: ComputedRef<CSSProperties>
} {
const overlayScopeRef = ref<HTMLElement | null>(null)
const contentStyle = computed<CSSProperties>(() => {
const overlay = overlayScopeRef.value?.closest(
'.p-dialog-mask, .p-overlay-mask'
)
if (!overlay) return {}
const zIndex = Number.parseInt(getComputedStyle(overlay).zIndex, 10)
if (!Number.isFinite(zIndex)) return {}
return { zIndex: Math.max(PRIMEVUE_DIALOG_CHILD_Z_INDEX_FLOOR, zIndex + 1) }
})
return { overlayScopeRef, contentStyle }
}

View File

@@ -23,6 +23,7 @@
<template #header>
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex w-full items-center justify-between gap-2"
@click.self="focusedAsset = null"
>
@@ -52,6 +53,7 @@
<AssetFilterBar
:assets="categoryFilteredAssets"
:show-ownership-filter
:content-style="selectContentStyle"
@filter-change="updateFilters"
@click.self="focusedAsset = null"
/>
@@ -72,7 +74,12 @@
</template>
<template #rightPanel>
<ModelInfoPanel v-if="focusedAsset" :asset="focusedAsset" :cache-key />
<ModelInfoPanel
v-if="focusedAsset"
:asset="focusedAsset"
:cache-key
:select-content-style="selectContentStyle"
/>
<div
v-else
class="flex h-full items-center justify-center p-6 text-center wrap-break-word text-muted"
@@ -92,6 +99,7 @@ import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
import ModelInfoPanel from '@/platform/assets/components/modelInfo/ModelInfoPanel.vue'
@@ -109,6 +117,8 @@ const { t } = useI18n()
const assetStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
const breakpoints = useBreakpoints(breakpointsTailwind)
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
const props = defineProps<{
nodeType?: string

View File

@@ -12,6 +12,7 @@
v-model="activeFileFormatObjects"
:label="$t('assetBrowser.fileFormats')"
:options="availableFileFormats"
:content-style="contentStyle"
class="min-w-32"
data-component-id="asset-filter-file-formats"
@update:model-value="handleFilterChange"
@@ -22,6 +23,7 @@
v-model="activeBaseModelObjects"
:label="$t('assetBrowser.baseModels')"
:options="availableBaseModels"
:content-style="contentStyle"
class="min-w-32"
data-component-id="asset-filter-base-models"
@update:model-value="handleFilterChange"
@@ -32,6 +34,7 @@
v-model="ownership"
:label="$t('assetBrowser.ownership')"
:options="ownershipOptions"
:content-style="contentStyle"
class="min-w-32"
data-component-id="asset-filter-ownership"
@update:model-value="handleFilterChange"
@@ -43,6 +46,7 @@
v-model="sortBy"
:label="$t('assetBrowser.sortBy')"
:options="sortOptions"
:content-style="contentStyle"
class="min-w-32"
data-component-id="asset-filter-sort"
@update:model-value="handleFilterChange"
@@ -57,6 +61,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
@@ -78,9 +83,14 @@ const sortOptions = computed(() => [
{ name: t('assetBrowser.sortZA'), value: 'name-desc' as const }
])
const { assets = [], showOwnershipFilter = false } = defineProps<{
const {
assets = [],
showOwnershipFilter = false,
contentStyle
} = defineProps<{
assets?: AssetItem[]
showOwnershipFilter?: boolean
contentStyle?: StyleValue
}>()
const selectedFileFormats = ref<SelectOption[]>([])

View File

@@ -1,5 +1,8 @@
<template>
<div class="flex flex-col gap-4 text-sm text-muted-foreground">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex flex-col gap-4 text-sm text-muted-foreground"
>
<div class="flex flex-col gap-2">
<p class="m-0">
{{ $t('assetBrowser.modelAssociatedWithLink') }}
@@ -39,6 +42,7 @@
"
:options="modelTypes"
:disabled="isLoading"
:content-style="selectContentStyle"
data-attr="upload-model-step2-type-selector"
/>
</div>
@@ -47,6 +51,7 @@
<script setup lang="ts">
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
@@ -58,4 +63,6 @@ defineProps<{
const modelValue = defineModel<string | undefined>()
const { modelTypes, isLoading } = useModelTypes()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
</script>

View File

@@ -77,7 +77,7 @@
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
/>
</SelectTrigger>
<SelectContent>
<SelectContent :style="selectContentStyle">
<SelectItem
v-for="option in modelTypes"
:key="option.value"
@@ -210,6 +210,7 @@
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import { computed, ref, useTemplateRef, watch } from 'vue'
import type { StyleValue } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
@@ -257,9 +258,10 @@ const accordionClass = cn(
'border-t border-border-default bg-modal-panel-background'
)
const { asset, cacheKey } = defineProps<{
const { asset, cacheKey, selectContentStyle } = defineProps<{
asset: AssetDisplayItem
cacheKey?: string
selectContentStyle?: StyleValue
}>()
const assetsStore = useAssetsStore()

View File

@@ -14,16 +14,21 @@
</template>
<template #header>
<div class="flex w-full items-center justify-between gap-2">
<div
:ref="primeVueOverlay.overlayScopeRef"
class="flex w-full items-center justify-between gap-2"
>
<div class="flex w-full items-center gap-2">
<SingleSelect
v-model="searchMode"
class="min-w-34"
:options="filterOptions"
:content-style="selectContentStyle"
/>
<SearchAutocomplete
v-model="searchQuery"
:suggestions="suggestions"
:content-style="selectContentStyle"
:placeholder="$t('manager.searchPlaceholder')"
option-label="query"
autofocus
@@ -87,6 +92,7 @@
v-model="sortField"
:label="$t('g.sort')"
:options="availableSortOptions"
:content-style="selectContentStyle"
class="w-48"
>
<template #icon>
@@ -163,6 +169,7 @@ import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { components } from '@/types/comfyRegistryTypes'
@@ -197,6 +204,8 @@ const { initialTab, initialPackId, onClose } = defineProps<{
provide(OnCloseKey, onClose)
const { t } = useI18n()
const primeVueOverlay = usePrimeVueOverlayChildStyle()
const selectContentStyle = primeVueOverlay.contentStyle
const { buildDocsUrl } = useExternalLink()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()