@kolkrabbi/kol-framework 0.33.0 → 0.34.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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/SideNav.jsx +68 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.33.0",
3
+ "version": "0.34.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",
@@ -20,8 +20,8 @@
20
20
  "react-router-dom": "^6.0.0 || ^7.0.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@kolkrabbi/kol-component": "^0.118.3",
24
- "@kolkrabbi/kol-icons": "^0.24.0"
23
+ "@kolkrabbi/kol-icons": "^0.24.0",
24
+ "@kolkrabbi/kol-component": "^0.119.0"
25
25
  },
26
26
  "files": [
27
27
  "src",
package/src/SideNav.jsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { NavLink, useLocation } from 'react-router-dom'
2
- import { useRef, useState } from 'react'
2
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
3
+ import { createPortal } from 'react-dom'
3
4
  import { Icon } from '@kolkrabbi/kol-icons'
4
5
  import { Tooltip } from '@kolkrabbi/kol-component'
5
6
  import ThemeToggle from './ThemeToggle'
@@ -79,6 +80,18 @@ import useDragResize from './useDragResize'
79
80
  * icon column) and, optionally, a `.kol-sidenav-hop-label`;
80
81
  * both follow the collapse. Same node in every consumer =
81
82
  * the first tree glyph at one y — the parity contract
83
+ * PANEL LEAF `{ id, label, icon, panel }` — a top-level node (tree or
84
+ * `bottomItems`) whose row is a DISCLOSURE: click opens,
85
+ * click again closes, lit while open (RailSettingsDisclosure,
86
+ * kol-fxr 2026-08-28 — user: "sidebar setting should open
87
+ * close on click and click again" · "put the theme toggle in
88
+ * the settings and out of the sidebar"). `panel` is a node or
89
+ * `({ collapsed, close }) => node`, PORTALLED to <body>
90
+ * directly above the row at the rail's own width (left/width
91
+ * from the aside, bottom = viewport − the row's top,
92
+ * `--kol-z-tooltip`), so it works collapsed (icon column)
93
+ * and expanded (rows) alike. Escape closes; any navigation
94
+ * or dispatch closes. fxr's RailSettings.jsx, promoted.
82
95
  * `bottomItems` top-level nodes pinned BELOW the theme slot, above the
83
96
  * footer (the shell pins theme → Settings; a Settings row in
84
97
  * the tree landed above the theme instead — "same rail, two
@@ -172,12 +185,14 @@ function SideNavInner({
172
185
  const handleNavigate = (e, to) => {
173
186
  onNavigate?.(e, to)
174
187
  onCloseDrawer?.()
188
+ setOpenPanel(null)
175
189
  if (collapsed && expandOnSelect) toggleCollapsed()
176
190
  }
177
191
  /* an action leaf dispatches — same drawer / rail behaviour as a navigation */
178
192
  const handleSelect = (e, row) => {
179
193
  row.onSelect(e)
180
194
  onCloseDrawer?.()
195
+ setOpenPanel(null)
181
196
  if (collapsed && expandOnSelect) toggleCollapsed()
182
197
  }
183
198
  /* the route leaf: NavLink under a router, `<a href>` on the agnostic seam */
@@ -191,6 +206,9 @@ function SideNavInner({
191
206
  * 2026-08-01) — arriving somewhere must not silently close wherever you
192
207
  * were. Loads closed, in-memory only (elder ruling 2026-08-06). */
193
208
  const [openCats, setOpenCats] = useState(() => new Set())
209
+ /* the ONE open panel leaf (by id) and where its panel sits */
210
+ const [openPanel, setOpenPanel] = useState(null)
211
+ const [panelBox, setPanelBox] = useState(null)
194
212
  const toggleCat = (id) => setOpenCats((prev) => {
195
213
  const next = new Set(prev)
196
214
  next.has(id) ? next.delete(id) : next.add(id)
@@ -204,6 +222,29 @@ function SideNavInner({
204
222
  const asideRef = useRef(null)
205
223
  const { collapsed, toggleCollapsed, grabProps } = useDragResize(asideRef, { defaultCollapsed })
206
224
 
225
+ /* the panel's anchor: its row — the panel's bottom edge meets the row's top
226
+ * edge, its width is the rail's; re-placed on resize and on collapse */
227
+ useLayoutEffect(() => {
228
+ if (!openPanel) { setPanelBox(null); return undefined }
229
+ const place = () => {
230
+ const rail = asideRef.current
231
+ const row = rail?.querySelector(`[data-panel-trigger="${CSS.escape(String(openPanel))}"]`)
232
+ if (!rail || !row) return
233
+ const rr = rail.getBoundingClientRect(), tr = row.getBoundingClientRect()
234
+ setPanelBox({ left: rr.left, width: rr.width, bottom: window.innerHeight - tr.top })
235
+ }
236
+ place()
237
+ window.addEventListener('resize', place)
238
+ return () => window.removeEventListener('resize', place)
239
+ }, [openPanel, collapsed])
240
+ useEffect(() => {
241
+ if (!openPanel) return undefined
242
+ const onKey = (e) => { if (e.key === 'Escape') setOpenPanel(null) }
243
+ window.addEventListener('keydown', onKey)
244
+ return () => window.removeEventListener('keydown', onKey)
245
+ }, [openPanel])
246
+ const panelNode = openPanel != null ? [...navTree, ...bottomItems].find((c) => c.id === openPanel) : null
247
+
207
248
  /* Rows under a category: a leaf is a NavLink; a group is a non-routing
208
249
  * header over its own rows, one step further in (recursive). A group lights
209
250
  * up the way a category does — when one of its leaves is the current route. */
@@ -253,11 +294,12 @@ function SideNavInner({
253
294
  const pages = pagesOf(cat)
254
295
  const isOpen = openCats.has(cat.id)
255
296
  const isLink = !pages.length && cat.to
256
- const isAction = !pages.length && !cat.to && cat.onSelect
297
+ const isPanel = !pages.length && !cat.to && !!cat.panel
298
+ const isAction = !pages.length && !cat.to && !isPanel && cat.onSelect
257
299
  /* THE COLLAPSE RULE: no icon, no route, no action = a LABEL over
258
300
  * its rows, not a row — `.kol-sidenav-section` hides on collapse
259
301
  * and the icon rows beneath it stay */
260
- if (!cat.icon && !cat.to && !cat.onSelect) {
302
+ if (!cat.icon && !cat.to && !cat.onSelect && !cat.panel) {
261
303
  return (
262
304
  <li key={cat.id ?? `${cat.label}-${ci}`} className="relative">
263
305
  <div className="kol-sidenav-section kol-eyebrow text-strong">{cat.label}</div>
@@ -282,6 +324,17 @@ function SideNavInner({
282
324
  {glyph}
283
325
  <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
284
326
  </RouteLeaf>
327
+ ) : isPanel ? (
328
+ <button
329
+ type="button"
330
+ data-panel-trigger={cat.id}
331
+ className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${openPanel === cat.id ? ' is-active' : ''}`}
332
+ aria-expanded={openPanel === cat.id}
333
+ onClick={() => setOpenPanel((o) => (o === cat.id ? null : cat.id))}
334
+ >
335
+ {glyph}
336
+ <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
337
+ </button>
285
338
  ) : isAction ? (
286
339
  <button
287
340
  type="button"
@@ -331,7 +384,7 @@ function SideNavInner({
331
384
  </Tooltip>
332
385
  ) : head}
333
386
 
334
- {!isLink && !isAction && isOpen && (
387
+ {!isLink && !isAction && !isPanel && isOpen && (
335
388
  <ul className="kol-sidenav-list">{renderRows(pages, 0)}</ul>
336
389
  )}
337
390
  </li>
@@ -407,6 +460,17 @@ function SideNavInner({
407
460
  </div>
408
461
  )}
409
462
 
463
+ {/* the open panel leaf's panel — a portal above its row, the rail's width */}
464
+ {panelNode && panelBox && createPortal(
465
+ <div
466
+ className="kol-sidenav-panel fixed flex flex-col gap-[2px] py-2 bg-surface-primary border-t border-r border-fg-08"
467
+ style={{ left: panelBox.left, width: panelBox.width, bottom: panelBox.bottom, zIndex: 'var(--kol-z-tooltip)' }}
468
+ >
469
+ {typeof panelNode.panel === 'function' ? panelNode.panel({ collapsed, close: () => setOpenPanel(null) }) : panelNode.panel}
470
+ </div>,
471
+ document.body,
472
+ )}
473
+
410
474
  {/* Grab edge — width/cursor/focus chrome in kol-framework.css
411
475
  * (.kol-sidenav-grab); hidden below lg by the narrow-mode block. */}
412
476
  <div className="kol-sidenav-grab absolute top-0 right-0 bottom-0 z-[1]" {...grabProps} />