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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidev-workspace",
3
- "version": "0.9.2",
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<{
@@ -4,81 +4,16 @@
4
4
  <aside
5
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
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
- <div
99
- class="flex h-full flex-col px-6 py-8 text-sidebar-foreground"
100
- >
101
- <div class="px-1 pb-4">
102
- <h2 class="text-lg font-semibold tracking-tight">
103
- {{ sidebar.title }}
104
- </h2>
105
- </div>
106
-
107
- <div class="px-1 pb-6">
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 { Search, Moon, Sun, Github, PanelLeft } from "lucide-vue-next";
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
+ }