slidev-workspace 0.9.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidev-workspace",
3
- "version": "0.9.1",
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": "^3.1.3",
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<{
@@ -2,86 +2,21 @@
2
2
  <div class="min-h-screen transition-colors sw-page">
3
3
  <div class="min-h-screen sw-layout">
4
4
  <aside
5
- class="sw-sidebar w-full border-r border-[#E8E8E8] bg-[#F1F1F1] text-sidebar-foreground dark:border-[#212121] dark:bg-[#191919]"
5
+ class="sw-sidebar w-full border-r border-border text-sidebar-foreground"
6
6
  >
7
- <div
8
- class="sticky top-0 flex h-screen flex-col px-6 py-10 text-sidebar-foreground w-[270px]"
9
- >
10
- <div class="px-1 pb-4">
11
- <h2 class="text-lg font-semibold tracking-tight">
12
- {{ sidebar.title }}
13
- </h2>
14
- </div>
15
-
16
- <div class="px-1 pb-6">
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
- <header class="sw-header bg-[#F5F5F5] dark:bg-[#121212]">
19
+ <header class="sw-header">
85
20
  <div class="max-w-[900px]">
86
21
  <div class="px-6 py-8 lg:px-12 lg:py-10">
87
22
  <Drawer direction="left">
@@ -94,86 +29,17 @@
94
29
  <PanelLeft class="size-5" />
95
30
  </button>
96
31
  </DrawerTrigger>
97
- <DrawerContent
98
- class="bg-[#F1F1F1] text-sidebar-foreground dark:bg-[#191919]"
99
- >
100
- <div
101
- class="flex h-full flex-col px-6 py-8 text-sidebar-foreground"
102
- >
103
- <div class="px-1 pb-4">
104
- <h2 class="text-lg font-semibold tracking-tight">
105
- {{ sidebar.title }}
106
- </h2>
107
- </div>
108
-
109
- <div class="px-1 pb-6">
110
- <div class="relative w-full">
111
- <Input
112
- class="pl-10 h-10 rounded-xl bg-background/70"
113
- placeholder="Search slides..."
114
- v-model="searchTerm"
115
- />
116
- <span
117
- class="absolute start-0 inset-y-0 flex items-center justify-center px-3"
118
- >
119
- <Search class="size-5 text-muted-foreground/50" />
120
- </span>
121
- </div>
122
- </div>
123
-
124
- <div class="px-1 pb-2">
125
- <h3
126
- class="text-xs font-semibold uppercase tracking-widest text-muted-foreground"
127
- >
128
- Categories
129
- </h3>
130
- </div>
131
-
132
- <div class="px-0.5 space-y-1 flex-1 overflow-auto">
133
- <button
134
- v-for="category in categoryOptions"
135
- :key="category.name"
136
- type="button"
137
- @click="selectedCategory = category.name"
138
- class="w-full flex items-center justify-between rounded-xl px-3 py-2 text-left text-sm transition-colors"
139
- :class="
140
- selectedCategory === category.name
141
- ? 'bg-sidebar-accent text-sidebar-accent-foreground'
142
- : 'hover:bg-sidebar-accent/70 text-sidebar-foreground'
143
- "
144
- >
145
- <span class="truncate">{{ category.name }}</span>
146
- <span class="text-xs text-muted-foreground">{{
147
- category.count
148
- }}</span>
149
- </button>
150
- </div>
151
-
152
- <div
153
- class="mt-auto flex items-center justify-between gap-3 pt-6"
154
- >
155
- <a
156
- v-if="sidebar.githubUrl"
157
- :href="sidebar.githubUrl"
158
- target="_blank"
159
- rel="noreferrer"
160
- 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"
161
- aria-label="Open GitHub repository"
162
- >
163
- <Github class="size-5" />
164
- </a>
165
- <div v-else />
166
- <button
167
- @click="toggleDarkMode"
168
- class="p-2 rounded-lg hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors cursor-pointer"
169
- aria-label="Toggle dark mode"
170
- type="button"
171
- >
172
- <Moon v-if="!isDark" class="size-5" />
173
- <Sun v-else class="size-5" />
174
- </button>
175
- </div>
176
- </div>
32
+ <DrawerContent class="sw-drawer text-sidebar-foreground">
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
+ />
177
43
  </DrawerContent>
178
44
  </Drawer>
179
45
 
@@ -208,7 +74,7 @@
208
74
  </div>
209
75
  </header>
210
76
 
211
- <section class="sw-main bg-[#F5F5F5] dark:bg-[#121212]">
77
+ <section class="sw-main">
212
78
  <div class="max-w-[900px]">
213
79
  <div
214
80
  class="grid grid-cols-1 gap-6 px-6 pb-12 sm:grid-cols-2 xl:grid-cols-3 lg:px-12"
@@ -232,14 +98,14 @@
232
98
 
233
99
  <script setup lang="ts">
234
100
  import { ref, computed, watch } from "vue";
235
- import { Search, Moon, Sun, Github, PanelLeft } from "lucide-vue-next";
101
+ import { PanelLeft } from "lucide-vue-next";
236
102
 
237
103
  import { useSlides } from "../composables/useSlides";
238
104
  import { useConfig } from "../composables/useConfig";
239
105
  import { useDarkMode } from "../composables/useDarkMode";
240
- import { Input } from "../components/ui/input";
241
106
  import { Drawer, DrawerContent, DrawerTrigger } from "../components/ui/drawer";
242
107
  import SlideCard from "./SlideCard.vue";
108
+ import SlideSidebar from "./SlideSidebar.vue";
243
109
 
244
110
  const searchTerm = ref("");
245
111
  const { slides, slidesCount } = useSlides();
@@ -359,14 +225,19 @@ const filteredSlides = computed(() => {
359
225
  }
360
226
 
361
227
  .sw-page {
362
- --sw-sidebar-bg: #f1f1f1;
363
- --sw-main-bg: #f5f5f5;
364
228
  background: var(--sw-main-bg);
365
229
  }
366
230
 
367
- :global(.dark) .sw-page {
231
+ :global(:root) {
232
+ --sw-sidebar-bg: #f1f1f1;
233
+ }
234
+
235
+ :global(.dark) {
368
236
  --sw-sidebar-bg: #191919;
369
- --sw-main-bg: #121212;
237
+ }
238
+
239
+ :global(.sw-drawer) {
240
+ background: var(--sw-sidebar-bg);
370
241
  }
371
242
 
372
243
  @media (max-width: 1024px) {
@@ -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
+ }