@kolkrabbi/kol-framework 0.13.0 → 0.15.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 +57 -6
- package/package.json +2 -2
- package/src/AppShell.jsx +5 -2
- package/src/SideNav.jsx +172 -238
- package/src/index.js +2 -2
- package/src/useDragResize.js +186 -0
package/kol-framework.css
CHANGED
|
@@ -35,6 +35,14 @@ body {
|
|
|
35
35
|
grid track that had to line up with it read 256, so the shell header's
|
|
36
36
|
brand block sat 4 off the sidebar column it exists to align to. */
|
|
37
37
|
--kol-sidenav-w: 16rem;
|
|
38
|
+
/* Grab-edge gesture (lobby: SideNavGrabResize, 2026-08-09). Minted HERE —
|
|
39
|
+
no --kol-* token carried these before (checked: only -w, -w-collapsed,
|
|
40
|
+
-dot-left shipped). kol-mirror carried them as bare literals, the
|
|
41
|
+
consumer-app slack the brief bans copying; both widths reference the
|
|
42
|
+
spacing ladder (the mirror strip's width lands on the ladder's first rung). */
|
|
43
|
+
--kol-sidenav-grab-w: var(--kol-spacing-1); /* the strip's hit width */
|
|
44
|
+
--kol-sidenav-snap: 12rem; /* drag under this snaps to -w-collapsed */
|
|
45
|
+
--kol-sidenav-step: var(--kol-spacing-4); /* keyboard resize increment */
|
|
38
46
|
--kol-sidenav-w-collapsed: 56px;
|
|
39
47
|
/* The workshop shell's RIGHT rail. EQUAL TO THE LEFT (user ruling
|
|
40
48
|
2026-08-01) — the two rails frame the same content and a lopsided frame is
|
|
@@ -104,10 +112,47 @@ body {
|
|
|
104
112
|
* said 1800 — same element, two answers (lobby/WidthSystemContradictions). */
|
|
105
113
|
@media (min-width: 1920px) { :root { --kol-container-max: var(--kol-content-shell, 1800px); } }
|
|
106
114
|
|
|
107
|
-
/* :root[data-sidenav="collapsed"]
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* the
|
|
115
|
+
/* :root[data-sidenav="collapsed"] — the manual collapse, rebuilt DELIBERATELY
|
|
116
|
+
* 2026-08-09 (lobby: SideNavGrabResize) after the 2026-07-29 removal of the
|
|
117
|
+
* renderer-less stamp. SideNav's grab edge (useDragResize.js) owns the state
|
|
118
|
+
* now; these rules mirror the narrow mode below, so "collapsed" is that same
|
|
119
|
+
* rail — one look, two triggers. */
|
|
120
|
+
:root[data-sidenav="collapsed"] .kol-brand-layout { grid-template-columns: var(--kol-sidenav-w-collapsed) minmax(0, 1fr); }
|
|
121
|
+
:root[data-sidenav="collapsed"] .kol-sidenav-hops { padding: 0 0 var(--kol-spacing-4); margin-bottom: var(--kol-spacing-4); }
|
|
122
|
+
:root[data-sidenav="collapsed"] .kol-sidenav-hop { padding: var(--kol-spacing-2) 0; justify-content: center; gap: 0; }
|
|
123
|
+
:root[data-sidenav="collapsed"] .kol-sidenav-hop-label { display: none; }
|
|
124
|
+
:root[data-sidenav="collapsed"] .kol-sidenav-body { display: none; }
|
|
125
|
+
:root[data-sidenav="collapsed"] .kol-sidenav-hop-count,
|
|
126
|
+
:root[data-sidenav="collapsed"] .kol-sidenav-section-toggle { display: none; }
|
|
127
|
+
/* Folded back from the elder's brand-local sidenav-collapse.css (2026-08-09
|
|
128
|
+
* port): the disclosure caret and the disclosed list are noise in a rail with
|
|
129
|
+
* no labels; the theme slot and footer centre; the footer wordmark swaps for
|
|
130
|
+
* its initial — both spans live in one <a>, so the link survives either state. */
|
|
131
|
+
:root[data-sidenav="collapsed"] .kol-sidenav-hop-caret,
|
|
132
|
+
:root[data-sidenav="collapsed"] .kol-sidenav-list { display: none; }
|
|
133
|
+
:root[data-sidenav="collapsed"] .kol-sidenav-theme-slot { padding-inline: 0; justify-content: center; }
|
|
134
|
+
:root[data-sidenav="collapsed"] .kol-sidenav-footer { padding-inline: 0; justify-content: center; }
|
|
135
|
+
.kol-sidenav-footer-mark { display: none; }
|
|
136
|
+
:root[data-sidenav="collapsed"] .kol-sidenav-footer-mark { display: inline; }
|
|
137
|
+
:root[data-sidenav="collapsed"] .kol-sidenav-footer-full { display: none; }
|
|
138
|
+
|
|
139
|
+
/* The grab edge itself — invisible by spec (col-resize cursor is the pointer
|
|
140
|
+
* affordance, the focus ring the keyboard one — recipe from
|
|
141
|
+
* .kol-btn:focus-visible, offset pulled inside because the strip hugs the
|
|
142
|
+
* layout edge). Positioning utilities ride inline in SideNav.jsx; the narrow
|
|
143
|
+
* mode hides the strip in its own block further down. */
|
|
144
|
+
.kol-sidenav-grab {
|
|
145
|
+
width: var(--kol-sidenav-grab-w);
|
|
146
|
+
cursor: col-resize;
|
|
147
|
+
}
|
|
148
|
+
.kol-sidenav-grab:focus-visible {
|
|
149
|
+
outline: 2px solid var(--kol-focus-ring);
|
|
150
|
+
outline-offset: -2px;
|
|
151
|
+
}
|
|
152
|
+
/* Live drag writes --kol-sidenav-w per pointermove — the grid's ease below
|
|
153
|
+
* would render the rail a frame behind the pointer, so the drag suspends it
|
|
154
|
+
* (useDragResize stamps the attribute for the drag's duration). */
|
|
155
|
+
:root[data-sidenav-dragging] .kol-brand-layout { transition: none; }
|
|
111
156
|
|
|
112
157
|
/* ───── Brand doc layout (sidebar + content) ─────
|
|
113
158
|
The grid lives in CSS for its dynamic --kol-sidenav-w + transition. */
|
|
@@ -123,10 +168,12 @@ body {
|
|
|
123
168
|
User's localStorage expand preference is overridden while viewport is narrow;
|
|
124
169
|
restored once they cross back over the threshold. */
|
|
125
170
|
.kol-brand-layout { grid-template-columns: var(--kol-sidenav-w-collapsed) minmax(0, 1fr); }
|
|
126
|
-
.kol-sidenav-hops { padding: 0 0
|
|
127
|
-
.kol-sidenav-hop { padding:
|
|
171
|
+
.kol-sidenav-hops { padding: 0 0 var(--kol-spacing-4); margin-bottom: var(--kol-spacing-4); }
|
|
172
|
+
.kol-sidenav-hop { padding: var(--kol-spacing-2) 0; justify-content: center; gap: 0; }
|
|
128
173
|
.kol-sidenav-hop-label { display: none; }
|
|
129
174
|
.kol-sidenav-body { display: none; }
|
|
175
|
+
/* forced narrow / drawer modes own the rail below lg — no resize here */
|
|
176
|
+
.kol-sidenav-grab { display: none; }
|
|
130
177
|
}
|
|
131
178
|
|
|
132
179
|
@media (max-width: 767px) {
|
|
@@ -700,6 +747,10 @@ body {
|
|
|
700
747
|
.kol-brand-layout[data-toc="true"] {
|
|
701
748
|
grid-template-columns: var(--kol-sidenav-w) minmax(0, 1fr) var(--kol-toc-w);
|
|
702
749
|
}
|
|
750
|
+
/* collapsed + TOC: the third column survives; only the rail track narrows */
|
|
751
|
+
:root[data-sidenav="collapsed"] .kol-brand-layout[data-toc="true"] {
|
|
752
|
+
grid-template-columns: var(--kol-sidenav-w-collapsed) minmax(0, 1fr) var(--kol-toc-w);
|
|
753
|
+
}
|
|
703
754
|
}
|
|
704
755
|
|
|
705
756
|
/* SideNav collapsibleSections chrome — inert unless that mode renders.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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,7 +27,7 @@
|
|
|
27
27
|
"app-shell"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@kolkrabbi/kol-component": "0.
|
|
30
|
+
"@kolkrabbi/kol-component": "0.27.0",
|
|
31
31
|
"@kolkrabbi/kol-icons": "0.10.0"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
package/src/AppShell.jsx
CHANGED
|
@@ -15,7 +15,10 @@ export const ShellTocContext = createContext(null)
|
|
|
15
15
|
* useLayoutEffect(() => { setTocCollapsed(true); return () => setTocCollapsed(false) }, [setTocCollapsed]) */
|
|
16
16
|
export const ShellTocCollapsedContext = createContext(null)
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/* `getActivePage` — ACCEPTED-but-inert since the 2026-08-09 SideNav port (the
|
|
19
|
+
* two-level elder model derives the active category from the route); kept so
|
|
20
|
+
* no call site breaks. */
|
|
21
|
+
export default function AppShell({ navTree = [], getActivePage: _getActivePage, header, footer, defaultTocContent }) {
|
|
19
22
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
|
20
23
|
const [tocContent, setTocContent] = useState(null)
|
|
21
24
|
const [tocCollapsed, setTocCollapsed] = useState(false)
|
|
@@ -58,7 +61,7 @@ export default function AppShell({ navTree = [], getActivePage, header, footer,
|
|
|
58
61
|
aria-hidden="true"
|
|
59
62
|
/>
|
|
60
63
|
|
|
61
|
-
<SideNav navTree={navTree}
|
|
64
|
+
<SideNav navTree={navTree} drawerOpen={drawerOpen} onCloseDrawer={() => setDrawerOpen(false)} />
|
|
62
65
|
<div className="min-w-0">
|
|
63
66
|
{header}
|
|
64
67
|
<Outlet />
|
package/src/SideNav.jsx
CHANGED
|
@@ -1,258 +1,182 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { NavLink, useLocation } from 'react-router-dom'
|
|
2
|
+
import { useRef, useState } from 'react'
|
|
3
3
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
|
+
import { Button, Tooltip } from '@kolkrabbi/kol-component'
|
|
4
5
|
import ThemeToggle from './ThemeToggle'
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
'
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
</li>
|
|
52
|
-
)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function RouteLeaf({ leaf, indent, onNavigate, isActiveFn }) {
|
|
56
|
-
return (
|
|
57
|
-
<li>
|
|
58
|
-
<NavLink
|
|
59
|
-
to={leaf.to}
|
|
60
|
-
end
|
|
61
|
-
className={({ isActive }) => ((isActiveFn ? isActiveFn(leaf.to) : isActive) ? linkActiveCls : linkCls)}
|
|
62
|
-
style={leafStyle(indent)}
|
|
63
|
-
onClick={onNavigate}
|
|
64
|
-
>
|
|
65
|
-
{leaf.label}
|
|
66
|
-
</NavLink>
|
|
67
|
-
</li>
|
|
68
|
-
)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function GroupNode({ group, basePath, activeSectionId, indent, onNavigate, isActiveFn }) {
|
|
72
|
-
const isAncestor = hasActiveDescendant(group.children, activeSectionId)
|
|
73
|
-
return (
|
|
74
|
-
<li>
|
|
75
|
-
<div
|
|
76
|
-
className={`kol-sidenav-group kol-helper-10 ${isAncestor ? 'text-emphasis' : 'text-subtle'}`}
|
|
77
|
-
style={{ paddingLeft: indent }}
|
|
78
|
-
>
|
|
79
|
-
{group.label}
|
|
80
|
-
</div>
|
|
81
|
-
<ul className="kol-sidenav-list">
|
|
82
|
-
{group.children.map((child, i) => (
|
|
83
|
-
<ChildNode
|
|
84
|
-
key={child.id ?? child.to ?? `g-${i}`}
|
|
85
|
-
child={child}
|
|
86
|
-
basePath={basePath}
|
|
87
|
-
activeSectionId={activeSectionId}
|
|
88
|
-
indent={indent + 12}
|
|
89
|
-
onNavigate={onNavigate}
|
|
90
|
-
isActiveFn={isActiveFn}
|
|
91
|
-
/>
|
|
92
|
-
))}
|
|
93
|
-
</ul>
|
|
94
|
-
</li>
|
|
95
|
-
)
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function ChildNode({ child, basePath, activeSectionId, indent, onNavigate, isActiveFn }) {
|
|
99
|
-
if (child.children) {
|
|
100
|
-
return (
|
|
101
|
-
<GroupNode
|
|
102
|
-
group={child}
|
|
103
|
-
basePath={basePath}
|
|
104
|
-
activeSectionId={activeSectionId}
|
|
105
|
-
indent={indent}
|
|
106
|
-
onNavigate={onNavigate}
|
|
107
|
-
isActiveFn={isActiveFn}
|
|
108
|
-
/>
|
|
109
|
-
)
|
|
110
|
-
}
|
|
111
|
-
if (child.to) {
|
|
112
|
-
return <RouteLeaf leaf={child} indent={indent} onNavigate={onNavigate} isActiveFn={isActiveFn} />
|
|
113
|
-
}
|
|
114
|
-
if (child.id) {
|
|
115
|
-
return (
|
|
116
|
-
<SectionLeaf
|
|
117
|
-
leaf={child}
|
|
118
|
-
basePath={basePath}
|
|
119
|
-
isActive={activeSectionId === child.id}
|
|
120
|
-
indent={indent}
|
|
121
|
-
onNavigate={onNavigate}
|
|
122
|
-
/>
|
|
123
|
-
)
|
|
124
|
-
}
|
|
125
|
-
return null
|
|
126
|
-
}
|
|
6
|
+
import useDragResize from './useDragResize'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* SideNav — the grouped navigation rail.
|
|
10
|
+
*
|
|
11
|
+
* REPLICATED from the elder reference (kol-website/apps/brand
|
|
12
|
+
* src/components/framework/SideNav.jsx, 2026-08-09 user mandate: "there is a
|
|
13
|
+
* better version in kol-website apps:brand") — the brand app's evolved fork
|
|
14
|
+
* folded back into the package, generic seams kept.
|
|
15
|
+
*
|
|
16
|
+
* TWO LEVELS ONLY (elder ruling 2026-08-01). A category is a grouping LABEL —
|
|
17
|
+
* a disclosure <button> with a trailing caret, no route. A page is a route.
|
|
18
|
+
* The `#anchor` scroll-spied section layer is gone, and with it the
|
|
19
|
+
* group/section tree walkers. The ONE exception: a category with `to` and no
|
|
20
|
+
* `pages` (Home) renders as a plain link row — it has nothing to disclose.
|
|
21
|
+
*
|
|
22
|
+
* navTree: [{ id, label, icon, to?, pages?: [{ label, to }] }]
|
|
23
|
+
* (legacy `children` route leaves are read as `pages`; section anchors are not.)
|
|
24
|
+
*
|
|
25
|
+
* Collapse: BOTH affordances share one contract —
|
|
26
|
+
* `:root[data-sidenav="collapsed"]` + the `kol-sidenav` storage schema, owned
|
|
27
|
+
* by useDragResize. The round chip Button (elder A/B, closed 2026-08-01) is
|
|
28
|
+
* the one-click toggle; the grab edge (lobby: SideNavGrabResize, 2026-08-09)
|
|
29
|
+
* resizes, snap-collapses, and carries the keyboard path. Collapsed rows get
|
|
30
|
+
* a DS Tooltip — the glyph is otherwise the only thing naming them.
|
|
31
|
+
*
|
|
32
|
+
* `background` (2026-08-09, prop): `true` (default) paints the rail surface +
|
|
33
|
+
* right hairline; `false` renders chromeless so the rail can float over a
|
|
34
|
+
* consumer's media (the brand-hero model — the consumer owns the plane).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const pageBase =
|
|
38
|
+
'kol-sidenav-link kol-helper-10 block relative py-[4px] pl-14 pr-6 no-underline transition-colors duration-150'
|
|
39
|
+
|
|
40
|
+
/* The active-dot pseudo-element is positioned by `--kol-sidenav-dot-left`
|
|
41
|
+
* (kol-components-atoms.css). The page row indents 3.5rem (pl-14); the dot
|
|
42
|
+
* sits 0.875rem inside that, in the gutter the icon column leaves. */
|
|
43
|
+
const pageStyle = { '--kol-sidenav-dot-left': '2.625rem' }
|
|
44
|
+
/* Category and page must not share a stop: category `emphasis`, page `strong`
|
|
45
|
+
* — two rungs of one ladder, so the tree reads as hierarchy (elder ruling). */
|
|
46
|
+
const pageCls = `${pageBase} text-strong hover:text-emphasis`
|
|
47
|
+
const pageActiveCls = `${pageBase} is-active`
|
|
48
|
+
|
|
49
|
+
/* Legacy DS trees carried `children` (groups + `#anchor` leaves). Route
|
|
50
|
+
* leaves survive as pages; anchor sections died with the two-level ruling. */
|
|
51
|
+
const pagesOf = (cat) => cat.pages ?? cat.children?.filter((c) => c.to) ?? []
|
|
127
52
|
|
|
128
53
|
export default function SideNav({
|
|
129
54
|
drawerOpen = false,
|
|
130
55
|
onCloseDrawer,
|
|
131
56
|
navTree = [],
|
|
132
|
-
getActivePage,
|
|
133
57
|
onNavigate,
|
|
134
|
-
/* INERT since 0.9.2 — accepted so no call site breaks, but nothing reads
|
|
135
|
-
* them; the collapse feature they drove has no CSS. See below. */
|
|
136
|
-
collapsed: _collapsed,
|
|
137
|
-
onToggle: _onToggle,
|
|
138
|
-
collapsibleSections = false,
|
|
139
58
|
isActive,
|
|
59
|
+
background = true,
|
|
140
60
|
}) {
|
|
141
61
|
const { pathname } = useLocation()
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const [expandedSections, setExpandedSections] = useState(() =>
|
|
161
|
-
collapsibleSections && activePage?.id ? { [activePage.id]: true } : {}
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
useEffect(() => {
|
|
165
|
-
if (!collapsibleSections || !activePage?.id) return
|
|
166
|
-
setExpandedSections((prev) => (prev[activePage.id] ? prev : { ...prev, [activePage.id]: true }))
|
|
167
|
-
}, [collapsibleSections, activePage?.id, pathname])
|
|
168
|
-
|
|
169
|
-
const toggleSection = (pageId) =>
|
|
170
|
-
setExpandedSections((prev) => ({ ...prev, [pageId]: !prev[pageId] }))
|
|
62
|
+
const isPageActive = (to) => (isActive ? isActive(to) : pathname === to)
|
|
63
|
+
const isActiveCat = (cat) => pagesOf(cat).some((p) => isPageActive(p.to))
|
|
64
|
+
|
|
65
|
+
/* Which categories are disclosed. INDEPENDENT per category (elder ruling
|
|
66
|
+
* 2026-08-01) — arriving somewhere must not silently close wherever you
|
|
67
|
+
* were. Loads closed, in-memory only (elder ruling 2026-08-06). */
|
|
68
|
+
const [openCats, setOpenCats] = useState(() => new Set())
|
|
69
|
+
const toggleCat = (id) => setOpenCats((prev) => {
|
|
70
|
+
const next = new Set(prev)
|
|
71
|
+
next.has(id) ? next.delete(id) : next.add(id)
|
|
72
|
+
return next
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/* One collapse contract, two gestures — the hook owns the stamp, the
|
|
76
|
+
* storage schema and the state; the chip Button and the grab edge both
|
|
77
|
+
* drive it. */
|
|
78
|
+
const asideRef = useRef(null)
|
|
79
|
+
const { collapsed, toggleCollapsed, grabProps } = useDragResize(asideRef)
|
|
171
80
|
|
|
172
81
|
return (
|
|
173
82
|
<aside
|
|
174
|
-
|
|
83
|
+
ref={asideRef}
|
|
84
|
+
className={`kol-sidenav sticky top-0 self-start h-dvh flex flex-col z-20${background ? ' bg-surface-primary border-r border-fg-08' : ''}${collapsed ? ' is-collapsed' : ''}${drawerOpen ? ' is-drawer-open' : ''}`}
|
|
175
85
|
>
|
|
176
|
-
{/* The
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
86
|
+
{/* The elder A/B (closed 2026-08-01): the DS Button IS the collapse
|
|
87
|
+
* control. Offset is half the pinned sm square, centring it on the
|
|
88
|
+
* rail's edge — the DS hardcodes that square and exposes no token. */}
|
|
89
|
+
<Button
|
|
90
|
+
variant="primary"
|
|
91
|
+
size="sm"
|
|
92
|
+
radius="full"
|
|
93
|
+
iconOnly={collapsed ? 'chevron-right' : 'chevron-left'}
|
|
94
|
+
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
95
|
+
aria-expanded={!collapsed}
|
|
96
|
+
title={collapsed ? 'Expand' : 'Collapse'}
|
|
97
|
+
onClick={toggleCollapsed}
|
|
98
|
+
className="kol-sidenav-toggle absolute top-5 right-[-0.875rem] z-[2]"
|
|
99
|
+
/>
|
|
184
100
|
|
|
185
101
|
<div className="kol-sidenav-scroll flex-1 flex flex-col justify-between overflow-y-auto pt-4 pb-4 [scrollbar-width:thin]">
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
end={page.to === '/'}
|
|
195
|
-
className={({ isActive: navActive }) =>
|
|
196
|
-
`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' : ''}`
|
|
197
|
-
}
|
|
198
|
-
onClick={onNavigate}
|
|
199
|
-
>
|
|
102
|
+
<nav aria-label="Sections">
|
|
103
|
+
<ul className="kol-sidenav-tree flex flex-col gap-[2px]">
|
|
104
|
+
{navTree.map((cat) => {
|
|
105
|
+
const pages = pagesOf(cat)
|
|
106
|
+
const isOpen = openCats.has(cat.id)
|
|
107
|
+
const isLink = !pages.length && cat.to
|
|
108
|
+
const rowCls = 'kol-sidenav-hop kol-helper-12 text-emphasis w-full relative flex items-center gap-3 py-2 pr-10 pl-6 no-underline'
|
|
109
|
+
const glyph = (
|
|
200
110
|
<span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
|
|
201
|
-
<Icon name={
|
|
111
|
+
<Icon name={cat.icon} size={16} />
|
|
202
112
|
</span>
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
{
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
113
|
+
)
|
|
114
|
+
/* A category is a <button>, not a link — it has no route by
|
|
115
|
+
definition, and an <a href> would promise a page that does
|
|
116
|
+
not exist. Clicking it discloses its pages. */
|
|
117
|
+
const head = isLink ? (
|
|
118
|
+
<NavLink
|
|
119
|
+
to={cat.to}
|
|
120
|
+
end
|
|
121
|
+
className={({ isActive: navActive }) => `${rowCls}${(isActive ? isActive(cat.to) : navActive) ? ' is-active' : ''}`}
|
|
122
|
+
onClick={onNavigate}
|
|
123
|
+
>
|
|
124
|
+
{glyph}
|
|
125
|
+
<span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
|
|
126
|
+
</NavLink>
|
|
127
|
+
) : (
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${isActiveCat(cat) ? ' is-active' : ''}`}
|
|
131
|
+
aria-expanded={isOpen}
|
|
132
|
+
onClick={() => toggleCat(cat.id)}
|
|
133
|
+
>
|
|
134
|
+
{glyph}
|
|
135
|
+
<span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
|
|
136
|
+
<Icon
|
|
137
|
+
name="chevron-down"
|
|
138
|
+
size={12}
|
|
139
|
+
className={`kol-sidenav-hop-caret absolute top-3 right-3 transition-transform duration-150${isOpen ? '' : ' -rotate-90'}`}
|
|
140
|
+
/>
|
|
141
|
+
</button>
|
|
142
|
+
)
|
|
143
|
+
return (
|
|
144
|
+
<li key={cat.id} className="relative">
|
|
145
|
+
{/* Collapsed rail hides the label — the tooltip is the only
|
|
146
|
+
thing naming the row. Portalled, so overflow can't clip it. */}
|
|
147
|
+
{collapsed ? (
|
|
148
|
+
<Tooltip label={cat.label} placement="right" triggerClassName="block">
|
|
149
|
+
{head}
|
|
150
|
+
</Tooltip>
|
|
151
|
+
) : head}
|
|
152
|
+
|
|
153
|
+
{!isLink && isOpen && (
|
|
154
|
+
<ul className="kol-sidenav-list mb-2 flex flex-col gap-[2px]">
|
|
155
|
+
{pages.map((page) => (
|
|
156
|
+
<li key={page.to}>
|
|
157
|
+
<NavLink
|
|
158
|
+
to={page.to}
|
|
159
|
+
end
|
|
160
|
+
style={pageStyle}
|
|
161
|
+
className={({ isActive: navActive }) => ((isActive ? isActive(page.to) : navActive) ? pageActiveCls : pageCls)}
|
|
162
|
+
onClick={onNavigate}
|
|
163
|
+
>
|
|
164
|
+
{page.label}
|
|
165
|
+
</NavLink>
|
|
166
|
+
</li>
|
|
167
|
+
))}
|
|
168
|
+
</ul>
|
|
169
|
+
)}
|
|
170
|
+
</li>
|
|
171
|
+
)
|
|
172
|
+
})}
|
|
173
|
+
</ul>
|
|
174
|
+
</nav>
|
|
175
|
+
|
|
176
|
+
{/* hop-bare carries its own gutter padding; collapsed falls back to
|
|
177
|
+
`icon` — a full-width label can't sit in the collapsed rail. */}
|
|
178
|
+
<div className="kol-sidenav-theme-slot flex">
|
|
179
|
+
<ThemeToggle variant={collapsed ? 'icon' : 'hop-bare'} size="md" />
|
|
256
180
|
</div>
|
|
257
181
|
</div>
|
|
258
182
|
|
|
@@ -263,10 +187,20 @@ export default function SideNav({
|
|
|
263
187
|
rel="noopener"
|
|
264
188
|
className="kol-helper-10 !font-normal no-underline group whitespace-nowrap overflow-hidden text-ellipsis min-w-0"
|
|
265
189
|
>
|
|
266
|
-
|
|
267
|
-
|
|
190
|
+
{/* Collapsed rail shows the initial alone; the full wordmark
|
|
191
|
+
overflows it. Both live in one <a> so the link survives either
|
|
192
|
+
state (CSS swaps them on the data-sidenav stamp). */}
|
|
193
|
+
<span className="kol-sidenav-footer-mark text-body group-hover:text-emphasis">K</span>
|
|
194
|
+
<span className="kol-sidenav-footer-full">
|
|
195
|
+
<span className="text-body group-hover:text-emphasis">Kolkrabbi Vinnustofa</span>
|
|
196
|
+
<span className="text-meta group-hover:text-emphasis"> · {new Date().getFullYear()}</span>
|
|
197
|
+
</span>
|
|
268
198
|
</a>
|
|
269
199
|
</div>
|
|
200
|
+
|
|
201
|
+
{/* Grab edge — width/cursor/focus chrome in kol-framework.css
|
|
202
|
+
* (.kol-sidenav-grab); hidden below lg by the narrow-mode block. */}
|
|
203
|
+
<div className="kol-sidenav-grab absolute top-0 right-0 bottom-0 z-[1]" {...grabProps} />
|
|
270
204
|
</aside>
|
|
271
205
|
)
|
|
272
206
|
}
|
package/src/index.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* @kol/framework — shared KOL app shell.
|
|
3
3
|
*
|
|
4
4
|
* Site chrome (sidenav, layout, theme toggle, footer, heroes) consumed across
|
|
5
|
-
* apps. SideNav takes its nav data as props (navTree
|
|
6
|
-
*
|
|
5
|
+
* apps. SideNav takes its nav data as props (navTree) so the tree stays
|
|
6
|
+
* app-local. CSS lives in src/styles: kol-framework.css
|
|
7
7
|
* and kol-brand-color.css.
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/* Grab-edge resize + snap-collapse for SideNav (lobby: SideNavGrabResize).
|
|
4
|
+
* Mechanics are kol-mirror's MirrorPlayground.jsx#L21-L76 — pointerdown on the
|
|
5
|
+
* strip, window-level move/up, body cursor + userSelect held for the drag,
|
|
6
|
+
* double-click resets. What the DS adds on top of that prior art:
|
|
7
|
+
* - live width goes to --kol-sidenav-w on :root, so the layout grid, the
|
|
8
|
+
* shell-header brand block and the aside all follow one number
|
|
9
|
+
* - dragging under --kol-sidenav-snap stamps :root[data-sidenav="collapsed"]
|
|
10
|
+
* (rendered again in kol-framework.css since 2026-08-09); back out re-expands
|
|
11
|
+
* - width + state survive reload. 'kol-sidenav' keeps the consumers' existing
|
|
12
|
+
* 'collapsed'|'expanded' schema; width lives under its own key so the
|
|
13
|
+
* brand app's reader of that value never breaks
|
|
14
|
+
* Keyboard path (a11y is not optional per the brief): the strip is a focusable
|
|
15
|
+
* separator — arrows resize by --kol-sidenav-step, ArrowLeft past the snap
|
|
16
|
+
* collapses, ArrowRight re-expands, Home resets to the stylesheet default.
|
|
17
|
+
* Every value the gesture needs is a --kol-sidenav-* token in kol-framework.css
|
|
18
|
+
* — no literals here, so a missing token (framework CSS not loaded) leaves the
|
|
19
|
+
* gesture inert rather than improvising numbers. */
|
|
20
|
+
|
|
21
|
+
const STATE_KEY = 'kol-sidenav'
|
|
22
|
+
const WIDTH_KEY = 'kol-sidenav-w'
|
|
23
|
+
|
|
24
|
+
const root = () => document.documentElement
|
|
25
|
+
|
|
26
|
+
/* Resolve a length token to px, or null when it is absent/unparsable. */
|
|
27
|
+
function readVarPx(name) {
|
|
28
|
+
const raw = getComputedStyle(root()).getPropertyValue(name).trim()
|
|
29
|
+
const n = parseFloat(raw)
|
|
30
|
+
if (!raw || Number.isNaN(n)) return null
|
|
31
|
+
return raw.endsWith('rem') ? n * parseFloat(getComputedStyle(root()).fontSize) : n
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Imperative DOM writes — pointermove must never re-render the nav tree.
|
|
35
|
+
* React state syncs from the DOM at rest (drag end / key press / reset). */
|
|
36
|
+
const stampCollapsed = (on) => {
|
|
37
|
+
if (on) root().setAttribute('data-sidenav', 'collapsed')
|
|
38
|
+
else root().removeAttribute('data-sidenav')
|
|
39
|
+
}
|
|
40
|
+
const writeWidth = (px) => {
|
|
41
|
+
if (px == null) root().style.removeProperty('--kol-sidenav-w')
|
|
42
|
+
else root().style.setProperty('--kol-sidenav-w', `${px}px`)
|
|
43
|
+
}
|
|
44
|
+
const readBack = () => {
|
|
45
|
+
const inline = parseFloat(root().style.getPropertyValue('--kol-sidenav-w'))
|
|
46
|
+
return {
|
|
47
|
+
collapsed: root().getAttribute('data-sidenav') === 'collapsed',
|
|
48
|
+
widthPx: Number.isNaN(inline) ? null : inline,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default function useDragResize(asideRef) {
|
|
53
|
+
const drag = useRef(null) // { startX, startW, snapPx, maxPx } during a drag
|
|
54
|
+
const defaultPx = useRef(null)
|
|
55
|
+
const collapsedPx = useRef(null)
|
|
56
|
+
const [collapsed, setCollapsed] = useState(false)
|
|
57
|
+
const [widthPx, setWidthPx] = useState(null) // null = stylesheet default
|
|
58
|
+
|
|
59
|
+
const syncAndPersist = () => {
|
|
60
|
+
const { collapsed: c, widthPx: w } = readBack()
|
|
61
|
+
setCollapsed(c)
|
|
62
|
+
setWidthPx(w)
|
|
63
|
+
try {
|
|
64
|
+
localStorage.setItem(STATE_KEY, c ? 'collapsed' : 'expanded')
|
|
65
|
+
if (w == null) localStorage.removeItem(WIDTH_KEY)
|
|
66
|
+
else localStorage.setItem(WIDTH_KEY, String(Math.round(w)))
|
|
67
|
+
} catch { /* storage blocked */ }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/* Boot: capture the stylesheet defaults BEFORE any inline override lands,
|
|
71
|
+
* then restore the persisted width/state. */
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
defaultPx.current = readVarPx('--kol-sidenav-w')
|
|
74
|
+
collapsedPx.current = readVarPx('--kol-sidenav-w-collapsed')
|
|
75
|
+
let w = null
|
|
76
|
+
let c = false
|
|
77
|
+
try {
|
|
78
|
+
w = parseFloat(localStorage.getItem(WIDTH_KEY)) || null
|
|
79
|
+
c = localStorage.getItem(STATE_KEY) === 'collapsed'
|
|
80
|
+
} catch { /* storage blocked */ }
|
|
81
|
+
if (w) { writeWidth(w); setWidthPx(w) }
|
|
82
|
+
if (c) { stampCollapsed(true); setCollapsed(true) }
|
|
83
|
+
}, [])
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const onMove = (e) => {
|
|
87
|
+
if (!drag.current) return
|
|
88
|
+
const { startX, startW, snapPx, maxPx } = drag.current
|
|
89
|
+
const next = startW + (e.clientX - startX)
|
|
90
|
+
if (next < snapPx) {
|
|
91
|
+
stampCollapsed(true)
|
|
92
|
+
} else {
|
|
93
|
+
stampCollapsed(false)
|
|
94
|
+
writeWidth(Math.min(next, maxPx))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const onUp = () => {
|
|
98
|
+
if (!drag.current) return
|
|
99
|
+
drag.current = null
|
|
100
|
+
root().removeAttribute('data-sidenav-dragging')
|
|
101
|
+
document.body.style.cursor = ''
|
|
102
|
+
document.body.style.userSelect = ''
|
|
103
|
+
syncAndPersist()
|
|
104
|
+
}
|
|
105
|
+
window.addEventListener('pointermove', onMove)
|
|
106
|
+
window.addEventListener('pointerup', onUp)
|
|
107
|
+
return () => {
|
|
108
|
+
window.removeEventListener('pointermove', onMove)
|
|
109
|
+
window.removeEventListener('pointerup', onUp)
|
|
110
|
+
}
|
|
111
|
+
}, [])
|
|
112
|
+
|
|
113
|
+
const onPointerDown = (e) => {
|
|
114
|
+
const snapPx = readVarPx('--kol-sidenav-snap')
|
|
115
|
+
if (defaultPx.current == null || snapPx == null) return // tokens absent → inert
|
|
116
|
+
e.preventDefault()
|
|
117
|
+
drag.current = {
|
|
118
|
+
startX: e.clientX,
|
|
119
|
+
startW: asideRef.current?.getBoundingClientRect().width ?? defaultPx.current,
|
|
120
|
+
snapPx,
|
|
121
|
+
/* mirror's ceiling (default × 3), resolved from the token not hardcoded */
|
|
122
|
+
maxPx: defaultPx.current * 3,
|
|
123
|
+
}
|
|
124
|
+
/* the grid's 180ms grid-template-columns ease would trail the pointer —
|
|
125
|
+
* kol-framework.css suspends it while this attribute is stamped */
|
|
126
|
+
root().setAttribute('data-sidenav-dragging', '')
|
|
127
|
+
document.body.style.cursor = 'col-resize'
|
|
128
|
+
document.body.style.userSelect = 'none'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const onDoubleClick = () => {
|
|
132
|
+
stampCollapsed(false)
|
|
133
|
+
writeWidth(null)
|
|
134
|
+
syncAndPersist()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* One-click toggle for the chip Button (elder A/B, folded back 2026-08-09)
|
|
138
|
+
* — same stamp, same storage schema, so the chip and the grab edge can
|
|
139
|
+
* never disagree about the state. */
|
|
140
|
+
const toggleCollapsed = () => {
|
|
141
|
+
const { collapsed: c } = readBack()
|
|
142
|
+
stampCollapsed(!c)
|
|
143
|
+
syncAndPersist()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const onKeyDown = (e) => {
|
|
147
|
+
const snapPx = readVarPx('--kol-sidenav-snap')
|
|
148
|
+
const stepPx = readVarPx('--kol-sidenav-step')
|
|
149
|
+
if (defaultPx.current == null || snapPx == null || stepPx == null) return
|
|
150
|
+
const { collapsed: c, widthPx: w } = readBack()
|
|
151
|
+
const current = w ?? defaultPx.current
|
|
152
|
+
if (e.key === 'ArrowRight') {
|
|
153
|
+
e.preventDefault()
|
|
154
|
+
if (c) stampCollapsed(false)
|
|
155
|
+
else writeWidth(Math.min(current + stepPx, defaultPx.current * 3))
|
|
156
|
+
syncAndPersist()
|
|
157
|
+
} else if (e.key === 'ArrowLeft') {
|
|
158
|
+
e.preventDefault()
|
|
159
|
+
if (c) return
|
|
160
|
+
const next = current - stepPx
|
|
161
|
+
if (next < snapPx) stampCollapsed(true)
|
|
162
|
+
else writeWidth(next)
|
|
163
|
+
syncAndPersist()
|
|
164
|
+
} else if (e.key === 'Home') {
|
|
165
|
+
e.preventDefault()
|
|
166
|
+
onDoubleClick()
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
collapsed,
|
|
172
|
+
toggleCollapsed,
|
|
173
|
+
grabProps: {
|
|
174
|
+
role: 'separator',
|
|
175
|
+
'aria-orientation': 'vertical',
|
|
176
|
+
'aria-label': 'Resize navigation',
|
|
177
|
+
'aria-valuenow': Math.round(collapsed ? collapsedPx.current : (widthPx ?? defaultPx.current)) || undefined,
|
|
178
|
+
'aria-valuemin': collapsedPx.current == null ? undefined : Math.round(collapsedPx.current),
|
|
179
|
+
'aria-valuemax': defaultPx.current == null ? undefined : Math.round(defaultPx.current * 3),
|
|
180
|
+
tabIndex: 0,
|
|
181
|
+
onPointerDown,
|
|
182
|
+
onDoubleClick,
|
|
183
|
+
onKeyDown,
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
}
|