Skip to content

Commit 3f9df08

Browse files
committed
.
1 parent 29158b6 commit 3f9df08

13 files changed

Lines changed: 1046 additions & 2 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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>
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<script setup lang="ts">
2+
import type { StorybookFileTree } from '#shared/types'
3+
4+
defineProps<{
5+
tree: StorybookFileTree[]
6+
currentStoryId: string | null
7+
baseUrl: string
8+
/** Base path segments for stories route (e.g., ['nuxt', 'v', '4.2.0']) */
9+
basePath: string[]
10+
}>()
11+
12+
const isOpen = shallowRef(false)
13+
14+
// Close drawer on navigation
15+
const route = useRoute()
16+
watch(
17+
() => route.fullPath,
18+
() => {
19+
isOpen.value = false
20+
},
21+
)
22+
23+
const isLocked = useScrollLock(document)
24+
// Prevent body scroll when drawer is open
25+
watch(isOpen, open => (isLocked.value = open))
26+
</script>
27+
28+
<template>
29+
<!-- Toggle button (mobile only) -->
30+
<ButtonBase
31+
variant="primary"
32+
class="md:hidden fixed bottom-4 inset-ie-4 z-45"
33+
:aria-label="$t('stories.toggle_tree')"
34+
@click="isOpen = !isOpen"
35+
:classicon="isOpen ? 'i-carbon:close' : 'i-carbon:folder'"
36+
/>
37+
38+
<!-- Backdrop -->
39+
<Transition
40+
enter-active-class="transition-opacity duration-200"
41+
enter-from-class="opacity-0"
42+
enter-to-class="opacity-100"
43+
leave-active-class="transition-opacity duration-200"
44+
leave-from-class="opacity-100"
45+
leave-to-class="opacity-0"
46+
>
47+
<div v-if="isOpen" class="md:hidden fixed inset-0 z-40 bg-black/50" @click="isOpen = false" />
48+
</Transition>
49+
50+
<!-- Drawer -->
51+
<Transition
52+
enter-active-class="transition-transform duration-200"
53+
enter-from-class="-translate-x-full"
54+
enter-to-class="translate-x-0"
55+
leave-active-class="transition-transform duration-200"
56+
leave-from-class="translate-x-0"
57+
leave-to-class="-translate-x-full"
58+
>
59+
<aside
60+
v-if="isOpen"
61+
class="md:hidden fixed inset-y-0 inset-is-0 z-50 w-72 bg-bg-subtle border-ie border-border overflow-y-auto"
62+
>
63+
<div
64+
class="sticky top-0 bg-bg-subtle border-b border-border px-4 py-3 flex items-center justify-start"
65+
>
66+
<span class="font-mono text-sm text-fg-muted">{{ $t('stories.stories_label') }}</span>
67+
<span aria-hidden="true" class="flex-shrink-1 flex-grow-1" />
68+
<ButtonBase
69+
:aria-label="$t('stories.close_tree')"
70+
@click="isOpen = false"
71+
classicon="i-carbon-close"
72+
/>
73+
</div>
74+
<StorybookFileTree
75+
:tree="tree"
76+
:current-story-id="currentStoryId"
77+
:base-url="baseUrl"
78+
:base-path="basePath"
79+
/>
80+
</aside>
81+
</Transition>
82+
</template>

app/components/VersionSelector.vue

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,15 @@ const versionToTags = computed(() => buildVersionToTagsMap(props.distTags))
7474
7575
/** Get URL for a specific version */
7676
function getVersionUrl(version: string): string {
77-
return props.urlPattern.replace('{version}', version)
77+
let url = props.urlPattern.replace('{version}', version)
78+
// Replace storyid placeholder if it exists
79+
if (url.includes('{storyid}')) {
80+
// Get current storyid from route query
81+
const route = useRoute()
82+
const currentStoryId = route.query.storyid as string
83+
url = url.replace('{storyid}', currentStoryId || '')
84+
}
85+
return url
7886
}
7987
8088
/** Safe semver comparison with fallback */
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { computed } from 'vue'
2+
import { useState } from '#app'
3+
4+
export function useStoryTreeState(baseUrl: string) {
5+
const stateKey = computed(() => `npmx-story-tree${baseUrl}`)
6+
7+
const expanded = useState<Set<string>>(stateKey.value, () => new Set<string>())
8+
9+
function toggleDir(path: string) {
10+
if (expanded.value.has(path)) {
11+
expanded.value.delete(path)
12+
} else {
13+
expanded.value.add(path)
14+
}
15+
}
16+
17+
function isExpanded(path: string) {
18+
return expanded.value.has(path)
19+
}
20+
21+
function autoExpandAncestors(path: string) {
22+
if (!path) return
23+
const parts = path.split('/').filter(Boolean)
24+
let prefix = ''
25+
for (const part of parts) {
26+
prefix = prefix ? `${prefix}/${part}` : part
27+
expanded.value.add(prefix)
28+
}
29+
}
30+
31+
return {
32+
toggleDir,
33+
isExpanded,
34+
autoExpandAncestors,
35+
}
36+
}

0 commit comments

Comments
 (0)