mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
fix: reviews update
This commit is contained in:
@@ -21,28 +21,4 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@keyframes marquee-scroll {
|
||||
0%,
|
||||
20% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
80%,
|
||||
100% {
|
||||
transform: translateX(var(--_marquee-end, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
.marquee-container {
|
||||
container-type: inline-size;
|
||||
mask-image: linear-gradient(to right, black 70%, transparent);
|
||||
}
|
||||
|
||||
.essential-node-card:hover .marquee-container {
|
||||
mask-image: none;
|
||||
}
|
||||
|
||||
.essential-node-card:hover .marquee-text {
|
||||
--_marquee-end: min(calc(-100% + 100cqw), 0px);
|
||||
animation: marquee-scroll 3s linear infinite alternate;
|
||||
}
|
||||
}
|
||||
|
||||
24
src/components/common/MarqueeLine.vue
Normal file
24
src/components/common/MarqueeLine.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div
|
||||
class="overflow-hidden [container-type:inline-size] [mask-image:linear-gradient(to_right,black_70%,transparent)] motion-safe:group-hover:[mask-image:none]"
|
||||
>
|
||||
<span
|
||||
class="whitespace-nowrap inline-block min-w-full text-center [--_marquee-end:min(calc(-100%+100cqw),0px)] motion-safe:group-hover:[animation:marquee-scroll_3s_linear_infinite_alternate]"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@keyframes marquee-scroll {
|
||||
0%,
|
||||
20% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
80%,
|
||||
100% {
|
||||
transform: translateX(var(--_marquee-end));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
58
src/components/common/TextTickerMultiLine.test.ts
Normal file
58
src/components/common/TextTickerMultiLine.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { splitTextAtWordBoundary } from './textTickerUtils'
|
||||
|
||||
describe('splitTextAtWordBoundary', () => {
|
||||
it('returns full text when ratio >= 1 (fits in one line)', () => {
|
||||
expect(splitTextAtWordBoundary('Load Checkpoint', 1)).toEqual([
|
||||
'Load Checkpoint',
|
||||
''
|
||||
])
|
||||
expect(splitTextAtWordBoundary('Load Checkpoint', 1.5)).toEqual([
|
||||
'Load Checkpoint',
|
||||
''
|
||||
])
|
||||
})
|
||||
|
||||
it('splits at last word boundary before estimated break', () => {
|
||||
// "Load Checkpoint Loader" = 22 chars, ratio 0.5 → estimate at char 11
|
||||
// lastIndexOf(' ', 11) → 15? No: "Load Checkpoint Loader"
|
||||
// 0123456789...
|
||||
// ' ' at index 4 and 15
|
||||
// lastIndexOf(' ', 11) → 4
|
||||
expect(splitTextAtWordBoundary('Load Checkpoint Loader', 0.5)).toEqual([
|
||||
'Load',
|
||||
'Checkpoint Loader'
|
||||
])
|
||||
})
|
||||
|
||||
it('splits longer text proportionally', () => {
|
||||
// ratio 0.7 → estimate at char 15
|
||||
// lastIndexOf(' ', 15) → 15 (the space between "Checkpoint" and "Loader")
|
||||
expect(splitTextAtWordBoundary('Load Checkpoint Loader', 0.7)).toEqual([
|
||||
'Load Checkpoint',
|
||||
'Loader'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns full text when no word boundary found', () => {
|
||||
expect(splitTextAtWordBoundary('Superlongwordwithoutspaces', 0.5)).toEqual([
|
||||
'Superlongwordwithoutspaces',
|
||||
''
|
||||
])
|
||||
})
|
||||
|
||||
it('handles single word text', () => {
|
||||
expect(splitTextAtWordBoundary('Checkpoint', 0.5)).toEqual([
|
||||
'Checkpoint',
|
||||
''
|
||||
])
|
||||
})
|
||||
|
||||
it('handles ratio near zero', () => {
|
||||
expect(splitTextAtWordBoundary('Load Checkpoint Loader', 0.1)).toEqual([
|
||||
'Load Checkpoint Loader',
|
||||
''
|
||||
])
|
||||
})
|
||||
})
|
||||
66
src/components/common/TextTickerMultiLine.vue
Normal file
66
src/components/common/TextTickerMultiLine.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hidden single-line measurement element for overflow detection -->
|
||||
<div
|
||||
ref="measureRef"
|
||||
class="invisible absolute inset-x-0 top-0 overflow-hidden whitespace-nowrap pointer-events-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<MarqueeLine v-if="!secondLine">
|
||||
<slot />
|
||||
</MarqueeLine>
|
||||
|
||||
<div v-else class="flex flex-col w-full">
|
||||
<MarqueeLine>{{ firstLine }}</MarqueeLine>
|
||||
<MarqueeLine>{{ secondLine }}</MarqueeLine>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMutationObserver, useResizeObserver } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MarqueeLine from './MarqueeLine.vue'
|
||||
import { splitTextAtWordBoundary } from './textTickerUtils'
|
||||
|
||||
const measureRef = ref<HTMLElement | null>(null)
|
||||
const firstLine = ref('')
|
||||
const secondLine = ref('')
|
||||
|
||||
function splitLines() {
|
||||
const el = measureRef.value
|
||||
const text = el?.textContent?.trim()
|
||||
if (!el || !text) {
|
||||
firstLine.value = ''
|
||||
secondLine.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const containerWidth = el.clientWidth
|
||||
const textWidth = el.scrollWidth
|
||||
|
||||
if (textWidth <= containerWidth) {
|
||||
firstLine.value = text
|
||||
secondLine.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const [first, second] = splitTextAtWordBoundary(
|
||||
text,
|
||||
containerWidth / textWidth
|
||||
)
|
||||
firstLine.value = first
|
||||
secondLine.value = second
|
||||
}
|
||||
|
||||
useResizeObserver(measureRef, splitLines)
|
||||
useMutationObserver(measureRef, splitLines, {
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true
|
||||
})
|
||||
</script>
|
||||
10
src/components/common/textTickerUtils.ts
Normal file
10
src/components/common/textTickerUtils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export function splitTextAtWordBoundary(
|
||||
text: string,
|
||||
ratio: number
|
||||
): [string, string] {
|
||||
if (ratio >= 1) return [text, '']
|
||||
const estimate = Math.floor(text.length * ratio)
|
||||
const breakIndex = text.lastIndexOf(' ', estimate)
|
||||
if (breakIndex <= 0) return [text, '']
|
||||
return [text.substring(0, breakIndex), text.substring(breakIndex + 1)]
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { TabId } from '@/types/nodeOrganizationTypes'
|
||||
|
||||
import NodeLibrarySidebarTabV2 from './NodeLibrarySidebarTabV2.vue'
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
@@ -115,7 +113,7 @@ describe('NodeLibrarySidebarTabV2', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const triggers = wrapper.findAllComponents(TabsTrigger)
|
||||
expect(triggers.length).toBe(3)
|
||||
expect(triggers).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render search box', () => {
|
||||
@@ -132,93 +130,3 @@ describe('NodeLibrarySidebarTabV2', () => {
|
||||
expect(wrapper.find('[data-testid="custom-panel"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NodeLibrarySidebarTabV2 expandedKeys logic', () => {
|
||||
describe('per-tab expandedKeys', () => {
|
||||
function createExpandedKeysState(initialTab: TabId = 'essentials') {
|
||||
const selectedTab = ref<TabId>(initialTab)
|
||||
const expandedKeysByTab = ref<Record<TabId, string[]>>({
|
||||
essentials: [],
|
||||
all: [],
|
||||
custom: []
|
||||
})
|
||||
const expandedKeys = computed({
|
||||
get: () => expandedKeysByTab.value[selectedTab.value],
|
||||
set: (value) => {
|
||||
expandedKeysByTab.value[selectedTab.value] = value
|
||||
}
|
||||
})
|
||||
|
||||
return { selectedTab, expandedKeysByTab, expandedKeys }
|
||||
}
|
||||
|
||||
it('should initialize with empty arrays for all tabs', () => {
|
||||
const { expandedKeysByTab } = createExpandedKeysState()
|
||||
|
||||
expect(expandedKeysByTab.value.essentials).toEqual([])
|
||||
expect(expandedKeysByTab.value.all).toEqual([])
|
||||
expect(expandedKeysByTab.value.custom).toEqual([])
|
||||
})
|
||||
|
||||
it('should return keys for the current tab', () => {
|
||||
const { selectedTab, expandedKeysByTab, expandedKeys } =
|
||||
createExpandedKeysState('essentials')
|
||||
|
||||
expandedKeysByTab.value.essentials = ['key1', 'key2']
|
||||
expandedKeysByTab.value.all = ['key3']
|
||||
|
||||
expect(expandedKeys.value).toEqual(['key1', 'key2'])
|
||||
|
||||
selectedTab.value = 'all'
|
||||
expect(expandedKeys.value).toEqual(['key3'])
|
||||
})
|
||||
|
||||
it('should set keys only for the current tab', () => {
|
||||
const { expandedKeysByTab, expandedKeys } =
|
||||
createExpandedKeysState('essentials')
|
||||
|
||||
expandedKeys.value = ['new-key1', 'new-key2']
|
||||
|
||||
expect(expandedKeysByTab.value.essentials).toEqual([
|
||||
'new-key1',
|
||||
'new-key2'
|
||||
])
|
||||
expect(expandedKeysByTab.value.all).toEqual([])
|
||||
expect(expandedKeysByTab.value.custom).toEqual([])
|
||||
})
|
||||
|
||||
it('should preserve keys when switching tabs', () => {
|
||||
const { selectedTab, expandedKeysByTab, expandedKeys } =
|
||||
createExpandedKeysState('essentials')
|
||||
|
||||
expandedKeys.value = ['essentials-key']
|
||||
selectedTab.value = 'all'
|
||||
expandedKeys.value = ['all-key']
|
||||
selectedTab.value = 'custom'
|
||||
expandedKeys.value = ['custom-key']
|
||||
|
||||
expect(expandedKeysByTab.value.essentials).toEqual(['essentials-key'])
|
||||
expect(expandedKeysByTab.value.all).toEqual(['all-key'])
|
||||
expect(expandedKeysByTab.value.custom).toEqual(['custom-key'])
|
||||
|
||||
selectedTab.value = 'essentials'
|
||||
expect(expandedKeys.value).toEqual(['essentials-key'])
|
||||
})
|
||||
|
||||
it('should not share keys between tabs', () => {
|
||||
const { selectedTab, expandedKeys } =
|
||||
createExpandedKeysState('essentials')
|
||||
|
||||
expandedKeys.value = ['shared-key']
|
||||
|
||||
selectedTab.value = 'all'
|
||||
expect(expandedKeys.value).toEqual([])
|
||||
|
||||
selectedTab.value = 'custom'
|
||||
expect(expandedKeys.value).toEqual([])
|
||||
|
||||
selectedTab.value = 'essentials'
|
||||
expect(expandedKeys.value).toEqual(['shared-key'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,6 +114,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBoxV2.vue'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import { usePerTabState } from '@/composables/usePerTabState'
|
||||
import {
|
||||
DEFAULT_SORTING_ID,
|
||||
DEFAULT_TAB_ID,
|
||||
@@ -148,15 +149,7 @@ const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
|
||||
custom: 'alphabetical'
|
||||
}
|
||||
)
|
||||
const sortOrder = computed({
|
||||
get: () => sortOrderByTab.value[selectedTab.value],
|
||||
set: (value) => {
|
||||
sortOrderByTab.value = {
|
||||
...sortOrderByTab.value,
|
||||
[selectedTab.value]: value
|
||||
}
|
||||
}
|
||||
})
|
||||
const sortOrder = usePerTabState(selectedTab, sortOrderByTab)
|
||||
|
||||
const sortingOptions = computed(() =>
|
||||
nodeOrganizationService.getSortingStrategies().map((strategy) => ({
|
||||
@@ -174,12 +167,7 @@ const expandedKeysByTab = ref<Record<TabId, string[]>>({
|
||||
all: [],
|
||||
custom: []
|
||||
})
|
||||
const expandedKeys = computed({
|
||||
get: () => expandedKeysByTab.value[selectedTab.value],
|
||||
set: (value) => {
|
||||
expandedKeysByTab.value[selectedTab.value] = value
|
||||
}
|
||||
})
|
||||
const expandedKeys = usePerTabState(selectedTab, expandedKeysByTab)
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const { startDrag } = useNodeDragToCanvas()
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'essential-node-card relative flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content',
|
||||
'bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border',
|
||||
'aspect-square'
|
||||
)
|
||||
"
|
||||
:data-node-name="nodeDef?.display_name"
|
||||
class="group relative flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border aspect-square"
|
||||
:data-node-name="node.data?.display_name"
|
||||
draggable="true"
|
||||
@click="handleClick"
|
||||
@dragstart="handleDragStart"
|
||||
@@ -19,40 +13,11 @@
|
||||
<i :class="cn(nodeIcon, 'size-14 text-muted-foreground')" />
|
||||
</div>
|
||||
|
||||
<!-- Hidden measurement span for line-break detection -->
|
||||
<span
|
||||
ref="measureRef"
|
||||
class="invisible absolute inset-x-0 top-0 pointer-events-none px-2 text-xs font-bold leading-4"
|
||||
aria-hidden="true"
|
||||
<TextTickerMultiLine
|
||||
class="shrink-0 h-8 w-full text-xs font-bold text-foreground leading-4"
|
||||
>
|
||||
{{ nodeDef?.display_name }}
|
||||
</span>
|
||||
|
||||
<!-- Single line (text fits without wrapping) -->
|
||||
<span
|
||||
v-if="!secondLine"
|
||||
class="shrink-0 h-8 flex items-center text-xs font-bold text-center text-foreground leading-3"
|
||||
>
|
||||
{{ nodeDef?.display_name }}
|
||||
</span>
|
||||
|
||||
<!-- Two lines: static first line + marquee second line -->
|
||||
<div v-else class="shrink-0 h-9 w-full flex flex-col justify-center">
|
||||
<div class="marquee-container w-full overflow-hidden h-[18px]">
|
||||
<span
|
||||
class="marquee-text inline-block whitespace-nowrap text-xs font-bold text-foreground leading-3 min-w-full text-center"
|
||||
>
|
||||
{{ firstLine }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="marquee-container w-full overflow-hidden h-[18px]">
|
||||
<span
|
||||
class="marquee-text inline-block whitespace-nowrap text-xs font-bold text-foreground leading-3 min-w-full text-center"
|
||||
>
|
||||
{{ secondLine }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ node.data?.display_name }}
|
||||
</TextTickerMultiLine>
|
||||
</div>
|
||||
|
||||
<Teleport v-if="showPreview" to="body">
|
||||
@@ -60,16 +25,19 @@
|
||||
:ref="(el) => (previewRef = el as HTMLElement)"
|
||||
:style="nodePreviewStyle"
|
||||
>
|
||||
<NodePreviewCard :node-def="nodeDef!" :show-inputs-and-outputs="false" />
|
||||
<NodePreviewCard
|
||||
:node-def="node.data!"
|
||||
:show-inputs-and-outputs="false"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { kebabCase } from 'es-toolkit/string'
|
||||
import { computed, inject, nextTick, ref, watch } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
import TextTickerMultiLine from '@/components/common/TextTickerMultiLine.vue'
|
||||
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
|
||||
import { SidebarContainerKey } from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import { useNodePreviewAndDrag } from '@/composables/node/useNodePreviewAndDrag'
|
||||
@@ -85,9 +53,8 @@ const emit = defineEmits<{
|
||||
click: [node: RenderedTreeExplorerNode<ComfyNodeDefImpl>]
|
||||
}>()
|
||||
|
||||
const nodeDef = computed(() => node.data)
|
||||
|
||||
const panelRef = inject(SidebarContainerKey, undefined)
|
||||
const nodeDef = computed(() => node.data)
|
||||
|
||||
const {
|
||||
previewRef,
|
||||
@@ -100,80 +67,13 @@ const {
|
||||
} = useNodePreviewAndDrag(nodeDef, { panelRef })
|
||||
|
||||
const nodeIcon = computed(() => {
|
||||
const nodeName = nodeDef.value?.name
|
||||
const nodeName = node.data?.name
|
||||
const iconName = nodeName ? kebabCase(nodeName) : 'node'
|
||||
return `icon-[comfy--${iconName}]`
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (!nodeDef.value) return
|
||||
if (!node.data) return
|
||||
emit('click', node)
|
||||
}
|
||||
|
||||
const measureRef = ref<HTMLElement | null>(null)
|
||||
const firstLine = ref('')
|
||||
const secondLine = ref('')
|
||||
|
||||
function splitLines() {
|
||||
const el = measureRef.value
|
||||
const text = nodeDef.value?.display_name
|
||||
if (!el || !text) {
|
||||
firstLine.value = ''
|
||||
secondLine.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const textNode = el.firstChild
|
||||
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
|
||||
firstLine.value = text
|
||||
secondLine.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(textNode)
|
||||
const rects = range.getClientRects()
|
||||
|
||||
if (rects.length <= 1) {
|
||||
firstLine.value = text
|
||||
secondLine.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const firstRectBottom = rects[0].bottom
|
||||
let splitIndex = text.length
|
||||
let low = 1
|
||||
let high = text.length - 1
|
||||
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2)
|
||||
range.setStart(textNode, mid)
|
||||
range.setEnd(textNode, mid + 1)
|
||||
const charRect = range.getBoundingClientRect()
|
||||
if (charRect.top >= firstRectBottom - 1) {
|
||||
splitIndex = mid
|
||||
high = mid - 1
|
||||
} else {
|
||||
low = mid + 1
|
||||
}
|
||||
}
|
||||
|
||||
if (splitIndex < text.length) {
|
||||
firstLine.value = text.substring(0, splitIndex).trim()
|
||||
secondLine.value = text.substring(splitIndex).trim()
|
||||
} else {
|
||||
firstLine.value = text
|
||||
secondLine.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
useResizeObserver(measureRef, splitLines)
|
||||
|
||||
watch(
|
||||
() => nodeDef.value?.display_name,
|
||||
async () => {
|
||||
await nextTick()
|
||||
splitLines()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
73
src/composables/usePerTabState.test.ts
Normal file
73
src/composables/usePerTabState.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { usePerTabState } from './usePerTabState'
|
||||
|
||||
type TabId = 'a' | 'b' | 'c'
|
||||
|
||||
describe('usePerTabState', () => {
|
||||
function setup(initialTab: TabId = 'a') {
|
||||
const selectedTab = ref<TabId>(initialTab)
|
||||
const stateByTab = ref<Record<TabId, string[]>>({
|
||||
a: [],
|
||||
b: [],
|
||||
c: []
|
||||
})
|
||||
const state = usePerTabState(selectedTab, stateByTab)
|
||||
return { selectedTab, stateByTab, state }
|
||||
}
|
||||
|
||||
it('should return state for the current tab', () => {
|
||||
const { selectedTab, stateByTab, state } = setup()
|
||||
|
||||
stateByTab.value.a = ['key1', 'key2']
|
||||
stateByTab.value.b = ['key3']
|
||||
|
||||
expect(state.value).toEqual(['key1', 'key2'])
|
||||
|
||||
selectedTab.value = 'b'
|
||||
expect(state.value).toEqual(['key3'])
|
||||
})
|
||||
|
||||
it('should set state only for the current tab', () => {
|
||||
const { stateByTab, state } = setup()
|
||||
|
||||
state.value = ['new-key1', 'new-key2']
|
||||
|
||||
expect(stateByTab.value.a).toEqual(['new-key1', 'new-key2'])
|
||||
expect(stateByTab.value.b).toEqual([])
|
||||
expect(stateByTab.value.c).toEqual([])
|
||||
})
|
||||
|
||||
it('should preserve state when switching tabs', () => {
|
||||
const { selectedTab, stateByTab, state } = setup()
|
||||
|
||||
state.value = ['a-key']
|
||||
selectedTab.value = 'b'
|
||||
state.value = ['b-key']
|
||||
selectedTab.value = 'c'
|
||||
state.value = ['c-key']
|
||||
|
||||
expect(stateByTab.value.a).toEqual(['a-key'])
|
||||
expect(stateByTab.value.b).toEqual(['b-key'])
|
||||
expect(stateByTab.value.c).toEqual(['c-key'])
|
||||
|
||||
selectedTab.value = 'a'
|
||||
expect(state.value).toEqual(['a-key'])
|
||||
})
|
||||
|
||||
it('should not share state between tabs', () => {
|
||||
const { selectedTab, state } = setup()
|
||||
|
||||
state.value = ['only-a']
|
||||
|
||||
selectedTab.value = 'b'
|
||||
expect(state.value).toEqual([])
|
||||
|
||||
selectedTab.value = 'c'
|
||||
expect(state.value).toEqual([])
|
||||
|
||||
selectedTab.value = 'a'
|
||||
expect(state.value).toEqual(['only-a'])
|
||||
})
|
||||
})
|
||||
14
src/composables/usePerTabState.ts
Normal file
14
src/composables/usePerTabState.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export function usePerTabState<K extends string, V>(
|
||||
selectedTab: Ref<K>,
|
||||
stateByTab: Ref<Record<K, V>>
|
||||
) {
|
||||
return computed({
|
||||
get: () => stateByTab.value[selectedTab.value],
|
||||
set: (value) => {
|
||||
stateByTab.value[selectedTab.value] = value
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user