@kolkrabbi/kol-framework 0.1.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/README.md +30 -0
- package/kol-brand-color.css +151 -0
- package/kol-framework.css +1225 -0
- package/package.json +46 -0
- package/src/AppShell.jsx +46 -0
- package/src/BrandHero.jsx +14 -0
- package/src/Layout.jsx +20 -0
- package/src/PageSection.jsx +24 -0
- package/src/PortalFooter.jsx +22 -0
- package/src/ScrollToTop.jsx +17 -0
- package/src/SideNav.jsx +205 -0
- package/src/SubPageHero.jsx +27 -0
- package/src/ThemeToggle.jsx +95 -0
- package/src/index.js +18 -0
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kolkrabbi/kol-framework",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.js",
|
|
8
|
+
"module": "./src/index.js",
|
|
9
|
+
"sideEffects": [
|
|
10
|
+
"*.css"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.js",
|
|
14
|
+
"./kol-framework.css": "./kol-framework.css",
|
|
15
|
+
"./kol-brand-color.css": "./kol-brand-color.css"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"*.css",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"kol",
|
|
24
|
+
"kolkrabbi",
|
|
25
|
+
"design-system",
|
|
26
|
+
"react",
|
|
27
|
+
"app-shell"
|
|
28
|
+
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@kolkrabbi/kol-component": "0.1.0",
|
|
31
|
+
"@kolkrabbi/kol-loader": "0.1.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"react": "^18.3.0 || ^19.0.0",
|
|
35
|
+
"react-dom": "^18.3.0 || ^19.0.0",
|
|
36
|
+
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/kolkrabbi/kol-design-system.git",
|
|
41
|
+
"directory": "packages/framework"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/AppShell.jsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { Outlet, useLocation } from 'react-router-dom'
|
|
3
|
+
import SideNav from './SideNav.jsx'
|
|
4
|
+
import { Icon } from '@kolkrabbi/kol-loader'
|
|
5
|
+
import { ModalProvider } from '@kolkrabbi/kol-component'
|
|
6
|
+
|
|
7
|
+
export default function AppShell({ navTree = [], getActivePage }) {
|
|
8
|
+
const [drawerOpen, setDrawerOpen] = useState(false)
|
|
9
|
+
const { pathname } = useLocation()
|
|
10
|
+
|
|
11
|
+
useEffect(() => { setDrawerOpen(false) }, [pathname])
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!drawerOpen) return
|
|
15
|
+
const onKey = (e) => { if (e.key === 'Escape') setDrawerOpen(false) }
|
|
16
|
+
window.addEventListener('keydown', onKey)
|
|
17
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
18
|
+
}, [drawerOpen])
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<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>
|
|
44
|
+
</ModalProvider>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export default function BrandHero({ id = 'hero', label, title, lede, mark }) {
|
|
2
|
+
return (
|
|
3
|
+
<section id={id} className="kol-page-hero">
|
|
4
|
+
{label && <p className="kol-prose-label">{label}</p>}
|
|
5
|
+
<div className="flex items-center gap-12 flex-wrap">
|
|
6
|
+
{mark}
|
|
7
|
+
<div className="flex-1 min-w-[280px]">
|
|
8
|
+
<h1 className="kol-prose-display">{title}</h1>
|
|
9
|
+
{lede && <p className="kol-prose-lede max-w-[60ch]">{lede}</p>}
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
</section>
|
|
13
|
+
)
|
|
14
|
+
}
|
package/src/Layout.jsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Outlet, useLocation } from 'react-router-dom'
|
|
2
|
+
import ScrollToTop from './ScrollToTop'
|
|
3
|
+
import { ExitPreview } from '@kolkrabbi/kol-component'
|
|
4
|
+
|
|
5
|
+
const clientSurfacePatterns = [/^\/site/]
|
|
6
|
+
|
|
7
|
+
export default function Layout() {
|
|
8
|
+
const { pathname } = useLocation()
|
|
9
|
+
const isClientSiteRoute = clientSurfacePatterns.some((re) => re.test(pathname))
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className="min-h-dvh flex flex-col">
|
|
13
|
+
<ScrollToTop />
|
|
14
|
+
<main className="flex-1 min-w-0">
|
|
15
|
+
<Outlet />
|
|
16
|
+
</main>
|
|
17
|
+
{isClientSiteRoute && <ExitPreview />}
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Divider } from '@kolkrabbi/kol-component'
|
|
2
|
+
|
|
3
|
+
export default function PageSection({ id, label, title, body, children, className = '', fullbleed = false, divider = false }) {
|
|
4
|
+
const hasHead = label || title || body
|
|
5
|
+
const cls = [
|
|
6
|
+
'kol-page',
|
|
7
|
+
'kol-page-section',
|
|
8
|
+
fullbleed && 'kol-page--fullbleed',
|
|
9
|
+
className,
|
|
10
|
+
].filter(Boolean).join(' ')
|
|
11
|
+
return (
|
|
12
|
+
<section id={id} className={cls}>
|
|
13
|
+
{divider && <Divider className="kol-page-section-divider" />}
|
|
14
|
+
{hasHead && (
|
|
15
|
+
<header className={fullbleed ? 'max-w-[960px]' : 'max-w-[720px]'}>
|
|
16
|
+
{label && <p className="kol-prose-label">{label}</p>}
|
|
17
|
+
{title && <h2 className="kol-prose-title">{title}</h2>}
|
|
18
|
+
{body && <p className="kol-prose-lede">{body}</p>}
|
|
19
|
+
</header>
|
|
20
|
+
)}
|
|
21
|
+
{children}
|
|
22
|
+
</section>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export default function PortalFooter() {
|
|
2
|
+
const year = new Date().getFullYear()
|
|
3
|
+
return (
|
|
4
|
+
<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>
|
|
20
|
+
</footer>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { useLocation } from 'react-router-dom'
|
|
3
|
+
|
|
4
|
+
export default function ScrollToTop() {
|
|
5
|
+
const { pathname, hash } = useLocation()
|
|
6
|
+
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (hash) {
|
|
9
|
+
const el = document.getElementById(hash.slice(1))
|
|
10
|
+
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
|
|
14
|
+
}, [pathname, hash])
|
|
15
|
+
|
|
16
|
+
return null
|
|
17
|
+
}
|
package/src/SideNav.jsx
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { Link, NavLink, useLocation } from 'react-router-dom'
|
|
3
|
+
import { Icon } from '@kolkrabbi/kol-loader'
|
|
4
|
+
import ThemeToggle from './ThemeToggle'
|
|
5
|
+
import { useScrollSpy } from '@kolkrabbi/kol-component'
|
|
6
|
+
|
|
7
|
+
/* Walk the active page's children and return all leaf section ids (for scroll-spy). */
|
|
8
|
+
function collectSectionIds(node) {
|
|
9
|
+
if (!node?.children) return []
|
|
10
|
+
const ids = []
|
|
11
|
+
const walk = (children) => {
|
|
12
|
+
for (const c of children) {
|
|
13
|
+
if (c.id && !c.to) ids.push(c.id)
|
|
14
|
+
if (c.children) walk(c.children)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
walk(node.children)
|
|
18
|
+
return ids
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const linkBase = 'kol-sidenav-link kol-helper-10 block relative py-[4px] no-underline transition-colors duration-150'
|
|
22
|
+
const linkCls = `${linkBase} text-body hover:text-emphasis`
|
|
23
|
+
const linkActiveCls = `${linkBase} is-active`
|
|
24
|
+
|
|
25
|
+
/* Walk the children tree; return true if any leaf matches activeSectionId. */
|
|
26
|
+
function hasActiveDescendant(children, activeSectionId) {
|
|
27
|
+
if (!activeSectionId) return false
|
|
28
|
+
for (const c of children ?? []) {
|
|
29
|
+
if (c.id === activeSectionId) return true
|
|
30
|
+
if (c.children && hasActiveDescendant(c.children, activeSectionId)) return true
|
|
31
|
+
}
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const leafStyle = (indent) => ({
|
|
36
|
+
paddingLeft: indent,
|
|
37
|
+
'--kol-sidenav-dot-left': `${indent - 14}px`,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
function SectionLeaf({ leaf, basePath, isActive, indent }) {
|
|
41
|
+
return (
|
|
42
|
+
<li>
|
|
43
|
+
<Link
|
|
44
|
+
to={`${basePath}#${leaf.id}`}
|
|
45
|
+
className={isActive ? linkActiveCls : linkCls}
|
|
46
|
+
style={leafStyle(indent)}
|
|
47
|
+
>
|
|
48
|
+
{leaf.label}
|
|
49
|
+
</Link>
|
|
50
|
+
</li>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function RouteLeaf({ leaf, indent }) {
|
|
55
|
+
return (
|
|
56
|
+
<li>
|
|
57
|
+
<NavLink
|
|
58
|
+
to={leaf.to}
|
|
59
|
+
end
|
|
60
|
+
className={({ isActive }) => (isActive ? linkActiveCls : linkCls)}
|
|
61
|
+
style={leafStyle(indent)}
|
|
62
|
+
>
|
|
63
|
+
{leaf.label}
|
|
64
|
+
</NavLink>
|
|
65
|
+
</li>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function GroupNode({ group, basePath, activeSectionId, indent }) {
|
|
70
|
+
const isAncestor = hasActiveDescendant(group.children, activeSectionId)
|
|
71
|
+
return (
|
|
72
|
+
<li>
|
|
73
|
+
<div
|
|
74
|
+
className={`kol-sidenav-group kol-helper-10 uppercase ${isAncestor ? 'text-emphasis' : 'text-subtle'}`}
|
|
75
|
+
style={{ paddingLeft: indent }}
|
|
76
|
+
>
|
|
77
|
+
{group.label}
|
|
78
|
+
</div>
|
|
79
|
+
<ul className="kol-sidenav-list">
|
|
80
|
+
{group.children.map((child, i) => (
|
|
81
|
+
<ChildNode
|
|
82
|
+
key={child.id ?? child.to ?? `g-${i}`}
|
|
83
|
+
child={child}
|
|
84
|
+
basePath={basePath}
|
|
85
|
+
activeSectionId={activeSectionId}
|
|
86
|
+
indent={indent + 12}
|
|
87
|
+
/>
|
|
88
|
+
))}
|
|
89
|
+
</ul>
|
|
90
|
+
</li>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function ChildNode({ child, basePath, activeSectionId, indent }) {
|
|
95
|
+
if (child.children) {
|
|
96
|
+
return <GroupNode group={child} basePath={basePath} activeSectionId={activeSectionId} indent={indent} />
|
|
97
|
+
}
|
|
98
|
+
if (child.to) {
|
|
99
|
+
return <RouteLeaf leaf={child} indent={indent} />
|
|
100
|
+
}
|
|
101
|
+
if (child.id) {
|
|
102
|
+
return (
|
|
103
|
+
<SectionLeaf
|
|
104
|
+
leaf={child}
|
|
105
|
+
basePath={basePath}
|
|
106
|
+
isActive={activeSectionId === child.id}
|
|
107
|
+
indent={indent}
|
|
108
|
+
/>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default function SideNav({ drawerOpen = false, onCloseDrawer, navTree = [], getActivePage }) {
|
|
115
|
+
const { pathname } = useLocation()
|
|
116
|
+
const activePage = getActivePage?.(pathname)
|
|
117
|
+
const sectionIds = activePage ? collectSectionIds(activePage) : []
|
|
118
|
+
const onPageRoot = activePage && pathname === activePage.to
|
|
119
|
+
const activeSectionId = useScrollSpy(onPageRoot ? sectionIds : [])
|
|
120
|
+
|
|
121
|
+
const isEditor = pathname.startsWith('/editor/')
|
|
122
|
+
const [collapsed, setCollapsed] = useState(isEditor)
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
const root = document.documentElement
|
|
126
|
+
if (collapsed) root.setAttribute('data-sidenav', 'collapsed')
|
|
127
|
+
else root.removeAttribute('data-sidenav')
|
|
128
|
+
}, [collapsed])
|
|
129
|
+
|
|
130
|
+
/* /editor → collapsed. Anywhere else → expanded. Manual chevron toggle
|
|
131
|
+
* works for the session but doesn't persist across navigation. */
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
setCollapsed(isEditor)
|
|
134
|
+
}, [isEditor])
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<aside
|
|
138
|
+
className={`kol-sidenav sticky top-0 self-start h-dvh flex flex-col border-r border-fg-08 z-20 bg-surface-primary${collapsed ? ' is-collapsed' : ''}${drawerOpen ? ' is-drawer-open' : ''}`}
|
|
139
|
+
>
|
|
140
|
+
<button
|
|
141
|
+
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 text-[14px] leading-none transition-colors duration-150 text-meta hover:text-emphasis hover:border-fg-24"
|
|
143
|
+
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
144
|
+
title={collapsed ? 'Expand' : 'Collapse'}
|
|
145
|
+
onClick={() => setCollapsed((v) => !v)}
|
|
146
|
+
>
|
|
147
|
+
<Icon name={collapsed ? 'chevron-right' : 'chevron-left'} size={12} />
|
|
148
|
+
</button>
|
|
149
|
+
|
|
150
|
+
<div className="kol-sidenav-scroll flex-1 flex flex-col justify-between overflow-y-auto pt-4 pb-4 [scrollbar-width:thin]">
|
|
151
|
+
<ul className="kol-sidenav-tree flex flex-col gap-[2px]">
|
|
152
|
+
{navTree.map((page) => {
|
|
153
|
+
const isActivePage = activePage?.id === page.id
|
|
154
|
+
return (
|
|
155
|
+
<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>
|
|
168
|
+
|
|
169
|
+
{isActivePage && page.children && (
|
|
170
|
+
<ul className="kol-sidenav-list mb-2 flex flex-col gap-2">
|
|
171
|
+
{page.children.map((child, i) => (
|
|
172
|
+
<ChildNode
|
|
173
|
+
key={child.id ?? child.to ?? `g-${i}`}
|
|
174
|
+
child={child}
|
|
175
|
+
basePath={page.to}
|
|
176
|
+
activeSectionId={activeSectionId}
|
|
177
|
+
indent={56}
|
|
178
|
+
/>
|
|
179
|
+
))}
|
|
180
|
+
</ul>
|
|
181
|
+
)}
|
|
182
|
+
</li>
|
|
183
|
+
)
|
|
184
|
+
})}
|
|
185
|
+
</ul>
|
|
186
|
+
|
|
187
|
+
<div className="flex flex-col">
|
|
188
|
+
<ThemeToggle variant="hop-bare" />
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div className="kol-sidenav-footer flex items-center pl-6 pr-4 h-14 border-t border-fg-08 min-w-0">
|
|
193
|
+
<a
|
|
194
|
+
href="https://kolkrabbi.io"
|
|
195
|
+
target="_blank"
|
|
196
|
+
rel="noopener"
|
|
197
|
+
className="kol-helper-10 !font-normal no-underline group whitespace-nowrap overflow-hidden text-ellipsis min-w-0"
|
|
198
|
+
>
|
|
199
|
+
<span className="text-body group-hover:text-emphasis">Kolkrabbi Vinnustofa</span>
|
|
200
|
+
<span className="text-meta group-hover:text-emphasis"> · {new Date().getFullYear()}</span>
|
|
201
|
+
</a>
|
|
202
|
+
</div>
|
|
203
|
+
</aside>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Link } from 'react-router-dom'
|
|
2
|
+
|
|
3
|
+
export default function SubPageHero({ backTo, backLabel, label, title, lede }) {
|
|
4
|
+
return (
|
|
5
|
+
<section className="kol-page-hero" id="hero">
|
|
6
|
+
{backTo && (
|
|
7
|
+
<Link
|
|
8
|
+
to={backTo}
|
|
9
|
+
className="kol-back-link kol-helper-12 uppercase tracking-widest text-body hover:text-emphasis no-underline"
|
|
10
|
+
>
|
|
11
|
+
{backLabel}
|
|
12
|
+
</Link>
|
|
13
|
+
)}
|
|
14
|
+
{label && (
|
|
15
|
+
<p className="kol-helper-12 uppercase tracking-widest text-meta m-0 mb-4">
|
|
16
|
+
{label}
|
|
17
|
+
</p>
|
|
18
|
+
)}
|
|
19
|
+
<div className="flex-1 min-w-[280px]">
|
|
20
|
+
<h1 className="kol-sans-display-01 text-auto m-0 mb-6">{title}</h1>
|
|
21
|
+
{lede && (
|
|
22
|
+
<p className="kol-sans-body-01 text-body m-0 max-w-[60ch]">{lede}</p>
|
|
23
|
+
)}
|
|
24
|
+
</div>
|
|
25
|
+
</section>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-loader'
|
|
3
|
+
|
|
4
|
+
const STORAGE_KEY = 'kol-theme'
|
|
5
|
+
|
|
6
|
+
function getInitialTheme() {
|
|
7
|
+
if (typeof document === 'undefined') return 'dark'
|
|
8
|
+
return document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Theme toggle — horizontal icon-swap button.
|
|
13
|
+
*
|
|
14
|
+
* Variants:
|
|
15
|
+
* icon — minimal 32×32 icon-only button (no container chrome). For
|
|
16
|
+
* top-bar / nav-bar use where the toggle sits inline with other
|
|
17
|
+
* icon buttons.
|
|
18
|
+
* hop — full-width labeled Button-primary-styled sidenav row
|
|
19
|
+
* (bg-fg-04, on-primary text). For sidenav rows where it pairs
|
|
20
|
+
* with other Button-primary "hop" entries.
|
|
21
|
+
* hop-bare — same shape + padding as hop, but fully transparent (no rest
|
|
22
|
+
* bg, no hover bg). For sidenav rows where the row should read
|
|
23
|
+
* as plain text + icon, not a button.
|
|
24
|
+
*
|
|
25
|
+
* Self-contained: owns theme state, writes `data-theme` to <html>, persists
|
|
26
|
+
* to localStorage under `kol-theme`. The icon-swap animation slides a pair
|
|
27
|
+
* of half-split theme-toggle icons horizontally — the `theme-toggle` SVG is
|
|
28
|
+
* designed as a split circle, so the slide produces a visible light/dark flip.
|
|
29
|
+
*/
|
|
30
|
+
export default function ThemeToggle({ variant = 'icon', className = '' }) {
|
|
31
|
+
const [theme, setTheme] = useState(getInitialTheme)
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
document.documentElement.dataset.theme = theme
|
|
35
|
+
try { localStorage.setItem(STORAGE_KEY, theme) } catch { /* storage blocked */ }
|
|
36
|
+
}, [theme])
|
|
37
|
+
|
|
38
|
+
const isDark = theme === 'dark'
|
|
39
|
+
const next = isDark ? 'light' : 'dark'
|
|
40
|
+
const handleToggle = () => setTheme(next)
|
|
41
|
+
|
|
42
|
+
const iconSwap = (size) => (
|
|
43
|
+
<span
|
|
44
|
+
className="relative inline-block overflow-hidden"
|
|
45
|
+
style={{ width: size, height: size }}
|
|
46
|
+
aria-hidden="true"
|
|
47
|
+
>
|
|
48
|
+
<span
|
|
49
|
+
className="flex transition-transform duration-500 ease-in-out"
|
|
50
|
+
style={{ width: size * 2, transform: isDark ? 'translateX(0)' : `translateX(-${size}px)` }}
|
|
51
|
+
>
|
|
52
|
+
<Icon name="theme-toggle" size={size} />
|
|
53
|
+
<Icon name="theme-toggle" size={size} />
|
|
54
|
+
</span>
|
|
55
|
+
</span>
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if (variant === 'hop' || variant === 'hop-bare') {
|
|
59
|
+
const bare = variant === 'hop-bare'
|
|
60
|
+
const chromeCls = bare
|
|
61
|
+
? 'w-full inline-flex items-center justify-start gap-2 py-1.5 px-6 kol-mono-14 bg-transparent text-emphasis transition-colors'
|
|
62
|
+
: 'kol-btn kol-btn-primary kol-btn-md kol-mono-14 w-full justify-start gap-2'
|
|
63
|
+
return (
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
onClick={handleToggle}
|
|
67
|
+
aria-label={`Switch to ${next} mode`}
|
|
68
|
+
className={`${chromeCls} ${className}`.trim()}
|
|
69
|
+
>
|
|
70
|
+
<span className="inline-flex items-center justify-center shrink-0" aria-hidden="true">
|
|
71
|
+
{iconSwap(16)}
|
|
72
|
+
</span>
|
|
73
|
+
{/* kol-sidenav-hop-label keeps the responsive label-hide rule firing
|
|
74
|
+
* at narrow viewports without subjecting the button to the muted
|
|
75
|
+
* sidenav-hop text-color override. */}
|
|
76
|
+
<span className="kol-sidenav-hop-label flex-1 min-w-0 text-left">
|
|
77
|
+
{isDark ? 'Dark mode' : 'Light mode'}
|
|
78
|
+
</span>
|
|
79
|
+
</button>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// variant === 'icon' (default)
|
|
84
|
+
return (
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={handleToggle}
|
|
88
|
+
aria-label={`Switch to ${next} mode`}
|
|
89
|
+
title={`Switch to ${next} mode`}
|
|
90
|
+
className={`inline-flex items-center justify-center w-8 h-8 p-0 bg-transparent border-0 cursor-pointer text-emphasis hover:opacity-80 transition-opacity duration-300 ${className}`.trim()}
|
|
91
|
+
>
|
|
92
|
+
{iconSwap(18)}
|
|
93
|
+
</button>
|
|
94
|
+
)
|
|
95
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kol/framework — shared KOL app shell.
|
|
3
|
+
*
|
|
4
|
+
* Site chrome (sidenav, layout, theme toggle, footer, heroes) consumed across
|
|
5
|
+
* apps. SideNav takes its nav data as props (navTree + getActivePage) so the
|
|
6
|
+
* tree stays app-local. CSS lives in src/styles: kol-framework.css
|
|
7
|
+
* and kol-brand-color.css.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { default as AppShell } from './AppShell.jsx'
|
|
11
|
+
export { default as SideNav } from './SideNav.jsx'
|
|
12
|
+
export { default as ThemeToggle } from './ThemeToggle.jsx'
|
|
13
|
+
export { default as Layout } from './Layout.jsx'
|
|
14
|
+
export { default as PageSection } from './PageSection.jsx'
|
|
15
|
+
export { default as PortalFooter } from './PortalFooter.jsx'
|
|
16
|
+
export { default as ScrollToTop } from './ScrollToTop.jsx'
|
|
17
|
+
export { default as BrandHero } from './BrandHero.jsx'
|
|
18
|
+
export { default as SubPageHero } from './SubPageHero.jsx'
|