slidev-workspace 0.9.2 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -3
- package/src/preview/components/SlideCard.stories.ts +44 -0
- package/src/preview/components/SlideCard.vue +0 -17
- package/src/preview/components/SlideDeck.vue +22 -154
- package/src/preview/components/SlideSidebar.stories.ts +70 -0
- package/src/preview/components/SlideSidebar.vue +143 -0
- package/src/preview/components/TreeView.stories.ts +84 -0
- package/src/preview/components/TreeView.test.ts +84 -0
- package/src/preview/components/TreeView.vue +92 -0
- package/src/preview/lib/categoryTree.test.ts +99 -0
- package/src/preview/lib/categoryTree.ts +99 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slidev-workspace",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "A workspace tool for managing multiple Slidev presentations with API-based content management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,20 +52,30 @@
|
|
|
52
52
|
"yaml": "^2.8.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
+
"@chromatic-com/storybook": "^5.0.0",
|
|
55
56
|
"@prettier/plugin-oxc": "^0.0.4",
|
|
57
|
+
"@storybook/addon-a11y": "^10.2.3",
|
|
58
|
+
"@storybook/addon-docs": "^10.2.3",
|
|
59
|
+
"@storybook/addon-onboarding": "^10.2.3",
|
|
60
|
+
"@storybook/addon-vitest": "^10.2.3",
|
|
61
|
+
"@storybook/vue3-vite": "^10.2.3",
|
|
62
|
+
"@testing-library/vue": "^8.1.0",
|
|
56
63
|
"@tsconfig/node22": "^22.0.2",
|
|
57
64
|
"@types/node": "^22.15.32",
|
|
65
|
+
"@vitest/browser": "3.2.4",
|
|
58
66
|
"@vue/tsconfig": "^0.7.0",
|
|
59
67
|
"jiti": "^2.4.2",
|
|
60
68
|
"npm-run-all2": "^8.0.4",
|
|
61
69
|
"oxlint": "~1.1.0",
|
|
70
|
+
"playwright": "^1.58.1",
|
|
62
71
|
"postcss": "^8.5.6",
|
|
63
72
|
"prettier": "3.5.3",
|
|
73
|
+
"storybook": "^10.2.3",
|
|
64
74
|
"tailwindcss": "^4.1.11",
|
|
65
75
|
"tsdown": "^0.11.9",
|
|
66
76
|
"typescript": "^5.8.3",
|
|
67
77
|
"vite-plugin-vue-devtools": "^7.7.7",
|
|
68
|
-
"vitest": "
|
|
78
|
+
"vitest": "3.2.4",
|
|
69
79
|
"vue-tsc": "^3.0.3"
|
|
70
80
|
},
|
|
71
81
|
"scripts": {
|
|
@@ -75,6 +85,8 @@
|
|
|
75
85
|
"test": "vitest",
|
|
76
86
|
"typecheck": "vue-tsc --noEmit",
|
|
77
87
|
"lint": "oxlint",
|
|
78
|
-
"format": "prettier --write src/"
|
|
88
|
+
"format": "prettier --write src/",
|
|
89
|
+
"storybook": "storybook dev -p 6006",
|
|
90
|
+
"build-storybook": "storybook build"
|
|
79
91
|
}
|
|
80
92
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/vue3-vite";
|
|
2
|
+
|
|
3
|
+
import "../assets/main.css";
|
|
4
|
+
import SlideCard from "./SlideCard.vue";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof SlideCard> = {
|
|
7
|
+
title: "Preview/SlideCard",
|
|
8
|
+
component: SlideCard,
|
|
9
|
+
args: {
|
|
10
|
+
title: "Design Systems 101",
|
|
11
|
+
description:
|
|
12
|
+
"Learn how to build scalable UI foundations with practical tokens, components, and patterns.",
|
|
13
|
+
url: "https://example.com",
|
|
14
|
+
author: "Lea Chiu",
|
|
15
|
+
date: "2026-02-01",
|
|
16
|
+
image: "https://picsum.photos/200",
|
|
17
|
+
},
|
|
18
|
+
render: (args) => ({
|
|
19
|
+
components: { SlideCard },
|
|
20
|
+
setup: () => ({ args }),
|
|
21
|
+
template: "<div class='max-w-sm'><SlideCard v-bind='args' /></div>",
|
|
22
|
+
}),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
|
|
27
|
+
type Story = StoryObj<typeof SlideCard>;
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {};
|
|
30
|
+
|
|
31
|
+
export const WithoutImage: Story = {
|
|
32
|
+
args: {
|
|
33
|
+
image: undefined,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const LongText: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
title:
|
|
40
|
+
"Building for the Next Billion Users: Design, Performance, and Accessibility, Accessibility",
|
|
41
|
+
description:
|
|
42
|
+
"A deep dive into performance budgets, resilient layouts, content strategies, and internationalization across diverse devices.",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -40,21 +40,6 @@
|
|
|
40
40
|
description
|
|
41
41
|
}}</CardDescription>
|
|
42
42
|
|
|
43
|
-
<div class="flex flex-wrap gap-1 mb-2">
|
|
44
|
-
<span
|
|
45
|
-
v-if="theme"
|
|
46
|
-
class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800"
|
|
47
|
-
>
|
|
48
|
-
{{ theme }}
|
|
49
|
-
</span>
|
|
50
|
-
<span
|
|
51
|
-
v-if="sourceDir"
|
|
52
|
-
class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800"
|
|
53
|
-
>
|
|
54
|
-
{{ sourceDir.split("/").pop() }}
|
|
55
|
-
</span>
|
|
56
|
-
</div>
|
|
57
|
-
|
|
58
43
|
<div
|
|
59
44
|
class="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t"
|
|
60
45
|
>
|
|
@@ -92,8 +77,6 @@ const props = defineProps<{
|
|
|
92
77
|
url: string;
|
|
93
78
|
author: string;
|
|
94
79
|
date: string;
|
|
95
|
-
theme?: string;
|
|
96
|
-
sourceDir?: string;
|
|
97
80
|
}>();
|
|
98
81
|
|
|
99
82
|
defineEmits<{
|
|
@@ -4,81 +4,16 @@
|
|
|
4
4
|
<aside
|
|
5
5
|
class="sw-sidebar w-full border-r border-border text-sidebar-foreground"
|
|
6
6
|
>
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
<div class="relative w-full">
|
|
18
|
-
<Input
|
|
19
|
-
class="pl-10 h-10 rounded-xl bg-background/70"
|
|
20
|
-
placeholder="Search slides..."
|
|
21
|
-
v-model="searchTerm"
|
|
22
|
-
/>
|
|
23
|
-
<span
|
|
24
|
-
class="absolute start-0 inset-y-0 flex items-center justify-center px-3"
|
|
25
|
-
>
|
|
26
|
-
<Search class="size-5 text-muted-foreground/50" />
|
|
27
|
-
</span>
|
|
28
|
-
</div>
|
|
29
|
-
</div>
|
|
30
|
-
|
|
31
|
-
<div class="px-1 pb-2">
|
|
32
|
-
<h3
|
|
33
|
-
class="text-xs font-semibold uppercase tracking-widest text-muted-foreground"
|
|
34
|
-
>
|
|
35
|
-
Categories
|
|
36
|
-
</h3>
|
|
37
|
-
</div>
|
|
38
|
-
|
|
39
|
-
<div class="px-0.5 space-y-1 max-h-[60vh] overflow-auto">
|
|
40
|
-
<button
|
|
41
|
-
v-for="category in categoryOptions"
|
|
42
|
-
:key="category.name"
|
|
43
|
-
type="button"
|
|
44
|
-
@click="selectedCategory = category.name"
|
|
45
|
-
class="w-full flex items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition-colors"
|
|
46
|
-
:class="
|
|
47
|
-
selectedCategory === category.name
|
|
48
|
-
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
|
49
|
-
: 'hover:bg-sidebar-accent/70 text-sidebar-foreground'
|
|
50
|
-
"
|
|
51
|
-
>
|
|
52
|
-
<span class="truncate">{{ category.name }}</span>
|
|
53
|
-
<span class="text-xs text-muted-foreground">{{
|
|
54
|
-
category.count
|
|
55
|
-
}}</span>
|
|
56
|
-
</button>
|
|
57
|
-
</div>
|
|
58
|
-
|
|
59
|
-
<div class="mt-auto flex items-center justify-between gap-3 pt-6">
|
|
60
|
-
<a
|
|
61
|
-
v-if="sidebar.githubUrl"
|
|
62
|
-
:href="sidebar.githubUrl"
|
|
63
|
-
target="_blank"
|
|
64
|
-
rel="noreferrer"
|
|
65
|
-
class="inline-flex items-center justify-center rounded-lg p-2 text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors cursor-pointer"
|
|
66
|
-
aria-label="Open GitHub repository"
|
|
67
|
-
>
|
|
68
|
-
<Github class="size-5" />
|
|
69
|
-
</a>
|
|
70
|
-
<div v-else />
|
|
71
|
-
<button
|
|
72
|
-
@click="toggleDarkMode"
|
|
73
|
-
class="p-2 rounded-lg hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors cursor-pointer"
|
|
74
|
-
aria-label="Toggle dark mode"
|
|
75
|
-
type="button"
|
|
76
|
-
>
|
|
77
|
-
<Moon v-if="!isDark" class="size-5" />
|
|
78
|
-
<Sun v-else class="size-5" />
|
|
79
|
-
</button>
|
|
80
|
-
</div>
|
|
81
|
-
</div>
|
|
7
|
+
<SlideSidebar
|
|
8
|
+
:title="sidebar.title"
|
|
9
|
+
:github-url="sidebar.githubUrl"
|
|
10
|
+
:categories="categoryOptions"
|
|
11
|
+
:is-dark="isDark"
|
|
12
|
+
variant="desktop"
|
|
13
|
+
v-model:search-term="searchTerm"
|
|
14
|
+
v-model:selected-category="selectedCategory"
|
|
15
|
+
@toggle-dark="toggleDarkMode"
|
|
16
|
+
/>
|
|
82
17
|
</aside>
|
|
83
18
|
|
|
84
19
|
<header class="sw-header">
|
|
@@ -95,83 +30,16 @@
|
|
|
95
30
|
</button>
|
|
96
31
|
</DrawerTrigger>
|
|
97
32
|
<DrawerContent class="sw-drawer text-sidebar-foreground">
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
<div class="relative w-full">
|
|
109
|
-
<Input
|
|
110
|
-
class="pl-10 h-10 rounded-xl bg-background/70"
|
|
111
|
-
placeholder="Search slides..."
|
|
112
|
-
v-model="searchTerm"
|
|
113
|
-
/>
|
|
114
|
-
<span
|
|
115
|
-
class="absolute start-0 inset-y-0 flex items-center justify-center px-3"
|
|
116
|
-
>
|
|
117
|
-
<Search class="size-5 text-muted-foreground/50" />
|
|
118
|
-
</span>
|
|
119
|
-
</div>
|
|
120
|
-
</div>
|
|
121
|
-
|
|
122
|
-
<div class="px-1 pb-2">
|
|
123
|
-
<h3
|
|
124
|
-
class="text-xs font-semibold uppercase tracking-widest text-muted-foreground"
|
|
125
|
-
>
|
|
126
|
-
Categories
|
|
127
|
-
</h3>
|
|
128
|
-
</div>
|
|
129
|
-
|
|
130
|
-
<div class="px-0.5 space-y-1 flex-1 overflow-auto">
|
|
131
|
-
<button
|
|
132
|
-
v-for="category in categoryOptions"
|
|
133
|
-
:key="category.name"
|
|
134
|
-
type="button"
|
|
135
|
-
@click="selectedCategory = category.name"
|
|
136
|
-
class="w-full flex items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition-colors"
|
|
137
|
-
:class="
|
|
138
|
-
selectedCategory === category.name
|
|
139
|
-
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
|
140
|
-
: 'hover:bg-sidebar-accent/70 text-sidebar-foreground'
|
|
141
|
-
"
|
|
142
|
-
>
|
|
143
|
-
<span class="truncate">{{ category.name }}</span>
|
|
144
|
-
<span class="text-xs text-muted-foreground">{{
|
|
145
|
-
category.count
|
|
146
|
-
}}</span>
|
|
147
|
-
</button>
|
|
148
|
-
</div>
|
|
149
|
-
|
|
150
|
-
<div
|
|
151
|
-
class="mt-auto flex items-center justify-between gap-3 pt-6"
|
|
152
|
-
>
|
|
153
|
-
<a
|
|
154
|
-
v-if="sidebar.githubUrl"
|
|
155
|
-
:href="sidebar.githubUrl"
|
|
156
|
-
target="_blank"
|
|
157
|
-
rel="noreferrer"
|
|
158
|
-
class="inline-flex items-center justify-center rounded-lg p-2 text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors cursor-pointer"
|
|
159
|
-
aria-label="Open GitHub repository"
|
|
160
|
-
>
|
|
161
|
-
<Github class="size-5" />
|
|
162
|
-
</a>
|
|
163
|
-
<div v-else />
|
|
164
|
-
<button
|
|
165
|
-
@click="toggleDarkMode"
|
|
166
|
-
class="p-2 rounded-lg hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors cursor-pointer"
|
|
167
|
-
aria-label="Toggle dark mode"
|
|
168
|
-
type="button"
|
|
169
|
-
>
|
|
170
|
-
<Moon v-if="!isDark" class="size-5" />
|
|
171
|
-
<Sun v-else class="size-5" />
|
|
172
|
-
</button>
|
|
173
|
-
</div>
|
|
174
|
-
</div>
|
|
33
|
+
<SlideSidebar
|
|
34
|
+
:title="sidebar.title"
|
|
35
|
+
:github-url="sidebar.githubUrl"
|
|
36
|
+
:categories="categoryOptions"
|
|
37
|
+
:is-dark="isDark"
|
|
38
|
+
variant="drawer"
|
|
39
|
+
v-model:search-term="searchTerm"
|
|
40
|
+
v-model:selected-category="selectedCategory"
|
|
41
|
+
@toggle-dark="toggleDarkMode"
|
|
42
|
+
/>
|
|
175
43
|
</DrawerContent>
|
|
176
44
|
</Drawer>
|
|
177
45
|
|
|
@@ -230,14 +98,14 @@
|
|
|
230
98
|
|
|
231
99
|
<script setup lang="ts">
|
|
232
100
|
import { ref, computed, watch } from "vue";
|
|
233
|
-
import {
|
|
101
|
+
import { PanelLeft } from "lucide-vue-next";
|
|
234
102
|
|
|
235
103
|
import { useSlides } from "../composables/useSlides";
|
|
236
104
|
import { useConfig } from "../composables/useConfig";
|
|
237
105
|
import { useDarkMode } from "../composables/useDarkMode";
|
|
238
|
-
import { Input } from "../components/ui/input";
|
|
239
106
|
import { Drawer, DrawerContent, DrawerTrigger } from "../components/ui/drawer";
|
|
240
107
|
import SlideCard from "./SlideCard.vue";
|
|
108
|
+
import SlideSidebar from "./SlideSidebar.vue";
|
|
241
109
|
|
|
242
110
|
const searchTerm = ref("");
|
|
243
111
|
const { slides, slidesCount } = useSlides();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/vue3-vite";
|
|
2
|
+
|
|
3
|
+
import "../assets/main.css";
|
|
4
|
+
import SlideSidebar from "./SlideSidebar.vue";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof SlideSidebar> = {
|
|
7
|
+
title: "Preview/SlideSidebar",
|
|
8
|
+
component: SlideSidebar,
|
|
9
|
+
args: {
|
|
10
|
+
title: "Slide Deck",
|
|
11
|
+
githubUrl: "https://github.com/slidevjs/slidev",
|
|
12
|
+
categories: [
|
|
13
|
+
{
|
|
14
|
+
name: "All",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "tech-slides",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "tech",
|
|
21
|
+
categories: [
|
|
22
|
+
{
|
|
23
|
+
name: "slides",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "workshops",
|
|
27
|
+
categories: [
|
|
28
|
+
{
|
|
29
|
+
name: "frontend",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "backend",
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "design",
|
|
40
|
+
categories: [
|
|
41
|
+
{
|
|
42
|
+
name: "systems",
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
selectedCategory: "tech/slides",
|
|
48
|
+
searchTerm: "",
|
|
49
|
+
isDark: false,
|
|
50
|
+
variant: "desktop",
|
|
51
|
+
},
|
|
52
|
+
argTypes: {
|
|
53
|
+
variant: {
|
|
54
|
+
control: "inline-radio",
|
|
55
|
+
options: ["desktop", "drawer"],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export default meta;
|
|
61
|
+
|
|
62
|
+
type Story = StoryObj<typeof SlideSidebar>;
|
|
63
|
+
|
|
64
|
+
export const Desktop: Story = {};
|
|
65
|
+
|
|
66
|
+
export const Drawer: Story = {
|
|
67
|
+
args: {
|
|
68
|
+
variant: "drawer",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div :class="containerClass">
|
|
3
|
+
<div class="px-1 pb-4">
|
|
4
|
+
<h2 class="text-lg font-semibold tracking-tight">
|
|
5
|
+
{{ title }}
|
|
6
|
+
</h2>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="px-1 pb-6">
|
|
10
|
+
<div class="relative w-full">
|
|
11
|
+
<Input
|
|
12
|
+
class="pl-10 h-10 rounded-xl bg-background/70"
|
|
13
|
+
placeholder="Search slides..."
|
|
14
|
+
v-model="searchTerm"
|
|
15
|
+
/>
|
|
16
|
+
<span
|
|
17
|
+
class="absolute start-0 inset-y-0 flex items-center justify-center px-3"
|
|
18
|
+
>
|
|
19
|
+
<Search class="size-5 text-muted-foreground/50" />
|
|
20
|
+
</span>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="px-1 pb-2">
|
|
25
|
+
<h3
|
|
26
|
+
class="text-xs font-semibold uppercase tracking-widest text-muted-foreground"
|
|
27
|
+
>
|
|
28
|
+
Categories
|
|
29
|
+
</h3>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div :class="categoriesClass">
|
|
33
|
+
<button
|
|
34
|
+
v-if="allCategory"
|
|
35
|
+
:key="allCategory.name"
|
|
36
|
+
type="button"
|
|
37
|
+
@click="selectedCategory = allCategory.name"
|
|
38
|
+
class="mb-2 w-full flex items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition-colors"
|
|
39
|
+
:class="
|
|
40
|
+
selectedCategory === allCategory.name
|
|
41
|
+
? 'bg-sidebar-accent text-sidebar-accent-foreground ring-1 ring-sidebar-border/70 dark:ring-sidebar-border/30'
|
|
42
|
+
: 'hover:bg-sidebar-accent/70 text-sidebar-foreground'
|
|
43
|
+
"
|
|
44
|
+
>
|
|
45
|
+
<span class="truncate">{{ allCategory.name }}</span>
|
|
46
|
+
</button>
|
|
47
|
+
|
|
48
|
+
<div
|
|
49
|
+
v-if="categoryTree.length === 0 && !allCategory"
|
|
50
|
+
class="px-3 py-2 text-xs text-muted-foreground"
|
|
51
|
+
>
|
|
52
|
+
No categories yet.
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<TreeView
|
|
56
|
+
v-else
|
|
57
|
+
:nodes="categoryTree"
|
|
58
|
+
v-model:selected="selectedCategory"
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div class="mt-auto flex items-center justify-between gap-3 pt-6">
|
|
63
|
+
<a
|
|
64
|
+
v-if="githubUrl"
|
|
65
|
+
:href="githubUrl"
|
|
66
|
+
target="_blank"
|
|
67
|
+
rel="noreferrer"
|
|
68
|
+
class="inline-flex items-center justify-center rounded-lg p-2 text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors cursor-pointer"
|
|
69
|
+
aria-label="Open GitHub repository"
|
|
70
|
+
>
|
|
71
|
+
<Github class="size-5" />
|
|
72
|
+
</a>
|
|
73
|
+
<div v-else />
|
|
74
|
+
<button
|
|
75
|
+
@click="emit('toggle-dark', $event)"
|
|
76
|
+
class="p-2 rounded-lg hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors cursor-pointer"
|
|
77
|
+
aria-label="Toggle dark mode"
|
|
78
|
+
type="button"
|
|
79
|
+
>
|
|
80
|
+
<Moon v-if="!isDark" class="size-5" />
|
|
81
|
+
<Sun v-else class="size-5" />
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
</template>
|
|
86
|
+
|
|
87
|
+
<script setup lang="ts">
|
|
88
|
+
import { computed } from "vue";
|
|
89
|
+
import { Github, Moon, Search, Sun } from "lucide-vue-next";
|
|
90
|
+
|
|
91
|
+
import { Input } from "../components/ui/input";
|
|
92
|
+
import {
|
|
93
|
+
normalizeCategories,
|
|
94
|
+
type CategoryNodeInput,
|
|
95
|
+
} from "../lib/categoryTree";
|
|
96
|
+
import TreeView from "./TreeView.vue";
|
|
97
|
+
|
|
98
|
+
const props = withDefaults(
|
|
99
|
+
defineProps<{
|
|
100
|
+
title: string;
|
|
101
|
+
githubUrl?: string;
|
|
102
|
+
categories: CategoryNodeInput[];
|
|
103
|
+
isDark: boolean;
|
|
104
|
+
variant?: "desktop" | "drawer";
|
|
105
|
+
}>(),
|
|
106
|
+
{
|
|
107
|
+
variant: "desktop",
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const emit = defineEmits<{
|
|
112
|
+
(e: "toggle-dark", event: MouseEvent): void;
|
|
113
|
+
}>();
|
|
114
|
+
|
|
115
|
+
const searchTerm = defineModel<string>("searchTerm", { default: "" });
|
|
116
|
+
const selectedCategory = defineModel<string>("selectedCategory", {
|
|
117
|
+
default: "All",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const ALL_CATEGORY_LABEL = "All";
|
|
121
|
+
|
|
122
|
+
const allCategory = computed(() =>
|
|
123
|
+
props.categories.find((category) => category.name === ALL_CATEGORY_LABEL),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const containerClass = computed(() =>
|
|
127
|
+
props.variant === "drawer"
|
|
128
|
+
? "flex h-full flex-col px-6 py-8 text-sidebar-foreground"
|
|
129
|
+
: "sticky top-0 flex h-screen w-[270px] flex-col px-6 py-10 text-sidebar-foreground",
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const categoriesClass = computed(() =>
|
|
133
|
+
props.variant === "drawer"
|
|
134
|
+
? "px-0.5 pb-2 space-y-1 flex-1 overflow-auto"
|
|
135
|
+
: "px-0.5 pb-2 space-y-1 max-h-[60vh] overflow-auto",
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const categoryTree = computed(() =>
|
|
139
|
+
normalizeCategories(
|
|
140
|
+
props.categories.filter((category) => category.name !== ALL_CATEGORY_LABEL),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
</script>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ref } from "vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/vue3-vite";
|
|
3
|
+
|
|
4
|
+
import "../assets/main.css";
|
|
5
|
+
import TreeView from "./TreeView.vue";
|
|
6
|
+
import type { TreeNode } from "../lib/categoryTree";
|
|
7
|
+
|
|
8
|
+
const sampleNodes: TreeNode[] = [
|
|
9
|
+
{
|
|
10
|
+
name: "tech-slides",
|
|
11
|
+
path: "tech-slides",
|
|
12
|
+
children: [],
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "tech",
|
|
16
|
+
path: "tech",
|
|
17
|
+
children: [
|
|
18
|
+
{
|
|
19
|
+
name: "slides",
|
|
20
|
+
path: "tech/slides",
|
|
21
|
+
children: [],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "workshops",
|
|
25
|
+
path: "tech/workshops",
|
|
26
|
+
children: [],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "design",
|
|
32
|
+
path: "design",
|
|
33
|
+
children: [
|
|
34
|
+
{
|
|
35
|
+
name: "systems",
|
|
36
|
+
path: "design/systems",
|
|
37
|
+
children: [],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
type TreeViewStoryArgs = {
|
|
44
|
+
nodes: TreeNode[];
|
|
45
|
+
selected: string;
|
|
46
|
+
expandedPaths: string[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const meta: Meta<TreeViewStoryArgs> = {
|
|
50
|
+
title: "Preview/TreeView",
|
|
51
|
+
component: TreeView,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export default meta;
|
|
55
|
+
|
|
56
|
+
type Story = StoryObj<TreeViewStoryArgs>;
|
|
57
|
+
|
|
58
|
+
export const Default: Story = {
|
|
59
|
+
args: {
|
|
60
|
+
nodes: sampleNodes,
|
|
61
|
+
selected: "tech/slides",
|
|
62
|
+
expandedPaths: ["tech"],
|
|
63
|
+
},
|
|
64
|
+
argTypes: {
|
|
65
|
+
expandedPaths: {
|
|
66
|
+
control: "object",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
render: (args: TreeViewStoryArgs) => ({
|
|
70
|
+
components: { TreeView },
|
|
71
|
+
setup() {
|
|
72
|
+
const selected = ref(args.selected);
|
|
73
|
+
const expanded = ref(new Set(args.expandedPaths));
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
args,
|
|
77
|
+
selected,
|
|
78
|
+
expanded,
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
template:
|
|
82
|
+
'<TreeView :nodes="args.nodes" v-model:selected="selected" v-model:expanded="expanded" />',
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/vue";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import TreeView from "./TreeView.vue";
|
|
5
|
+
import type { TreeNode } from "../lib/categoryTree";
|
|
6
|
+
|
|
7
|
+
const sampleNodes: TreeNode[] = [
|
|
8
|
+
{
|
|
9
|
+
name: "tech-slides",
|
|
10
|
+
path: "tech-slides",
|
|
11
|
+
children: [],
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: "tech",
|
|
15
|
+
path: "tech",
|
|
16
|
+
children: [
|
|
17
|
+
{
|
|
18
|
+
name: "slides",
|
|
19
|
+
path: "tech/slides",
|
|
20
|
+
children: [],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "workshops",
|
|
24
|
+
path: "tech/workshops",
|
|
25
|
+
children: [],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "design",
|
|
31
|
+
path: "design",
|
|
32
|
+
children: [
|
|
33
|
+
{
|
|
34
|
+
name: "systems",
|
|
35
|
+
path: "design/systems",
|
|
36
|
+
children: [],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
describe("TreeView", () => {
|
|
43
|
+
it("emits expanded updates for parent nodes", async () => {
|
|
44
|
+
const onUpdateExpanded = vi.fn();
|
|
45
|
+
|
|
46
|
+
render(TreeView, {
|
|
47
|
+
props: {
|
|
48
|
+
nodes: sampleNodes,
|
|
49
|
+
selected: "",
|
|
50
|
+
expanded: new Set<string>(),
|
|
51
|
+
"onUpdate:selected": vi.fn(),
|
|
52
|
+
"onUpdate:expanded": onUpdateExpanded,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const techButton = screen.getByRole("button", { name: "tech" });
|
|
57
|
+
expect(techButton.getAttribute("aria-expanded")).toBe("false");
|
|
58
|
+
|
|
59
|
+
await fireEvent.click(techButton);
|
|
60
|
+
|
|
61
|
+
expect(onUpdateExpanded).toHaveBeenCalledTimes(1);
|
|
62
|
+
const nextExpanded = onUpdateExpanded.mock.calls[0][0] as Set<string>;
|
|
63
|
+
expect(nextExpanded.has("tech")).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("emits selection updates for leaf nodes", async () => {
|
|
67
|
+
const onUpdateSelected = vi.fn();
|
|
68
|
+
|
|
69
|
+
render(TreeView, {
|
|
70
|
+
props: {
|
|
71
|
+
nodes: sampleNodes,
|
|
72
|
+
selected: "",
|
|
73
|
+
expanded: new Set<string>(["tech"]),
|
|
74
|
+
"onUpdate:selected": onUpdateSelected,
|
|
75
|
+
"onUpdate:expanded": vi.fn(),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const slidesButton = screen.getByRole("button", { name: "slides" });
|
|
80
|
+
await fireEvent.click(slidesButton);
|
|
81
|
+
|
|
82
|
+
expect(onUpdateSelected).toHaveBeenCalledWith("tech/slides");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="space-y-2">
|
|
3
|
+
<div v-for="node in nodes" :key="node.path" class="space-y-1">
|
|
4
|
+
<button
|
|
5
|
+
type="button"
|
|
6
|
+
class="w-full flex items-center justify-between rounded-xl text-left transition-colors"
|
|
7
|
+
:class="rowClass(node, 1)"
|
|
8
|
+
:aria-expanded="node.children.length > 0 ? isExpanded(node) : undefined"
|
|
9
|
+
@click="handleNodeClick(node, 1)"
|
|
10
|
+
>
|
|
11
|
+
<span class="truncate">{{ node.name }}</span>
|
|
12
|
+
<ChevronRight
|
|
13
|
+
v-if="node.children.length > 0"
|
|
14
|
+
class="size-4 text-muted-foreground transition-transform"
|
|
15
|
+
:class="isExpanded(node) ? 'rotate-90' : ''"
|
|
16
|
+
/>
|
|
17
|
+
</button>
|
|
18
|
+
|
|
19
|
+
<div v-if="isExpanded(node)" class="space-y-1 pl-3">
|
|
20
|
+
<div v-for="child in node.children" :key="child.path" class="space-y-1">
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
class="w-full flex items-center justify-between rounded-lg text-left transition-colors"
|
|
24
|
+
:class="rowClass(child, 2)"
|
|
25
|
+
@click="handleNodeClick(child, 2)"
|
|
26
|
+
>
|
|
27
|
+
<span class="truncate">{{ child.name }}</span>
|
|
28
|
+
</button>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script setup lang="ts">
|
|
36
|
+
import { ChevronRight } from "lucide-vue-next";
|
|
37
|
+
|
|
38
|
+
import type { TreeNode } from "../lib/categoryTree";
|
|
39
|
+
|
|
40
|
+
defineProps<{
|
|
41
|
+
nodes: TreeNode[];
|
|
42
|
+
}>();
|
|
43
|
+
|
|
44
|
+
const selected = defineModel<string>("selected", { default: "" });
|
|
45
|
+
const expanded = defineModel<Set<string>>("expanded", {
|
|
46
|
+
default: () => new Set<string>(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const isExpanded = (node: TreeNode) => expanded.value.has(node.path);
|
|
50
|
+
|
|
51
|
+
const isSelected = (node: TreeNode) => selected.value === node.path;
|
|
52
|
+
|
|
53
|
+
const isAncestorSelected = (node: TreeNode) => {
|
|
54
|
+
if (!selected.value) return false;
|
|
55
|
+
return selected.value.startsWith(`${node.path}/`);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// The current TreeView UI only supports two nesting levels.
|
|
59
|
+
// Deeper trees should be flattened or handled by a future recursive version.
|
|
60
|
+
const rowClass = (node: TreeNode, level: 1 | 2) => {
|
|
61
|
+
const size = level === 1 ? "px-3 py-2 text-sm" : "px-3 py-1.5 text-sm";
|
|
62
|
+
|
|
63
|
+
if (isSelected(node)) {
|
|
64
|
+
return `${size} bg-sidebar-accent text-sidebar-accent-foreground ring-1 ring-sidebar-border/70 dark:ring-sidebar-border/30`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isAncestorSelected(node)) {
|
|
68
|
+
return `${size} bg-sidebar-accent/40 text-sidebar-foreground`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return `${size} hover:bg-sidebar-accent/70 text-sidebar-foreground`;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const toggleExpanded = (node: TreeNode) => {
|
|
75
|
+
const next = new Set(expanded.value);
|
|
76
|
+
if (next.has(node.path)) {
|
|
77
|
+
next.delete(node.path);
|
|
78
|
+
} else {
|
|
79
|
+
next.add(node.path);
|
|
80
|
+
}
|
|
81
|
+
expanded.value = next;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const handleNodeClick = (node: TreeNode, level: 1 | 2) => {
|
|
85
|
+
if (level === 1 && node.children.length > 0) {
|
|
86
|
+
toggleExpanded(node);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
selected.value = node.path;
|
|
91
|
+
};
|
|
92
|
+
</script>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { normalizeCategories, type CategoryNodeInput } from "./categoryTree";
|
|
4
|
+
|
|
5
|
+
describe("normalizeCategories", () => {
|
|
6
|
+
it("returns an empty array when no categories are provided", () => {
|
|
7
|
+
expect(normalizeCategories([])).toEqual([]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("builds a tree from flat category paths and sorts nodes", () => {
|
|
11
|
+
const input: CategoryNodeInput[] = [
|
|
12
|
+
{ name: "tech/workshops" },
|
|
13
|
+
{ name: "design/systems" },
|
|
14
|
+
{ name: "tech/slides" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
expect(normalizeCategories(input)).toEqual([
|
|
18
|
+
{
|
|
19
|
+
name: "design",
|
|
20
|
+
path: "design",
|
|
21
|
+
children: [
|
|
22
|
+
{
|
|
23
|
+
name: "systems",
|
|
24
|
+
path: "design/systems",
|
|
25
|
+
children: [],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "tech",
|
|
31
|
+
path: "tech",
|
|
32
|
+
children: [
|
|
33
|
+
{
|
|
34
|
+
name: "slides",
|
|
35
|
+
path: "tech/slides",
|
|
36
|
+
children: [],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "workshops",
|
|
40
|
+
path: "tech/workshops",
|
|
41
|
+
children: [],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("prefers nested shape when provided and respects explicit paths", () => {
|
|
49
|
+
const input: CategoryNodeInput[] = [
|
|
50
|
+
{
|
|
51
|
+
name: "tech",
|
|
52
|
+
categories: [{ name: "workshops" }, { name: "slides" }],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "design",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "special",
|
|
59
|
+
path: "custom",
|
|
60
|
+
categories: [{ name: "vip" }],
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
expect(normalizeCategories(input)).toEqual([
|
|
65
|
+
{
|
|
66
|
+
name: "design",
|
|
67
|
+
path: "design",
|
|
68
|
+
children: [],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "special",
|
|
72
|
+
path: "custom",
|
|
73
|
+
children: [
|
|
74
|
+
{
|
|
75
|
+
name: "vip",
|
|
76
|
+
path: "custom/vip",
|
|
77
|
+
children: [],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "tech",
|
|
83
|
+
path: "tech",
|
|
84
|
+
children: [
|
|
85
|
+
{
|
|
86
|
+
name: "slides",
|
|
87
|
+
path: "tech/slides",
|
|
88
|
+
children: [],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "workshops",
|
|
92
|
+
path: "tech/workshops",
|
|
93
|
+
children: [],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export interface CategoryNodeInput {
|
|
2
|
+
name: string;
|
|
3
|
+
categories?: CategoryNodeInput[];
|
|
4
|
+
count?: number;
|
|
5
|
+
path?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TreeNode {
|
|
9
|
+
name: string;
|
|
10
|
+
path: string;
|
|
11
|
+
children: TreeNode[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type RawNode = {
|
|
15
|
+
name: string;
|
|
16
|
+
path: string;
|
|
17
|
+
children: RawNode[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function normalizeCategories(
|
|
21
|
+
categories: CategoryNodeInput[],
|
|
22
|
+
): TreeNode[] {
|
|
23
|
+
if (categories.length === 0) return [];
|
|
24
|
+
const hasTreeShape = categories.some(
|
|
25
|
+
(category) => category.categories && category.categories.length > 0,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const rawNodes = sortRawNodes(
|
|
29
|
+
hasTreeShape
|
|
30
|
+
? buildTreeFromNested(categories)
|
|
31
|
+
: buildTreeFromFlat(categories),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return finalizeNodes(rawNodes);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildTreeFromNested(
|
|
38
|
+
categories: CategoryNodeInput[],
|
|
39
|
+
parentPath = "",
|
|
40
|
+
): RawNode[] {
|
|
41
|
+
return categories
|
|
42
|
+
.map((category) => {
|
|
43
|
+
const path =
|
|
44
|
+
category.path ??
|
|
45
|
+
(parentPath ? `${parentPath}/${category.name}` : category.name);
|
|
46
|
+
return {
|
|
47
|
+
name: category.name,
|
|
48
|
+
path,
|
|
49
|
+
children: buildTreeFromNested(category.categories ?? [], path),
|
|
50
|
+
};
|
|
51
|
+
})
|
|
52
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildTreeFromFlat(categories: CategoryNodeInput[]): RawNode[] {
|
|
56
|
+
const root: RawNode[] = [];
|
|
57
|
+
|
|
58
|
+
for (const category of categories) {
|
|
59
|
+
const categoryPath = category.path ?? category.name;
|
|
60
|
+
const segments = categoryPath.split("/").filter(Boolean);
|
|
61
|
+
let current = root;
|
|
62
|
+
let currentPath = "";
|
|
63
|
+
|
|
64
|
+
for (const segment of segments) {
|
|
65
|
+
const nextPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
66
|
+
let node = current.find((candidate) => candidate.path === nextPath);
|
|
67
|
+
if (!node) {
|
|
68
|
+
node = {
|
|
69
|
+
name: segment,
|
|
70
|
+
path: nextPath,
|
|
71
|
+
children: [],
|
|
72
|
+
};
|
|
73
|
+
current.push(node);
|
|
74
|
+
}
|
|
75
|
+
current = node.children;
|
|
76
|
+
currentPath = nextPath;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return root.sort((a, b) => a.name.localeCompare(b.name));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function finalizeNodes(nodes: RawNode[]): TreeNode[] {
|
|
84
|
+
return nodes.map((node) => ({
|
|
85
|
+
name: node.name,
|
|
86
|
+
path: node.path,
|
|
87
|
+
children: finalizeNodes(node.children),
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function sortRawNodes(nodes: RawNode[]): RawNode[] {
|
|
92
|
+
return nodes
|
|
93
|
+
.slice()
|
|
94
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
95
|
+
.map((node) => ({
|
|
96
|
+
...node,
|
|
97
|
+
children: sortRawNodes(node.children),
|
|
98
|
+
}));
|
|
99
|
+
}
|