@kolkrabbi/kol-framework 0.1.2 → 0.2.0
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 +112 -20
- package/package.json +3 -3
- package/src/AppShell.jsx +56 -24
- package/src/PortalFooter.jsx +93 -16
- package/src/ShellHeader.jsx +147 -0
- package/src/SideNav.jsx +104 -27
- package/src/index.js +2 -0
package/kol-framework.css
CHANGED
|
@@ -657,30 +657,12 @@ code, pre {
|
|
|
657
657
|
padding-top: 32px;
|
|
658
658
|
}
|
|
659
659
|
|
|
660
|
+
/* Positioning only — the chip look itself is the CopyButton atom
|
|
661
|
+
* (.kol-copy-btn in kol-theme). */
|
|
660
662
|
.kol-codeblock-copy {
|
|
661
663
|
position: absolute;
|
|
662
664
|
top: 8px;
|
|
663
665
|
right: 8px;
|
|
664
|
-
display: inline-flex;
|
|
665
|
-
align-items: center;
|
|
666
|
-
gap: 6px;
|
|
667
|
-
padding: 6px 10px;
|
|
668
|
-
background: color-mix(in srgb, var(--kol-surface-primary) 80%, transparent);
|
|
669
|
-
border: 1px solid var(--kol-fg-16);
|
|
670
|
-
border-radius: 3px;
|
|
671
|
-
color: var(--kol-fg-64);
|
|
672
|
-
cursor: pointer;
|
|
673
|
-
font-family: var(--kol-font-family-mono, monospace);
|
|
674
|
-
font-size: 10px;
|
|
675
|
-
font-weight: 600;
|
|
676
|
-
letter-spacing: 0.08em;
|
|
677
|
-
text-transform: uppercase;
|
|
678
|
-
transition: color 150ms ease, border-color 150ms ease, background 150ms ease;
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
.kol-codeblock-copy:hover {
|
|
682
|
-
color: var(--kol-surface-on-primary);
|
|
683
|
-
border-color: var(--kol-fg-48);
|
|
684
666
|
}
|
|
685
667
|
|
|
686
668
|
/* ───── Reveal utility ───── */
|
|
@@ -1223,3 +1205,113 @@ code, pre {
|
|
|
1223
1205
|
}
|
|
1224
1206
|
|
|
1225
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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,8 +27,8 @@
|
|
|
27
27
|
"app-shell"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@kolkrabbi/kol-component": "0.
|
|
31
|
-
"@kolkrabbi/kol-loader": "0.
|
|
30
|
+
"@kolkrabbi/kol-component": "0.3.0",
|
|
31
|
+
"@kolkrabbi/kol-loader": "0.3.0"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"react": "^18.3.0 || ^19.0.0",
|
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
|
-
|
|
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
|
-
<
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
}
|
package/src/PortalFooter.jsx
CHANGED
|
@@ -1,22 +1,99 @@
|
|
|
1
|
-
|
|
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
|
-
<
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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({
|
|
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 [
|
|
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
|
-
|
|
134
|
-
|
|
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
|
|
@@ -139,10 +184,10 @@ export default function SideNav({ drawerOpen = false, onCloseDrawer, navTree = [
|
|
|
139
184
|
>
|
|
140
185
|
<button
|
|
141
186
|
type="button"
|
|
142
|
-
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
|
|
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={
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
{
|
|
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'
|