Skip to content

Commit 825f764

Browse files
jaysin586claude
andcommitted
feat(sidebar): add Sidebar component with nav utilities
- Add collapsible Sidebar with spring animations, active-state highlighting, persisted open/close state, and external link support - Add NavItem/NavSection types with optional `exact` flag for controlling prefix-matching behavior per item - Add isActivePath and getDocsTitleByPath nav utilities - Add fetchOtherProjects server helper for Humanspeak registry - Add runed dependency for PersistedState - Export all new public API from index.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f517b4c commit 825f764

File tree

7 files changed

+304
-0
lines changed

7 files changed

+304
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
"@resvg/resvg-js": "^2.6.2",
9898
"bits-ui": "^2.16.2",
9999
"clsx": "^2.1.1",
100+
"runed": "^0.37.1",
100101
"satori": "^0.24.1",
101102
"satori-html": "^0.3.2",
102103
"tailwind-merge": "^3.5.0"

pnpm-lock.yaml

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/components/Sidebar.svelte

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<!--
2+
Shared sidebar navigation component for Humanspeak docs sites.
3+
Collapsible sections with spring animations, active-state highlighting, and external link support.
4+
-->
5+
<script lang="ts">
6+
import { motion } from '@humanspeak/svelte-motion'
7+
import ArrowRight from '@lucide/svelte/icons/arrow-right'
8+
import ChevronDown from '@lucide/svelte/icons/chevron-down'
9+
import ExternalLink from '@lucide/svelte/icons/external-link'
10+
import Heart from '@lucide/svelte/icons/heart'
11+
import { PersistedState } from 'runed'
12+
import { slide } from 'svelte/transition'
13+
import type { DocsKitConfig } from '../config.js'
14+
import type { NavItem, NavSection } from '../types/nav.js'
15+
import { isActivePath } from '../utils/nav.js'
16+
17+
const defaultLoveAndRespect: NavItem[] = [
18+
{ title: 'Beye.ai', href: 'https://beye.ai', icon: Heart, external: true }
19+
]
20+
21+
const hoverScale = { scale: 1.25 }
22+
const hoverShift = { x: 2 }
23+
const springFast = { type: 'spring' as const, stiffness: 500, damping: 15 }
24+
const springSoft = { type: 'spring' as const, stiffness: 400, damping: 25 }
25+
26+
const {
27+
config,
28+
sections,
29+
currentPath,
30+
otherProjects = [],
31+
loveAndRespect = defaultLoveAndRespect
32+
}: {
33+
config: DocsKitConfig
34+
sections: NavSection[]
35+
currentPath: string
36+
otherProjects?: NavItem[]
37+
loveAndRespect?: NavItem[]
38+
} = $props()
39+
40+
const openSections = new PersistedState<Record<string, boolean>>(
41+
`${config.slug}-sidebar-sections`,
42+
{}
43+
)
44+
45+
// Ensure items in built-in sections have Heart icons as defaults
46+
// (otherProjects come from server data and can't carry component references)
47+
const withDefaultIcon = (items: NavItem[], defaultIcon: typeof Heart): NavItem[] =>
48+
items.map((item) => (item.icon ? item : { ...item, icon: defaultIcon }))
49+
50+
const navigation: NavSection[] = $derived([
51+
...sections,
52+
...(loveAndRespect.length > 0
53+
? [
54+
{
55+
title: 'Love and Respect',
56+
icon: Heart,
57+
items: withDefaultIcon(loveAndRespect, Heart)
58+
}
59+
]
60+
: []),
61+
...(otherProjects.length > 0
62+
? [
63+
{
64+
title: 'Other Projects',
65+
icon: Heart,
66+
items: withDefaultIcon(otherProjects, Heart)
67+
}
68+
]
69+
: [])
70+
])
71+
72+
const isSectionOpen = (section: NavSection): boolean => {
73+
if (section.title in openSections.current) return openSections.current[section.title]
74+
return true
75+
}
76+
77+
const toggleSection = (section: NavSection) => {
78+
openSections.current = {
79+
...openSections.current,
80+
[section.title]: !isSectionOpen(section)
81+
}
82+
}
83+
</script>
84+
85+
<nav class="p-2">
86+
<div class="space-y-2">
87+
{#each navigation as section (section.title)}
88+
<div>
89+
<button
90+
onclick={() => toggleSection(section)}
91+
class="flex w-full items-center justify-between rounded-md px-3 py-1.5 text-sm font-semibold tracking-wide text-text-primary uppercase transition-colors duration-150 hover:bg-muted"
92+
>
93+
<span class="flex items-center gap-2 text-left">
94+
<motion.span
95+
class="inline-flex shrink-0"
96+
whileHover={hoverScale}
97+
transition={springFast}
98+
>
99+
{#if section.icon}
100+
<svelte:component
101+
this={section.icon}
102+
size={14}
103+
class="text-muted-foreground"
104+
/>
105+
{/if}
106+
</motion.span>
107+
{section.title}
108+
</span>
109+
<ChevronDown
110+
size={12}
111+
class="shrink-0 text-muted-foreground transition-transform duration-200 {isSectionOpen(
112+
section
113+
)
114+
? 'rotate-180'
115+
: ''}"
116+
/>
117+
</button>
118+
{#if isSectionOpen(section)}
119+
<ul
120+
class="mt-1 ml-3 space-y-1 border-l border-border pl-1"
121+
transition:slide={{ duration: 200 }}
122+
>
123+
{#each section.items as item (item.href)}
124+
<motion.li
125+
whileHover={hoverShift}
126+
transition={springSoft}
127+
>
128+
<a
129+
href={item.href}
130+
target={item?.external ? '_blank' : undefined}
131+
rel={item?.external ? 'noopener' : undefined}
132+
class="group flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150
133+
{isActivePath(item.href, currentPath, item.exact)
134+
? 'bg-accent text-accent-foreground'
135+
: 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
136+
>
137+
{#if item.icon}
138+
<motion.span
139+
class="mr-3 inline-flex"
140+
whileHover={hoverScale}
141+
transition={springFast}
142+
>
143+
<svelte:component
144+
this={item.icon}
145+
size={14}
146+
class={isActivePath(item.href, currentPath, item.exact)
147+
? 'text-accent-foreground'
148+
: 'text-muted-foreground group-hover:text-foreground'}
149+
/>
150+
</motion.span>
151+
{:else}
152+
<ArrowRight size={12} class="mr-3 text-muted-foreground" />
153+
{/if}
154+
{item.title}
155+
{#if item?.external}
156+
<ExternalLink size={12} class="ml-2 opacity-50" />
157+
{/if}
158+
</a>
159+
</motion.li>
160+
{/each}
161+
</ul>
162+
{/if}
163+
</div>
164+
{/each}
165+
</div>
166+
</nav>

src/lib/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,19 @@ export { default as Header } from './components/Header.svelte'
99
export { default as OG } from './components/OG.svelte'
1010
export { default as SeoContextProvider } from './components/SeoContextProvider.svelte'
1111
export { default as SeoHead } from './components/SeoHead.svelte'
12+
export { default as Sidebar } from './components/Sidebar.svelte'
1213
export { default as ThemeToggle } from './components/ThemeToggle.svelte'
1314

1415
// Icons (brand icons only — use @lucide/svelte for UI icons)
1516
export { default as GitHubIcon } from './components/icons/GitHubIcon.svelte'
1617
export { default as NpmIcon } from './components/icons/NpmIcon.svelte'
1718

19+
// Nav types
20+
export type { NavItem, NavSection } from './types/nav.js'
21+
1822
// Utilities
23+
export { fetchOtherProjects } from './utils/fetchOtherProjects.js'
24+
export { getDocsTitleByPath, isActivePath } from './utils/nav.js'
1925
export { cn } from './utils/shadcn.js'
2026

2127
// Contexts

src/lib/types/nav.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { Component } from 'svelte'
2+
3+
export type NavItem = {
4+
title: string
5+
href: string
6+
icon?: Component
7+
external?: boolean
8+
/** When true, only exact path matches activate this item (no prefix matching) */
9+
exact?: boolean
10+
}
11+
12+
export type NavSection = {
13+
title: string
14+
icon?: Component
15+
items: NavItem[]
16+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { NavItem } from '../types/nav.js'
2+
3+
type OtherProject = {
4+
url: string
5+
slug: string
6+
shortDescription: string
7+
}
8+
9+
/**
10+
* Server-side helper to fetch "Other Projects" from the Humanspeak registry.
11+
* Filters out the current project by slug and returns NavItem[] for direct use in Sidebar.
12+
*
13+
* @param slug - The current project's slug to filter out (e.g., 'markdown', 'motion')
14+
* @returns NavItem[] of other Humanspeak projects
15+
*/
16+
export async function fetchOtherProjects(slug: string): Promise<NavItem[]> {
17+
try {
18+
const controller = new AbortController()
19+
const timeoutId = setTimeout(() => controller.abort(), 5000)
20+
21+
const response = await fetch('https://svelte.page/api/v1/others', {
22+
signal: controller.signal
23+
})
24+
clearTimeout(timeoutId)
25+
26+
if (!response.ok) {
27+
throw new Error(`HTTP ${response.status}`)
28+
}
29+
30+
const projects = (await response.json()) as OtherProject[]
31+
32+
if (!Array.isArray(projects)) {
33+
throw new Error('Invalid response: expected array')
34+
}
35+
36+
return projects
37+
.filter(
38+
(p): p is OtherProject =>
39+
p != null &&
40+
typeof p.slug === 'string' &&
41+
typeof p.url === 'string' &&
42+
typeof p.shortDescription === 'string'
43+
)
44+
.filter((project) => project.slug !== slug)
45+
.map((project) => ({
46+
title: project.slug.toLowerCase(),
47+
href: project.url,
48+
external: true
49+
}))
50+
} catch (error) {
51+
console.error('Failed to fetch other projects:', error)
52+
return []
53+
}
54+
}

src/lib/utils/nav.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { NavSection } from '../types/nav.js'
2+
3+
/**
4+
* Look up a docs page title by its pathname
5+
* @param sections - The navigation sections to search
6+
* @param pathname - The URL pathname (e.g., '/docs/infinite-scroll')
7+
* @returns The page title or null if not found
8+
*/
9+
export function getDocsTitleByPath(sections: NavSection[], pathname: string): string | null {
10+
for (const section of sections) {
11+
const item = section.items.find((item) => item.href === pathname)
12+
if (item) return item.title
13+
}
14+
return null
15+
}
16+
17+
/**
18+
* Check if a navigation item is active based on the current path.
19+
* By default uses prefix matching (e.g. `/docs/foo` matches `/docs/foo/bar`).
20+
* Set `exact: true` to disable prefix matching for index-style routes.
21+
*/
22+
export function isActivePath(href: string, currentPath: string, exact = false): boolean {
23+
const basePath = currentPath.split(/[?#]/)[0]
24+
if (exact) {
25+
return (
26+
basePath === href ||
27+
currentPath.startsWith(`${href}?`) ||
28+
currentPath.startsWith(`${href}#`)
29+
)
30+
}
31+
return (
32+
basePath === href ||
33+
currentPath.startsWith(`${href}?`) ||
34+
currentPath.startsWith(`${href}#`) ||
35+
basePath.startsWith(`${href}/`)
36+
)
37+
}

0 commit comments

Comments
 (0)