@kolkrabbi/kol-framework 0.11.1 → 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 +3 -3
- package/src/ShellHeader.jsx +59 -25
- 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",
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"app-shell"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@kolkrabbi/kol-component": "0.
|
|
31
|
-
"@kolkrabbi/kol-icons": "0.
|
|
30
|
+
"@kolkrabbi/kol-component": "0.24.0",
|
|
31
|
+
"@kolkrabbi/kol-icons": "0.10.0"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"react": "^18.3.0 || ^19.0.0",
|
package/src/ShellHeader.jsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
2
|
-
import { SearchInput, Tooltip } from '@kolkrabbi/kol-component'
|
|
2
|
+
import { IconFrame, SearchInput, Tooltip, glyphSize } from '@kolkrabbi/kol-component'
|
|
3
3
|
import ThemeToggle from './ThemeToggle.jsx'
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -45,12 +45,32 @@ import ThemeToggle from './ThemeToggle.jsx'
|
|
|
45
45
|
/* ONE header-action glyph size (user 2026-08-01: "icon in navbar is small and
|
|
46
46
|
* wrong"). The row mixed sizes: the ThemeToggle draws its solo glyph at 24 on
|
|
47
47
|
* the `lg` rung while search, GitHub and the hamburger sat at 18 — four
|
|
48
|
-
* controls, two scales, in a row eight inches wide.
|
|
49
|
-
*
|
|
48
|
+
* controls, two scales, in a row eight inches wide.
|
|
49
|
+
*
|
|
50
|
+
* It is NOT an independent number. Every header control is a `lg` rung, and
|
|
51
|
+
* `SOLO.lg` (hooks/glyphLadders.js) is 24 — this constant is that ladder rung
|
|
52
|
+
* under a name the chrome can import. Naming it once was not enough on its own:
|
|
53
|
+
* it shipped exported and the two rail toggles still hardcoded 18 three rows
|
|
54
|
+
* below it, which is the whole reason the row looked wrong after the fix. The
|
|
55
|
+
* durable answer is that no header control writes a glyph size at all — it
|
|
56
|
+
* writes `size="lg"` and the ladder resolves it.
|
|
57
|
+
*
|
|
58
|
+
* @deprecated 2026-08-01 — **zero consumers as of this change.** Every header
|
|
59
|
+
* control now names its rung and takes the glyph from `SOLO`. Kept exported
|
|
60
|
+
* because it is public API of a published package (breaking an export needs a
|
|
61
|
+
* changeset + major bump); a consumer still importing it gets the right number,
|
|
62
|
+
* it is just no longer how the chrome decides. Passing it as `iconSize` is the
|
|
63
|
+
* anti-pattern this whole change removes: it pins the glyph while leaving the
|
|
64
|
+
* SQUARE on whatever rung the call site forgot to set, which is precisely how
|
|
65
|
+
* the search trigger ended up drawing an lg glyph in an md box. */
|
|
50
66
|
export const HEADER_ICON = 24
|
|
51
67
|
|
|
52
|
-
|
|
53
|
-
|
|
68
|
+
/* `iconBtnCls` lived here — a private class string re-implementing the icon
|
|
69
|
+
* button box (h-9 w-9, rounded, transparent, hover wash) on three call sites,
|
|
70
|
+
* with a near-duplicate of it hand-written in the showcase's GitHub link. Four
|
|
71
|
+
* containers, one job. Deleted 2026-08-01: the box has an owner, and it is
|
|
72
|
+
* `Button variant="ghost" quiet iconOnly` — the shape the search trigger in
|
|
73
|
+
* ShellLayout had been calling correctly the entire time. */
|
|
54
74
|
|
|
55
75
|
export default function ShellHeader({
|
|
56
76
|
brand,
|
|
@@ -93,19 +113,24 @@ export default function ShellHeader({
|
|
|
93
113
|
* quiet square = button geometry + fill none + no label
|
|
94
114
|
* (was the old `icon` variant default). */}
|
|
95
115
|
<span className="hidden lg:inline-flex"><ThemeToggle fill="none" label={false} size="lg" /></span>
|
|
96
|
-
<span className="lg:hidden"><ThemeToggle fill="none" label={false} /></span>
|
|
116
|
+
<span className="lg:hidden"><ThemeToggle fill="none" label={false} size="lg" /></span>
|
|
97
117
|
</Tooltip>
|
|
98
118
|
)}
|
|
99
119
|
{onMenuClick && (
|
|
100
120
|
<Tooltip label="Open navigation menu">
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
121
|
+
{/* IconFrame, not Button (user ruling 2026-08-01). Header
|
|
122
|
+
* chrome takes a click but must not light up; the frame has
|
|
123
|
+
* no state rules at all, which is the property Button can
|
|
124
|
+
* only approximate by dropping its own. `nav` rests at
|
|
125
|
+
* oq-64 — the `text-fg-64` this control hand-wrote for
|
|
126
|
+
* months. */}
|
|
127
|
+
<IconFrame
|
|
128
|
+
name="hamburger"
|
|
129
|
+
variant="nav"
|
|
130
|
+
size="lg"
|
|
104
131
|
onClick={onMenuClick}
|
|
105
132
|
aria-label="Open navigation menu"
|
|
106
|
-
|
|
107
|
-
<Icon name="hamburger" size={HEADER_ICON} />
|
|
108
|
-
</button>
|
|
133
|
+
/>
|
|
109
134
|
</Tooltip>
|
|
110
135
|
)}
|
|
111
136
|
</div>
|
|
@@ -129,7 +154,12 @@ export default function ShellHeader({
|
|
|
129
154
|
aria-current={active ? 'page' : undefined}
|
|
130
155
|
onClick={onNavigate ? (event) => onNavigate(event, item) : undefined}
|
|
131
156
|
>
|
|
132
|
-
{
|
|
157
|
+
{/* The ladder, not a literal (2026-08-01). This hardcoded
|
|
158
|
+
* 14 — the same value ADJACENT.sm carries, but written
|
|
159
|
+
* out, so the tab could not follow the rule it was
|
|
160
|
+
* meant to. The tab's own type is 14/18, which is that
|
|
161
|
+
* rung. */}
|
|
162
|
+
{item.icon && <Icon name={item.icon} size={glyphSize('sm')} />}
|
|
133
163
|
{item.label}
|
|
134
164
|
</a>
|
|
135
165
|
)
|
|
@@ -146,28 +176,32 @@ export default function ShellHeader({
|
|
|
146
176
|
<div className="hidden lg:flex items-center gap-1 pb-2">
|
|
147
177
|
{onNavToggle && (
|
|
148
178
|
<Tooltip label="Toggle navigation sidebar">
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
179
|
+
{/* The collapsed tint is a VARIANT SWAP, not a state:
|
|
180
|
+
* `ghost` rests at oq-48, `nav` at oq-64, and both are
|
|
181
|
+
* static classes with no rules to fire. That is the only
|
|
182
|
+
* honest way to express it on a frame — Button's `quiet`
|
|
183
|
+
* is an opacity transition, i.e. exactly the kind of
|
|
184
|
+
* behaviour this control was ruled out of. */}
|
|
185
|
+
<IconFrame
|
|
186
|
+
name="panel-left"
|
|
187
|
+
variant={navCollapsed ? 'ghost' : 'nav'}
|
|
188
|
+
size="sm"
|
|
152
189
|
onClick={onNavToggle}
|
|
153
190
|
aria-label="Toggle navigation sidebar"
|
|
154
191
|
aria-pressed={!navCollapsed}
|
|
155
|
-
|
|
156
|
-
<Icon name="panel-left" size={18} />
|
|
157
|
-
</button>
|
|
192
|
+
/>
|
|
158
193
|
</Tooltip>
|
|
159
194
|
)}
|
|
160
195
|
{onTocToggle && (
|
|
161
196
|
<Tooltip label="Toggle table of contents sidebar">
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
197
|
+
<IconFrame
|
|
198
|
+
name="panel-right"
|
|
199
|
+
variant={tocCollapsed ? 'ghost' : 'nav'}
|
|
200
|
+
size="sm"
|
|
165
201
|
onClick={onTocToggle}
|
|
166
202
|
aria-label="Toggle table of contents sidebar"
|
|
167
203
|
aria-pressed={!tocCollapsed}
|
|
168
|
-
|
|
169
|
-
<Icon name="panel-right" size={18} />
|
|
170
|
-
</button>
|
|
204
|
+
/>
|
|
171
205
|
</Tooltip>
|
|
172
206
|
)}
|
|
173
207
|
</div>
|
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
|
+
}
|