feat: unify sidebar panel header layout with SidebarTopArea component (#9740)

## Summary

Unify the search bar + action buttons layout across all left sidebar
panels (Node Library, Workflows, Model Library, Media Assets) using a
shared `SidebarTopArea` presentation component.

## Changes

- **What**:
- Add `SidebarTopArea.vue` — layout component with `flex-1` default slot
(search) and `#actions` slot (buttons), plus optional `bottomDivider`
prop
- Replace raw `<button>` elements in Node Library with `<Button
variant="secondary" size="icon">`
- Replace reka-ui `TabsTrigger` with shared `Tab/TabList` component in
Node Library
- Move Media Assets tab list from hover-only `#tool-buttons` to
always-visible header below search area
- Unify spacing (`gap-2`, `p-2 2xl:px-4`) and divider styles across all
sidebar panels
- Remove unused `assetType` prop and header from
`AssetsSidebarGridView`/`AssetsSidebarListView`

## Review Focus

- `SidebarTopArea` API simplicity — just slots + one optional prop
- Node Library still requires `TabsRoot` in the body for reka-ui
`TabsContent` in child panels
- Media Assets tabs are now always visible instead of hover-only

[screen-capture
(1).webm](https://github.com/user-attachments/assets/fe1d8f7b-5674-4bb3-9842-569e4c3af6c9)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9740-feat-unify-sidebar-panel-header-layout-with-SidebarTopArea-component-3206d73d365081ea8ba7fd6ac54e0169)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Jin Yi
2026-03-13 22:32:18 +09:00
committed by GitHub
parent 5167a511ce
commit 10b0350d01
19 changed files with 226 additions and 218 deletions

View File

@@ -1,20 +1,5 @@
<template>
<div class="flex h-full flex-col">
<!-- Assets Header -->
<div v-if="assets.length" class="px-2 2xl:px-4">
<div
class="flex items-center py-2 font-inter text-sm/normal font-normal text-muted-foreground"
>
{{
t(
assetType === 'input'
? 'sideToolbar.importedAssetsHeader'
: 'sideToolbar.generatedAssetsHeader'
)
}}
</div>
</div>
<!-- Assets Grid -->
<VirtualGrid
class="flex-1"
@@ -40,22 +25,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const {
assets,
isSelected,
assetType = 'output',
showOutputCount,
getOutputCount
} = defineProps<{
const { assets, isSelected, showOutputCount, getOutputCount } = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
assetType?: 'input' | 'output'
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
}>()
@@ -68,8 +45,6 @@ const emit = defineEmits<{
(e: 'output-count-click', asset: AssetItem): void
}>()
const { t } = useI18n()
type AssetGridItem = { key: string; asset: AssetItem }
const assetItems = computed<AssetGridItem[]>(() =>

View File

@@ -50,8 +50,7 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
selectableAssets: [],
isSelected: () => false,
isStackExpanded: () => false,
toggleStack: async () => {},
assetType: 'output'
toggleStack: async () => {}
},
global: {
stubs: {
@@ -61,18 +60,6 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
})
describe('AssetsSidebarListView', () => {
it('shows generated assets header when there are assets', () => {
const wrapper = mountListView([buildOutputItem(buildAsset('a1', 'x.png'))])
expect(wrapper.text()).toContain('sideToolbar.generatedAssetsHeader')
})
it('does not show assets header when there are no assets', () => {
const wrapper = mountListView([])
expect(wrapper.text()).not.toContain('sideToolbar.generatedAssetsHeader')
})
it('marks mp4 assets as video previews', () => {
const videoAsset = {
...buildAsset('video-asset', 'clip.mp4'),

View File

@@ -1,19 +1,5 @@
<template>
<div class="flex h-full flex-col">
<div v-if="assetItems.length" class="px-2">
<div
class="flex items-center p-2 font-inter text-sm/normal font-normal text-muted-foreground"
>
{{
t(
assetType === 'input'
? 'sideToolbar.importedAssetsHeader'
: 'sideToolbar.generatedAssetsHeader'
)
}}
</div>
</div>
<VirtualGrid
class="flex-1"
:items="assetItems"
@@ -106,15 +92,13 @@ const {
selectableAssets,
isSelected,
isStackExpanded,
toggleStack,
assetType = 'output'
toggleStack
} = defineProps<{
assetItems: OutputStackListItem[]
selectableAssets: AssetItem[]
isSelected: (assetId: string) => boolean
isStackExpanded: (asset: AssetItem) => boolean
toggleStack: (asset: AssetItem) => Promise<void>
assetType?: 'input' | 'output'
}>()
const assetsStore = useAssetsStore()

View File

@@ -24,17 +24,6 @@
</div>
</div>
</template>
<template #tool-buttons>
<!-- Normal Tab View -->
<TabList v-if="!isInFolderView" v-model="activeTab">
<Tab class="font-inter" value="output">{{
$t('sideToolbar.labels.generated')
}}</Tab>
<Tab class="font-inter" value="input">{{
$t('sideToolbar.labels.imported')
}}</Tab>
</TabList>
</template>
<template #header>
<!-- Job Detail View Header -->
<div v-if="isInFolderView" class="px-2 2xl:px-4">
@@ -50,15 +39,24 @@
v-model:sort-by="sortBy"
v-model:view-mode="viewMode"
v-model:media-type-filters="mediaTypeFilters"
class="px-2 pb-1 2xl:px-4"
bottom-divider
:show-generation-time-sort="activeTab === 'output'"
/>
<Divider type="dashed" class="my-2" />
<!-- Tab list -->
<div
v-if="!isInFolderView"
class="border-b border-comfy-input p-2 2xl:px-4"
>
<TabList v-model="activeTab">
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
</TabList>
</div>
</template>
<template #body>
<div
v-if="showLoadingState"
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 px-2"
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 p-2"
>
<div
v-for="n in skeletonCount"
@@ -85,7 +83,11 @@
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<div
v-else
class="relative size-full py-2"
@click="handleEmptySpaceClick"
>
<AssetsSidebarListView
v-if="isListView"
:asset-items="listViewAssetItems"
@@ -93,7 +95,6 @@
:selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded"
:toggle-stack="toggleListViewStack"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@preview-asset="handleZoomClick"
@context-menu="handleAssetContextMenu"
@@ -103,7 +104,6 @@
v-else
:assets="displayAssets"
:is-selected="isSelected"
:asset-type="activeTab"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@select-asset="handleAssetSelect"
@@ -203,7 +203,6 @@ import {
useStorage,
useTimeoutFn
} from '@vueuse/core'
import Divider from 'primevue/divider'
import { useToast } from 'primevue/usetoast'
import {
computed,

View File

@@ -20,15 +20,14 @@
</Button>
</template>
<template #header>
<div class="px-2 2xl:px-4">
<SidebarTopArea>
<SearchInput
ref="searchBoxRef"
v-model:model-value="searchQuery"
class="workflows-search-box"
:placeholder="$t('g.searchPlaceholder', { subject: searchSubject })"
@search="handleSearch"
/>
</div>
</SidebarTopArea>
</template>
<template #body>
<div v-if="!isSearching" class="comfyui-workflows-panel">
@@ -147,6 +146,7 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import TextDivider from '@/components/common/TextDivider.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'

View File

@@ -21,7 +21,7 @@
</Button>
</template>
<template #header>
<div class="px-2 2xl:px-4">
<SidebarTopArea>
<SearchInput
ref="searchBoxRef"
v-model:model-value="searchQuery"
@@ -32,7 +32,7 @@
"
@search="handleSearch"
/>
</div>
</SidebarTopArea>
</template>
<template #body>
<ElectronDownloadItems v-if="isDesktop" />
@@ -57,6 +57,7 @@ import { Divider } from 'primevue'
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'

View File

@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -96,12 +95,6 @@ describe('NodeLibrarySidebarTabV2', () => {
return mount(NodeLibrarySidebarTabV2, {
global: {
plugins: [createTestingPinia({ stubActions: false }), i18n],
components: {
TabsRoot,
TabsList,
TabsTrigger,
TabsContent
},
stubs: {
teleport: true
}
@@ -112,7 +105,7 @@ describe('NodeLibrarySidebarTabV2', () => {
it('should render with tabs', () => {
const wrapper = mountComponent()
const triggers = wrapper.findAllComponents(TabsTrigger)
const triggers = wrapper.findAll('[role="tab"]')
expect(triggers).toHaveLength(3)
})

View File

@@ -1,22 +1,23 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.nodes')">
<template #header>
<TabsRoot v-model="selectedTab" class="flex flex-col">
<div class="flex items-center justify-between gap-2 px-2 pb-2 2xl:px-4">
<SearchInput
ref="searchBoxRef"
v-model="searchQuery"
:placeholder="$t('g.search') + '...'"
@search="handleSearch"
/>
<SidebarTopArea bottom-divider>
<SearchInput
ref="searchBoxRef"
v-model="searchQuery"
:placeholder="$t('g.search') + '...'"
@search="handleSearch"
/>
<template #actions>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
<Button
variant="secondary"
size="icon"
:aria-label="$t('g.sort')"
class="hover:bg-comfy-input-hover flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-comfy-input"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
</button>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
@@ -42,12 +43,13 @@
</DropdownMenuRoot>
<DropdownMenuRoot v-if="selectedTab === 'all'">
<DropdownMenuTrigger as-child>
<button
<Button
variant="secondary"
size="icon"
:aria-label="$t('sideToolbar.nodeLibraryTab.filter')"
class="hover:bg-comfy-input-hover flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg border-none bg-comfy-input"
>
<i class="icon-[lucide--list-filter] size-4" />
</button>
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
@@ -102,65 +104,55 @@
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<Separator decorative class="border border-dashed border-comfy-input" />
<!-- Tab list in header (fixed) -->
<TabsList
class="bg-background flex gap-4 border-b border-comfy-input p-4"
>
<TabsTrigger
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
:class="
cn(
'cursor-pointer rounded-lg border-none px-3 py-2 outline-none select-none',
'text-foreground text-sm transition-colors',
selectedTab === tab.value
? 'bg-comfy-input font-bold'
: 'bg-transparent font-normal'
)
"
>
</template>
</SidebarTopArea>
<div class="border-b border-comfy-input p-2 2xl:px-4">
<TabList v-model="selectedTab">
<Tab v-for="tab in tabs" :key="tab.value" :value="tab.value">
{{ tab.label }}
</TabsTrigger>
</TabsList>
</TabsRoot>
</Tab>
</TabList>
</div>
</template>
<template #body>
<NodeDragPreview />
<!-- Tab content (scrollable) -->
<TabsRoot v-model="selectedTab" class="h-full">
<EssentialNodesPanel
v-if="
flags.nodeLibraryEssentialsEnabled && selectedTab === 'essentials'
"
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
:flat-nodes="essentialFlatNodes"
@node-click="handleNodeClick"
/>
<AllNodesPanel
v-if="selectedTab === 'all'"
v-model:expanded-keys="expandedKeys"
:sections="renderedSections"
:fill-node-info="fillNodeInfo"
:sort-order="sortOrder"
@node-click="handleNodeClick"
/>
<BlueprintsPanel
v-if="selectedTab === 'blueprints'"
v-model:expanded-keys="expandedKeys"
:sections="renderedBlueprintsSections"
@node-click="handleNodeClick"
/>
</TabsRoot>
<div class="flex h-full flex-col">
<div class="min-h-0 flex-1 overflow-y-auto py-2">
<TabPanel
v-if="flags.nodeLibraryEssentialsEnabled"
:model-value="selectedTab"
value="essentials"
>
<EssentialNodesPanel
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
:flat-nodes="essentialFlatNodes"
@node-click="handleNodeClick"
/>
</TabPanel>
<TabPanel :model-value="selectedTab" value="all">
<AllNodesPanel
v-model:expanded-keys="expandedKeys"
:sections="renderedSections"
:fill-node-info="fillNodeInfo"
:sort-order="sortOrder"
@node-click="handleNodeClick"
/>
</TabPanel>
<TabPanel :model-value="selectedTab" value="blueprints">
<BlueprintsPanel
v-model:expanded-keys="expandedKeys"
:sections="renderedBlueprintsSections"
@node-click="handleNodeClick"
/>
</TabPanel>
</div>
</div>
</template>
</SidebarTabTemplate>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import { useLocalStorage } from '@vueuse/core'
import {
DropdownMenuCheckboxItem,
@@ -170,17 +162,18 @@ import {
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRoot,
DropdownMenuTrigger,
Separator,
TabsList,
TabsRoot,
TabsTrigger
DropdownMenuTrigger
} from 'reka-ui'
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import TabPanel from '@/components/tab/TabPanel.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { usePerTabState } from '@/composables/usePerTabState'

View File

@@ -7,9 +7,9 @@
)
"
>
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
<div class="comfy-vue-side-bar-header flex flex-col">
<Toolbar
class="min-h-16 rounded-none border-x-0 border-t-0 bg-transparent px-2 2xl:px-4"
class="min-h-16 rounded-none border-x-0 border-t-0 bg-transparent px-3 2xl:px-4"
:pt="sidebarPt"
>
<template #start>

View File

@@ -0,0 +1,17 @@
<template>
<div class="flex items-center gap-2 p-2 2xl:px-4">
<div class="min-w-0 flex-1">
<slot />
</div>
<div v-if="$slots.actions" class="flex shrink-0 items-center gap-2">
<slot name="actions" />
</div>
</div>
<div v-if="bottomDivider" class="border-t border-dashed border-comfy-input" />
</template>
<script setup lang="ts">
defineProps<{
bottomDivider?: boolean
}>()
</script>

View File

@@ -1,5 +1,5 @@
<template>
<TabsContent value="all" class="h-full flex-1 overflow-y-auto">
<div class="h-full flex-1 overflow-y-auto">
<!-- Favorites section -->
<h3
class="mb-0 px-4 py-2 text-xs font-medium tracking-wide text-muted-foreground uppercase"
@@ -34,11 +34,10 @@
@add-to-favorites="handleAddToFavorites"
/>
</div>
</TabsContent>
</div>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import { computed } from 'vue'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'

View File

@@ -1,5 +1,5 @@
<template>
<TabsContent value="blueprints" class="h-full flex-1 overflow-y-auto">
<div class="h-full flex-1 overflow-y-auto">
<div v-for="(section, index) in sections" :key="section.title ?? index">
<h3
v-if="section.title"
@@ -14,12 +14,10 @@
@node-click="(node) => emit('nodeClick', node)"
/>
</div>
</TabsContent>
</div>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type {

View File

@@ -1,5 +1,5 @@
<template>
<TabsContent value="custom" class="flex h-full flex-1 flex-col">
<div class="flex h-full flex-1 flex-col">
<div
v-for="(section, index) in sections"
:key="section.title ?? index"
@@ -30,12 +30,10 @@
{{ $t('g.manageExtensions') }}
</Button>
</div>
</TabsContent>
</div>
</template>
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import TreeExplorerV2 from '@/components/common/TreeExplorerV2.vue'
import Button from '@/components/ui/button/Button.vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'

View File

@@ -1,9 +1,5 @@
<template>
<TabsContent
ref="panelEl"
value="essentials"
class="h-full flex-1 overflow-y-auto px-3"
>
<div ref="panelEl" class="h-full flex-1 overflow-y-auto px-3">
<div class="flex flex-col gap-2 pb-6">
<!-- Flat sorted grid when alphabetical -->
<div
@@ -57,29 +53,26 @@
</CollapsibleRoot>
</template>
</div>
</TabsContent>
</div>
</template>
<script setup lang="ts">
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger,
TabsContent
CollapsibleTrigger
} from 'reka-ui'
import type { ComponentPublicInstance } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { cn } from '@/utils/tailwindUtil'
const panelEl = ref<ComponentPublicInstance | null>(null)
const panelRef = computed(() => panelEl.value?.$el as HTMLElement | null)
provide('essentialsPanelRef', panelRef)
import EssentialNodeCard from './EssentialNodeCard.vue'
const panelEl = ref<HTMLDivElement | null>(null)
provide('essentialsPanelRef', panelEl)
const { root, flatNodes = [] } = defineProps<{
root: RenderedTreeExplorerNode<ComfyNodeDefImpl>
flatNodes?: RenderedTreeExplorerNode<ComfyNodeDefImpl>[]

View File

@@ -1,48 +1,74 @@
<template>
<button
:id="tabId"
:class="tabClasses"
:id="`tab-${props.value}`"
role="tab"
type="button"
:aria-selected="isActive"
:aria-controls="panelId"
:tabindex="0"
:aria-controls="`tabpanel-${props.value}`"
:data-state="isActive ? 'active' : 'inactive'"
:tabindex="isActive ? 0 : -1"
:class="
cn(
'flex shrink-0 items-center justify-center',
'cursor-pointer rounded-lg border-none px-2.5 py-2 text-sm transition-all duration-200',
'focus-visible:ring-ring/20 outline-hidden focus-visible:ring-1',
isActive
? 'bg-interface-menu-component-surface-hovered text-text-primary'
: 'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface',
props.class
)
"
@click="handleClick"
@keydown="handleKeydown"
>
<slot />
</button>
</template>
<script setup lang="ts" generic="T extends string = string">
import type { Ref } from 'vue'
import type { HTMLAttributes } from 'vue'
import { computed, inject } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { value, panelId } = defineProps<{
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
const props = defineProps<{
value: T
panelId?: string
class?: HTMLAttributes['class']
}>()
const currentValue = inject<Ref<T>>('tabs-value')
const updateValue = inject<(value: T) => void>('tabs-update')
const context = inject(TAB_LIST_INJECTION_KEY)
const tabId = computed(() => `tab-${value}`)
const isActive = computed(() => currentValue?.value === value)
const isActive = computed(() => context?.modelValue.value === props.value)
const tabClasses = computed(() => {
return cn(
// Base styles from TextButton
'flex shrink-0 items-center justify-center',
'cursor-pointer rounded-lg px-2.5 py-2 text-sm transition-all duration-200',
'border-none outline-hidden',
// State styles with semantic tokens
isActive.value
? 'text-bold bg-interface-menu-component-surface-hovered text-text-primary'
: 'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
)
})
function handleClick() {
context?.select(props.value)
}
const handleClick = () => {
updateValue?.(value)
function handleKeydown(event: KeyboardEvent) {
const tablist = (event.currentTarget as HTMLElement).parentElement
if (!tablist) return
const tabs = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]'))
const currentIndex = tabs.indexOf(event.currentTarget as HTMLElement)
let targetIndex = -1
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
targetIndex = (currentIndex + 1) % tabs.length
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
targetIndex = (currentIndex - 1 + tabs.length) % tabs.length
} else if (event.key === 'Home') {
targetIndex = 0
} else if (event.key === 'End') {
targetIndex = tabs.length - 1
}
if (targetIndex !== -1) {
event.preventDefault()
tabs[targetIndex].focus()
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div role="tablist" class="flex w-full items-center gap-2 pb-1">
<div role="tablist" class="flex w-full items-center gap-2">
<slot />
</div>
</template>
@@ -7,11 +7,16 @@
<script setup lang="ts" generic="T extends string = string">
import { provide } from 'vue'
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
const modelValue = defineModel<T>({ required: true })
// Provide for child Tab components
provide('tabs-value', modelValue)
provide('tabs-update', (value: T) => {
modelValue.value = value
function select(value: string) {
modelValue.value = value as T
}
provide(TAB_LIST_INJECTION_KEY, {
modelValue,
select
})
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div
v-if="isActive"
:id="`tabpanel-${value}`"
role="tabpanel"
tabindex="0"
:aria-labelledby="`tab-${value}`"
>
<slot />
</div>
</template>
<script setup lang="ts" generic="T extends string = string">
import { computed, inject } from 'vue'
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
const { value, modelValue } = defineProps<{
value: T
modelValue?: T
}>()
const context = inject(TAB_LIST_INJECTION_KEY, undefined)
const isActive = computed(() =>
modelValue !== undefined
? modelValue === value
: context?.modelValue.value === value
)
</script>

View File

@@ -0,0 +1,9 @@
import type { InjectionKey, Ref } from 'vue'
interface TabListContext {
modelValue: Ref<string>
select: (value: string) => void
}
export const TAB_LIST_INJECTION_KEY: InjectionKey<TabListContext> =
Symbol('TabListContext')

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex items-center gap-3">
<SidebarTopArea :bottom-divider>
<SearchInput
:model-value="searchQuery"
:placeholder="
@@ -7,7 +7,7 @@
"
@update:model-value="handleSearchChange"
/>
<div class="flex items-center gap-1.5">
<template #actions>
<MediaAssetFilterButton
v-if="isCloud"
v-tooltip.top="{ value: $t('assetBrowser.filterBy') }"
@@ -32,11 +32,12 @@
/>
</template>
</MediaAssetSettingsButton>
</div>
</div>
</template>
</SidebarTopArea>
</template>
<script setup lang="ts">
import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import { isCloud } from '@/platform/distribution/types'
@@ -46,10 +47,11 @@ import MediaAssetSettingsButton from './MediaAssetSettingsButton.vue'
import MediaAssetSettingsMenu from './MediaAssetSettingsMenu.vue'
import type { SortBy } from './MediaAssetSettingsMenu.vue'
const { showGenerationTimeSort = false } = defineProps<{
const { showGenerationTimeSort = false, bottomDivider = false } = defineProps<{
searchQuery: string
showGenerationTimeSort?: boolean
mediaTypeFilters: string[]
bottomDivider?: boolean
}>()
const emit = defineEmits<{