mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
refactor: restructure BaseModalLayout from flexbox to CSS Grid (#8211)
## Summary Refactors `BaseModalLayout` from a flexbox-based layout to CSS Grid, enabling smoother panel transitions and improved layout control. ## Changes ### Layout Restructure - **Flexbox → CSS Grid**: Replaced nested flexbox with a 3-column CSS Grid (`nav | main | aside`) - **Smooth panel transitions**: Panel show/hide now animates via `grid-template-columns` instead of Vue `<Transition>` with `translateX` - **Removed transition CSS**: Deleted `.slide-panel-*` and `.fade-*` transition styles (no longer needed with grid approach) ### Right Panel Improvements - **Dedicated header**: Added header with close button, right panel toggle, and customizable title slot (`rightPanelHeaderTitle`, `rightPanelHeaderActions`) - **New prop**: Added `rightPanelTitle` prop for simple text title in right panel header ### UX & Accessibility - **ESC key handling**: Pressing Escape closes the right panel (if open) before closing the dialog - **Accessibility**: Added `aria-label` attributes to all panel toggle and close buttons - **i18n**: Added translation keys: `showLeftPanel`, `hideLeftPanel`, `showRightPanel`, `hideRightPanel`, `closeDialog` --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -189,9 +189,7 @@ test.describe('Templates', () => {
|
|||||||
const templateGrid = comfyPage.page.locator(
|
const templateGrid = comfyPage.page.locator(
|
||||||
'[data-testid="template-workflows-content"]'
|
'[data-testid="template-workflows-content"]'
|
||||||
)
|
)
|
||||||
const nav = comfyPage.page
|
const nav = comfyPage.page.locator('header', { hasText: 'Templates' })
|
||||||
.locator('header')
|
|
||||||
.filter({ hasText: 'Templates' })
|
|
||||||
|
|
||||||
await comfyPage.templates.waitForMinimumCardCount(1)
|
await comfyPage.templates.waitForMinimumCardCount(1)
|
||||||
await expect(templateGrid).toBeVisible()
|
await expect(templateGrid).toBeVisible()
|
||||||
@@ -201,7 +199,8 @@ test.describe('Templates', () => {
|
|||||||
await comfyPage.page.setViewportSize(mobileSize)
|
await comfyPage.page.setViewportSize(mobileSize)
|
||||||
await comfyPage.templates.waitForMinimumCardCount(1)
|
await comfyPage.templates.waitForMinimumCardCount(1)
|
||||||
await expect(templateGrid).toBeVisible()
|
await expect(templateGrid).toBeVisible()
|
||||||
await expect(nav).not.toBeVisible() // Nav should collapse at mobile size
|
// Nav header is clipped by overflow-hidden parent at mobile size
|
||||||
|
await expect(nav).not.toBeInViewport()
|
||||||
|
|
||||||
const tabletSize = { width: 1024, height: 800 }
|
const tabletSize = { width: 1024, height: 800 }
|
||||||
await comfyPage.page.setViewportSize(tabletSize)
|
await comfyPage.page.setViewportSize(tabletSize)
|
||||||
|
|||||||
@@ -1,46 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="base-widget-layout rounded-2xl overflow-hidden relative">
|
<div
|
||||||
<Button
|
class="base-widget-layout rounded-2xl overflow-hidden relative"
|
||||||
v-show="!isRightPanelOpen && hasRightPanel"
|
@keydown.esc.capture="handleEscape"
|
||||||
size="lg"
|
|
||||||
:class="
|
|
||||||
cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
|
|
||||||
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
|
|
||||||
})
|
|
||||||
"
|
|
||||||
@click="toggleRightPanel"
|
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--panel-right]" />
|
<div
|
||||||
</Button>
|
class="grid h-full w-full transition-[grid-template-columns] duration-300 ease-out"
|
||||||
<Button
|
:style="gridStyle"
|
||||||
size="lg"
|
|
||||||
class="absolute top-4 right-6 z-10 transition-opacity duration-200 w-10"
|
|
||||||
@click="closeDialog"
|
|
||||||
>
|
>
|
||||||
<i class="pi pi-times" />
|
|
||||||
</Button>
|
|
||||||
<div class="flex h-full w-full">
|
|
||||||
<Transition name="slide-panel">
|
|
||||||
<nav
|
<nav
|
||||||
v-if="$slots.leftPanel && showLeftPanel"
|
class="h-full overflow-hidden"
|
||||||
:class="[
|
:inert="!showLeftPanel"
|
||||||
PANEL_SIZES.width,
|
:aria-hidden="!showLeftPanel"
|
||||||
PANEL_SIZES.minWidth,
|
|
||||||
PANEL_SIZES.maxWidth
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<slot name="leftPanel"></slot>
|
<div v-if="hasLeftPanel" class="h-full min-w-40 max-w-56">
|
||||||
|
<slot name="leftPanel" />
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<div class="flex-1 flex bg-base-background">
|
<div class="flex flex-col bg-base-background overflow-hidden">
|
||||||
<div class="flex h-full w-full flex-col">
|
|
||||||
<header
|
<header
|
||||||
v-if="$slots.header"
|
v-if="$slots.header"
|
||||||
class="w-full h-18 px-6 flex items-center justify-between gap-2"
|
class="w-full h-18 px-6 flex items-center justify-between gap-2"
|
||||||
>
|
>
|
||||||
<div class="flex flex-1 shrink-0 gap-2">
|
<div class="flex flex-1 shrink-0 gap-2">
|
||||||
<Button v-if="!notMobile" size="icon" @click="toggleLeftPanel">
|
<Button
|
||||||
|
v-if="!notMobile"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="
|
||||||
|
showLeftPanel ? t('g.hideLeftPanel') : t('g.showLeftPanel')
|
||||||
|
"
|
||||||
|
@click="toggleLeftPanel"
|
||||||
|
>
|
||||||
<i
|
<i
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
@@ -51,32 +41,34 @@
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<slot name="header"></slot>
|
<slot name="header" />
|
||||||
</div>
|
</div>
|
||||||
<slot name="header-right-area"></slot>
|
<slot name="header-right-area" />
|
||||||
<div
|
<template v-if="!isRightPanelOpen">
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'flex justify-end gap-2 w-0',
|
|
||||||
hasRightPanel && !isRightPanelOpen ? 'min-w-22' : 'min-w-10'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
v-if="isRightPanelOpen && hasRightPanel"
|
v-if="hasRightPanel"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
class="w-10 p-0"
|
||||||
|
:aria-label="t('g.showRightPanel')"
|
||||||
@click="toggleRightPanel"
|
@click="toggleRightPanel"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--panel-right-close]" />
|
<i class="icon-[lucide--panel-right] size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<Button
|
||||||
|
size="lg"
|
||||||
|
class="w-10"
|
||||||
|
:aria-label="t('g.closeDialog')"
|
||||||
|
@click="closeDialog"
|
||||||
|
>
|
||||||
|
<i class="pi pi-times" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 flex-col">
|
<main class="flex min-h-0 flex-1 flex-col">
|
||||||
<!-- Fallback title bar when no leftPanel is provided -->
|
<slot name="contentFilter" />
|
||||||
<slot name="contentFilter"></slot>
|
|
||||||
<h2
|
<h2
|
||||||
v-if="!$slots.leftPanel"
|
v-if="!hasLeftPanel"
|
||||||
class="text-xxl m-0 px-6 pt-2 pb-6 capitalize"
|
class="text-xxl m-0 px-6 pt-2 pb-6 capitalize"
|
||||||
>
|
>
|
||||||
{{ contentTitle }}
|
{{ contentTitle }}
|
||||||
@@ -84,17 +76,53 @@
|
|||||||
<div
|
<div
|
||||||
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
|
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
|
||||||
>
|
>
|
||||||
<slot name="content"></slot>
|
<slot name="content" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
v-if="hasRightPanel && isRightPanelOpen"
|
v-if="hasRightPanel"
|
||||||
class="w-1/4 min-w-40 max-w-80 pt-16 pb-8"
|
class="overflow-hidden"
|
||||||
|
:inert="!isRightPanelOpen"
|
||||||
|
:aria-hidden="!isRightPanelOpen"
|
||||||
>
|
>
|
||||||
<slot name="rightPanel"></slot>
|
<div
|
||||||
</aside>
|
class="min-w-72 w-72 flex flex-col bg-modal-panel-background h-full"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
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">
|
||||||
|
{{ rightPanelTitle }}
|
||||||
|
</h2>
|
||||||
|
<div v-else class="flex-1">
|
||||||
|
<slot name="rightPanelHeaderTitle" />
|
||||||
</div>
|
</div>
|
||||||
|
<slot name="rightPanelHeaderActions" />
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
class="w-10 p-0"
|
||||||
|
:aria-label="t('g.hideRightPanel')"
|
||||||
|
@click="toggleRightPanel"
|
||||||
|
>
|
||||||
|
<i class="icon-[lucide--panel-right-close] size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
class="w-10 p-0"
|
||||||
|
:aria-label="t('g.closeDialog')"
|
||||||
|
@click="closeDialog"
|
||||||
|
>
|
||||||
|
<i class="pi pi-times" />
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<slot name="rightPanel" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -102,27 +130,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useBreakpoints } from '@vueuse/core'
|
import { useBreakpoints } from '@vueuse/core'
|
||||||
import { computed, inject, ref, useSlots, watch } from 'vue'
|
import { computed, inject, ref, useSlots, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { OnCloseKey } from '@/types/widgetTypes'
|
import { OnCloseKey } from '@/types/widgetTypes'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
const { contentTitle } = defineProps<{
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const { contentTitle, rightPanelTitle } = defineProps<{
|
||||||
contentTitle: string
|
contentTitle: string
|
||||||
|
rightPanelTitle?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
|
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
|
||||||
default: false
|
default: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const BREAKPOINTS = { md: 880 }
|
|
||||||
const PANEL_SIZES = {
|
|
||||||
width: 'w-1/3',
|
|
||||||
minWidth: 'min-w-40',
|
|
||||||
maxWidth: 'max-w-56'
|
|
||||||
}
|
|
||||||
|
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
|
const hasLeftPanel = computed(() => !!slots.leftPanel)
|
||||||
|
const hasRightPanel = computed(() => !!slots.rightPanel)
|
||||||
|
|
||||||
|
const BREAKPOINTS = { md: 880 }
|
||||||
|
|
||||||
const closeDialog = inject(OnCloseKey, () => {})
|
const closeDialog = inject(OnCloseKey, () => {})
|
||||||
|
|
||||||
const breakpoints = useBreakpoints(BREAKPOINTS)
|
const breakpoints = useBreakpoints(BREAKPOINTS)
|
||||||
@@ -131,8 +161,6 @@ const notMobile = breakpoints.greater('md')
|
|||||||
const isLeftPanelOpen = ref<boolean>(true)
|
const isLeftPanelOpen = ref<boolean>(true)
|
||||||
const mobileMenuOpen = ref<boolean>(false)
|
const mobileMenuOpen = ref<boolean>(false)
|
||||||
|
|
||||||
const hasRightPanel = computed(() => !!slots.rightPanel)
|
|
||||||
|
|
||||||
watch(notMobile, (isDesktop) => {
|
watch(notMobile, (isDesktop) => {
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
mobileMenuOpen.value = false
|
mobileMenuOpen.value = false
|
||||||
@@ -146,6 +174,12 @@ const showLeftPanel = computed(() => {
|
|||||||
return shouldShow
|
return shouldShow
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const gridStyle = computed(() => ({
|
||||||
|
gridTemplateColumns: hasRightPanel.value
|
||||||
|
? `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
|
||||||
|
: `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr`
|
||||||
|
}))
|
||||||
|
|
||||||
const toggleLeftPanel = () => {
|
const toggleLeftPanel = () => {
|
||||||
if (notMobile.value) {
|
if (notMobile.value) {
|
||||||
isLeftPanelOpen.value = !isLeftPanelOpen.value
|
isLeftPanelOpen.value = !isLeftPanelOpen.value
|
||||||
@@ -157,6 +191,23 @@ const toggleLeftPanel = () => {
|
|||||||
const toggleRightPanel = () => {
|
const toggleRightPanel = () => {
|
||||||
isRightPanelOpen.value = !isRightPanelOpen.value
|
isRightPanelOpen.value = !isRightPanelOpen.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEscape(event: KeyboardEvent) {
|
||||||
|
const target = event.target
|
||||||
|
if (!(target instanceof HTMLElement)) return
|
||||||
|
if (
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
target instanceof HTMLSelectElement ||
|
||||||
|
target.isContentEditable
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isRightPanelOpen.value) {
|
||||||
|
event.stopPropagation()
|
||||||
|
isRightPanelOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.base-widget-layout {
|
.base-widget-layout {
|
||||||
@@ -171,28 +222,4 @@ const toggleRightPanel = () => {
|
|||||||
max-width: 1724px;
|
max-width: 1724px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fade transition for buttons */
|
|
||||||
.fade-enter-active,
|
|
||||||
.fade-leave-active {
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-from,
|
|
||||||
.fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slide transition for left panel */
|
|
||||||
.slide-panel-enter-active,
|
|
||||||
.slide-panel-leave-active {
|
|
||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
will-change: transform;
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-panel-enter-from,
|
|
||||||
.slide-panel-leave-to {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -100,6 +100,11 @@
|
|||||||
"no": "No",
|
"no": "No",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
|
"closeDialog": "Close dialog",
|
||||||
|
"showLeftPanel": "Show left panel",
|
||||||
|
"hideLeftPanel": "Hide left panel",
|
||||||
|
"showRightPanel": "Show right panel",
|
||||||
|
"hideRightPanel": "Hide right panel",
|
||||||
"or": "or",
|
"or": "or",
|
||||||
"pressKeysForNewBinding": "Press keys for new binding",
|
"pressKeysForNewBinding": "Press keys for new binding",
|
||||||
"defaultBanner": "default banner",
|
"defaultBanner": "default banner",
|
||||||
|
|||||||
Reference in New Issue
Block a user