@kolkrabbi/kol-framework 0.1.3 → 0.2.1

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/kol-framework.css CHANGED
@@ -1205,3 +1205,113 @@ code, pre {
1205
1205
  }
1206
1206
 
1207
1207
  /* .kol-checker + .kol-grid-bg — moved to src/editor/styles/kol-editor.css */
1208
+
1209
+ /* ───── AppShell TOC rail + SideNav collapsible-sections (monorepo shell merge, 2026-07-03) ───── */
1210
+
1211
+ :root {
1212
+ --kol-toc-w: 160px;
1213
+ }
1214
+
1215
+ /* Opt-in third grid column: AppShell sets data-toc="true" on .kol-brand-layout
1216
+ only when a page registers TOC content (ShellTocContext) or the app passes
1217
+ defaultTocContent. Without the attribute the base 2-col template applies. */
1218
+ @media (min-width: 1280px) {
1219
+ .kol-brand-layout[data-toc="true"] {
1220
+ grid-template-columns: var(--kol-sidenav-w) minmax(0, 1fr) var(--kol-toc-w);
1221
+ }
1222
+ }
1223
+
1224
+ /* SideNav collapsibleSections chrome — inert unless that mode renders.
1225
+ Rail (collapsed) + forced rail (≤1024px) hide count/toggle with the labels;
1226
+ the open ≤767px drawer restores them, mirroring -hop-label. */
1227
+ .kol-sidenav.is-collapsed .kol-sidenav-hop-count,
1228
+ .kol-sidenav.is-collapsed .kol-sidenav-section-toggle {
1229
+ display: none;
1230
+ }
1231
+
1232
+ @media (max-width: 1024px) {
1233
+ .kol-sidenav-hop-count,
1234
+ .kol-sidenav-section-toggle {
1235
+ display: none;
1236
+ }
1237
+ }
1238
+
1239
+ @media (max-width: 767px) {
1240
+ .kol-sidenav.is-drawer-open .kol-sidenav-hop-count { display: inline; }
1241
+ .kol-sidenav.is-drawer-open .kol-sidenav-section-toggle { display: inline-flex; }
1242
+ }
1243
+
1244
+ /* ── ShellHeader — kol-shell-header-* ─────────────────────────────────────
1245
+ Chrome the JSX utilities can't express: hidden scrollbar on the tab strip,
1246
+ the tab type/underline recipe with its active state, and the wide-viewport
1247
+ (1600px) bumps. Colors are --kol-fg-* tokens; no raw color-mix() here.
1248
+ Target: packages/framework/kol-framework.css */
1249
+
1250
+ /* Brand block — reserves the nav-column width at lg+ so Row-2 tabs align to
1251
+ the shell grid (keyed to the live sidenav token; source used lg:w-[256px]). */
1252
+ @media (min-width: 1024px) {
1253
+ .kol-shell-header-brand {
1254
+ width: var(--kol-sidenav-w, 256px);
1255
+ }
1256
+ }
1257
+
1258
+ /* Tab strip — horizontally scrollable, scrollbar hidden. */
1259
+ .kol-shell-header-tabs {
1260
+ display: flex;
1261
+ flex-wrap: nowrap;
1262
+ gap: 24px;
1263
+ overflow-x: auto;
1264
+ padding-top: 8px;
1265
+ scrollbar-width: none;
1266
+ -ms-overflow-style: none;
1267
+ }
1268
+
1269
+ .kol-shell-header-tabs::-webkit-scrollbar {
1270
+ display: none;
1271
+ }
1272
+
1273
+ /* Section tab — mono type, muted rest, underline on active (aria-current). */
1274
+ .kol-shell-header-tab {
1275
+ display: inline-flex;
1276
+ align-items: center;
1277
+ gap: 6px;
1278
+ font-family: var(--kol-font-family-mono, monospace);
1279
+ font-size: 13px;
1280
+ font-weight: 500;
1281
+ letter-spacing: 0.02em;
1282
+ color: var(--kol-fg-64);
1283
+ white-space: nowrap;
1284
+ flex-shrink: 0;
1285
+ padding: 8px 0 12px;
1286
+ border-bottom: 2px solid transparent;
1287
+ transition: color 0.2s ease, border-color 0.2s ease;
1288
+ text-decoration: none;
1289
+ }
1290
+
1291
+ .kol-shell-header-tab:hover {
1292
+ color: var(--kol-fg-88);
1293
+ }
1294
+
1295
+ .kol-shell-header-tab[aria-current="page"] {
1296
+ color: var(--kol-surface-on-primary);
1297
+ border-bottom-color: var(--kol-surface-on-primary);
1298
+ }
1299
+
1300
+ /* Wide-viewport bumps (matches the shell's 1600px nav font step). */
1301
+ @media (min-width: 1600px) {
1302
+ .kol-shell-header-tabs {
1303
+ gap: 32px;
1304
+ }
1305
+
1306
+ .kol-shell-header-tab {
1307
+ font-size: 16px;
1308
+ gap: 8px;
1309
+ padding-top: 10px;
1310
+ padding-bottom: 14px;
1311
+ }
1312
+
1313
+ .kol-shell-header-tab svg {
1314
+ width: 18px;
1315
+ height: 18px;
1316
+ }
1317
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "KOL app shell — sidenav, layout, theme toggle, footer, heroes + brand color layer. SideNav takes nav data as props so the tree stays app-local.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "app-shell"
28
28
  ],
29
29
  "dependencies": {
30
- "@kolkrabbi/kol-component": "0.2.0",
30
+ "@kolkrabbi/kol-component": "0.4.0",
31
31
  "@kolkrabbi/kol-loader": "0.3.0"
32
32
  },
33
33
  "peerDependencies": {
package/src/AppShell.jsx CHANGED
@@ -1,11 +1,24 @@
1
- import { useEffect, useState } from 'react'
1
+ import { createContext, useEffect, useState } from 'react'
2
2
  import { Outlet, useLocation } from 'react-router-dom'
3
3
  import SideNav from './SideNav.jsx'
4
4
  import { Icon } from '@kolkrabbi/kol-loader'
5
5
  import { ModalProvider } from '@kolkrabbi/kol-component'
6
6
 
7
- export default function AppShell({ navTree = [], getActivePage }) {
7
+ /* Pages can register right-rail TOC content via this context.
8
+ * Usage: const setTocContent = useContext(ShellTocContext)
9
+ * useLayoutEffect(() => { setTocContent(<MyToc />); return () => setTocContent(null) }, [])
10
+ * No page registered + no defaultTocContent prop → no rail, layout unchanged. */
11
+ export const ShellTocContext = createContext(null)
12
+
13
+ /* Pages can suppress the TOC rail (e.g. hide an app-level defaultTocContent).
14
+ * Usage: const setTocCollapsed = useContext(ShellTocCollapsedContext)
15
+ * useLayoutEffect(() => { setTocCollapsed(true); return () => setTocCollapsed(false) }, [setTocCollapsed]) */
16
+ export const ShellTocCollapsedContext = createContext(null)
17
+
18
+ export default function AppShell({ navTree = [], getActivePage, header, footer, defaultTocContent }) {
8
19
  const [drawerOpen, setDrawerOpen] = useState(false)
20
+ const [tocContent, setTocContent] = useState(null)
21
+ const [tocCollapsed, setTocCollapsed] = useState(false)
9
22
  const { pathname } = useLocation()
10
23
 
11
24
  useEffect(() => { setDrawerOpen(false) }, [pathname])
@@ -17,30 +30,49 @@ export default function AppShell({ navTree = [], getActivePage }) {
17
30
  return () => window.removeEventListener('keydown', onKey)
18
31
  }, [drawerOpen])
19
32
 
33
+ const effectiveTocContent = tocContent ?? defaultTocContent
34
+ const showToc = Boolean(effectiveTocContent) && !tocCollapsed
35
+
20
36
  return (
21
37
  <ModalProvider>
22
- <div className="kol-brand-layout" data-drawer-open={drawerOpen ? 'true' : undefined}>
23
- <button
24
- type="button"
25
- className="kol-sidenav-hamburger md:hidden fixed top-3 left-3 z-30 w-10 h-10 inline-flex items-center justify-center rounded-full bg-surface-primary border border-fg-08 text-emphasis"
26
- aria-label={drawerOpen ? 'Close menu' : 'Open menu'}
27
- aria-expanded={drawerOpen}
28
- onClick={() => setDrawerOpen((v) => !v)}
29
- >
30
- <Icon name={drawerOpen ? 'x' : 'menu'} size={18} />
31
- </button>
32
-
33
- <div
34
- className="kol-sidenav-backdrop fixed inset-0 z-20 bg-black/50 opacity-0 pointer-events-none transition-opacity duration-200 md:hidden"
35
- onClick={() => setDrawerOpen(false)}
36
- aria-hidden="true"
37
- />
38
-
39
- <SideNav navTree={navTree} getActivePage={getActivePage} drawerOpen={drawerOpen} onCloseDrawer={() => setDrawerOpen(false)} />
40
- <div className="min-w-0">
41
- <Outlet />
42
- </div>
43
- </div>
38
+ <ShellTocContext.Provider value={setTocContent}>
39
+ <ShellTocCollapsedContext.Provider value={setTocCollapsed}>
40
+ <div
41
+ className="kol-brand-layout"
42
+ data-drawer-open={drawerOpen ? 'true' : undefined}
43
+ data-toc={showToc ? 'true' : undefined}
44
+ >
45
+ <button
46
+ type="button"
47
+ className="kol-sidenav-hamburger md:hidden fixed top-3 left-3 z-30 w-10 h-10 inline-flex items-center justify-center rounded-full bg-surface-primary border border-fg-08 text-emphasis"
48
+ aria-label={drawerOpen ? 'Close menu' : 'Open menu'}
49
+ aria-expanded={drawerOpen}
50
+ onClick={() => setDrawerOpen((v) => !v)}
51
+ >
52
+ <Icon name={drawerOpen ? 'x' : 'menu'} size={18} />
53
+ </button>
54
+
55
+ <div
56
+ className="kol-sidenav-backdrop fixed inset-0 z-20 bg-black/50 opacity-0 pointer-events-none transition-opacity duration-200 md:hidden"
57
+ onClick={() => setDrawerOpen(false)}
58
+ aria-hidden="true"
59
+ />
60
+
61
+ <SideNav navTree={navTree} getActivePage={getActivePage} drawerOpen={drawerOpen} onCloseDrawer={() => setDrawerOpen(false)} />
62
+ <div className="min-w-0">
63
+ {header}
64
+ <Outlet />
65
+ {footer}
66
+ </div>
67
+
68
+ {showToc && (
69
+ <aside className="kol-toc-rail hidden xl:block min-w-0 sticky top-0 self-start h-dvh overflow-y-auto pt-8 pr-6">
70
+ {effectiveTocContent}
71
+ </aside>
72
+ )}
73
+ </div>
74
+ </ShellTocCollapsedContext.Provider>
75
+ </ShellTocContext.Provider>
44
76
  </ModalProvider>
45
77
  )
46
78
  }
@@ -1,22 +1,99 @@
1
- export default function PortalFooter() {
1
+ /**
2
+ * PortalFooter — site footer chrome.
3
+ *
4
+ * Zero-prop: the Kolkrabbi favicon mark + credit line (portal default,
5
+ * unchanged). Opt-in slots turn it into a full site footer:
6
+ * brand node — brand block (wordmark/logo), top-left.
7
+ * columns [{ title, links: [{ label, href }] }] — link columns, top-right.
8
+ * socials [{ label, href, icon? }] — bottom-bar strip; icon node optional,
9
+ * label renders as text when no icon (and feeds aria-label).
10
+ * note node — bottom-bar legal/credit line.
11
+ * children node — escape hatch, rendered between columns and the bottom bar.
12
+ * Links are plain <a href> — router-agnostic. Strings render as authored.
13
+ */
14
+ export default function PortalFooter({ brand, columns, socials, note, children }) {
2
15
  const year = new Date().getFullYear()
16
+ const hasColumns = columns?.length > 0
17
+ const hasSocials = socials?.length > 0
18
+
19
+ if (!brand && !hasColumns && !hasSocials && !note && !children) {
20
+ return (
21
+ <footer className="kol-portal-footer">
22
+ <a
23
+ href="https://kolkrabbi.io"
24
+ target="_blank"
25
+ rel="noopener"
26
+ aria-label="Kolkrabbi Vinnustofa"
27
+ >
28
+ <img src="/favicon/favicon.svg" alt="" width="32" height="32" />
29
+ </a>
30
+ <p className="kol-helper-12 text-meta">
31
+ <a href="https://kolkrabbi.io" target="_blank" rel="noopener" className="hover:text-strong">
32
+ Kolkrabbi Vinnustofa
33
+ </a>
34
+ {' · '}
35
+ {year}
36
+ </p>
37
+ </footer>
38
+ )
39
+ }
40
+
41
+ const hasTop = Boolean(brand || hasColumns)
42
+
3
43
  return (
4
44
  <footer className="kol-portal-footer">
5
- <a
6
- href="https://kolkrabbi.io"
7
- target="_blank"
8
- rel="noopener"
9
- aria-label="Kolkrabbi Vinnustofa"
10
- >
11
- <img src="/favicon/favicon.svg" alt="" width="32" height="32" />
12
- </a>
13
- <p className="kol-helper-12 text-meta">
14
- <a href="https://kolkrabbi.io" target="_blank" rel="noopener" className="hover:text-strong">
15
- Kolkrabbi Vinnustofa
16
- </a>
17
- {' · '}
18
- {year}
19
- </p>
45
+ <div className="w-full flex flex-col gap-12">
46
+ {hasTop && (
47
+ <div className="flex flex-col md:flex-row md:justify-between items-start gap-10 md:gap-12">
48
+ {brand}
49
+ {hasColumns && (
50
+ <nav className="flex flex-wrap items-start gap-12 md:gap-16 lg:gap-20">
51
+ {columns.map((column) => (
52
+ <div key={column.title} className="flex flex-col gap-4">
53
+ <p className="kol-helper-12 tracking-widest text-meta">{column.title}</p>
54
+ <ul className="m-0 p-0 list-none flex flex-col gap-1 lg:gap-2">
55
+ {column.links.map((link) => (
56
+ <li key={link.label}>
57
+ <a href={link.href} className="kol-sans-heading-03 text-auto">
58
+ {link.label}
59
+ </a>
60
+ </li>
61
+ ))}
62
+ </ul>
63
+ </div>
64
+ ))}
65
+ </nav>
66
+ )}
67
+ </div>
68
+ )}
69
+
70
+ {children}
71
+
72
+ {(note || hasSocials) && (
73
+ <div className="flex flex-col gap-3">
74
+ {(hasTop || children) && <div className="border-t border-[var(--kol-fg-08)]" />}
75
+ <div className="flex flex-wrap justify-between items-center gap-4">
76
+ {note && <p className="kol-helper-12 text-meta">{note}</p>}
77
+ {hasSocials && (
78
+ <div className="flex items-center gap-4">
79
+ {socials.map(({ label, href, icon }) => (
80
+ <a
81
+ key={label}
82
+ href={href}
83
+ target="_blank"
84
+ rel="noopener"
85
+ aria-label={label}
86
+ className="kol-helper-12 text-meta hover:text-strong inline-flex items-center gap-2"
87
+ >
88
+ {icon ?? label}
89
+ </a>
90
+ ))}
91
+ </div>
92
+ )}
93
+ </div>
94
+ </div>
95
+ )}
96
+ </div>
20
97
  </footer>
21
98
  )
22
99
  }
@@ -0,0 +1,147 @@
1
+ import { Icon } from '@kolkrabbi/kol-loader'
2
+ import { SearchInput } from '@kolkrabbi/kol-component'
3
+ import ThemeToggle from './ThemeToggle.jsx'
4
+
5
+ /**
6
+ * ShellHeader — the shell's sticky two-row top chrome.
7
+ *
8
+ * Row 1 is the brand block (consumer-supplied `brand` node — logo, wordmark,
9
+ * link element, whatever the app wants) plus right-side controls: an `actions`
10
+ * slot, the framework ThemeToggle, and an optional hamburger. Row 2 is a
11
+ * horizontal section-tab strip plus an optional inline search and the
12
+ * dock-left / dock-right rail toggles. The bar is stateless — every action is
13
+ * delegated up via callbacks; only ThemeToggle owns state (its own).
14
+ *
15
+ * Router-agnostic: tabs are plain anchors built from `nav` items
16
+ * (`{ label, href, icon? }`). Active styling keys on `aria-current="page"`,
17
+ * set by the `isActive(href)` predicate; `onNavigate(event, item)` is the
18
+ * client-routing seam (preventDefault + push in the consumer).
19
+ *
20
+ * Row 2 renders only when it has content (tabs, search, or a rail toggle).
21
+ * Tab-strip chrome (mono type, active underline, hidden scrollbar, wide-
22
+ * viewport bumps) lives in kol-framework.css under kol-shell-header-*.
23
+ *
24
+ * @param {ReactNode} brand Row-1 left slot — logo/wordmark + link, consumer-supplied
25
+ * @param {Array} nav section tabs: [{ label, href, icon? }]
26
+ * @param {Function} isActive (href) => boolean — marks the active tab
27
+ * @param {Function} onNavigate (event, item) => void — tab click seam for client routing
28
+ * @param {boolean|Object} search Row-2 inline search; object spreads onto SearchInput
29
+ * ({ value, onChange, onFocus, placeholder, shortcutHint, onClear, … })
30
+ * @param {ReactNode} actions Row-1 trailing slot before the theme toggle (e.g. a search-overlay trigger)
31
+ * @param {boolean} showThemeToggle render the framework ThemeToggle (default true)
32
+ * @param {Function} onMenuClick hamburger click; button renders only when set
33
+ * @param {Function} onNavToggle dock-left click → toggle left nav; button renders only when set (lg+)
34
+ * @param {Function} onTocToggle dock-right click → toggle TOC rail; button renders only when set (lg+)
35
+ * @param {boolean} navCollapsed tints the dock-left button
36
+ * @param {boolean} tocCollapsed tints the dock-right button
37
+ * @param {string} className extra classes on the outer bar
38
+ */
39
+
40
+ const iconBtnCls =
41
+ 'flex h-9 w-9 items-center justify-center rounded p-0 bg-transparent border-0 cursor-pointer transition-colors hover:bg-fg-08 hover:text-emphasis'
42
+
43
+ export default function ShellHeader({
44
+ brand,
45
+ nav = [],
46
+ isActive,
47
+ onNavigate,
48
+ search,
49
+ actions,
50
+ showThemeToggle = true,
51
+ onMenuClick,
52
+ onNavToggle,
53
+ onTocToggle,
54
+ navCollapsed,
55
+ tocCollapsed,
56
+ className = '',
57
+ }) {
58
+ const hasTabRow = nav.length > 0 || Boolean(search) || Boolean(onNavToggle) || Boolean(onTocToggle)
59
+
60
+ return (
61
+ <div className={`kol-shell-header sticky top-0 z-50 shrink-0 bg-surface-primary ${className}`.trim()}>
62
+ {/* Row 1: brand block + controls */}
63
+ <div className="border-b border-fg-08">
64
+ <div className="mx-auto max-w-[1800px] px-4 py-4 md:px-6 lg:px-8">
65
+ <div className="flex items-center justify-between">
66
+ {/* kol-shell-header-brand reserves the nav-column width at lg+ so
67
+ * the Row-2 tabs align to the shell grid. */}
68
+ <div className="kol-shell-header-brand flex min-w-0 items-center gap-8">
69
+ {brand}
70
+ </div>
71
+ <div className="flex shrink-0 items-center gap-1">
72
+ {actions}
73
+ {showThemeToggle && <ThemeToggle />}
74
+ {onMenuClick && (
75
+ <button
76
+ type="button"
77
+ className={`${iconBtnCls} text-fg-64`}
78
+ onClick={onMenuClick}
79
+ aria-label="Open navigation menu"
80
+ >
81
+ <Icon name="menu" size={18} />
82
+ </button>
83
+ )}
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ {/* Row 2: section tabs + inline search + rail toggles */}
90
+ {hasTabRow && (
91
+ <div className="border-b border-fg-08">
92
+ <div className="mx-auto max-w-[1800px] px-4 md:px-5 lg:px-6">
93
+ <div className="kol-shell-header-tabs">
94
+ <nav className="flex flex-1 gap-6" aria-label="Sections">
95
+ {nav.map((item) => {
96
+ const active = isActive ? isActive(item.href) : false
97
+ return (
98
+ <a
99
+ key={item.href ?? item.label}
100
+ href={item.href}
101
+ className="kol-shell-header-tab"
102
+ aria-current={active ? 'page' : undefined}
103
+ onClick={onNavigate ? (event) => onNavigate(event, item) : undefined}
104
+ >
105
+ {item.icon && <Icon name={item.icon} size={14} />}
106
+ {item.label}
107
+ </a>
108
+ )
109
+ })}
110
+ </nav>
111
+ {search && (
112
+ <SearchInput
113
+ className="shrink-0 self-center"
114
+ {...(search === true ? {} : search)}
115
+ />
116
+ )}
117
+ {(onNavToggle || onTocToggle) && (
118
+ <div className="hidden lg:flex items-center gap-1 pb-2">
119
+ {onNavToggle && (
120
+ <button
121
+ type="button"
122
+ className={`${iconBtnCls} ${navCollapsed ? 'text-fg-32' : 'text-fg-64'}`}
123
+ onClick={onNavToggle}
124
+ aria-label="Toggle navigation sidebar"
125
+ >
126
+ <Icon name="dock-left" size={18} />
127
+ </button>
128
+ )}
129
+ {onTocToggle && (
130
+ <button
131
+ type="button"
132
+ className={`${iconBtnCls} ${tocCollapsed ? 'text-fg-32' : 'text-fg-64'}`}
133
+ onClick={onTocToggle}
134
+ aria-label="Toggle table of contents sidebar"
135
+ >
136
+ <Icon name="dock-right" size={18} />
137
+ </button>
138
+ )}
139
+ </div>
140
+ )}
141
+ </div>
142
+ </div>
143
+ </div>
144
+ )}
145
+ </div>
146
+ )
147
+ }
package/src/SideNav.jsx CHANGED
@@ -37,13 +37,14 @@ const leafStyle = (indent) => ({
37
37
  '--kol-sidenav-dot-left': `${indent - 14}px`,
38
38
  })
39
39
 
40
- function SectionLeaf({ leaf, basePath, isActive, indent }) {
40
+ function SectionLeaf({ leaf, basePath, isActive, indent, onNavigate }) {
41
41
  return (
42
42
  <li>
43
43
  <Link
44
44
  to={`${basePath}#${leaf.id}`}
45
45
  className={isActive ? linkActiveCls : linkCls}
46
46
  style={leafStyle(indent)}
47
+ onClick={onNavigate}
47
48
  >
48
49
  {leaf.label}
49
50
  </Link>
@@ -51,14 +52,15 @@ function SectionLeaf({ leaf, basePath, isActive, indent }) {
51
52
  )
52
53
  }
53
54
 
54
- function RouteLeaf({ leaf, indent }) {
55
+ function RouteLeaf({ leaf, indent, onNavigate, isActiveFn }) {
55
56
  return (
56
57
  <li>
57
58
  <NavLink
58
59
  to={leaf.to}
59
60
  end
60
- className={({ isActive }) => (isActive ? linkActiveCls : linkCls)}
61
+ className={({ isActive }) => ((isActiveFn ? isActiveFn(leaf.to) : isActive) ? linkActiveCls : linkCls)}
61
62
  style={leafStyle(indent)}
63
+ onClick={onNavigate}
62
64
  >
63
65
  {leaf.label}
64
66
  </NavLink>
@@ -66,7 +68,7 @@ function RouteLeaf({ leaf, indent }) {
66
68
  )
67
69
  }
68
70
 
69
- function GroupNode({ group, basePath, activeSectionId, indent }) {
71
+ function GroupNode({ group, basePath, activeSectionId, indent, onNavigate, isActiveFn }) {
70
72
  const isAncestor = hasActiveDescendant(group.children, activeSectionId)
71
73
  return (
72
74
  <li>
@@ -84,6 +86,8 @@ function GroupNode({ group, basePath, activeSectionId, indent }) {
84
86
  basePath={basePath}
85
87
  activeSectionId={activeSectionId}
86
88
  indent={indent + 12}
89
+ onNavigate={onNavigate}
90
+ isActiveFn={isActiveFn}
87
91
  />
88
92
  ))}
89
93
  </ul>
@@ -91,12 +95,21 @@ function GroupNode({ group, basePath, activeSectionId, indent }) {
91
95
  )
92
96
  }
93
97
 
94
- function ChildNode({ child, basePath, activeSectionId, indent }) {
98
+ function ChildNode({ child, basePath, activeSectionId, indent, onNavigate, isActiveFn }) {
95
99
  if (child.children) {
96
- return <GroupNode group={child} basePath={basePath} activeSectionId={activeSectionId} indent={indent} />
100
+ return (
101
+ <GroupNode
102
+ group={child}
103
+ basePath={basePath}
104
+ activeSectionId={activeSectionId}
105
+ indent={indent}
106
+ onNavigate={onNavigate}
107
+ isActiveFn={isActiveFn}
108
+ />
109
+ )
97
110
  }
98
111
  if (child.to) {
99
- return <RouteLeaf leaf={child} indent={indent} />
112
+ return <RouteLeaf leaf={child} indent={indent} onNavigate={onNavigate} isActiveFn={isActiveFn} />
100
113
  }
101
114
  if (child.id) {
102
115
  return (
@@ -105,21 +118,37 @@ function ChildNode({ child, basePath, activeSectionId, indent }) {
105
118
  basePath={basePath}
106
119
  isActive={activeSectionId === child.id}
107
120
  indent={indent}
121
+ onNavigate={onNavigate}
108
122
  />
109
123
  )
110
124
  }
111
125
  return null
112
126
  }
113
127
 
114
- export default function SideNav({ drawerOpen = false, onCloseDrawer, navTree = [], getActivePage }) {
128
+ export default function SideNav({
129
+ drawerOpen = false,
130
+ onCloseDrawer,
131
+ navTree = [],
132
+ getActivePage,
133
+ onNavigate,
134
+ collapsed: collapsedProp,
135
+ onToggle,
136
+ collapsibleSections = false,
137
+ isActive,
138
+ }) {
115
139
  const { pathname } = useLocation()
116
140
  const activePage = getActivePage?.(pathname)
117
141
  const sectionIds = activePage ? collectSectionIds(activePage) : []
118
142
  const onPageRoot = activePage && pathname === activePage.to
119
143
  const activeSectionId = useScrollSpy(onPageRoot ? sectionIds : [])
120
144
 
145
+ /* Root collapse: controlled when `collapsed` is defined (pair with
146
+ * onToggle), otherwise internal state. */
121
147
  const isEditor = pathname.startsWith('/editor/')
122
- const [collapsed, setCollapsed] = useState(isEditor)
148
+ const [internalCollapsed, setInternalCollapsed] = useState(isEditor)
149
+ const isControlled = collapsedProp !== undefined
150
+ const collapsed = isControlled ? collapsedProp : internalCollapsed
151
+ const handleToggle = isControlled ? onToggle : () => setInternalCollapsed((v) => !v)
123
152
 
124
153
  useEffect(() => {
125
154
  const root = document.documentElement
@@ -128,10 +157,26 @@ export default function SideNav({ drawerOpen = false, onCloseDrawer, navTree = [
128
157
  }, [collapsed])
129
158
 
130
159
  /* /editor → collapsed. Anywhere else → expanded. Manual chevron toggle
131
- * works for the session but doesn't persist across navigation. */
160
+ * works for the session but doesn't persist across navigation.
161
+ * Skipped in controlled mode — the parent owns collapse. */
162
+ useEffect(() => {
163
+ if (!isControlled) setInternalCollapsed(isEditor)
164
+ }, [isEditor, isControlled])
165
+
166
+ /* Per-page section expand/collapse (opt-in via collapsibleSections).
167
+ * The section containing the active route auto-expands on every
168
+ * navigation; manual toggles persist for the session. */
169
+ const [expandedSections, setExpandedSections] = useState(() =>
170
+ collapsibleSections && activePage?.id ? { [activePage.id]: true } : {}
171
+ )
172
+
132
173
  useEffect(() => {
133
- setCollapsed(isEditor)
134
- }, [isEditor])
174
+ if (!collapsibleSections || !activePage?.id) return
175
+ setExpandedSections((prev) => (prev[activePage.id] ? prev : { ...prev, [activePage.id]: true }))
176
+ }, [collapsibleSections, activePage?.id, pathname])
177
+
178
+ const toggleSection = (pageId) =>
179
+ setExpandedSections((prev) => ({ ...prev, [pageId]: !prev[pageId] }))
135
180
 
136
181
  return (
137
182
  <aside
@@ -142,7 +187,7 @@ export default function SideNav({ drawerOpen = false, onCloseDrawer, navTree = [
142
187
  className="kol-sidenav-toggle absolute top-5 right-[-12px] z-[2] w-6 h-6 inline-flex items-center justify-center bg-[var(--kol-surface-primary)] border border-[var(--kol-border-default)] rounded-full p-0 cursor-pointer kol-helper-14 transition-colors duration-150 text-meta hover:text-emphasis hover:border-fg-24"
143
188
  aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
144
189
  title={collapsed ? 'Expand' : 'Collapse'}
145
- onClick={() => setCollapsed((v) => !v)}
190
+ onClick={handleToggle}
146
191
  >
147
192
  <Icon name={collapsed ? 'chevron-right' : 'chevron-left'} size={12} />
148
193
  </button>
@@ -151,22 +196,52 @@ export default function SideNav({ drawerOpen = false, onCloseDrawer, navTree = [
151
196
  <ul className="kol-sidenav-tree flex flex-col gap-[2px]">
152
197
  {navTree.map((page) => {
153
198
  const isActivePage = activePage?.id === page.id
199
+ const isSectionCollapsible = collapsibleSections && page.children?.length > 0
200
+ const sectionExpanded = isSectionCollapsible && !!expandedSections[page.id]
201
+ const hopLink = (
202
+ <NavLink
203
+ to={page.to}
204
+ end={page.to === '/'}
205
+ className={({ isActive: navActive }) =>
206
+ `kol-sidenav-hop kol-helper-12 relative flex items-center gap-3 py-2 pr-10 pl-6 no-underline${(isActive ? isActive(page.to) : navActive) ? ' is-active' : ''}`
207
+ }
208
+ onClick={onNavigate}
209
+ >
210
+ <span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
211
+ <Icon name={page.icon} size={16} />
212
+ </span>
213
+ <span className="kol-sidenav-hop-label flex-1 min-w-0">{page.label}</span>
214
+ {isSectionCollapsible && (
215
+ <span className="kol-sidenav-hop-count kol-helper-10 text-meta shrink-0">
216
+ ({page.children.length})
217
+ </span>
218
+ )}
219
+ </NavLink>
220
+ )
154
221
  return (
155
222
  <li key={page.id}>
156
- <NavLink
157
- to={page.to}
158
- end={page.to === '/'}
159
- className={({ isActive }) =>
160
- `kol-sidenav-hop kol-helper-12 relative flex items-center gap-3 py-2 pr-10 pl-6 no-underline${isActive ? ' is-active' : ''}`
161
- }
162
- >
163
- <span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
164
- <Icon name={page.icon} size={16} />
165
- </span>
166
- <span className="kol-sidenav-hop-label flex-1 min-w-0">{page.label}</span>
167
- </NavLink>
223
+ {isSectionCollapsible ? (
224
+ <div className="relative">
225
+ {hopLink}
226
+ <button
227
+ type="button"
228
+ className="kol-sidenav-section-toggle absolute right-3 top-1/2 -translate-y-1/2 z-[1] inline-flex items-center justify-center w-5 h-5 p-0 bg-transparent border-0 cursor-pointer text-meta hover:text-emphasis transition-colors duration-150"
229
+ aria-label={sectionExpanded ? `Collapse ${page.label}` : `Expand ${page.label}`}
230
+ aria-expanded={sectionExpanded}
231
+ onClick={() => toggleSection(page.id)}
232
+ >
233
+ <Icon
234
+ name="chevron-right"
235
+ size={12}
236
+ className={`transition-transform duration-150${sectionExpanded ? ' rotate-90' : ''}`}
237
+ />
238
+ </button>
239
+ </div>
240
+ ) : (
241
+ hopLink
242
+ )}
168
243
 
169
- {isActivePage && page.children && (
244
+ {page.children && (collapsibleSections ? sectionExpanded : isActivePage) && (
170
245
  <ul className="kol-sidenav-list mb-2 flex flex-col gap-2">
171
246
  {page.children.map((child, i) => (
172
247
  <ChildNode
@@ -175,6 +250,8 @@ export default function SideNav({ drawerOpen = false, onCloseDrawer, navTree = [
175
250
  basePath={page.to}
176
251
  activeSectionId={activeSectionId}
177
252
  indent={56}
253
+ onNavigate={onNavigate}
254
+ isActiveFn={isActive}
178
255
  />
179
256
  ))}
180
257
  </ul>
package/src/index.js CHANGED
@@ -8,7 +8,9 @@
8
8
  */
9
9
 
10
10
  export { default as AppShell } from './AppShell.jsx'
11
+ export { ShellTocContext, ShellTocCollapsedContext } from './AppShell.jsx'
11
12
  export { default as SideNav } from './SideNav.jsx'
13
+ export { default as ShellHeader } from './ShellHeader.jsx'
12
14
  export { default as ThemeToggle } from './ThemeToggle.jsx'
13
15
  export { default as Layout } from './Layout.jsx'
14
16
  export { default as PageSection } from './PageSection.jsx'