@kolkrabbi/kol-shell 0.29.0 → 0.31.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 +4 -4
- package/src/AppShell.jsx +73 -4
- package/src/NavRail.jsx +24 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
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",
|
|
@@ -20,10 +20,10 @@
|
|
|
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.145.0",
|
|
24
24
|
"@kolkrabbi/kol-icons": "^0.25.0",
|
|
25
|
-
"@kolkrabbi/kol-
|
|
26
|
-
"@kolkrabbi/kol-
|
|
25
|
+
"@kolkrabbi/kol-theme": "^0.112.0",
|
|
26
|
+
"@kolkrabbi/kol-framework": "^0.36.0"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"src",
|
package/src/AppShell.jsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Button } from '@kolkrabbi/kol-component'
|
|
2
3
|
import NavRail from './NavRail.jsx'
|
|
3
4
|
import { NavHiddenContext } from './navHidden.js'
|
|
4
5
|
import { SettingsToggleContext } from './settingsToggle.js'
|
|
@@ -41,7 +42,17 @@ import TouchDeviceOverlay, { useTouchPrimary } from './TouchDeviceOverlay.jsx'
|
|
|
41
42
|
* toggle lives on the settings page, not in the rail (user, 2026-08-28).
|
|
42
43
|
* @param {string} props.railToggleKey a key that toggles the rail (e.g. '\\') — ignored while typing in a field;
|
|
43
44
|
* the rail comes back on every `currentPath` change (ShellHomeSystem, 2026-08-27)
|
|
44
|
-
* @param {'shell'|'bare'|'overlay'} props.touch the touch-primary policy (default 'shell' = the rail regardless):
|
|
45
|
+
* @param {'shell'|'bare'|'overlay'|'drawer'} props.touch the touch-primary policy (default 'shell' = the rail regardless):
|
|
46
|
+
* 'drawer' takes the rail OFF-CANVAS below `drawerBelow`, hands its width
|
|
47
|
+
* back to the content, and renders a trigger that brings it in over a scrim.
|
|
48
|
+
* Tapping a destination closes it. (ShellRailNoDrawerOnMobile, kol-chess
|
|
49
|
+
* 2026-08-31: at 390 the 48px rail is 12.3% of the viewport, and
|
|
50
|
+
* `railToggleKey` is a KEY — a phone has no keyboard, so on the device where
|
|
51
|
+
* the rail costs most it could not be dismissed at all. `bare` was the only
|
|
52
|
+
* other way to reclaim the width and it throws navigation away entirely.)
|
|
53
|
+
* @param {number} [props.drawerBelow=768] viewport width under which `touch="drawer"` folds. A width, not a
|
|
54
|
+
* pointer test: an iPad is coarse and has room, a narrow desktop window is
|
|
55
|
+
* fine-pointered and does not.
|
|
45
56
|
* `bare` renders the children with NO shell on a coarse-pointer device unless
|
|
46
57
|
* localStorage `kol-desktop` is '1' (fxr's gate); `overlay` keeps the shell and
|
|
47
58
|
* mounts TouchDeviceOverlay once (monitor's)
|
|
@@ -95,11 +106,34 @@ export default function AppShell({
|
|
|
95
106
|
navKeys = false,
|
|
96
107
|
settingsPath,
|
|
97
108
|
settingsKey,
|
|
109
|
+
drawerBelow = 768,
|
|
98
110
|
children,
|
|
99
111
|
}) {
|
|
100
112
|
const [navHidden, setNavHidden] = useState(false)
|
|
101
113
|
const coarse = useTouchPrimary()
|
|
102
114
|
|
|
115
|
+
/* DRAWER MODE. A width query, not a pointer one — see `drawerBelow`. */
|
|
116
|
+
const [narrow, setNarrow] = useState(
|
|
117
|
+
() => typeof window !== 'undefined' && window.matchMedia(`(max-width: ${drawerBelow - 1}px)`).matches,
|
|
118
|
+
)
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (typeof window === 'undefined') return undefined
|
|
121
|
+
const mq = window.matchMedia(`(max-width: ${drawerBelow - 1}px)`)
|
|
122
|
+
const on = (e) => setNarrow(e.matches)
|
|
123
|
+
setNarrow(mq.matches)
|
|
124
|
+
mq.addEventListener('change', on)
|
|
125
|
+
return () => mq.removeEventListener('change', on)
|
|
126
|
+
}, [drawerBelow])
|
|
127
|
+
const drawer = touch === 'drawer' && narrow
|
|
128
|
+
const [drawerOpen, setDrawerOpen] = useState(false)
|
|
129
|
+
useEffect(() => { if (!drawer) setDrawerOpen(false) }, [drawer])
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!drawer || !drawerOpen) return undefined
|
|
132
|
+
const onKey = (e) => { if (e.key === 'Escape') setDrawerOpen(false) }
|
|
133
|
+
window.addEventListener('keydown', onKey)
|
|
134
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
135
|
+
}, [drawer, drawerOpen])
|
|
136
|
+
|
|
103
137
|
/* TOGGLING A DESTINATION (user 2026-08-30: "comma opens and closes the
|
|
104
138
|
* settings page, and clicking the icon in sidebar opens and clicking again
|
|
105
139
|
* closes"). The shell already owned two keyboard behaviours; this is a third
|
|
@@ -153,8 +187,16 @@ export default function AppShell({
|
|
|
153
187
|
[settingsPath, toggleSettings, onNavigate],
|
|
154
188
|
)
|
|
155
189
|
|
|
156
|
-
/*
|
|
157
|
-
|
|
190
|
+
/* THE ROUTE CHANGE MEANS THE OPPOSITE IN EACH MODE. For `railToggleKey` the
|
|
191
|
+
* rail comes back on every navigation (ShellHomeSystem, 2026-08-27); for a
|
|
192
|
+
* drawer, tapping a destination must CLOSE it. The old unconditional
|
|
193
|
+
* `setNavHidden(false)` also made `navHidden` unusable as a consumer seam —
|
|
194
|
+
* child effects run before parent effects, so a consumer hiding the rail on a
|
|
195
|
+
* path change was overwritten in the same commit. */
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (drawer) setDrawerOpen(false)
|
|
198
|
+
else setNavHidden(false)
|
|
199
|
+
}, [currentPath, drawer])
|
|
158
200
|
|
|
159
201
|
/* THE WASH ALSO GOES ON THE ROOT (2026-08-30). It is set on the content
|
|
160
202
|
* wrapper below, which every page inherits — but a PORTALLED surface does
|
|
@@ -228,9 +270,35 @@ export default function AppShell({
|
|
|
228
270
|
* margin closes with it — one variable, both sides. */}
|
|
229
271
|
<div
|
|
230
272
|
className="kol-app-shell min-h-dvh bg-surface-primary"
|
|
231
|
-
|
|
273
|
+
/* A DRAWER ZEROES THE TOKEN TOO. Off-canvas means the content owns the
|
|
274
|
+
* whole viewport, so the same one variable that closes the content's
|
|
275
|
+
* margin for `navHidden` closes it here — the rail then takes its own
|
|
276
|
+
* `--kol-shell-drawer-width` rather than this token. */
|
|
277
|
+
data-rail-drawer={drawer ? (drawerOpen ? 'open' : 'closed') : undefined}
|
|
278
|
+
style={navHidden || drawer ? { '--kol-shell-rail-width': '0px' } : undefined}
|
|
232
279
|
>
|
|
233
280
|
{touch === 'overlay' && <TouchDeviceOverlay appName={appName} />}
|
|
281
|
+
{/* THE TRIGGER SHIPS HERE, not in every consumer's page header — the rail
|
|
282
|
+
* is the shell's, so the only way to reach it is too. Hamburger closed,
|
|
283
|
+
* × open; 32px square clears the 24px touch floor. */}
|
|
284
|
+
{drawer && (
|
|
285
|
+
<Button
|
|
286
|
+
variant="nav"
|
|
287
|
+
iconOnly={drawerOpen ? 'x' : 'hamburger'}
|
|
288
|
+
iconComponent={iconComponent}
|
|
289
|
+
aria-label={drawerOpen ? 'Close navigation' : 'Open navigation'}
|
|
290
|
+
aria-expanded={drawerOpen}
|
|
291
|
+
className="kol-shell-drawer-trigger"
|
|
292
|
+
onClick={() => setDrawerOpen((o) => !o)}
|
|
293
|
+
/>
|
|
294
|
+
)}
|
|
295
|
+
{drawer && drawerOpen && (
|
|
296
|
+
<div
|
|
297
|
+
className="kol-shell-drawer-scrim"
|
|
298
|
+
onClick={() => setDrawerOpen(false)}
|
|
299
|
+
aria-hidden="true"
|
|
300
|
+
/>
|
|
301
|
+
)}
|
|
234
302
|
{!navHidden && (
|
|
235
303
|
<Rail
|
|
236
304
|
items={items}
|
|
@@ -240,6 +308,7 @@ export default function AppShell({
|
|
|
240
308
|
/* the rail routes through `navigate`, so its settings row toggles like the key */
|
|
241
309
|
onNavigate={navigate}
|
|
242
310
|
iconComponent={iconComponent}
|
|
311
|
+
drawer={drawer}
|
|
243
312
|
/>
|
|
244
313
|
)}
|
|
245
314
|
{/* THE BACK OF THE BACK — surface-primary, always, in every app; the
|
package/src/NavRail.jsx
CHANGED
|
@@ -2,6 +2,11 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
2
2
|
import gsap from 'gsap'
|
|
3
3
|
import { Button } from '@kolkrabbi/kol-component'
|
|
4
4
|
import { GRAB } from '@kolkrabbi/kol-component/utilities/motion'
|
|
5
|
+
/* the grab pill's proximity wake, travel and dwell — ONE implementation, shared
|
|
6
|
+
* with kol-framework's useDragResize (OneGrabGestureBothRails, kol-fxr
|
|
7
|
+
* 2026-08-30: two rails on one screen felt different because each package had
|
|
8
|
+
* built the gesture itself). It lived here until then. */
|
|
9
|
+
import { useGrabEdge } from '@kolkrabbi/kol-component'
|
|
5
10
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
6
11
|
import Logomark from './Logomark.jsx'
|
|
7
12
|
|
|
@@ -62,6 +67,9 @@ import Logomark from './Logomark.jsx'
|
|
|
62
67
|
* @param {Object} logomark `{ svgUrl, title }` — the mark, and the app name beside it when open
|
|
63
68
|
* @param {string} currentPath active match: '/' exact, else prefix → aria-current="page"
|
|
64
69
|
* @param {Function} onNavigate `(path) => void` — every click, the mark included
|
|
70
|
+
* @param {boolean} drawer off-canvas mode: no grab, no drag, no token writes, rows always
|
|
71
|
+
* labelled, and the rail sizes itself from `--kol-shell-drawer-width` (default 240px).
|
|
72
|
+
* AppShell sets this from `touch="drawer"` — see ShellRailNoDrawerOnMobile, 2026-08-31.
|
|
65
73
|
*/
|
|
66
74
|
const RAIL_W = '--kol-shell-rail-width'
|
|
67
75
|
const CLOSED = 48
|
|
@@ -83,45 +91,21 @@ const openWidth = () => {
|
|
|
83
91
|
* lands on you, holds while you move inside the radius, then travels to where you
|
|
84
92
|
* are now — inside `GRAB.range`, the middle band of the edge, so it never rides
|
|
85
93
|
* up beside the logomark. One window listener, rAF-throttled. */
|
|
86
|
-
function useGrabEdge(ref) {
|
|
87
|
-
useEffect(() => {
|
|
88
|
-
let raf = 0
|
|
89
|
-
const onMove = ({ clientX, clientY }) => {
|
|
90
|
-
if (raf) return
|
|
91
|
-
raf = requestAnimationFrame(() => {
|
|
92
|
-
raf = 0
|
|
93
|
-
const h = ref.current
|
|
94
|
-
if (!h) return
|
|
95
|
-
const r = h.getBoundingClientRect()
|
|
96
|
-
const dist = Math.abs(clientX - (r.left + r.width / 2))
|
|
97
|
-
const near = dist <= GRAB.near || (h.classList.contains('is-near') && dist <= GRAB.sleep)
|
|
98
|
-
h.classList.toggle('is-near', near)
|
|
99
|
-
if (!near) return
|
|
100
|
-
/* the travel band: bipolar from the middle, GRAB.range of the height */
|
|
101
|
-
const edge = (r.height * (1 - GRAB.range)) / 2
|
|
102
|
-
const along = Math.min(Math.max(clientY - r.top, edge), r.height - edge)
|
|
103
|
-
if (h.dataset.grabSeeded && Math.abs(along - Number(h.dataset.grabTarget)) < GRAB.stick) return
|
|
104
|
-
h.dataset.grabTarget = String(along)
|
|
105
|
-
const vars = { '--kol-rail-grab-y': `${along}px` }
|
|
106
|
-
/* the CSS fallback is 50%, a percentage — nothing to tween from, so the
|
|
107
|
-
* first sighting sets and every move after tweens */
|
|
108
|
-
if (h.dataset.grabSeeded) gsap.to(h, { ...vars, ...GRAB.travel, overwrite: 'auto' })
|
|
109
|
-
else { gsap.set(h, vars); h.dataset.grabSeeded = '1' }
|
|
110
|
-
})
|
|
111
|
-
}
|
|
112
|
-
window.addEventListener('pointermove', onMove)
|
|
113
|
-
return () => { window.removeEventListener('pointermove', onMove); cancelAnimationFrame(raf) }
|
|
114
|
-
}, [ref])
|
|
115
|
-
}
|
|
116
94
|
|
|
117
95
|
/* THE DRAG — hold the pill and the width follows the pointer between closed and
|
|
118
96
|
* open; release snaps to the nearer state, a click (no travel past GRAB.slop)
|
|
119
97
|
* toggles. `onSnap` reports the resting state once — the sub rows render only
|
|
120
98
|
* while open, because closed they would still take their height and push the
|
|
121
99
|
* rungs below them. */
|
|
122
|
-
function useRailDrag(railRef, grabRef, onSnap, snapRef) {
|
|
100
|
+
function useRailDrag(railRef, grabRef, onSnap, snapRef, enabled = true) {
|
|
123
101
|
useEffect(() => {
|
|
124
102
|
const strip = grabRef.current, rail = railRef.current, root = document.documentElement
|
|
103
|
+
/* A DRAWER IS NOT A DRAGGABLE RAIL (ShellRailNoDrawerOnMobile, kol-chess
|
|
104
|
+
* 2026-08-31). In drawer mode the rail is off-canvas at a fixed width and
|
|
105
|
+
* the content owns the whole viewport, so the drag must not run at all —
|
|
106
|
+
* it writes `--kol-shell-rail-width` on `:root` per pointermove, which is
|
|
107
|
+
* exactly the inline token a consumer could not reach without `!important`. */
|
|
108
|
+
if (!enabled) return undefined
|
|
125
109
|
if (!strip || !rail) return undefined
|
|
126
110
|
gsap.set(root, { [RAIL_W]: `${CLOSED}px` })
|
|
127
111
|
let drag = null
|
|
@@ -284,24 +268,29 @@ export default function NavRail({
|
|
|
284
268
|
onNavigate,
|
|
285
269
|
iconComponent,
|
|
286
270
|
hidden = false,
|
|
271
|
+
drawer = false,
|
|
287
272
|
}) {
|
|
288
273
|
const railRef = useRef(null)
|
|
289
274
|
const grabRef = useRef(null)
|
|
290
275
|
const [railOpen, setRailOpen] = useState(false)
|
|
291
276
|
const snapOpenRef = useRef(null)
|
|
277
|
+
/* the grab strip is not rendered in drawer mode, so the hook's ref stays null
|
|
278
|
+
* and it no-ops on its own — no second argument needed */
|
|
292
279
|
useGrabEdge(grabRef)
|
|
293
|
-
useRailDrag(railRef, grabRef, setRailOpen, snapOpenRef)
|
|
280
|
+
useRailDrag(railRef, grabRef, setRailOpen, snapOpenRef, !drawer)
|
|
294
281
|
if (hidden) return null
|
|
295
282
|
const row = (item) => (
|
|
296
|
-
<RailItem key={item.path} {...item} currentPath={currentPath} onNavigate={onNavigate} iconComponent={iconComponent} railOpen={railOpen} onOpenRail={() => snapOpenRef.current?.()} />
|
|
283
|
+
<RailItem key={item.path} {...item} currentPath={currentPath} onNavigate={onNavigate} iconComponent={iconComponent} railOpen={drawer || railOpen} onOpenRail={() => snapOpenRef.current?.()} />
|
|
297
284
|
)
|
|
298
285
|
return (
|
|
299
286
|
<div
|
|
300
287
|
ref={railRef}
|
|
301
288
|
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"
|
|
302
|
-
|
|
289
|
+
/* A drawer takes its own width, NOT the live rail token — that token is
|
|
290
|
+
* zeroed in drawer mode so the content gets the whole viewport back. */
|
|
291
|
+
style={{ width: drawer ? 'var(--kol-shell-drawer-width, 240px)' : `var(${RAIL_W})` }}
|
|
303
292
|
>
|
|
304
|
-
<div ref={grabRef} className="kol-rail-grab" />
|
|
293
|
+
{!drawer && <div ref={grabRef} className="kol-rail-grab" />}
|
|
305
294
|
{logomark && (
|
|
306
295
|
/* the mark, and the app name beside it when open — uppercase like the
|
|
307
296
|
* rows (user 2026-08-28: "uppercase CONSISTENCY"). The `w-8` centring box
|