Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/components/Package/Playgrounds.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const providerIcons: Record<string, string> = {
'nuxt-new': 'i-simple-icons:nuxtdotjs',
'vite-new': 'i-simple-icons:vite',
'jsfiddle': 'i-carbon:code',
'storybook': 'i-simple-icons:storybook',
}

// Map provider id to color class
Expand All @@ -30,6 +31,7 @@ const providerColors: Record<string, string> = {
'nuxt-new': 'text-provider-nuxt',
'vite-new': 'text-provider-vite',
'jsfiddle': 'text-provider-jsfiddle',
'storybook': 'text-provider-storybook',
}

function getIcon(provider: string): string {
Expand Down
129 changes: 129 additions & 0 deletions app/components/Storybook/FileTree.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<script setup lang="ts">
import type { StorybookFileTree } from '#shared/types'
import type { RouteLocationRaw } from 'vue-router'
import { getFileIcon } from '~/utils/file-icons'
import { getFirstStoryInDirectory } from '~/utils/storybook-tree'

const props = defineProps<{
tree: StorybookFileTree[]
currentStoryId: string | null
baseUrl: string
/** Base path segments for the stories route (e.g., ['nuxt', 'v', '4.2.0']) */
basePath: string[]
depth?: number
}>()

const depth = computed(() => props.depth ?? 0)

// Check if a node or any of its children is currently selected
function isNodeActive(node: StorybookFileTree): boolean {
if (node.type === 'story' && props.currentStoryId === node.storyId) return true
if (node.type === 'directory') {
return props.currentStoryId?.startsWith(node.path + '/') || false
}
return false
}

// Build route object for a story
function getStoryRoute(node: StorybookFileTree): RouteLocationRaw {
if (node.type === 'story') {
return {

Check failure on line 30 in app/components/Storybook/FileTree.vue

View workflow job for this annotation

GitHub Actions / 💪 Type check

Type '{ name: "stories"; params: { path: string[]; }; query: { storyid: string | undefined; }; }' is not assignable to type 'RouteLocationRaw'.
name: 'stories',
params: { path: props.basePath },
query: { storyid: node.storyId },
}
}
// For directories - navigate to first story in that directory
if (node.type === 'directory') {
const firstStory = getFirstStoryInDirectory(node)
if (firstStory) {
return {

Check failure on line 40 in app/components/Storybook/FileTree.vue

View workflow job for this annotation

GitHub Actions / 💪 Type check

Type '{ name: "stories"; params: { path: string[]; }; query: { storyid: string | undefined; }; }' is not assignable to type 'RouteLocationRaw'.
name: 'stories',
params: { path: props.basePath },
query: { storyid: firstStory.storyId },
}
}
}
return { name: 'stories', params: { path: props.basePath } }

Check failure on line 47 in app/components/Storybook/FileTree.vue

View workflow job for this annotation

GitHub Actions / 💪 Type check

Type '{ name: "stories"; params: { path: string[]; }; }' is not assignable to type 'RouteLocationRaw'.
}

// Get icon for story or directory
function getNodeIcon(node: StorybookFileTree): string {
if (node.type === 'directory') {
return isNodeActive(node)
? 'i-carbon:folder-open text-yellow-500'
: 'i-carbon:folder text-yellow-600'
}

if (node.storyId) {
// Try to get icon based on story file type if available
if (node.story?.importPath) {
return getFileIcon(node.story.importPath)
}
// Default story icon
return 'i-vscode-icons-file-type-storybook'
}

return getFileIcon(node.name)
}

const { toggleDir, isExpanded, autoExpandAncestors } = useStoryTreeState(props.baseUrl)

Check failure on line 70 in app/components/Storybook/FileTree.vue

View workflow job for this annotation

GitHub Actions / 💪 Type check

'autoExpandAncestors' is declared but its value is never read.

// Handle directory click - toggle expansion and navigate to first story
function handleDirectoryClick(node: StorybookFileTree) {
if (node.type !== 'directory') return

// Toggle directory expansion
toggleDir(node.path)

// Navigate to first story in directory (if available)
const route = getStoryRoute(node)
if (route.query?.storyid) {

Check failure on line 81 in app/components/Storybook/FileTree.vue

View workflow job for this annotation

GitHub Actions / 💪 Type check

Property 'query' does not exist on type 'RouteLocationRaw'.
navigateTo(route)
}
}
</script>

<template>
<ul class="list-none m-0 p-0" :class="depth === 0 ? 'py-2' : ''">
<li v-for="node in tree" :key="node.path">
<!-- Directory -->
<template v-if="node.type === 'directory'">
<ButtonBase
class="w-full justify-start! rounded-none! border-none!"
block
:aria-pressed="isNodeActive(node)"
:style="{ paddingLeft: `${depth * 12 + 12}px` }"
@click="handleDirectoryClick(node)"
:classicon="isExpanded(node.path) ? 'i-carbon:chevron-down' : 'i-carbon:chevron-right'"
>
<span class="w-4 h-4 shrink-0" :class="getNodeIcon(node)" />
<span class="truncate">{{ node.name }}</span>
</ButtonBase>
<StorybookFileTree
v-if="isExpanded(node.path) && node.children"
:tree="node.children"
:current-story-id="currentStoryId"
:base-url="baseUrl"
:base-path="basePath"
:depth="depth + 1"
/>
</template>

<!-- Story -->
<template v-else>
<LinkBase
variant="button-secondary"
:to="getStoryRoute(node)"
:aria-current="currentStoryId === node.storyId"
class="w-full justify-start! rounded-none! border-none!"
block
:style="{ paddingLeft: `${depth * 12 + 32}px` }"
:classicon="getNodeIcon(node)"
>
<span class="truncate">{{ node.name }}</span>
</LinkBase>
</template>
</li>
</ul>
</template>
82 changes: 82 additions & 0 deletions app/components/Storybook/MobileTreeDrawer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<script setup lang="ts">
import type { StorybookFileTree } from '#shared/types'

defineProps<{
tree: StorybookFileTree[]
currentStoryId: string | null
baseUrl: string
/** Base path segments for stories route (e.g., ['nuxt', 'v', '4.2.0']) */
basePath: string[]
}>()

const isOpen = shallowRef(false)

// Close drawer on navigation
const route = useRoute()
watch(
() => route.fullPath,
() => {
isOpen.value = false
},
)

const isLocked = useScrollLock(document)
// Prevent body scroll when drawer is open
watch(isOpen, open => (isLocked.value = open))
</script>

<template>
<!-- Toggle button (mobile only) -->
<ButtonBase
variant="primary"
class="md:hidden fixed bottom-4 inset-ie-4 z-45"
:aria-label="$t('stories.toggle_tree')"
@click="isOpen = !isOpen"
:classicon="isOpen ? 'i-carbon:close' : 'i-carbon:folder'"
/>

<!-- Backdrop -->
<Transition
enter-active-class="transition-opacity duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="isOpen" class="md:hidden fixed inset-0 z-40 bg-black/50" @click="isOpen = false" />
</Transition>

<!-- Drawer -->
<Transition
enter-active-class="transition-transform duration-200"
enter-from-class="-translate-x-full"
enter-to-class="translate-x-0"
leave-active-class="transition-transform duration-200"
leave-from-class="translate-x-0"
leave-to-class="-translate-x-full"
>
<aside
v-if="isOpen"
class="md:hidden fixed inset-y-0 inset-is-0 z-50 w-72 bg-bg-subtle border-ie border-border overflow-y-auto"
>
<div
class="sticky top-0 bg-bg-subtle border-b border-border px-4 py-3 flex items-center justify-start"
>
<span class="font-mono text-sm text-fg-muted">{{ $t('stories.stories_label') }}</span>
<span aria-hidden="true" class="flex-shrink-1 flex-grow-1" />
<ButtonBase
:aria-label="$t('stories.close_tree')"
@click="isOpen = false"
classicon="i-carbon-close"
/>
</div>
<StorybookFileTree
:tree="tree"
:current-story-id="currentStoryId"
:base-url="baseUrl"
:base-path="basePath"
/>
</aside>
</Transition>
</template>
36 changes: 36 additions & 0 deletions app/composables/useStoryTreeState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { computed } from 'vue'
import { useState } from '#app'

export function useStoryTreeState(baseUrl: string) {
const stateKey = computed(() => `npmx-story-tree${baseUrl}`)

const expanded = useState<Set<string>>(stateKey.value, () => new Set<string>())

function toggleDir(path: string) {
if (expanded.value.has(path)) {
expanded.value.delete(path)
} else {
expanded.value.add(path)
}
}

function isExpanded(path: string) {
return expanded.value.has(path)
}

function autoExpandAncestors(path: string) {
if (!path) return
const parts = path.split('/').filter(Boolean)
let prefix = ''
for (const part of parts) {
prefix = prefix ? `${prefix}/${part}` : part
expanded.value.add(prefix)
}
}

return {
toggleDir,
isExpanded,
autoExpandAncestors,
}
}
Loading
Loading