|
| 1 | +<script setup lang="ts"> |
| 2 | +import type { StorybookFileTree } from '#shared/types' |
| 3 | +import type { RouteLocationRaw } from 'vue-router' |
| 4 | +import { getFileIcon } from '~/utils/file-icons' |
| 5 | +import { getFirstStoryInDirectory } from '~/utils/storybook-tree' |
| 6 | +
|
| 7 | +const props = defineProps<{ |
| 8 | + tree: StorybookFileTree[] |
| 9 | + currentStoryId: string | null |
| 10 | + baseUrl: string |
| 11 | + /** Base path segments for the stories route (e.g., ['nuxt', 'v', '4.2.0']) */ |
| 12 | + basePath: string[] |
| 13 | + depth?: number |
| 14 | +}>() |
| 15 | +
|
| 16 | +const depth = computed(() => props.depth ?? 0) |
| 17 | +
|
| 18 | +// Check if a node or any of its children is currently selected |
| 19 | +function isNodeActive(node: StorybookFileTree): boolean { |
| 20 | + if (node.type === 'story' && props.currentStoryId === node.storyId) return true |
| 21 | + if (node.type === 'directory') { |
| 22 | + return props.currentStoryId?.startsWith(node.path + '/') || false |
| 23 | + } |
| 24 | + return false |
| 25 | +} |
| 26 | +
|
| 27 | +// Build route object for a story |
| 28 | +function getStoryRoute(node: StorybookFileTree): RouteLocationRaw { |
| 29 | + if (node.type === 'story') { |
| 30 | + return { |
| 31 | + name: 'stories', |
| 32 | + params: { path: props.basePath }, |
| 33 | + query: { storyid: node.storyId }, |
| 34 | + } |
| 35 | + } |
| 36 | + // For directories - navigate to first story in that directory |
| 37 | + if (node.type === 'directory') { |
| 38 | + const firstStory = getFirstStoryInDirectory(node) |
| 39 | + if (firstStory) { |
| 40 | + return { |
| 41 | + name: 'stories', |
| 42 | + params: { path: props.basePath }, |
| 43 | + query: { storyid: firstStory.storyId }, |
| 44 | + } |
| 45 | + } |
| 46 | + } |
| 47 | + return { name: 'stories', params: { path: props.basePath } } |
| 48 | +} |
| 49 | +
|
| 50 | +// Get icon for story or directory |
| 51 | +function getNodeIcon(node: StorybookFileTree): string { |
| 52 | + if (node.type === 'directory') { |
| 53 | + return isNodeActive(node) |
| 54 | + ? 'i-carbon:folder-open text-yellow-500' |
| 55 | + : 'i-carbon:folder text-yellow-600' |
| 56 | + } |
| 57 | +
|
| 58 | + if (node.storyId) { |
| 59 | + // Try to get icon based on story file type if available |
| 60 | + if (node.story?.importPath) { |
| 61 | + return getFileIcon(node.story.importPath) |
| 62 | + } |
| 63 | + // Default story icon |
| 64 | + return 'i-vscode-icons-file-type-storybook' |
| 65 | + } |
| 66 | +
|
| 67 | + return getFileIcon(node.name) |
| 68 | +} |
| 69 | +
|
| 70 | +const { toggleDir, isExpanded, autoExpandAncestors } = useStoryTreeState(props.baseUrl) |
| 71 | +
|
| 72 | +// Handle directory click - toggle expansion and navigate to first story |
| 73 | +function handleDirectoryClick(node: StorybookFileTree) { |
| 74 | + if (node.type !== 'directory') return |
| 75 | +
|
| 76 | + // Toggle directory expansion |
| 77 | + toggleDir(node.path) |
| 78 | +
|
| 79 | + // Navigate to first story in directory (if available) |
| 80 | + const route = getStoryRoute(node) |
| 81 | + if (route.query?.storyid) { |
| 82 | + navigateTo(route) |
| 83 | + } |
| 84 | +} |
| 85 | +</script> |
| 86 | + |
| 87 | +<template> |
| 88 | + <ul class="list-none m-0 p-0" :class="depth === 0 ? 'py-2' : ''"> |
| 89 | + <li v-for="node in tree" :key="node.path"> |
| 90 | + <!-- Directory --> |
| 91 | + <template v-if="node.type === 'directory'"> |
| 92 | + <ButtonBase |
| 93 | + class="w-full justify-start! rounded-none! border-none!" |
| 94 | + block |
| 95 | + :aria-pressed="isNodeActive(node)" |
| 96 | + :style="{ paddingLeft: `${depth * 12 + 12}px` }" |
| 97 | + @click="handleDirectoryClick(node)" |
| 98 | + :classicon="isExpanded(node.path) ? 'i-carbon:chevron-down' : 'i-carbon:chevron-right'" |
| 99 | + > |
| 100 | + <span class="w-4 h-4 shrink-0" :class="getNodeIcon(node)" /> |
| 101 | + <span class="truncate">{{ node.name }}</span> |
| 102 | + </ButtonBase> |
| 103 | + <StorybookFileTree |
| 104 | + v-if="isExpanded(node.path) && node.children" |
| 105 | + :tree="node.children" |
| 106 | + :current-story-id="currentStoryId" |
| 107 | + :base-url="baseUrl" |
| 108 | + :base-path="basePath" |
| 109 | + :depth="depth + 1" |
| 110 | + /> |
| 111 | + </template> |
| 112 | + |
| 113 | + <!-- Story --> |
| 114 | + <template v-else> |
| 115 | + <LinkBase |
| 116 | + variant="button-secondary" |
| 117 | + :to="getStoryRoute(node)" |
| 118 | + :aria-current="currentStoryId === node.storyId" |
| 119 | + class="w-full justify-start! rounded-none! border-none!" |
| 120 | + block |
| 121 | + :style="{ paddingLeft: `${depth * 12 + 32}px` }" |
| 122 | + :classicon="getNodeIcon(node)" |
| 123 | + > |
| 124 | + <span class="truncate">{{ node.name }}</span> |
| 125 | + </LinkBase> |
| 126 | + </template> |
| 127 | + </li> |
| 128 | + </ul> |
| 129 | +</template> |
0 commit comments