@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 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"] removed 2026-07-29 — the manual collapse
108
- * feature died with the editor's exit (brand ruling); the responsive rail
109
- * below 1024px is the surviving narrow mode. --kol-sidenav-w-collapsed stays:
110
- * the rail still uses it. */
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 16px; margin-bottom: 16px; }
127
- .kol-sidenav-hop { padding: 8px 0; justify-content: center; gap: 0; }
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.11.1",
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.19.0",
31
- "@kolkrabbi/kol-icons": "0.8.11"
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",
@@ -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. Named once here so a new
49
- * control cannot pick its own. */
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
- const iconBtnCls =
53
- 'flex h-9 w-9 items-center justify-center rounded p-0 bg-transparent border-0 cursor-pointer transition-colors hover:bg-fg-08 hover:text-emphasis'
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
- <button
102
- type="button"
103
- className={`${iconBtnCls} text-fg-64`}
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
- {item.icon && <Icon name={item.icon} size={14} />}
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
- <button
150
- type="button"
151
- className={`${iconBtnCls} ${navCollapsed ? 'text-fg-32' : 'text-fg-64'}`}
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
- <button
163
- type="button"
164
- className={`${iconBtnCls} ${tocCollapsed ? 'text-fg-32' : 'text-fg-64'}`}
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
- /* The collapse state machine went with the button (finding #3). It stamped
148
- * `data-sidenav="collapsed"` on <html> and an `is-collapsed` class on the
149
- * aside, neither of which any stylesheet has read since 2026-07-29 — state
150
- * with no renderer is just a lie the component tells itself.
151
- *
152
- * `collapsed` / `onToggle` are still ACCEPTED so no consumer's call site
153
- * breaks; they are inert, and the prop docs say so. A consumer that wants a
154
- * collapsing rail owns it locally (kol-website's apps/brand does exactly
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, finding
177
- * #3). Its CSS — `:root[data-sidenav="collapsed"]` — was deleted
178
- * 2026-07-29 when the manual-collapse feature was ruled dead, but the
179
- * button, the `data-sidenav` stamp and the `is-collapsed` class all
180
- * kept shipping, so every consumer of the package got a control that
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
+ }