@kolkrabbi/kol-framework 0.29.0 → 0.30.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
@@ -160,6 +160,16 @@ body {
160
160
  display: block;
161
161
  padding: 0.25rem 1.5rem 0.25rem 3.5rem;
162
162
  }
163
+ /* THE COLLAPSE RULE (WorkspaceSidebarGeometry, kol-fxr 2026-08-27 — user: "I
164
+ * would rather all use the same geometry layout"): a row that can survive
165
+ * collapse has an icon; one that cannot is not a row, it is a LABEL. A
166
+ * top-level node with no icon renders this — the eyebrow over its rows — and
167
+ * it is hidden with the labels when the rail narrows, so the rail reads as one
168
+ * thing narrowing (the brand sidebar's model) instead of rows vanishing. */
169
+ .kol-sidenav-section {
170
+ display: block;
171
+ padding: 0.5rem 1.5rem 0.25rem 1.5rem;
172
+ }
163
173
  .kol-sidenav-footer { padding-inline: 1.5rem 1rem; }
164
174
 
165
175
  /* The aside's OWN box (SideNavMobilePosition, kol-website 2026-08-25) — the
@@ -198,6 +208,7 @@ body {
198
208
  * no labels; the theme slot and footer centre; the footer wordmark swaps for
199
209
  * its initial — both spans live in one <a>, so the link survives either state. */
200
210
  :root[data-sidenav="collapsed"] .kol-sidenav-hop-caret,
211
+ :root[data-sidenav="collapsed"] .kol-sidenav-section,
201
212
  :root[data-sidenav="collapsed"] .kol-sidenav-list { display: none; }
202
213
  :root[data-sidenav="collapsed"] .kol-sidenav-theme-slot { padding-inline: 0; justify-content: center; }
203
214
  :root[data-sidenav="collapsed"] .kol-sidenav-footer { padding-inline: 0; justify-content: center; }
@@ -259,6 +270,7 @@ body {
259
270
  .kol-sidenav-hops { padding: 0 0 var(--kol-spacing-4); margin-bottom: var(--kol-spacing-4); }
260
271
  .kol-sidenav-hop { padding: var(--kol-spacing-2) 0; justify-content: center; gap: 0; }
261
272
  .kol-sidenav-hop-label { display: none; }
273
+ .kol-sidenav-section { display: none; }
262
274
  .kol-sidenav-body { display: none; }
263
275
  /* forced narrow / drawer modes own the rail below lg — no resize here */
264
276
  .kol-sidenav-grab { display: none; }
@@ -280,6 +292,7 @@ body {
280
292
  /* Restore expanded labels + body inside the open drawer (override the rail rules above). */
281
293
  .kol-sidenav.is-drawer-open .kol-sidenav-hop { padding: 8px 0; justify-content: flex-start; gap: 12px; }
282
294
  .kol-sidenav.is-drawer-open .kol-sidenav-hop-label { display: inline; }
295
+ .kol-sidenav.is-drawer-open .kol-sidenav-section { display: block; }
283
296
  .kol-sidenav.is-drawer-open .kol-sidenav-body { display: block; }
284
297
  .kol-brand-layout[data-drawer-open="true"] .kol-sidenav-backdrop {
285
298
  opacity: 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-framework",
3
- "version": "0.29.0",
3
+ "version": "0.30.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.114.0",
24
- "@kolkrabbi/kol-icons": "^0.23.0"
23
+ "@kolkrabbi/kol-component": "^0.118.1",
24
+ "@kolkrabbi/kol-icons": "^0.24.0"
25
25
  },
26
26
  "files": [
27
27
  "src",
package/src/SideNav.jsx CHANGED
@@ -34,6 +34,28 @@ import useDragResize from './useDragResize'
34
34
  * navTree: [{ id, label, icon, to?, pages?: [{ label, to } | { label, children }] }]
35
35
  * (legacy `children` on a category is read as `pages`; section anchors are not.)
36
36
  *
37
+ * WORKSPACE RAILS (WorkspaceSidebarGeometry, kol-fxr 2026-08-27 — user: "I see
38
+ * these 3 as very similar things … I would rather all use the same geometry
39
+ * layout"; three forks, ~1,370 lines, wearing this component's classes):
40
+ *
41
+ * ACTION LEAVES — `{ label, onSelect, active? }` beside `{ label, to }`, at
42
+ * any depth and as a category (`{ id, label, icon, onSelect, active? }`): a
43
+ * rail that DISPATCHES (labs swaps an effect, monitor picks a module, mirror
44
+ * selects a surface) instead of navigating. A <button>, lit by `active`;
45
+ * never a route, so no history is walked (labs' `?preset=` replace-not-push
46
+ * stays what it is).
47
+ *
48
+ * ROUTER-AGNOSTIC — pass `currentPath` and the component never touches
49
+ * react-router: route leaves render `<a href>` and call `onNavigate(event,
50
+ * to)` (kol-shell's contract); a router-free app can mount it. Without
51
+ * `currentPath` it is the NavLink rail it always was.
52
+ *
53
+ * THE COLLAPSE RULE — a row that can survive collapse HAS AN ICON; one that
54
+ * cannot is not a row, it is a LABEL. A top-level node with no `icon` (and no
55
+ * `to` / `onSelect`) renders `.kol-sidenav-section` — the eyebrow voice, hidden
56
+ * on collapse — over its `pages`; the icon rows beneath stay, so the rail
57
+ * reads as one thing narrowing, the brand sidebar's model.
58
+ *
37
59
  * Collapse: the pill-marked grab edge is THE single control (user build
38
60
  * order 2026-08-09, completing SideNavGrabResize — the chip Button is gone
39
61
  * in both states). Click toggles expand↔collapse, drag resizes with
@@ -64,7 +86,7 @@ const pageActiveCls = `${pageBase} is-active`
64
86
  /* A category's rows: route leaves (`to`) and route groups (`children`).
65
87
  * `#anchor` leaves (`id` only) are dropped — that layer died with the brand
66
88
  * app's 2026-08-01 ruling and is not reopened here. */
67
- const rowsOf = (nodes) => nodes.filter((c) => c.to || c.children)
89
+ const rowsOf = (nodes) => nodes.filter((c) => c.to || c.children || c.onSelect)
68
90
  const pagesOf = (cat) => rowsOf(cat.pages ?? cat.children ?? [])
69
91
  /* Every route leaf under a set of rows, through any depth of groups. */
70
92
  const leavesOf = (rows) => rows.flatMap((r) => (r.children ? leavesOf(rowsOf(r.children)) : [r]))
@@ -73,7 +95,21 @@ const leavesOf = (rows) => rows.flatMap((r) => (r.children ? leavesOf(rowsOf(r.c
73
95
  * this layer. Depth 0 carries no indent so a flat tree renders unchanged. */
74
96
  const indentOf = (depth) => `calc(3.5rem + ${depth} * var(--kol-spacing-3))`
75
97
 
76
- export default function SideNav({
98
+ /* Router-agnostic seam: with `currentPath` the rail never calls react-router
99
+ * (a hook cannot be conditional, so the router read lives in its own wrapper
100
+ * that mounts only when the prop is absent). */
101
+ export default function SideNav(props) {
102
+ if (props.currentPath != null) return <SideNavInner {...props} pathname={props.currentPath} router={false} />
103
+ return <RouterSideNav {...props} />
104
+ }
105
+ function RouterSideNav(props) {
106
+ const { pathname } = useLocation()
107
+ return <SideNavInner {...props} pathname={pathname} router />
108
+ }
109
+
110
+ function SideNavInner({
111
+ pathname,
112
+ router,
77
113
  drawerOpen = false,
78
114
  onCloseDrawer,
79
115
  navTree = [],
@@ -87,19 +123,32 @@ export default function SideNav({
87
123
  * choices. */
88
124
  hairline = false,
89
125
  }) {
90
- const { pathname } = useLocation()
91
126
  const isPageActive = (to) => (isActive ? isActive(to) : pathname === to)
92
- const isActiveCat = (cat) => leavesOf(pagesOf(cat)).some((p) => isPageActive(p.to))
127
+ /* a leaf is lit by its route, or — an action leaf — by its own `active` */
128
+ const isLeafActive = (leaf) => (leaf.onSelect ? !!leaf.active : isPageActive(leaf.to))
129
+ const isActiveCat = (cat) => (cat.onSelect ? !!cat.active : leavesOf(pagesOf(cat)).some(isLeafActive))
93
130
 
94
131
  /* Every navigation closes the mobile drawer — the prop sat accepted-but-
95
132
  * unread until the kol-website session flagged it (2026-08-09) — and, from
96
133
  * the collapsed rail, expands the sidebar ("pressing any icon would expand
97
134
  * the sidebar", same ruling as the chip removal). */
98
- const handleNavigate = (e) => {
99
- onNavigate?.(e)
135
+ const handleNavigate = (e, to) => {
136
+ onNavigate?.(e, to)
137
+ onCloseDrawer?.()
138
+ if (collapsed) toggleCollapsed()
139
+ }
140
+ /* an action leaf dispatches — same drawer / rail behaviour as a navigation */
141
+ const handleSelect = (e, row) => {
142
+ row.onSelect(e)
100
143
  onCloseDrawer?.()
101
144
  if (collapsed) toggleCollapsed()
102
145
  }
146
+ /* the route leaf: NavLink under a router, `<a href>` on the agnostic seam */
147
+ const RouteLeaf = ({ to, className, style, children }) => router ? (
148
+ <NavLink to={to} end style={style} className={({ isActive: navActive }) => className(isActive ? isActive(to) : navActive)} onClick={(e) => handleNavigate(e, to)}>{children}</NavLink>
149
+ ) : (
150
+ <a href={to} style={style} className={className(isPageActive(to))} onClick={(e) => { e.preventDefault(); handleNavigate(e, to) }}>{children}</a>
151
+ )
103
152
 
104
153
  /* Which categories are disclosed. INDEPENDENT per category (elder ruling
105
154
  * 2026-08-01) — arriving somewhere must not silently close wherever you
@@ -123,7 +172,7 @@ export default function SideNav({
123
172
  * up the way a category does — when one of its leaves is the current route. */
124
173
  const renderRows = (rows, depth) => rows.map((row, i) => {
125
174
  if (row.children) {
126
- const lit = leavesOf(rowsOf(row.children)).some((l) => isPageActive(l.to))
175
+ const lit = leavesOf(rowsOf(row.children)).some(isLeafActive)
127
176
  return (
128
177
  <li key={row.id ?? `${row.label}-${i}`}>
129
178
  <div
@@ -136,18 +185,28 @@ export default function SideNav({
136
185
  </li>
137
186
  )
138
187
  }
188
+ /* the dot keeps its 0.875rem lead on the text at every depth */
189
+ const leafStyle = depth ? { paddingLeft: indentOf(depth), '--kol-sidenav-dot-left': `calc(2.625rem + ${depth} * var(--kol-spacing-3))` } : pageStyle
190
+ if (row.onSelect) {
191
+ return (
192
+ <li key={row.id ?? `${row.label}-${i}`}>
193
+ <button
194
+ type="button"
195
+ style={leafStyle}
196
+ className={`${row.active ? pageActiveCls : pageCls} w-full bg-transparent border-0 cursor-pointer text-left truncate`}
197
+ aria-pressed={!!row.active}
198
+ onClick={(e) => handleSelect(e, row)}
199
+ >
200
+ {row.label}
201
+ </button>
202
+ </li>
203
+ )
204
+ }
139
205
  return (
140
206
  <li key={row.to}>
141
- <NavLink
142
- to={row.to}
143
- end
144
- /* the dot keeps its 0.875rem lead on the text at every depth */
145
- style={depth ? { paddingLeft: indentOf(depth), '--kol-sidenav-dot-left': `calc(2.625rem + ${depth} * var(--kol-spacing-3))` } : pageStyle}
146
- className={({ isActive: navActive }) => ((isActive ? isActive(row.to) : navActive) ? pageActiveCls : pageCls)}
147
- onClick={handleNavigate}
148
- >
207
+ <RouteLeaf to={row.to} style={leafStyle} className={(active) => (active ? pageActiveCls : pageCls)}>
149
208
  {row.label}
150
- </NavLink>
209
+ </RouteLeaf>
151
210
  </li>
152
211
  )
153
212
  })
@@ -170,10 +229,22 @@ export default function SideNav({
170
229
  <div className="kol-sidenav-scroll flex-1 flex flex-col justify-between overflow-y-auto pt-4 pb-4 [scrollbar-width:thin]">
171
230
  <nav aria-label="Sections">
172
231
  <ul className="kol-sidenav-tree flex flex-col gap-[2px]">
173
- {navTree.map((cat) => {
232
+ {navTree.map((cat, ci) => {
174
233
  const pages = pagesOf(cat)
175
234
  const isOpen = openCats.has(cat.id)
176
235
  const isLink = !pages.length && cat.to
236
+ const isAction = !pages.length && !cat.to && cat.onSelect
237
+ /* THE COLLAPSE RULE: no icon, no route, no action = a LABEL over
238
+ * its rows, not a row — `.kol-sidenav-section` hides on collapse
239
+ * and the icon rows beneath it stay */
240
+ if (!cat.icon && !cat.to && !cat.onSelect) {
241
+ return (
242
+ <li key={cat.id ?? `${cat.label}-${ci}`} className="relative">
243
+ <div className="kol-sidenav-section kol-eyebrow text-strong">{cat.label}</div>
244
+ {pages.length > 0 && <ul className="kol-sidenav-list">{renderRows(pages, 0)}</ul>}
245
+ </li>
246
+ )
247
+ }
177
248
  /* Box utilities deliberately absent — the hop's layout lives in
178
249
  * .kol-sidenav-hop (kol-framework.css): a utility here would
179
250
  * outrank the collapsed-state overrides (2026-08-09 defect). */
@@ -187,15 +258,20 @@ export default function SideNav({
187
258
  definition, and an <a href> would promise a page that does
188
259
  not exist. Clicking it discloses its pages. */
189
260
  const head = isLink ? (
190
- <NavLink
191
- to={cat.to}
192
- end
193
- className={({ isActive: navActive }) => `${rowCls}${(isActive ? isActive(cat.to) : navActive) ? ' is-active' : ''}`}
194
- onClick={handleNavigate}
261
+ <RouteLeaf to={cat.to} className={(active) => `${rowCls}${active ? ' is-active' : ''}`}>
262
+ {glyph}
263
+ <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
264
+ </RouteLeaf>
265
+ ) : isAction ? (
266
+ <button
267
+ type="button"
268
+ className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${cat.active ? ' is-active' : ''}`}
269
+ aria-pressed={!!cat.active}
270
+ onClick={(e) => handleSelect(e, cat)}
195
271
  >
196
272
  {glyph}
197
273
  <span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
198
- </NavLink>
274
+ </button>
199
275
  ) : (
200
276
  <button
201
277
  type="button"
@@ -235,7 +311,7 @@ export default function SideNav({
235
311
  </Tooltip>
236
312
  ) : head}
237
313
 
238
- {!isLink && isOpen && (
314
+ {!isLink && !isAction && isOpen && (
239
315
  <ul className="kol-sidenav-list">{renderRows(pages, 0)}</ul>
240
316
  )}
241
317
  </li>