@kolkrabbi/kol-shell 0.15.0 → 0.16.1
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 +6 -6
- package/src/AppShell.jsx +23 -29
- package/src/NavRail.jsx +224 -83
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "KOL application shell — fixed 48px NavRail + AppShell layout root, PageShell/PageHeader scaffolds, ContentFilters catalog organism, GridCard, SettingsScaffold, WalkthroughPanel, ShortcutsOverlay. App chrome (kol-framework owns site chrome). Nav items, content, shortcuts and settings are consumer-injected. Sits above @kolkrabbi/kol-{theme,component,framework}.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,18 +12,18 @@
|
|
|
12
12
|
"./src/*": "./src/*"
|
|
13
13
|
},
|
|
14
14
|
"peerDependencies": {
|
|
15
|
-
"@kolkrabbi/kol-component": ">=0.
|
|
16
|
-
"@kolkrabbi/kol-framework": ">=0.34.0",
|
|
15
|
+
"@kolkrabbi/kol-component": ">=0.126.0",
|
|
17
16
|
"@kolkrabbi/kol-icons": ">=0.16.0",
|
|
18
|
-
"@kolkrabbi/kol-theme": ">=0.
|
|
17
|
+
"@kolkrabbi/kol-theme": ">=0.84.0",
|
|
18
|
+
"gsap": "^3.13.0",
|
|
19
19
|
"react": "^18.3.0 || ^19.0.0",
|
|
20
20
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@kolkrabbi/kol-component": "^0.
|
|
23
|
+
"@kolkrabbi/kol-component": "^0.126.0",
|
|
24
24
|
"@kolkrabbi/kol-icons": "^0.24.0",
|
|
25
25
|
"@kolkrabbi/kol-framework": "^0.35.0",
|
|
26
|
-
"@kolkrabbi/kol-theme": "^0.
|
|
26
|
+
"@kolkrabbi/kol-theme": "^0.88.0"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"src",
|
package/src/AppShell.jsx
CHANGED
|
@@ -9,21 +9,17 @@ import TouchDeviceOverlay, { useTouchPrimary } from './TouchDeviceOverlay.jsx'
|
|
|
9
9
|
* children and wire `currentPath`/`onNavigate` from your router at the call
|
|
10
10
|
* site.
|
|
11
11
|
*
|
|
12
|
-
* THE RAIL IS
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* theme (kol-mirror's bump, 2026-08-28): the grid, the sidenav's box and
|
|
24
|
-
* collapse rules and its width tokens live there, not in kol-theme — without
|
|
25
|
-
* it the root is `display: block` and the rail is a horizontal bar, with a
|
|
26
|
-
* green build and a clean console.
|
|
12
|
+
* THE RAIL IS FLAT AGAIN (RailFlatGrabOpen, kol-mirror 2026-08-28 — user: "the
|
|
13
|
+
* div structure is super simple ok … it's 1 div parent and everything is just
|
|
14
|
+
* in that container"): 0.13.0 had made it a collapsed kol-framework `SideNav`
|
|
15
|
+
* inside the brand grid; 0.16.0 reverses that. The root is a plain wrapper, the
|
|
16
|
+
* rail is `position: fixed`, and the content is offset by
|
|
17
|
+
* `margin-left: var(--kol-shell-rail-width)` — the ONE variable the rail writes
|
|
18
|
+
* per pointermove during a grab-drag and tweens on release, so the page is
|
|
19
|
+
* pushed live through both. Hidden rail = `0px`.
|
|
20
|
+
*
|
|
21
|
+
* `kol-framework.css` is no longer required by the shell (it was, for one
|
|
22
|
+
* grid rule, 0.13–0.15).
|
|
27
23
|
*
|
|
28
24
|
* @param {Array} props.items nav items `{ icon, path, label }`
|
|
29
25
|
* @param {Array} props.bottomItems items pinned below the theme toggle
|
|
@@ -31,11 +27,11 @@ import TouchDeviceOverlay, { useTouchPrimary } from './TouchDeviceOverlay.jsx'
|
|
|
31
27
|
* @param {string} props.currentPath the router's current pathname
|
|
32
28
|
* @param {Function} props.onNavigate `(path) => void`
|
|
33
29
|
* @param {ElementType} props.iconComponent icon renderer seam (see Button)
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
30
|
+
* NOTE (0.16.0): `settings` and `themeToggle` left with the SideNav-backed rail
|
|
31
|
+
* — the flat rail carries neither. Settings is a `bottomItems` rung whose
|
|
32
|
+
* open/close is the consumer's `onNavigate` (kol-mirror's model: navigating to
|
|
33
|
+
* `/settings` while already there returns to the last page), and the theme
|
|
34
|
+
* toggle lives on the settings page, not in the rail (user, 2026-08-28).
|
|
39
35
|
* @param {string} props.railToggleKey a key that toggles the rail (e.g. '\\') — ignored while typing in a field;
|
|
40
36
|
* the rail comes back on every `currentPath` change (ShellHomeSystem, 2026-08-27)
|
|
41
37
|
* @param {'shell'|'bare'|'overlay'} props.touch the touch-primary policy (default 'shell' = the rail regardless):
|
|
@@ -66,8 +62,6 @@ export default function AppShell({
|
|
|
66
62
|
currentPath,
|
|
67
63
|
onNavigate,
|
|
68
64
|
iconComponent,
|
|
69
|
-
settings,
|
|
70
|
-
themeToggle,
|
|
71
65
|
railToggleKey,
|
|
72
66
|
touch = 'shell',
|
|
73
67
|
appName,
|
|
@@ -120,11 +114,11 @@ export default function AppShell({
|
|
|
120
114
|
return (
|
|
121
115
|
<NavHiddenContext.Provider value={{ navHidden, setNavHidden }}>
|
|
122
116
|
{/* `kol-app-shell` = the app tier: neutral ::selection (kol-theme).
|
|
123
|
-
*
|
|
124
|
-
*
|
|
117
|
+
* A hidden rail zeroes the live width token, so the content's own
|
|
118
|
+
* margin closes with it — one variable, both sides. */}
|
|
125
119
|
<div
|
|
126
|
-
className="kol-app-shell
|
|
127
|
-
style={navHidden ? {
|
|
120
|
+
className="kol-app-shell min-h-dvh bg-surface-primary"
|
|
121
|
+
style={navHidden ? { '--kol-shell-rail-width': '0px' } : undefined}
|
|
128
122
|
>
|
|
129
123
|
{touch === 'overlay' && <TouchDeviceOverlay appName={appName} />}
|
|
130
124
|
{!navHidden && (
|
|
@@ -135,13 +129,13 @@ export default function AppShell({
|
|
|
135
129
|
currentPath={currentPath}
|
|
136
130
|
onNavigate={onNavigate}
|
|
137
131
|
iconComponent={iconComponent}
|
|
138
|
-
settings={settings}
|
|
139
|
-
themeToggle={themeToggle ?? !settings}
|
|
140
132
|
/>
|
|
141
133
|
)}
|
|
142
134
|
{/* THE BACK OF THE BACK — surface-primary, always, in every app; the
|
|
143
135
|
* page paints its wash over it (ShellPageWash). */}
|
|
144
|
-
|
|
136
|
+
{/* the margin IS the rail's live width, so the content is pushed
|
|
137
|
+
* through the drag and the snap (RailFlatGrabOpen) */}
|
|
138
|
+
<div className="bg-surface-primary min-w-0" style={{ marginLeft: `var(--kol-shell-rail-width)`, '--kol-shell-page-wash': pageWash }}>
|
|
145
139
|
{children}
|
|
146
140
|
</div>
|
|
147
141
|
</div>
|
package/src/NavRail.jsx
CHANGED
|
@@ -1,39 +1,203 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import gsap from 'gsap'
|
|
3
|
+
import { Button } from '@kolkrabbi/kol-component'
|
|
4
|
+
import { GRAB } from '@kolkrabbi/kol-component/utilities/motion'
|
|
2
5
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
6
|
import Logomark from './Logomark.jsx'
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
|
-
* NavRail —
|
|
7
|
-
* (
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* (48 vs 56 wide, 20 vs 16px glyphs, 40 vs 38 pitch, first glyph at y 76 vs
|
|
12
|
-
* 26). There is no second component now: this file maps the shell's flat
|
|
13
|
-
* `{ icon, path, label }` items onto the sidenav's tree and mounts it
|
|
14
|
-
* collapsed — the pill-marked grab edge opens it to the brand sidebar, the
|
|
15
|
-
* drag is the transition, and it stays a rail while it navigates
|
|
16
|
-
* (`expandOnSelect={false}`).
|
|
9
|
+
* NavRail — the flat rail. ONE fixed column, every child a direct child of it
|
|
10
|
+
* (RailFlatGrabOpen, kol-mirror 2026-08-28 — user, on the monitor rail he
|
|
11
|
+
* measured it against: "the div structure is super simple ok … it's 1 div
|
|
12
|
+
* parent and everything is just in that container", and on the SideNav-backed
|
|
13
|
+
* rail that had replaced it: it was "impossible" to get this shape out of it).
|
|
17
14
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* 0.13.0's footer placement: "why did you move the logo from top to bottom?
|
|
23
|
-
* … I've never seen that before so you are the first"). Its `title` is the
|
|
24
|
-
* label beside the mark in the open rail; collapsed, the mark alone.
|
|
15
|
+
* This REVERSES RailSideNavPixelParity (0.13.0), which made the rail a collapsed
|
|
16
|
+
* kol-framework `SideNav`. That ticket's pixel argument is settled the other way:
|
|
17
|
+
* the rail does not share pixels with the brand sidebar because it never becomes
|
|
18
|
+
* one. `RailLogomarkAtTop` stands — the mark is at the top in both states.
|
|
25
19
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
20
|
+
* THE GRAB OPENS IT (user: "when you grab it should not move the icons one
|
|
21
|
+
* pixel, only reveal the title, and a chevron to see sub categories" · "when it
|
|
22
|
+
* opens it should push the main content inside"). Closed, every row is the rail
|
|
23
|
+
* minus its gutter — the 32px rung alone shows and the label and chevron exist
|
|
24
|
+
* but are clipped by the row's own width. Open, the clip reveals them. Nothing
|
|
25
|
+
* in the icon column moves between the two states, and the content column
|
|
26
|
+
* follows because AppShell offsets by the same live variable.
|
|
27
|
+
*
|
|
28
|
+
* ONE WIDTH VARIABLE: `--kol-shell-rail-width` on `:root` — this rail is that
|
|
29
|
+
* wide and AppShell's content is offset by it, written per pointermove during a
|
|
30
|
+
* drag and tweened on release, so the page is pushed live through both. A drag
|
|
31
|
+
* writes the DOM, not React state: a pointermove per frame is not a render.
|
|
32
|
+
*
|
|
33
|
+
* The pill is kol-r2b2's, verbatim (user: "make it like it is in kol-r2b2, it
|
|
34
|
+
* has animation and gsap") — `.kol-rail-grab` in kol-theme's kol-animation.css,
|
|
35
|
+
* its pointer numbers in kol-component's `utilities/motion` (`GRAB`).
|
|
36
|
+
*
|
|
37
|
+
* @param {Array} items `{ icon, path, label, sub?: [{ path, label }] }`
|
|
38
|
+
* @param {Array} bottomItems pinned below the rule (Settings)
|
|
39
|
+
* @param {Object} logomark `{ svgUrl, title }` — the mark, and the app name beside it when open
|
|
40
|
+
* @param {string} currentPath active match: '/' exact, else prefix → aria-current="page"
|
|
41
|
+
* @param {Function} onNavigate `(path) => void` — every click, the mark included
|
|
36
42
|
*/
|
|
43
|
+
const RAIL_W = '--kol-shell-rail-width'
|
|
44
|
+
const CLOSED = 48
|
|
45
|
+
|
|
46
|
+
/* the OPEN width is the sidenav's ladder (kol-framework: 264, 320 from 1536),
|
|
47
|
+
* read at drag time so the rail lands on whichever rung the window is on */
|
|
48
|
+
const openWidth = () => {
|
|
49
|
+
const v = getComputedStyle(document.documentElement).getPropertyValue('--kol-sidenav-w').trim()
|
|
50
|
+
const px = parseFloat(v)
|
|
51
|
+
return Number.isFinite(px) ? (v.endsWith('rem') ? px * 16 : px) : 264
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* THE GRAB EDGE — proximity, not contact: the pill wakes within `GRAB.near` of
|
|
55
|
+
* the line and sleeps only past `GRAB.sleep` (hovering the line itself flapped
|
|
56
|
+
* the class every frame and restarted the fade). Along the line it DWELLS (user
|
|
57
|
+
* ruling 2026-08-28: "make it come to the cursor but have some sticky time where
|
|
58
|
+
* it lands, so it's not constantly jerking"): it targets the pointer's own
|
|
59
|
+
* position and re-targets only once the pointer is `GRAB.stick` px away, so it
|
|
60
|
+
* lands on you, holds while you move inside the radius, then travels to where you
|
|
61
|
+
* are now. One window listener, rAF-throttled. */
|
|
62
|
+
function useGrabEdge(ref) {
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
let raf = 0
|
|
65
|
+
const onMove = ({ clientX, clientY }) => {
|
|
66
|
+
if (raf) return
|
|
67
|
+
raf = requestAnimationFrame(() => {
|
|
68
|
+
raf = 0
|
|
69
|
+
const h = ref.current
|
|
70
|
+
if (!h) return
|
|
71
|
+
const r = h.getBoundingClientRect()
|
|
72
|
+
const dist = Math.abs(clientX - (r.left + r.width / 2))
|
|
73
|
+
const near = dist <= GRAB.near || (h.classList.contains('is-near') && dist <= GRAB.sleep)
|
|
74
|
+
h.classList.toggle('is-near', near)
|
|
75
|
+
if (!near) return
|
|
76
|
+
const along = Math.min(Math.max(clientY - r.top, 0), r.height)
|
|
77
|
+
if (h.dataset.grabSeeded && Math.abs(along - Number(h.dataset.grabTarget)) < GRAB.stick) return
|
|
78
|
+
h.dataset.grabTarget = String(along)
|
|
79
|
+
const vars = { '--kol-rail-grab-y': `${along}px` }
|
|
80
|
+
/* the CSS fallback is 50%, a percentage — nothing to tween from, so the
|
|
81
|
+
* first sighting sets and every move after tweens */
|
|
82
|
+
if (h.dataset.grabSeeded) gsap.to(h, { ...vars, ...GRAB.travel, overwrite: 'auto' })
|
|
83
|
+
else { gsap.set(h, vars); h.dataset.grabSeeded = '1' }
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
window.addEventListener('pointermove', onMove)
|
|
87
|
+
return () => { window.removeEventListener('pointermove', onMove); cancelAnimationFrame(raf) }
|
|
88
|
+
}, [ref])
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* THE DRAG — hold the pill and the width follows the pointer between closed and
|
|
92
|
+
* open; release snaps to the nearer state, a click (no travel past GRAB.slop)
|
|
93
|
+
* toggles. `onSnap` reports the resting state once — the sub rows render only
|
|
94
|
+
* while open, because closed they would still take their height and push the
|
|
95
|
+
* rungs below them. */
|
|
96
|
+
function useRailDrag(railRef, grabRef, onSnap) {
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const strip = grabRef.current, rail = railRef.current, root = document.documentElement
|
|
99
|
+
if (!strip || !rail) return undefined
|
|
100
|
+
gsap.set(root, { [RAIL_W]: `${CLOSED}px` })
|
|
101
|
+
let drag = null
|
|
102
|
+
const snapTo = (w) => {
|
|
103
|
+
gsap.to(root, { [RAIL_W]: `${w}px`, ...GRAB.snap, overwrite: 'auto' })
|
|
104
|
+
onSnap(w !== CLOSED)
|
|
105
|
+
}
|
|
106
|
+
const onDown = (e) => {
|
|
107
|
+
strip.setPointerCapture(e.pointerId)
|
|
108
|
+
gsap.killTweensOf(root)
|
|
109
|
+
drag = { x: e.clientX, w: rail.offsetWidth, open: openWidth(), moved: false }
|
|
110
|
+
strip.classList.add('is-dragging')
|
|
111
|
+
}
|
|
112
|
+
const onMove = (e) => {
|
|
113
|
+
if (!drag) return
|
|
114
|
+
const dx = e.clientX - drag.x
|
|
115
|
+
if (Math.abs(dx) > GRAB.slop) drag.moved = true
|
|
116
|
+
root.style.setProperty(RAIL_W, `${Math.min(Math.max(drag.w + dx, CLOSED), drag.open)}px`)
|
|
117
|
+
}
|
|
118
|
+
const onUp = () => {
|
|
119
|
+
if (!drag) return
|
|
120
|
+
const w = rail.offsetWidth
|
|
121
|
+
const open = drag.open
|
|
122
|
+
snapTo(drag.moved ? (w > (CLOSED + open) / 2 ? open : CLOSED) : (drag.w > CLOSED ? CLOSED : open))
|
|
123
|
+
drag = null
|
|
124
|
+
strip.classList.remove('is-dragging')
|
|
125
|
+
}
|
|
126
|
+
strip.addEventListener('pointerdown', onDown)
|
|
127
|
+
strip.addEventListener('pointermove', onMove)
|
|
128
|
+
strip.addEventListener('pointerup', onUp)
|
|
129
|
+
strip.addEventListener('pointercancel', onUp)
|
|
130
|
+
return () => {
|
|
131
|
+
strip.removeEventListener('pointerdown', onDown)
|
|
132
|
+
strip.removeEventListener('pointermove', onMove)
|
|
133
|
+
strip.removeEventListener('pointerup', onUp)
|
|
134
|
+
strip.removeEventListener('pointercancel', onUp)
|
|
135
|
+
}
|
|
136
|
+
}, [railRef, grabRef, onSnap])
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* one row: the rung exactly where the closed rail had it, then the label and
|
|
140
|
+
* (with `sub`) the chevron — the row clips to the rail's width */
|
|
141
|
+
function RailItem({ icon, path, label, sub, currentPath, onNavigate, iconComponent, railOpen }) {
|
|
142
|
+
const [open, setOpen] = useState(false)
|
|
143
|
+
const active = path === '/' ? currentPath === '/' : currentPath.startsWith(path)
|
|
144
|
+
return (
|
|
145
|
+
<>
|
|
146
|
+
<div className="flex items-center gap-3 w-full overflow-hidden shrink-0">
|
|
147
|
+
<Button
|
|
148
|
+
iconOnly={icon}
|
|
149
|
+
iconSize={20}
|
|
150
|
+
iconComponent={iconComponent}
|
|
151
|
+
variant="nav"
|
|
152
|
+
size="md"
|
|
153
|
+
className="shrink-0"
|
|
154
|
+
/* nav ACTIVE keys off aria-current="page" (kol-components-atoms.css) —
|
|
155
|
+
* a brighter glyph, never the pressed fill */
|
|
156
|
+
aria-current={active ? 'page' : undefined}
|
|
157
|
+
/* oq-96 on every rung: the nav variant's oq-64 rest is site-nav quiet
|
|
158
|
+
* and too dim on an icon rail (user: "make the color .96 on every icon
|
|
159
|
+
* in the rail") */
|
|
160
|
+
style={{ color: 'var(--kol-oq-96)' }}
|
|
161
|
+
onClick={() => onNavigate?.(path)}
|
|
162
|
+
title={label}
|
|
163
|
+
aria-label={label}
|
|
164
|
+
/>
|
|
165
|
+
<span
|
|
166
|
+
className="kol-helper-12 uppercase text-oq-96 flex-1 min-w-0 truncate cursor-pointer"
|
|
167
|
+
onClick={() => onNavigate?.(path)}
|
|
168
|
+
>
|
|
169
|
+
{label}
|
|
170
|
+
</span>
|
|
171
|
+
{sub?.length > 0 && (
|
|
172
|
+
<Button
|
|
173
|
+
iconOnly={open ? 'chevron-down' : 'chevron-right'}
|
|
174
|
+
iconSize={16}
|
|
175
|
+
iconComponent={iconComponent}
|
|
176
|
+
variant="nav"
|
|
177
|
+
size="sm"
|
|
178
|
+
className="shrink-0"
|
|
179
|
+
style={{ color: 'var(--kol-oq-96)' }}
|
|
180
|
+
aria-expanded={open}
|
|
181
|
+
onClick={() => setOpen((v) => !v)}
|
|
182
|
+
title={label}
|
|
183
|
+
aria-label={`${label} sub categories`}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
{railOpen && open && sub.map((s) => (
|
|
188
|
+
<div key={s.path} className="flex items-center w-full overflow-hidden shrink-0 pl-11">
|
|
189
|
+
<span
|
|
190
|
+
className="kol-helper-12 uppercase text-oq-64 hover:text-oq-96 truncate cursor-pointer"
|
|
191
|
+
onClick={() => onNavigate?.(s.path)}
|
|
192
|
+
>
|
|
193
|
+
{s.label}
|
|
194
|
+
</span>
|
|
195
|
+
</div>
|
|
196
|
+
))}
|
|
197
|
+
</>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
37
201
|
export default function NavRail({
|
|
38
202
|
items = [],
|
|
39
203
|
bottomItems = [],
|
|
@@ -41,66 +205,43 @@ export default function NavRail({
|
|
|
41
205
|
currentPath = '',
|
|
42
206
|
onNavigate,
|
|
43
207
|
iconComponent,
|
|
44
|
-
settings,
|
|
45
|
-
themeToggle = !settings,
|
|
46
208
|
hidden = false,
|
|
47
209
|
}) {
|
|
210
|
+
const railRef = useRef(null)
|
|
211
|
+
const grabRef = useRef(null)
|
|
212
|
+
const [railOpen, setRailOpen] = useState(false)
|
|
213
|
+
useGrabEdge(grabRef)
|
|
214
|
+
useRailDrag(railRef, grabRef, setRailOpen)
|
|
48
215
|
if (hidden) return null
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
id: settings.path,
|
|
53
|
-
label: settings.label,
|
|
54
|
-
icon: settings.icon,
|
|
55
|
-
panel: ({ collapsed, close }) => (
|
|
56
|
-
<>
|
|
57
|
-
<div className="kol-sidenav-theme-slot flex">
|
|
58
|
-
<ThemeToggle variant={collapsed ? 'icon' : 'hop-bare'} size="md" />
|
|
59
|
-
</div>
|
|
60
|
-
<button
|
|
61
|
-
type="button"
|
|
62
|
-
className="kol-sidenav-hop kol-helper-12 text-strong hover:text-emphasis bg-transparent border-0 cursor-pointer text-left"
|
|
63
|
-
onClick={() => { close(); onNavigate?.(settings.path) }}
|
|
64
|
-
title={settings.label}
|
|
65
|
-
aria-label={settings.label}
|
|
66
|
-
>
|
|
67
|
-
<span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
|
|
68
|
-
<Glyph name="arrow-right" size={16} />
|
|
69
|
-
</span>
|
|
70
|
-
<span className="kol-sidenav-hop-label flex-1 min-w-0 truncate">{settings.label}</span>
|
|
71
|
-
</button>
|
|
72
|
-
</>
|
|
73
|
-
),
|
|
74
|
-
}
|
|
75
|
-
/* the shell's route match: '/' exact, anything else by prefix */
|
|
76
|
-
const isActive = (to) => (to === '/' ? currentPath === '/' : currentPath.startsWith(to))
|
|
216
|
+
const row = (item) => (
|
|
217
|
+
<RailItem key={item.path} {...item} currentPath={currentPath} onNavigate={onNavigate} iconComponent={iconComponent} railOpen={railOpen} />
|
|
218
|
+
)
|
|
77
219
|
return (
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
footer={false}
|
|
90
|
-
header={logomark ? (
|
|
91
|
-
<button
|
|
92
|
-
type="button"
|
|
93
|
-
className="flex items-center gap-3 w-full bg-transparent border-0 p-0 cursor-pointer text-emphasis no-underline text-left"
|
|
220
|
+
<div
|
|
221
|
+
ref={railRef}
|
|
222
|
+
className="kol-shell-rail bg-surface-primary border-r border-fg-08 fixed inset-y-0 left-0 flex flex-col items-start pt-4 pb-4 px-2 gap-2"
|
|
223
|
+
style={{ width: `var(${RAIL_W})` }}
|
|
224
|
+
>
|
|
225
|
+
<div ref={grabRef} className="kol-rail-grab" />
|
|
226
|
+
{logomark && (
|
|
227
|
+
/* the mark, and the app name beside it when open — uppercase like the
|
|
228
|
+
* rows (user 2026-08-28: "uppercase CONSISTENCY"). The `w-8` centring box
|
|
229
|
+
* puts the 20px mark on the same x as the 20px glyphs below it. */
|
|
230
|
+
<div
|
|
94
231
|
onClick={() => onNavigate?.('/')}
|
|
232
|
+
className="text-oq-96 cursor-pointer flex items-center gap-3 w-full overflow-hidden shrink-0 pt-1 mb-4"
|
|
95
233
|
title={logomark.title}
|
|
96
|
-
aria-label={logomark.title}
|
|
97
234
|
>
|
|
98
|
-
<span className="
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
235
|
+
<span className="w-8 flex justify-center shrink-0"><Logomark svgUrl={logomark.svgUrl} size={20} /></span>
|
|
236
|
+
<span className="kol-helper-12 uppercase flex-1 min-w-0 truncate">{logomark.title}</span>
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
{items.map(row)}
|
|
240
|
+
{/* the spacer IS the layout */}
|
|
241
|
+
<div className="flex-1" />
|
|
242
|
+
{/* the rule runs the full rail width, out past the px-2 */}
|
|
243
|
+
{bottomItems.length > 0 && <div className="self-stretch -mx-2 border-t border-fg-08" />}
|
|
244
|
+
{bottomItems.map(row)}
|
|
245
|
+
</div>
|
|
105
246
|
)
|
|
106
247
|
}
|