@kolkrabbi/kol-framework 0.51.0 → 0.53.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 +5 -5
- package/src/SideNav.jsx +30 -464
- package/src/loaderApp.js +8 -3
- package/src/vite.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "Site shell and page layout",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,16 +14,16 @@
|
|
|
14
14
|
"./kol-brand-color.css": "./kol-brand-color.css"
|
|
15
15
|
},
|
|
16
16
|
"peerDependencies": {
|
|
17
|
-
"@kolkrabbi/kol-component": ">=0.
|
|
17
|
+
"@kolkrabbi/kol-component": ">=0.283.0",
|
|
18
18
|
"@kolkrabbi/kol-icons": ">=0.18.0",
|
|
19
|
-
"@kolkrabbi/kol-theme": ">=0.
|
|
19
|
+
"@kolkrabbi/kol-theme": ">=0.179.0",
|
|
20
20
|
"react": "^18.3.0 || ^19.0.0",
|
|
21
21
|
"react-dom": "^18.3.0 || ^19.0.0",
|
|
22
22
|
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@kolkrabbi/kol-component": "^0.
|
|
26
|
-
"@kolkrabbi/kol-icons": "^0.
|
|
25
|
+
"@kolkrabbi/kol-component": "^0.284.0",
|
|
26
|
+
"@kolkrabbi/kol-icons": "^0.41.0"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"src",
|
package/src/SideNav.jsx
CHANGED
|
@@ -1,479 +1,45 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { createPortal } from 'react-dom'
|
|
4
|
-
import { Icon } from '@kolkrabbi/kol-icons'
|
|
5
|
-
import { Tooltip } from '@kolkrabbi/kol-component'
|
|
1
|
+
import { useHref, useLocation, useNavigate } from 'react-router-dom'
|
|
2
|
+
import { NavRail } from '@kolkrabbi/kol-component'
|
|
6
3
|
import ThemeToggle from './ThemeToggle'
|
|
7
|
-
import { useDragResize } from '@kolkrabbi/kol-component'
|
|
8
4
|
|
|
9
5
|
/**
|
|
10
|
-
* SideNav — the
|
|
6
|
+
* SideNav — `NavRail variant="site"` under its old name (2026-10-22, the one nav rail).
|
|
11
7
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* better version in kol-website apps:brand") — the brand app's evolved fork
|
|
15
|
-
* folded back into the package, generic seams kept.
|
|
8
|
+
* @deprecated — use `NavRail variant="site"` from `@kolkrabbi/kol-component`. This name is on the
|
|
9
|
+
* retirement ledger; it stays so every site keeps resolving.
|
|
16
10
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* disclose.
|
|
11
|
+
* The drawing moved to kol-component class-for-class (`organisms/NavRailSite.jsx`; the 479-line
|
|
12
|
+
* original is in kol-ds-ui `_tmp/2026-10-22-rail-to-component/`). Two things stay here because
|
|
13
|
+
* kol-component cannot carry them: the ROUTER read and the THEME TOGGLE.
|
|
21
14
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* kol-studio lost two groups and six routes from its rail on adopting 0.23.0.
|
|
26
|
-
* User, 2026-08-26, verbatim: "ok that is such a problem, sometimes Im
|
|
27
|
-
* talking about a specific thing and it gets applied as a literal fit-all
|
|
28
|
-
* rule, which isnt the case. its very hard to work in such uncertainty."
|
|
29
|
-
* Re-scoped (sidenav-nested-groups, 0.24.0): nested ROUTE groups are
|
|
30
|
-
* supported. A `{ label, children }` node inside a category renders as a
|
|
31
|
-
* non-routing group header with its rows indented one step under it,
|
|
32
|
-
* recursively. The tree shape is the opt-in — there is no prop — and a tree
|
|
33
|
-
* with no group nodes renders byte-for-byte as before.
|
|
15
|
+
* Router: without `currentPath` this reads react-router (`useLocation`) and navigates with
|
|
16
|
+
* `useNavigate` — the NavLink rail it always was, `onNavigate(event, to)` still called first. With
|
|
17
|
+
* `currentPath` the router is never touched (kol-shell's agnostic contract).
|
|
34
18
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* WORKSPACE RAILS (WorkspaceSidebarGeometry, kol-fxr 2026-08-27 — user: "I see
|
|
39
|
-
* these 3 as very similar things … I would rather all use the same geometry
|
|
40
|
-
* layout"; three forks, ~1,370 lines, wearing this component's classes):
|
|
41
|
-
*
|
|
42
|
-
* ACTION LEAVES — `{ label, onSelect, active? }` beside `{ label, to }`, at
|
|
43
|
-
* any depth and as a category (`{ id, label, icon, onSelect, active? }`): a
|
|
44
|
-
* rail that DISPATCHES (labs swaps an effect, monitor picks a module, mirror
|
|
45
|
-
* selects a surface) instead of navigating. A <button>, lit by `active`;
|
|
46
|
-
* never a route, so no history is walked (labs' `?preset=` replace-not-push
|
|
47
|
-
* stays what it is).
|
|
48
|
-
*
|
|
49
|
-
* ROUTER-AGNOSTIC — pass `currentPath` and the component never touches
|
|
50
|
-
* react-router: route leaves render `<a href>` and call `onNavigate(event,
|
|
51
|
-
* to)` (kol-shell's contract); a router-free app can mount it. Without
|
|
52
|
-
* `currentPath` it is the NavLink rail it always was.
|
|
53
|
-
*
|
|
54
|
-
* THE COLLAPSE RULE — a row that can survive collapse HAS AN ICON; one that
|
|
55
|
-
* cannot is not a row, it is a LABEL. A top-level node with no `icon` (and no
|
|
56
|
-
* `to` / `onSelect`) renders `.kol-sidenav-section` — the eyebrow voice, hidden
|
|
57
|
-
* on collapse — over its `pages`; the icon rows beneath stay, so the rail
|
|
58
|
-
* reads as one thing narrowing, the brand sidebar's model.
|
|
59
|
-
*
|
|
60
|
-
* Collapse: the pill-marked grab edge is THE single control (user build
|
|
61
|
-
* order 2026-08-09, completing SideNavGrabResize — the chip Button is gone
|
|
62
|
-
* in both states). Click toggles expand↔collapse, drag resizes with
|
|
63
|
-
* snap-collapse and snap-to-default, keyboard on the separator: arrows /
|
|
64
|
-
* Home / Enter / Space. One contract: `:root[data-sidenav="collapsed"]` +
|
|
65
|
-
* the `kol-sidenav` storage schema, owned by useDragResize. Collapsed rows
|
|
66
|
-
* get a DS Tooltip — the glyph is otherwise the only thing naming them.
|
|
67
|
-
*
|
|
68
|
-
* THE SHELL RAIL IS THIS COMPONENT (RailSideNavPixelParity, kol-fxr 2026-08-28
|
|
69
|
-
* — user, on the brand sidebar's drag: "same component both states super
|
|
70
|
-
* nice"): kol-shell's `AppShell` mounts a SideNav — collapsed by default, the
|
|
71
|
-
* drag opens it — so an app rail and the brand sidebar are one geometry to the
|
|
72
|
-
* pixel (fxr measured 48 vs 56 wide, 20 vs 16px glyphs, 40 vs 38 pitch, first
|
|
73
|
-
* glyph at y 76 vs 26 between the old NavRail and this rail collapsed). The
|
|
74
|
-
* seams the shell needed, all opt-in, a tree with none of them renders as before:
|
|
75
|
-
* `header` a node ABOVE the tree, outside the scroll region, in the
|
|
76
|
-
* hop's geometry (`.kol-sidenav-header`, kol-framework.css)
|
|
77
|
-
* — the app's logomark (RailLogomarkAtTop, 2026-08-28 —
|
|
78
|
-
* user: "why did you move the logo from top to bottom?").
|
|
79
|
-
* Give it a `.kol-sidenav-hop-icon` span (20px mark on the
|
|
80
|
-
* icon column) and, optionally, a `.kol-sidenav-hop-label`;
|
|
81
|
-
* both follow the collapse. Same node in every consumer =
|
|
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.
|
|
95
|
-
* `bottomItems` top-level nodes pinned BELOW the theme slot, above the
|
|
96
|
-
* footer (the shell pins theme → Settings; a Settings row in
|
|
97
|
-
* the tree landed above the theme instead — "same rail, two
|
|
98
|
-
* bottoms")
|
|
99
|
-
* `footer` the footer box's content: undefined = the Kolkrabbi link
|
|
100
|
-
* (K / wordmark, as ever); `false` = no footer; a node =
|
|
101
|
-
* that node (the shell's logomark → '/')
|
|
102
|
-
* `themeToggle` `false` drops the theme slot (default `true`)
|
|
103
|
-
* `iconComponent` the hop glyph's renderer, kol-shell's seam — `(props:
|
|
104
|
-
* { name, size }) => element`; default the DS `Icon`
|
|
105
|
-
* `expandOnSelect` `false` keeps the rail collapsed when a row navigates or
|
|
106
|
-
* dispatches — an app rail stays a rail. Default `true`, the
|
|
107
|
-
* brand ruling ("pressing any icon would expand the sidebar")
|
|
108
|
-
* `defaultCollapsed` boots collapsed when nothing is stored (useDragResize)
|
|
109
|
-
* `background` (2026-08-09, prop): `true` (default) paints the rail surface;
|
|
110
|
-
* `false` renders chromeless so the rail can float over a consumer's media
|
|
111
|
-
* (the brand-hero model — the consumer owns the plane). `hairline` (2026-08-26,
|
|
112
|
-
* default `false`) paints the right-edge `border-r border-fg-08` — split out
|
|
113
|
-
* of `background` so the line is a choice of its own.
|
|
19
|
+
* Theme: `themeToggle` (default `true`) renders kol-framework's `ThemeToggle` in the rail's slot;
|
|
20
|
+
* `false` drops it.
|
|
114
21
|
*/
|
|
115
|
-
|
|
116
|
-
const pageBase =
|
|
117
|
-
'kol-sidenav-link kol-helper-10 block relative py-[4px] pl-14 pr-6 no-underline transition-colors duration-150'
|
|
118
|
-
|
|
119
|
-
/* The active-dot pseudo-element is positioned by `--kol-sidenav-dot-left`
|
|
120
|
-
* (kol-components-atoms.css). The page row indents 3.5rem (pl-14); the dot
|
|
121
|
-
* sits 0.875rem inside that, in the gutter the icon column leaves. */
|
|
122
|
-
const pageStyle = { '--kol-sidenav-dot-left': '2.625rem' }
|
|
123
|
-
/* Category and page must not share a stop: category `emphasis`, page `strong`
|
|
124
|
-
* — two rungs of one ladder, so the tree reads as hierarchy (elder ruling). */
|
|
125
|
-
const pageCls = `${pageBase} text-strong hover:text-emphasis`
|
|
126
|
-
const pageActiveCls = `${pageBase} is-active`
|
|
127
|
-
|
|
128
|
-
/* A category's rows: route leaves (`to`) and route groups (`children`).
|
|
129
|
-
* `#anchor` leaves (`id` only) are dropped — that layer died with the brand
|
|
130
|
-
* app's 2026-08-01 ruling and is not reopened here. */
|
|
131
|
-
const rowsOf = (nodes) => nodes.filter((c) => c.to || c.children || c.onSelect)
|
|
132
|
-
const pagesOf = (cat) => rowsOf(cat.pages ?? cat.children ?? [])
|
|
133
|
-
/* Every route leaf under a set of rows, through any depth of groups. */
|
|
134
|
-
const leavesOf = (rows) => rows.flatMap((r) => (r.children ? leavesOf(rowsOf(r.children)) : [r]))
|
|
135
|
-
/* A nested row sits one --kol-spacing-3 step further in per depth. Inline,
|
|
136
|
-
* not a rule: `pl-14` rides the leaf as a utility and outranks anything in
|
|
137
|
-
* this layer. Depth 0 carries no indent so a flat tree renders unchanged. */
|
|
138
|
-
const indentOf = (depth) => `calc(3.5rem + ${depth} * var(--kol-spacing-3))`
|
|
139
|
-
|
|
140
|
-
/* Router-agnostic seam: with `currentPath` the rail never calls react-router
|
|
141
|
-
* (a hook cannot be conditional, so the router read lives in its own wrapper
|
|
142
|
-
* that mounts only when the prop is absent). */
|
|
143
22
|
export default function SideNav(props) {
|
|
144
|
-
if (props.currentPath != null) return <
|
|
23
|
+
if (props.currentPath != null) return <NavRail variant="site" {...props} themeToggle={themeSlot(props.themeToggle)} />
|
|
145
24
|
return <RouterSideNav {...props} />
|
|
146
25
|
}
|
|
147
|
-
function RouterSideNav(props) {
|
|
148
|
-
const { pathname } = useLocation()
|
|
149
|
-
return <SideNavInner {...props} pathname={pathname} router />
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function SideNavInner({
|
|
153
|
-
pathname,
|
|
154
|
-
router,
|
|
155
|
-
drawerOpen = false,
|
|
156
|
-
onCloseDrawer,
|
|
157
|
-
navTree = [],
|
|
158
|
-
onNavigate,
|
|
159
|
-
isActive,
|
|
160
|
-
background = true,
|
|
161
|
-
/* the right-edge hairline is OPT-IN (sidenav-rail-hairline, kol-studio
|
|
162
|
-
* 2026-08-26 — user: the footer's border-t is right, the border-r is wrong
|
|
163
|
-
* as a default). It used to ride `background` — two decisions under one
|
|
164
|
-
* name; a rail floating over media and a rail without a line are different
|
|
165
|
-
* choices. */
|
|
166
|
-
hairline = false,
|
|
167
|
-
header,
|
|
168
|
-
bottomItems = [],
|
|
169
|
-
footer,
|
|
170
|
-
themeToggle = true,
|
|
171
|
-
iconComponent,
|
|
172
|
-
expandOnSelect = true,
|
|
173
|
-
defaultCollapsed = false,
|
|
174
|
-
}) {
|
|
175
|
-
const Glyph = iconComponent ?? Icon
|
|
176
|
-
const isPageActive = (to) => (isActive ? isActive(to) : pathname === to)
|
|
177
|
-
/* a leaf is lit by its route, or — an action leaf — by its own `active` */
|
|
178
|
-
const isLeafActive = (leaf) => (leaf.onSelect ? !!leaf.active : isPageActive(leaf.to))
|
|
179
|
-
const isActiveCat = (cat) => (cat.onSelect ? !!cat.active : leavesOf(pagesOf(cat)).some(isLeafActive))
|
|
180
|
-
|
|
181
|
-
/* Every navigation closes the mobile drawer — the prop sat accepted-but-
|
|
182
|
-
* unread until the kol-website session flagged it (2026-08-09) — and, from
|
|
183
|
-
* the collapsed rail, expands the sidebar ("pressing any icon would expand
|
|
184
|
-
* the sidebar", same ruling as the chip removal). */
|
|
185
|
-
const handleNavigate = (e, to) => {
|
|
186
|
-
onNavigate?.(e, to)
|
|
187
|
-
onCloseDrawer?.()
|
|
188
|
-
setOpenPanel(null)
|
|
189
|
-
if (collapsed && expandOnSelect) toggleCollapsed()
|
|
190
|
-
}
|
|
191
|
-
/* an action leaf dispatches — same drawer / rail behaviour as a navigation */
|
|
192
|
-
const handleSelect = (e, row) => {
|
|
193
|
-
row.onSelect(e)
|
|
194
|
-
onCloseDrawer?.()
|
|
195
|
-
setOpenPanel(null)
|
|
196
|
-
if (collapsed && expandOnSelect) toggleCollapsed()
|
|
197
|
-
}
|
|
198
|
-
/* the route leaf: NavLink under a router, `<a href>` on the agnostic seam */
|
|
199
|
-
const RouteLeaf = ({ to, className, style, children }) => router ? (
|
|
200
|
-
<NavLink to={to} end style={style} className={({ isActive: navActive }) => className(isActive ? isActive(to) : navActive)} onClick={(e) => handleNavigate(e, to)}>{children}</NavLink>
|
|
201
|
-
) : (
|
|
202
|
-
<a href={to} style={style} className={className(isPageActive(to))} onClick={(e) => { e.preventDefault(); handleNavigate(e, to) }}>{children}</a>
|
|
203
|
-
)
|
|
204
|
-
|
|
205
|
-
/* Which categories are disclosed. INDEPENDENT per category (elder ruling
|
|
206
|
-
* 2026-08-01) — arriving somewhere must not silently close wherever you
|
|
207
|
-
* were. Loads closed, in-memory only (elder ruling 2026-08-06). */
|
|
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)
|
|
212
|
-
const toggleCat = (id) => setOpenCats((prev) => {
|
|
213
|
-
const next = new Set(prev)
|
|
214
|
-
next.has(id) ? next.delete(id) : next.add(id)
|
|
215
|
-
return next
|
|
216
|
-
})
|
|
217
|
-
const discloseCat = (id) => setOpenCats((prev) => new Set(prev).add(id))
|
|
218
|
-
|
|
219
|
-
/* One collapse contract, two gestures — the hook owns the stamp, the
|
|
220
|
-
* storage schema and the state; the chip Button and the grab edge both
|
|
221
|
-
* drive it. */
|
|
222
|
-
const asideRef = useRef(null)
|
|
223
|
-
const { collapsed, toggleCollapsed, grabProps } = useDragResize(asideRef, { defaultCollapsed })
|
|
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
|
-
|
|
248
|
-
/* Rows under a category: a leaf is a NavLink; a group is a non-routing
|
|
249
|
-
* header over its own rows, one step further in (recursive). A group lights
|
|
250
|
-
* up the way a category does — when one of its leaves is the current route. */
|
|
251
|
-
const renderRows = (rows, depth) => rows.map((row, i) => {
|
|
252
|
-
if (row.children) {
|
|
253
|
-
const lit = leavesOf(rowsOf(row.children)).some(isLeafActive)
|
|
254
|
-
return (
|
|
255
|
-
<li key={row.id ?? `${row.label}-${i}`}>
|
|
256
|
-
<div
|
|
257
|
-
className={`kol-sidenav-group kol-helper-10${lit ? ' text-emphasis' : ' text-subtle'}`}
|
|
258
|
-
style={depth ? { paddingLeft: indentOf(depth) } : undefined}
|
|
259
|
-
>
|
|
260
|
-
{row.label}
|
|
261
|
-
</div>
|
|
262
|
-
<ul className="kol-sidenav-list">{renderRows(rowsOf(row.children), depth + 1)}</ul>
|
|
263
|
-
</li>
|
|
264
|
-
)
|
|
265
|
-
}
|
|
266
|
-
/* the dot keeps its 0.875rem lead on the text at every depth */
|
|
267
|
-
const leafStyle = depth ? { paddingLeft: indentOf(depth), '--kol-sidenav-dot-left': `calc(2.625rem + ${depth} * var(--kol-spacing-3))` } : pageStyle
|
|
268
|
-
if (row.onSelect) {
|
|
269
|
-
return (
|
|
270
|
-
<li key={row.id ?? `${row.label}-${i}`}>
|
|
271
|
-
<button
|
|
272
|
-
type="button"
|
|
273
|
-
style={leafStyle}
|
|
274
|
-
className={`${row.active ? pageActiveCls : pageCls} w-full bg-transparent border-0 cursor-pointer text-left truncate`}
|
|
275
|
-
aria-pressed={!!row.active}
|
|
276
|
-
onClick={(e) => handleSelect(e, row)}
|
|
277
|
-
>
|
|
278
|
-
{row.label}
|
|
279
|
-
</button>
|
|
280
|
-
</li>
|
|
281
|
-
)
|
|
282
|
-
}
|
|
283
|
-
return (
|
|
284
|
-
<li key={row.to}>
|
|
285
|
-
<RouteLeaf to={row.to} style={leafStyle} className={(active) => (active ? pageActiveCls : pageCls)}>
|
|
286
|
-
{row.label}
|
|
287
|
-
</RouteLeaf>
|
|
288
|
-
</li>
|
|
289
|
-
)
|
|
290
|
-
})
|
|
291
|
-
|
|
292
|
-
/* one renderer for the tree and the pinned rows (RailSideNavPixelParity) */
|
|
293
|
-
const renderCat = (cat, ci) => {
|
|
294
|
-
const pages = pagesOf(cat)
|
|
295
|
-
const isOpen = openCats.has(cat.id)
|
|
296
|
-
const isLink = !pages.length && cat.to
|
|
297
|
-
const isPanel = !pages.length && !cat.to && !!cat.panel
|
|
298
|
-
const isAction = !pages.length && !cat.to && !isPanel && cat.onSelect
|
|
299
|
-
/* THE COLLAPSE RULE: no icon, no route, no action = a LABEL over
|
|
300
|
-
* its rows, not a row — `.kol-sidenav-section` hides on collapse
|
|
301
|
-
* and the icon rows beneath it stay */
|
|
302
|
-
if (!cat.icon && !cat.to && !cat.onSelect && !cat.panel) {
|
|
303
|
-
return (
|
|
304
|
-
<li key={cat.id ?? `${cat.label}-${ci}`} className="relative">
|
|
305
|
-
<div className="kol-sidenav-section kol-eyebrow text-strong">{cat.label}</div>
|
|
306
|
-
{pages.length > 0 && <ul className="kol-sidenav-list">{renderRows(pages, 0)}</ul>}
|
|
307
|
-
</li>
|
|
308
|
-
)
|
|
309
|
-
}
|
|
310
|
-
/* Box utilities deliberately absent — the hop's layout lives in
|
|
311
|
-
* .kol-sidenav-hop (kol-framework.css): a utility here would
|
|
312
|
-
* outrank the collapsed-state overrides (2026-08-09 defect). */
|
|
313
|
-
const rowCls = 'kol-sidenav-hop kol-helper-12 text-emphasis no-underline'
|
|
314
|
-
const glyph = (
|
|
315
|
-
<span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
|
|
316
|
-
<Glyph name={cat.icon} size={16} />
|
|
317
|
-
</span>
|
|
318
|
-
)
|
|
319
|
-
/* A category is a <button>, not a link — it has no route by
|
|
320
|
-
definition, and an <a href> would promise a page that does
|
|
321
|
-
not exist. Clicking it discloses its pages. */
|
|
322
|
-
const head = isLink ? (
|
|
323
|
-
<RouteLeaf to={cat.to} className={(active) => `${rowCls}${active ? ' is-active' : ''}`}>
|
|
324
|
-
{glyph}
|
|
325
|
-
<span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
|
|
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>
|
|
338
|
-
) : isAction ? (
|
|
339
|
-
<button
|
|
340
|
-
type="button"
|
|
341
|
-
className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${cat.active ? ' is-active' : ''}`}
|
|
342
|
-
aria-pressed={!!cat.active}
|
|
343
|
-
onClick={(e) => handleSelect(e, cat)}
|
|
344
|
-
>
|
|
345
|
-
{glyph}
|
|
346
|
-
<span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
|
|
347
|
-
</button>
|
|
348
|
-
) : (
|
|
349
|
-
<button
|
|
350
|
-
type="button"
|
|
351
|
-
className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${isActiveCat(cat) ? ' is-active' : ''}`}
|
|
352
|
-
aria-expanded={isOpen}
|
|
353
|
-
/* Collapsed rail: the click meant "open this" — expand the
|
|
354
|
-
* rail AND disclose the category, or the tap is wasted on
|
|
355
|
-
* an expand that lands on a closed tree. */
|
|
356
|
-
onClick={() => {
|
|
357
|
-
if (collapsed) {
|
|
358
|
-
toggleCollapsed()
|
|
359
|
-
discloseCat(cat.id)
|
|
360
|
-
} else {
|
|
361
|
-
toggleCat(cat.id)
|
|
362
|
-
}
|
|
363
|
-
}}
|
|
364
|
-
>
|
|
365
|
-
{glyph}
|
|
366
|
-
<span className="kol-sidenav-hop-label flex-1 min-w-0">{cat.label}</span>
|
|
367
|
-
{/* plain wrapper span — Icon stamps inline-flex on its own
|
|
368
|
-
* element, which would outrank the rail's display:none */}
|
|
369
|
-
<span
|
|
370
|
-
className={`kol-sidenav-hop-caret transition-transform duration-150${isOpen ? '' : ' -rotate-90'}`}
|
|
371
|
-
aria-hidden="true"
|
|
372
|
-
>
|
|
373
|
-
<Icon name="chevron-down" size={12} />
|
|
374
|
-
</span>
|
|
375
|
-
</button>
|
|
376
|
-
)
|
|
377
|
-
return (
|
|
378
|
-
<li key={cat.id} className="relative">
|
|
379
|
-
{/* Collapsed rail hides the label — the tooltip is the only
|
|
380
|
-
thing naming the row. Portalled, so overflow can't clip it. */}
|
|
381
|
-
{collapsed ? (
|
|
382
|
-
<Tooltip label={cat.label} placement="right" triggerClassName="block">
|
|
383
|
-
{head}
|
|
384
|
-
</Tooltip>
|
|
385
|
-
) : head}
|
|
386
|
-
|
|
387
|
-
{!isLink && !isAction && !isPanel && isOpen && (
|
|
388
|
-
<ul className="kol-sidenav-list">{renderRows(pages, 0)}</ul>
|
|
389
|
-
)}
|
|
390
|
-
</li>
|
|
391
|
-
)
|
|
392
|
-
}
|
|
393
26
|
|
|
27
|
+
function RouterSideNav({ onNavigate, ...props }) {
|
|
28
|
+
const { pathname } = useLocation()
|
|
29
|
+
const navigate = useNavigate()
|
|
30
|
+
const base = useHref('/').replace(/\/$/, '')
|
|
394
31
|
return (
|
|
395
|
-
<
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
>
|
|
404
|
-
{/* NO chip Button in either state (user build order 2026-08-09 — "MAKE
|
|
405
|
-
* IT"; supersedes the elder A/B): the pill-marked grab edge below is
|
|
406
|
-
* THE single collapse/resize control — click toggles, drag resizes,
|
|
407
|
-
* snap-to-default on release, Home/Enter/Space on the keyboard. */}
|
|
408
|
-
|
|
409
|
-
{header && <div className="kol-sidenav-header">{header}</div>}
|
|
410
|
-
|
|
411
|
-
{/* ONLY the tree scrolls (fxr's re-measure, 2026-08-28): the theme slot
|
|
412
|
-
* and the pinned rows used to sit inside this region, so on a 26-row
|
|
413
|
-
* collapsed tree they scrolled off below the footer — "pinned" only
|
|
414
|
-
* held while the tree was shorter than the rail. They are the block
|
|
415
|
-
* after it now, above the footer, the tree scrolling underneath. */}
|
|
416
|
-
<div className="kol-sidenav-scroll flex-1 overflow-y-auto pt-4 pb-4 [scrollbar-width:thin]">
|
|
417
|
-
<nav aria-label="Sections">
|
|
418
|
-
<ul className="kol-sidenav-tree flex flex-col gap-[2px]">
|
|
419
|
-
{navTree.map(renderCat)}
|
|
420
|
-
</ul>
|
|
421
|
-
</nav>
|
|
422
|
-
</div>
|
|
423
|
-
|
|
424
|
-
{/* hop-bare carries its own gutter padding; collapsed falls back to
|
|
425
|
-
`icon` — a full-width label can't sit in the collapsed rail. */}
|
|
426
|
-
<div className="pb-4">
|
|
427
|
-
{themeToggle && (
|
|
428
|
-
<div className="kol-sidenav-theme-slot flex">
|
|
429
|
-
<ThemeToggle variant={collapsed ? 'icon' : 'hop-bare'} size="md" />
|
|
430
|
-
</div>
|
|
431
|
-
)}
|
|
432
|
-
{/* pinned rows — BELOW the theme, above the footer: the shell's bottom
|
|
433
|
-
* pair (theme → Settings), specified once */}
|
|
434
|
-
{bottomItems.length > 0 && (
|
|
435
|
-
<nav aria-label="Pinned">
|
|
436
|
-
<ul className="kol-sidenav-tree flex flex-col gap-[2px]">{bottomItems.map(renderCat)}</ul>
|
|
437
|
-
</nav>
|
|
438
|
-
)}
|
|
439
|
-
</div>
|
|
440
|
-
|
|
441
|
-
{footer !== false && footer !== null && (
|
|
442
|
-
<div className="kol-sidenav-footer flex items-center h-14 border-t border-fg-08 min-w-0">
|
|
443
|
-
{footer !== undefined ? footer : (
|
|
444
|
-
<a
|
|
445
|
-
href="https://kolkrabbi.io"
|
|
446
|
-
target="_blank"
|
|
447
|
-
rel="noopener"
|
|
448
|
-
className="kol-helper-10 !font-normal no-underline group whitespace-nowrap overflow-hidden text-ellipsis min-w-0"
|
|
449
|
-
>
|
|
450
|
-
{/* Collapsed rail shows the initial alone; the full wordmark
|
|
451
|
-
overflows it. Both live in one <a> so the link survives either
|
|
452
|
-
state (CSS swaps them on the data-sidenav stamp). */}
|
|
453
|
-
<span className="kol-sidenav-footer-mark text-body group-hover:text-emphasis">K</span>
|
|
454
|
-
<span className="kol-sidenav-footer-full">
|
|
455
|
-
<span className="text-body group-hover:text-emphasis">Kolkrabbi Vinnustofa</span>
|
|
456
|
-
<span className="text-meta group-hover:text-emphasis"> · {new Date().getFullYear()}</span>
|
|
457
|
-
</span>
|
|
458
|
-
</a>
|
|
459
|
-
)}
|
|
460
|
-
</div>
|
|
461
|
-
)}
|
|
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
|
-
|
|
474
|
-
{/* Grab edge — width/cursor/focus chrome in kol-framework.css
|
|
475
|
-
* (.kol-sidenav-grab); hidden below lg by the narrow-mode block. */}
|
|
476
|
-
<div className="kol-sidenav-grab absolute top-0 right-0 bottom-0 z-[1]" {...grabProps} />
|
|
477
|
-
</aside>
|
|
32
|
+
<NavRail
|
|
33
|
+
variant="site"
|
|
34
|
+
{...props}
|
|
35
|
+
currentPath={pathname}
|
|
36
|
+
hrefFor={(to) => `${base}${to}`}
|
|
37
|
+
onNavigate={(e, to) => { onNavigate?.(e, to); navigate(to) }}
|
|
38
|
+
themeToggle={themeSlot(props.themeToggle)}
|
|
39
|
+
/>
|
|
478
40
|
)
|
|
479
41
|
}
|
|
42
|
+
|
|
43
|
+
const themeSlot = (on = true) => (on === false ? false : ({ collapsed }) => (
|
|
44
|
+
<ThemeToggle variant={collapsed ? 'icon' : 'hop-bare'} size="md" />
|
|
45
|
+
))
|
package/src/loaderApp.js
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
* hideLoaderApp — the app loader's other half (`loaderApp()` in `@kolkrabbi/kol-framework/vite` writes it into
|
|
3
3
|
* index.html). Call it once React has committed: two frames later the loader fades (240 ms, none under reduced
|
|
4
4
|
* motion — the CSS drops the transition) and leaves the DOM. Safe to call when there is no loader.
|
|
5
|
+
*
|
|
6
|
+
* `minMs` (default 600, LoaderAppMinimumTime — kol-monitor, user ruling 2026-10-22) holds the loader at least
|
|
7
|
+
* that long from navigation start, which is when the static loader first paints: a fast load flashed the mark
|
|
8
|
+
* for a blink, a slow one waits nothing extra. Reduced motion drops the fade, never the minimum. `minMs: 0` is
|
|
9
|
+
* the old behaviour.
|
|
5
10
|
*/
|
|
6
|
-
export function hideLoaderApp() {
|
|
11
|
+
export function hideLoaderApp({ minMs = 600 } = {}) {
|
|
7
12
|
if (typeof document === 'undefined') return
|
|
8
13
|
const el = document.getElementById('kol-loader-app')
|
|
9
14
|
if (!el) return
|
|
10
|
-
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
15
|
+
setTimeout(() => requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
11
16
|
el.classList.add('is-done')
|
|
12
17
|
setTimeout(() => { el.remove(); document.getElementById('kol-loader-app-css')?.remove() }, 300)
|
|
13
|
-
}))
|
|
18
|
+
})), Math.max(0, minMs - performance.now()))
|
|
14
19
|
}
|
package/src/vite.js
CHANGED
|
@@ -34,7 +34,7 @@ export function devFavicon({ label = 'K', ground = '#7C5CFF', ink = '#FFFFFF' }
|
|
|
34
34
|
* owned in DS and consumed by the fxr monitor and mirror? with custom graphic input as prop?"*). The app's mark
|
|
35
35
|
* centred on the theme's ground, pulsing while the bundle downloads. It has to be static HTML in `index.html` —
|
|
36
36
|
* a React component would only appear after the wait it covers — so this plugin writes it there: fxr's loader,
|
|
37
|
-
* once, with the graphic as an option. Call `hideLoaderApp()` (kol-framework) after the first commit.
|
|
37
|
+
* once, with the graphic as an option. Call `hideLoaderApp()` (kol-framework) after the first commit — it holds the loader 600 ms from navigation start (`minMs`).
|
|
38
38
|
*
|
|
39
39
|
* Ground and ink follow the stored `data-theme`, else the system (run `THEME_BOOT_SCRIPT` before it, as every
|
|
40
40
|
* KOL app does). Reduced motion: no pulse, no fade.
|