@kolkrabbi/kol-framework 0.33.0 → 0.35.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.33.0",
3
+ "version": "0.35.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,7 +20,7 @@
20
20
  "react-router-dom": "^6.0.0 || ^7.0.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@kolkrabbi/kol-component": "^0.118.3",
23
+ "@kolkrabbi/kol-component": "^0.120.0",
24
24
  "@kolkrabbi/kol-icons": "^0.24.0"
25
25
  },
26
26
  "files": [
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} />
@@ -84,9 +84,13 @@ export default function ThemeToggle({
84
84
  label = true,
85
85
  iconRight = false,
86
86
  fullWidth = false,
87
+ /* `tone="sunken"` (ControlToneSunken, kol-website 2026-08-28): the control set's dark well + fg-96
88
+ * ink, so the toggle beside a sunken Dropdown matches it; `inverse` aliased. Rules in kol-theme. */
89
+ tone = 'default',
87
90
  className = '',
88
91
  }) {
89
92
  const { theme, mode, cycle, clear } = useTheme()
93
+ const extra = `${tone === 'sunken' || tone === 'inverse' ? 'kol-tone-sunken' : ''} ${className}`.trim()
90
94
 
91
95
  /* Label and slot follow the RESOLVED theme, not the stored choice: when the
92
96
  * page is unset, what the user is looking at is the OS's answer, and the
@@ -149,7 +153,7 @@ export default function ThemeToggle({
149
153
  /* ── DEPRECATED aliases — old chrome verbatim (0.6.x) ── */
150
154
  if (variant === 'icon') {
151
155
  return (
152
- <button {...shared} className={`kol-theme-toggle kol-theme-toggle-none kol-btn-${size} kol-btn-icon ${className}`.trim()}>
156
+ <button {...shared} className={`kol-theme-toggle kol-theme-toggle-none kol-btn-${size} kol-btn-icon ${extra}`.trim()}>
153
157
  {iconSwap(glyphSolo)}
154
158
  </button>
155
159
  )
@@ -171,7 +175,7 @@ export default function ThemeToggle({
171
175
  ? `w-full inline-flex items-center justify-start gap-2 ${barePadY} px-6 ${mono} bg-transparent text-emphasis`
172
176
  : 'kol-theme-toggle kol-theme-toggle-subtle kol-btn-md kol-mono-14 w-full justify-start gap-2'
173
177
  return (
174
- <button {...shared} className={`${chromeCls} ${className}`.trim()}>
178
+ <button {...shared} className={`${chromeCls} ${extra}`.trim()}>
175
179
  <span className="inline-flex items-center justify-center shrink-0" aria-hidden="true">
176
180
  {iconSwap(bare ? glyphWithText : glyphSolo)}
177
181
  </span>
@@ -187,7 +191,7 @@ export default function ThemeToggle({
187
191
 
188
192
  if (variant === 'flush') {
189
193
  const glyph = label ? glyphWithText : glyphSolo
190
- const cls = `kol-theme-toggle kol-theme-toggle-flush gap-2 ${mono} ${width} ${className}`
194
+ const cls = `kol-theme-toggle kol-theme-toggle-flush gap-2 ${mono} ${width} ${extra}`
191
195
  return (
192
196
  <button {...shared} className={cls.replace(/\s+/g, ' ').trim()}>
193
197
  {!iconRight && iconSwap(glyph)}
@@ -204,13 +208,13 @@ export default function ThemeToggle({
204
208
  if (!label) {
205
209
  // icon-only pins the square box per rung — geometry condition, not a variant
206
210
  return (
207
- <button {...shared} className={`kol-theme-toggle ${fillCls} kol-btn-${size} kol-btn-icon ${width} ${className}`.replace(/\s+/g, ' ').trim()}>
211
+ <button {...shared} className={`kol-theme-toggle ${fillCls} kol-btn-${size} kol-btn-icon ${width} ${extra}`.replace(/\s+/g, ' ').trim()}>
208
212
  {iconSwap(glyphSolo)}
209
213
  </button>
210
214
  )
211
215
  }
212
216
  return (
213
- <button {...shared} className={`kol-theme-toggle ${fillCls} kol-btn-${size} ${mono} gap-2 ${width} ${className}`.replace(/\s+/g, ' ').trim()}>
217
+ <button {...shared} className={`kol-theme-toggle ${fillCls} kol-btn-${size} ${mono} gap-2 ${width} ${extra}`.replace(/\s+/g, ' ').trim()}>
214
218
  {!iconRight && iconSwap(glyphWithText)}
215
219
  <span className={fullWidth ? 'flex-1 min-w-0 text-left' : ''}>{MODE_LABEL[next]}</span>
216
220
  {iconRight && iconSwap(glyphWithText)}