@kolkrabbi/kol-framework 0.13.0 → 0.14.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 +46 -6
- package/package.json +1 -1
- package/src/SideNav.jsx +20 -18
- package/src/useDragResize.js +176 -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,36 @@ 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
|
+
|
|
128
|
+
/* The grab edge itself — invisible by spec (col-resize cursor is the pointer
|
|
129
|
+
* affordance, the focus ring the keyboard one — recipe from
|
|
130
|
+
* .kol-btn:focus-visible, offset pulled inside because the strip hugs the
|
|
131
|
+
* layout edge). Positioning utilities ride inline in SideNav.jsx; the narrow
|
|
132
|
+
* mode hides the strip in its own block further down. */
|
|
133
|
+
.kol-sidenav-grab {
|
|
134
|
+
width: var(--kol-sidenav-grab-w);
|
|
135
|
+
cursor: col-resize;
|
|
136
|
+
}
|
|
137
|
+
.kol-sidenav-grab:focus-visible {
|
|
138
|
+
outline: 2px solid var(--kol-focus-ring);
|
|
139
|
+
outline-offset: -2px;
|
|
140
|
+
}
|
|
141
|
+
/* Live drag writes --kol-sidenav-w per pointermove — the grid's ease below
|
|
142
|
+
* would render the rail a frame behind the pointer, so the drag suspends it
|
|
143
|
+
* (useDragResize stamps the attribute for the drag's duration). */
|
|
144
|
+
:root[data-sidenav-dragging] .kol-brand-layout { transition: none; }
|
|
111
145
|
|
|
112
146
|
/* ───── Brand doc layout (sidebar + content) ─────
|
|
113
147
|
The grid lives in CSS for its dynamic --kol-sidenav-w + transition. */
|
|
@@ -123,10 +157,12 @@ body {
|
|
|
123
157
|
User's localStorage expand preference is overridden while viewport is narrow;
|
|
124
158
|
restored once they cross back over the threshold. */
|
|
125
159
|
.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:
|
|
160
|
+
.kol-sidenav-hops { padding: 0 0 var(--kol-spacing-4); margin-bottom: var(--kol-spacing-4); }
|
|
161
|
+
.kol-sidenav-hop { padding: var(--kol-spacing-2) 0; justify-content: center; gap: 0; }
|
|
128
162
|
.kol-sidenav-hop-label { display: none; }
|
|
129
163
|
.kol-sidenav-body { display: none; }
|
|
164
|
+
/* forced narrow / drawer modes own the rail below lg — no resize here */
|
|
165
|
+
.kol-sidenav-grab { display: none; }
|
|
130
166
|
}
|
|
131
167
|
|
|
132
168
|
@media (max-width: 767px) {
|
|
@@ -700,6 +736,10 @@ body {
|
|
|
700
736
|
.kol-brand-layout[data-toc="true"] {
|
|
701
737
|
grid-template-columns: var(--kol-sidenav-w) minmax(0, 1fr) var(--kol-toc-w);
|
|
702
738
|
}
|
|
739
|
+
/* collapsed + TOC: the third column survives; only the rail track narrows */
|
|
740
|
+
:root[data-sidenav="collapsed"] .kol-brand-layout[data-toc="true"] {
|
|
741
|
+
grid-template-columns: var(--kol-sidenav-w-collapsed) minmax(0, 1fr) var(--kol-toc-w);
|
|
742
|
+
}
|
|
703
743
|
}
|
|
704
744
|
|
|
705
745
|
/* 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.14.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",
|
package/src/SideNav.jsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react'
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { Link, NavLink, useLocation } from 'react-router-dom'
|
|
3
3
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
4
|
import ThemeToggle from './ThemeToggle'
|
|
5
|
+
import useDragResize from './useDragResize'
|
|
5
6
|
import { useScrollSpy } from '@kolkrabbi/kol-component'
|
|
6
7
|
|
|
7
8
|
/* Walk the active page's children and return all leaf section ids (for scroll-spy). */
|
|
@@ -144,15 +145,14 @@ export default function SideNav({
|
|
|
144
145
|
const onPageRoot = activePage && pathname === activePage.to
|
|
145
146
|
const activeSectionId = useScrollSpy(onPageRoot ? sectionIds : [])
|
|
146
147
|
|
|
147
|
-
/*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
* that) until the feature is deliberately rebuilt here. */
|
|
148
|
+
/* Collapse, rebuilt DELIBERATELY 2026-08-09 (lobby: SideNavGrabResize) —
|
|
149
|
+
* the 2026-07-29/30 removals killed a stamp with no renderer; the grab edge
|
|
150
|
+
* below reinstates BOTH halves. useDragResize owns the state, stamps
|
|
151
|
+
* :root[data-sidenav="collapsed"], and kol-framework.css renders it again.
|
|
152
|
+
* `collapsed` / `onToggle` remain ACCEPTED-but-inert — the gesture is
|
|
153
|
+
* self-contained, so call sites still don't break. */
|
|
154
|
+
const asideRef = useRef(null)
|
|
155
|
+
const { grabProps } = useDragResize(asideRef)
|
|
156
156
|
|
|
157
157
|
/* Per-page section expand/collapse (opt-in via collapsibleSections).
|
|
158
158
|
* The section containing the active route auto-expands on every
|
|
@@ -171,16 +171,14 @@ export default function SideNav({
|
|
|
171
171
|
|
|
172
172
|
return (
|
|
173
173
|
<aside
|
|
174
|
+
ref={asideRef}
|
|
174
175
|
className={`kol-sidenav sticky top-0 self-start h-dvh flex flex-col border-r border-fg-08 z-20 bg-surface-primary${drawerOpen ? ' is-drawer-open' : ''}`}
|
|
175
176
|
>
|
|
176
|
-
{/* The collapse chevron was REMOVED 2026-07-30 (lobby ruling,
|
|
177
|
-
* #3).
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* did nothing at all. Nothing styles any of it (see the tombstone at
|
|
182
|
-
* kol-framework.css:85). The surviving narrow mode is the responsive
|
|
183
|
-
* ≤1024px rail, which needs no control. */}
|
|
177
|
+
{/* The collapse chevron Button was REMOVED 2026-07-30 (lobby ruling,
|
|
178
|
+
* finding #3) as a control with no renderer. The grab edge at the end
|
|
179
|
+
* of this aside is its deliberate 2026-08-09 replacement — pointer-drag
|
|
180
|
+
* resizes, drag under --kol-sidenav-snap collapses, double-click and
|
|
181
|
+
* Home reset, arrows resize from the keyboard. See useDragResize.js. */}
|
|
184
182
|
|
|
185
183
|
<div className="kol-sidenav-scroll flex-1 flex flex-col justify-between overflow-y-auto pt-4 pb-4 [scrollbar-width:thin]">
|
|
186
184
|
<ul className="kol-sidenav-tree flex flex-col gap-[2px]">
|
|
@@ -267,6 +265,10 @@ export default function SideNav({
|
|
|
267
265
|
<span className="text-meta group-hover:text-emphasis"> · {new Date().getFullYear()}</span>
|
|
268
266
|
</a>
|
|
269
267
|
</div>
|
|
268
|
+
|
|
269
|
+
{/* Grab edge — width/cursor/focus chrome in kol-framework.css
|
|
270
|
+
* (.kol-sidenav-grab); hidden below lg by the narrow-mode block. */}
|
|
271
|
+
<div className="kol-sidenav-grab absolute top-0 right-0 bottom-0 z-[1]" {...grabProps} />
|
|
270
272
|
</aside>
|
|
271
273
|
)
|
|
272
274
|
}
|
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
const onKeyDown = (e) => {
|
|
138
|
+
const snapPx = readVarPx('--kol-sidenav-snap')
|
|
139
|
+
const stepPx = readVarPx('--kol-sidenav-step')
|
|
140
|
+
if (defaultPx.current == null || snapPx == null || stepPx == null) return
|
|
141
|
+
const { collapsed: c, widthPx: w } = readBack()
|
|
142
|
+
const current = w ?? defaultPx.current
|
|
143
|
+
if (e.key === 'ArrowRight') {
|
|
144
|
+
e.preventDefault()
|
|
145
|
+
if (c) stampCollapsed(false)
|
|
146
|
+
else writeWidth(Math.min(current + stepPx, defaultPx.current * 3))
|
|
147
|
+
syncAndPersist()
|
|
148
|
+
} else if (e.key === 'ArrowLeft') {
|
|
149
|
+
e.preventDefault()
|
|
150
|
+
if (c) return
|
|
151
|
+
const next = current - stepPx
|
|
152
|
+
if (next < snapPx) stampCollapsed(true)
|
|
153
|
+
else writeWidth(next)
|
|
154
|
+
syncAndPersist()
|
|
155
|
+
} else if (e.key === 'Home') {
|
|
156
|
+
e.preventDefault()
|
|
157
|
+
onDoubleClick()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
collapsed,
|
|
163
|
+
grabProps: {
|
|
164
|
+
role: 'separator',
|
|
165
|
+
'aria-orientation': 'vertical',
|
|
166
|
+
'aria-label': 'Resize navigation',
|
|
167
|
+
'aria-valuenow': Math.round(collapsed ? collapsedPx.current : (widthPx ?? defaultPx.current)) || undefined,
|
|
168
|
+
'aria-valuemin': collapsedPx.current == null ? undefined : Math.round(collapsedPx.current),
|
|
169
|
+
'aria-valuemax': defaultPx.current == null ? undefined : Math.round(defaultPx.current * 3),
|
|
170
|
+
tabIndex: 0,
|
|
171
|
+
onPointerDown,
|
|
172
|
+
onDoubleClick,
|
|
173
|
+
onKeyDown,
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
}
|