Updates: More Modal Modification (#8256)

Refactors modal dialog layouts for improved flexibility and consistency.

**Changes:**
- Add dedicated slot for left panel header title with dynamic
content/icons
- Consolidate side panel rendering within `BaseModalLayout`
- Remove redundant `PanelHeader` and `RightSidePanel` components
- Apply `select-none` to text elements to prevent accidental selection

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-01-23 20:41:35 -08:00
committed by GitHub
parent e5583fe955
commit 15655ddb76
19 changed files with 143 additions and 207 deletions

View File

@@ -82,9 +82,7 @@ test.describe('Templates', () => {
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.page
.locator(
'nav > div:nth-child(3) > div > span:has-text("Getting Started")'
)
.getByRole('button', { name: 'Getting Started' })
.click()
await comfyPage.templates.loadTemplate('default')
await expect(comfyPage.templates.content).toBeHidden()

View File

@@ -3,17 +3,14 @@
:content-title="$t('templateWorkflows.title', 'Workflow Templates')"
class="workflow-template-selector-dialog"
>
<template #leftPanelHeaderTitle>
<i class="icon-[comfy--template]" />
<h2 class="text-neutral text-base">
{{ $t('sideToolbar.templates', 'Templates') }}
</h2>
</template>
<template #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems">
<template #header-icon>
<i class="icon-[comfy--template]" />
</template>
<template #header-title>
<span class="text-neutral text-base">{{
$t('sideToolbar.templates', 'Templates')
}}</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="navItems" />
</template>
<template #header>

View File

@@ -117,7 +117,7 @@
</template>
<template #rightPanel>
<RightSidePanel></RightSidePanel>
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4"></div>
</template>
</BaseModalLayout>
</template>
@@ -136,7 +136,6 @@ import SingleSelect from '@/components/input/SingleSelect.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 RightSidePanel from '@/components/widget/panel/RightSidePanel.vue'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'

View File

@@ -15,7 +15,6 @@ import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
import LeftSidePanel from '../panel/LeftSidePanel.vue'
import RightSidePanel from '../panel/RightSidePanel.vue'
import BaseModalLayout from './BaseModalLayout.vue'
interface StoryArgs {
@@ -69,7 +68,6 @@ const createStoryTemplate = (args: StoryArgs) => ({
components: {
BaseModalLayout,
LeftSidePanel,
RightSidePanel,
SearchBox,
MultiSelect,
SingleSelect,
@@ -175,16 +173,15 @@ const createStoryTemplate = (args: StoryArgs) => ({
template: `
<div>
<BaseModalLayout v-if="!args.hasRightPanel" :content-title="args.contentTitle || 'Content Title'">
<!-- Left Panel Header Title -->
<template v-if="args.hasLeftPanel" #leftPanelHeaderTitle>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
<span class="text-neutral text-base">Title</span>
</template>
<!-- Left Panel -->
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation" />
</template>
<!-- Header -->
@@ -299,16 +296,15 @@ const createStoryTemplate = (args: StoryArgs) => ({
<BaseModalLayout v-else :content-title="args.contentTitle || 'Content Title'">
<!-- Same content but WITH right panel -->
<!-- Left Panel Header Title -->
<template v-if="args.hasLeftPanel" #leftPanelHeaderTitle>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
<span class="text-neutral text-base">Title</span>
</template>
<!-- Left Panel -->
<template v-if="args.hasLeftPanel" #leftPanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Title</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedNavItem" :nav-items="tempNavigation" />
</template>
<!-- Header -->
@@ -415,7 +411,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Right Panel - Only when hasRightPanel is true -->
<template #rightPanel>
<RightSidePanel />
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4"></div>
</template>
</BaseModalLayout>
</div>

View File

@@ -8,13 +8,26 @@
:style="gridStyle"
>
<nav
class="h-full overflow-hidden"
class="h-full overflow-hidden bg-modal-panel-background flex flex-col"
:inert="!showLeftPanel"
:aria-hidden="!showLeftPanel"
>
<div v-if="hasLeftPanel" class="h-full min-w-40 max-w-56">
<slot name="leftPanel" />
</div>
<header
data-component-id="LeftPanelHeader"
class="flex w-full h-18 shrink-0 gap-2 pl-6 pr-3 items-center-safe"
>
<slot name="leftPanelHeaderTitle" />
<Button
v-if="!notMobile && showLeftPanel"
size="lg"
class="w-10 p-0 ml-auto"
:aria-label="t('g.hideLeftPanel')"
@click="toggleLeftPanel"
>
<i class="icon-[lucide--panel-left-close]" />
</Button>
</header>
<slot name="leftPanel" />
</nav>
<div class="flex flex-col bg-base-background overflow-hidden">
@@ -24,22 +37,13 @@
>
<div class="flex flex-1 shrink-0 gap-2">
<Button
v-if="!notMobile"
size="icon"
:aria-label="
showLeftPanel ? t('g.hideLeftPanel') : t('g.showLeftPanel')
"
v-if="!notMobile && !showLeftPanel"
size="lg"
class="w-10 p-0"
:aria-label="t('g.showLeftPanel')"
@click="toggleLeftPanel"
>
<i
:class="
cn(
showLeftPanel
? 'icon-[lucide--panel-left]'
: 'icon-[lucide--panel-left-close]'
)
"
/>
<i class="icon-[lucide--panel-left]" />
</Button>
<slot name="header" />
</div>
@@ -69,7 +73,7 @@
<slot name="contentFilter" />
<h2
v-if="!hasLeftPanel"
class="text-xxl m-0 px-6 pt-2 pb-6 capitalize"
class="text-xxl m-0 select-none px-6 pt-2 pb-6 capitalize"
>
{{ contentTitle }}
</h2>
@@ -94,7 +98,10 @@
data-component-id="RightPanelHeader"
class="flex h-18 shrink-0 items-center gap-2 px-6"
>
<h2 v-if="rightPanelTitle" class="flex-1 text-base font-semibold">
<h2
v-if="rightPanelTitle"
class="flex-1 select-none text-base font-semibold"
>
{{ rightPanelTitle }}
</h2>
<div v-else class="flex-1">
@@ -134,7 +141,6 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { OnCloseKey } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()

View File

@@ -5,7 +5,7 @@
disabled: !isOverflowing,
pt: { text: { class: 'whitespace-nowrap' } }
}"
class="flex cursor-pointer items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
class="flex cursor-pointer select-none items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
:class="
active
? 'bg-interface-menu-component-surface-selected'

View File

@@ -7,20 +7,6 @@ const meta: Meta<typeof LeftSidePanel> = {
title: 'Components/Widget/Panel/LeftSidePanel',
component: LeftSidePanel,
argTypes: {
'header-icon': {
table: {
type: { summary: 'slot' },
defaultValue: { summary: 'undefined' }
},
control: false
},
'header-title': {
table: {
type: { summary: 'slot' },
defaultValue: { summary: 'undefined' }
},
control: false
},
'onUpdate:modelValue': {
table: { disable: true }
}
@@ -59,14 +45,7 @@ export const Default: Story = {
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Navigation</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
</div>
`
})
@@ -126,14 +105,7 @@ export const WithGroups: Story = {
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--puzzle] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Model Selector</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
<div class="mt-4 p-2 text-sm">
Selected: {{ selectedItem }}
</div>
@@ -176,14 +148,7 @@ export const DefaultIcons: Story = {
},
template: `
<div style="height: 400px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--folder] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Files</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
</div>
`
})
@@ -228,14 +193,7 @@ export const LongLabels: Story = {
},
template: `
<div style="height: 500px; width: 256px;">
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems">
<template #header-icon>
<i class="icon-[lucide--settings] size-4 text-neutral" />
</template>
<template #header-title>
<span class="text-neutral text-base">Settings</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedItem" :nav-items="args.navItems" />
</div>
`
})

View File

@@ -1,47 +1,41 @@
<template>
<div class="flex h-full w-full flex-col bg-modal-panel-background">
<PanelHeader>
<template #icon>
<slot name="header-icon"></slot>
</template>
<slot name="header-title"></slot>
</PanelHeader>
<nav
class="flex scrollbar-hide flex-1 flex-col gap-1 overflow-y-auto px-3 py-4"
<div
class="flex w-full flex-auto overflow-y-auto gap-1 min-h-0 flex-col bg-modal-panel-background scrollbar-hide px-3"
>
<template
v-for="item in navItems"
:key="'title' in item ? item.title : item.id"
>
<template v-for="(item, index) in navItems" :key="index">
<div v-if="'items' in item" class="flex flex-col gap-2">
<NavTitle
v-model="collapsedGroups[item.title]"
:title="item.title"
:collapsible="item.collapsible"
/>
<template v-if="!item.collapsible || !collapsedGroups[item.title]">
<NavItem
v-for="subItem in item.items"
:key="subItem.id"
:icon="subItem.icon"
:badge="subItem.badge"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
{{ subItem.label }}
</NavItem>
</template>
</div>
<div v-else class="flex flex-col gap-2">
<div v-if="'items' in item" class="flex flex-col gap-2">
<NavTitle
v-model="collapsedGroups[item.title]"
:title="item.title"
:collapsible="item.collapsible"
/>
<template v-if="!item.collapsible || !collapsedGroups[item.title]">
<NavItem
:icon="item.icon"
:badge="item.badge"
:active="activeItem === item.id"
@click="activeItem = item.id"
v-for="subItem in item.items"
:key="subItem.id"
:icon="subItem.icon"
:badge="subItem.badge"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
{{ item.label }}
{{ subItem.label }}
</NavItem>
</div>
</template>
</nav>
</template>
</div>
<div v-else class="flex flex-col gap-2">
<NavItem
:icon="item.icon"
:badge="item.badge"
:active="activeItem === item.id"
@click="activeItem = item.id"
>
{{ item.label }}
</NavItem>
</div>
</template>
</div>
</template>
@@ -52,8 +46,6 @@ import NavItem from '@/components/widget/nav/NavItem.vue'
import NavTitle from '@/components/widget/nav/NavTitle.vue'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import PanelHeader from './PanelHeader.vue'
const { navItems = [], modelValue } = defineProps<{
navItems?: (NavItemData | NavGroupData)[]
modelValue?: string | null

View File

@@ -1,12 +0,0 @@
<template>
<header class="flex h-16 items-center justify-between px-6">
<div class="flex items-center gap-2 pl-1">
<slot name="icon">
<i class="text-neutral icon-[lucide--puzzle] text-base" />
</slot>
<h2 class="text-neutral text-base font-bold">
<slot></slot>
</h2>
</div>
</header>
</template>

View File

@@ -1,5 +0,0 @@
<template>
<div class="size-full bg-modal-panel-background pr-6 pb-8 pl-4">
<slot></slot>
</div>
</template>

View File

@@ -1,5 +1,7 @@
<template>
<div class="absolute left-2 bottom-2 flex flex-wrap justify-start gap-1">
<div
class="absolute left-2 bottom-2 flex flex-wrap justify-start gap-1 select-none"
>
<span
v-for="badge in badges"
:key="badge.label"

View File

@@ -7,19 +7,18 @@
:right-panel-title="$t('assetBrowser.modelInfo.title')"
@close="handleClose"
>
<template v-if="shouldShowLeftPanel" #leftPanelHeaderTitle>
<i class="icon-[comfy--ai-model] size-4" />
<h2 class="flex-auto select-none text-base font-semibold text-nowrap">
{{ displayTitle }}
</h2>
</template>
<template v-if="shouldShowLeftPanel" #leftPanel>
<LeftSidePanel
v-model="selectedNavItem"
data-component-id="AssetBrowserModal-LeftSidePanel"
:nav-items
>
<template #header-icon>
<div class="icon-[comfy--ai-model] size-4" />
</template>
<template #header-title>
<span class="capitalize">{{ displayTitle }}</span>
</template>
</LeftSidePanel>
/>
</template>
<template #header>

View File

@@ -7,7 +7,7 @@
:tabindex="interactive ? 0 : -1"
:class="
cn(
'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
'select-none rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
interactive &&
'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4',
focused && 'bg-secondary-background outline-solid'

View File

@@ -79,8 +79,9 @@ const fileFormats = ref<SelectOption[]>([])
const baseModels = ref<SelectOption[]>([])
const sortBy = ref<SortOption>('recent')
const { availableFileFormats, availableBaseModels } =
useAssetFilterOptions(assets)
const { availableFileFormats, availableBaseModels } = useAssetFilterOptions(
() => assets
)
const emit = defineEmits<{
filterChange: [filters: FilterState]

View File

@@ -15,7 +15,7 @@
</div>
<div
v-else-if="assets.length === 0"
class="flex h-full flex-col items-center justify-center py-16 text-muted-foreground"
class="flex h-full select-none 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">

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col gap-2 px-4 py-2 text-sm text-base-foreground">
<div class="flex items-center justify-between relative">
<span>{{ label }}</span>
<span class="select-none">{{ label }}</span>
<slot name="label-action" />
</div>
<slot />

View File

@@ -5,7 +5,7 @@
>
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="text-xs uppercase font-inter">
<span class="text-xs uppercase font-inter select-none">
{{ t('assetBrowser.modelInfo.basicInfo') }}
</span>
</template>
@@ -58,7 +58,7 @@
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="text-xs uppercase font-inter">
<span class="text-xs uppercase font-inter select-none">
{{ t('assetBrowser.modelInfo.modelTagging') }}
</span>
</template>
@@ -134,7 +134,7 @@
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="text-xs uppercase font-inter">
<span class="text-xs uppercase font-inter select-none">
{{ t('assetBrowser.modelInfo.modelDescription') }}
</span>
</template>

View File

@@ -279,12 +279,15 @@ export const useAssetsStore = defineStore('assets', () => {
const pendingRequestByKey = new Map<string, ModelPaginationState>()
function createState(): ModelPaginationState {
function createState(
existingAssets?: Map<string, AssetItem>
): ModelPaginationState {
const assets = new Map(existingAssets)
return reactive({
assets: new Map(),
assets,
offset: 0,
hasMore: true,
isLoading: false
isLoading: true
})
}
@@ -336,8 +339,10 @@ export const useAssetsStore = defineStore('assets', () => {
key: string,
fetcher: (options: PaginationOptions) => Promise<AssetItem[]>
): Promise<void> {
const state = createState()
state.isLoading = true
const existingState = modelStateByKey.value.get(key)
const state = createState(existingState?.assets)
const seenIds = new Set<string>()
const hasExistingData = modelStateByKey.value.has(key)
if (hasExistingData) {
@@ -363,19 +368,15 @@ export const useAssetsStore = defineStore('assets', () => {
pendingRequestByKey.delete(key)
modelStateByKey.value.set(key, state)
}
state.assets = new Map(newAssets.map((a) => [a.id, a]))
} else {
const assetsToAdd = newAssets.filter(
(a) => !state.assets.has(a.id)
)
if (assetsToAdd.length > 0) {
assetsArrayCache.delete(key)
for (const asset of assetsToAdd) {
state.assets.set(asset.id, asset)
}
}
}
// Merge new assets into existing map and track seen IDs
for (const asset of newAssets) {
seenIds.add(asset.id)
state.assets.set(asset.id, asset)
}
state.assets = new Map(state.assets)
state.offset += newAssets.length
state.hasMore = newAssets.length === MODEL_BATCH_SIZE
@@ -388,17 +389,24 @@ export const useAssetsStore = defineStore('assets', () => {
}
} catch (err) {
if (isStale(key, state)) return
console.error(`Error loading batch for ${key}:`, err)
state.error = err instanceof Error ? err : new Error(String(err))
state.hasMore = false
console.error(`Error loading batch for ${key}:`, err)
if (state.offset === 0) {
state.isLoading = false
pendingRequestByKey.delete(key)
// TODO: Add toast indicator for first-batch load failures
}
state.isLoading = false
pendingRequestByKey.delete(key)
return
}
}
const staleIds = [...state.assets.keys()].filter(
(id) => !seenIds.has(id)
)
for (const id of staleIds) {
state.assets.delete(id)
}
assetsArrayCache.delete(key)
}
await loadBatches()

View File

@@ -4,15 +4,12 @@
:content-title="$t('manager.discoverCommunityContent')"
class="manager-dialog"
>
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--puzzle]" />
<h2 class="text-neutral text-base">{{ $t('manager.title') }}</h2>
</template>
<template #leftPanel>
<LeftSidePanel v-model="selectedNavId" :nav-items="navItems">
<template #header-icon>
<i class="icon-[lucide--puzzle]" />
</template>
<template #header-title>
<span class="text-neutral text-base">{{ $t('manager.title') }}</span>
</template>
</LeftSidePanel>
<LeftSidePanel v-model="selectedNavId" :nav-items="navItems" />
</template>
<template #header>