@kolkrabbi/kol-workshop 0.34.0 → 0.36.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.
@@ -1,5 +1,5 @@
1
1
  import { createContext, useContext, useMemo, useRef, useState, useEffect, Suspense } from 'react'
2
- import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
2
+ import { Outlet, Link, useLocation, useNavigate, useNavigationType } from 'react-router-dom'
3
3
  import { ShellHeader } from '@kolkrabbi/kol-framework'
4
4
  import ShellSidebar, { RAIL_FOLD_EVENT } from './ShellSidebar.jsx'
5
5
  import { IconFrame, ShellDrawer, ShellSearchOverlay, ShortcutsOverlay, SettingsPanel, SettingsSections, SettingsSwitch, Tooltip, useDragResize } from '@kolkrabbi/kol-component'
@@ -99,7 +99,7 @@ const useRailGrab = (token, side, hide) => {
99
99
  }
100
100
 
101
101
  const NavColumn = ({ children, grip, railRef }) => (
102
- <aside ref={railRef} aria-label="Navigation" className="shell-rail shell-rail--nav shell-sidebar-sticky hidden lg:block pt-6 md:pt-6 lg:pt-8 pb-8">
102
+ <aside ref={railRef} aria-label="Navigation" className="shell-rail shell-rail--nav shell-sidebar-sticky hidden lg:block pt-6 md:pt-6 lg:pt-8 pb-14">
103
103
  {children}
104
104
  {grip}
105
105
  </aside>
@@ -139,7 +139,7 @@ const MainColumn = ({ children, fullHeight, width = 'canvas', padStart, padEnd }
139
139
  * below). The breakpoint here and the one in gridCols are one decision and
140
140
  * must not be stated twice differently. */
141
141
  const TocColumn = ({ children, grip, railRef }) => (
142
- <aside ref={railRef} aria-label="Table of contents" className="shell-rail shell-rail--toc shell-sidebar-sticky hidden xl:block pt-6 md:pt-6 lg:pt-8 pb-8">
142
+ <aside ref={railRef} aria-label="Table of contents" className="shell-rail shell-rail--toc shell-sidebar-sticky hidden xl:block pt-6 md:pt-6 lg:pt-8 pb-14">
143
143
  {grip}
144
144
  {/* The width is the grid track (--kol-shell-toc-w). An empty rail still
145
145
  * holds its column (user ruling 2026-08-01): a rail that disappears
@@ -231,28 +231,58 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
231
231
 
232
232
  /* a new page starts at the top of the one scroll region; a hash is the
233
233
  * browser's to resolve */
234
+ /* BACK RETURNS TO WHERE YOU WERE (2026-10-01 — user: "'back' loses the place vertically in the
235
+ * scroll … now I have to scroll and find my place"). Each history entry remembers its own
236
+ * scroll offset; Back/Forward (POP) restores it, a fresh navigation still starts at the top.
237
+ * The page may still be laying out when the entry returns (lazy walls, async demos), so the
238
+ * restore is retried for a moment until the region is tall enough to reach the offset. */
239
+ const navigationType = useNavigationType()
240
+ const scrollMemory = useRef(new Map())
234
241
  useEffect(() => {
235
- if (location.hash) return
236
- document.getElementById('shell-scroll')?.scrollTo(0, 0)
237
- }, [location.pathname, location.hash])
242
+ const el = document.getElementById('shell-scroll')
243
+ if (!el) return undefined
244
+ const key = location.key
245
+ const save = () => scrollMemory.current.set(key, el.scrollTop)
246
+ el.addEventListener('scroll', save, { passive: true })
247
+ return () => el.removeEventListener('scroll', save)
248
+ }, [location.key])
249
+ useEffect(() => {
250
+ if (location.hash) return undefined
251
+ const el = document.getElementById('shell-scroll')
252
+ if (!el) return undefined
253
+ const saved = navigationType === 'POP' ? scrollMemory.current.get(location.key) : 0
254
+ if (!saved) { el.scrollTo(0, 0); return undefined }
255
+ let frame
256
+ let tries = 60
257
+ const restore = () => {
258
+ el.scrollTo(0, saved)
259
+ if (Math.abs(el.scrollTop - saved) > 1 && tries-- > 0) frame = requestAnimationFrame(restore)
260
+ }
261
+ restore()
262
+ return () => cancelAnimationFrame(frame)
263
+ // eslint-disable-next-line react-hooks/exhaustive-deps
264
+ }, [location.pathname, location.hash, location.key])
238
265
 
239
266
  /* THE shortcut map. One list, rendered by the `S` sheet AND bound by the
240
267
  * handler below — a shortcut that isn't in this array doesn't exist, so the
241
268
  * sheet can never drift from the bindings. The sheet is kol-component's
242
269
  * ShortcutsOverlay (2026-09-28): the same sheet, on the same key, as every
243
270
  * app — it was a `?` sheet of its own here. `?` still opens it. */
271
+ /* FEWEST WORDS (the showcase review W16, 2026-09-30 — user: "shortcuts overlay, too wordy"):
272
+ * a label names the thing the key does, nothing more. `\\` hides or shows both rails at once. */
244
273
  const SHORTCUTS = [
245
274
  { section: 'Search', items: [
246
- { id: 'k', label: 'Search everything', combo: '⌘ K , /' },
247
- { id: 'enter', label: 'All results, on the search page', combo: '↵' },
275
+ { id: 'k', label: 'Search', combo: '⌘ K , /' },
276
+ { id: 'enter', label: 'All results', combo: '↵' },
248
277
  ] },
249
278
  { section: 'Shell', items: [
250
- { id: 'sheet', label: 'This sheet', combo: 'S' },
279
+ { id: 'sheet', label: 'Shortcuts', combo: 'S' },
251
280
  { id: 'settings', label: 'Settings', combo: ',' },
252
281
  { id: 'nav', label: 'Left rail', combo: '[' },
253
282
  { id: 'toc', label: 'Right rail', combo: ']' },
254
- { id: 'fold', label: 'Fold or open every chapter', combo: 'C' },
255
- { id: 'esc', label: 'Close what is open', combo: 'Esc' },
283
+ { id: 'rails', label: 'Both rails', combo: '\\' },
284
+ { id: 'fold', label: 'Fold all', combo: 'C' },
285
+ { id: 'esc', label: 'Close', combo: 'Esc' },
256
286
  ] },
257
287
  ...(shortcuts.length ? [{ section: 'Page', items: shortcuts.map(({ id, label, combo }) => ({ id, label, combo })) }] : []),
258
288
  ]
@@ -288,6 +318,14 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
288
318
  } else if (e.key === ']') {
289
319
  e.preventDefault()
290
320
  setTocCollapsed((v) => { writeSettings({ ...readSettings(), tocHidden: !v }); return !v })
321
+ } else if (e.key === '\\') {
322
+ /* BOTH RAILS (W16): any rail showing → hide both; both hidden → show both */
323
+ e.preventDefault()
324
+ const s = readSettings()
325
+ const hide = !(s.navHidden && s.tocHidden)
326
+ setNavCollapsed(hide)
327
+ setTocCollapsed(hide)
328
+ writeSettings({ ...s, navHidden: hide, tocHidden: hide })
291
329
  } else if (e.key === 'c' || e.key === 'C') {
292
330
  e.preventDefault()
293
331
  foldedRef.current = !foldedRef.current
@@ -437,7 +475,7 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
437
475
  { id: 'toc', label: 'Right rail', render: () => <SettingsSwitch on={!tocCollapsed} onChange={(on) => { setTocCollapsed(!on); setPref('tocHidden', !on) }} /> },
438
476
  ] },
439
477
  { label: 'Search', rows: [
440
- { id: 'scope', label: 'Quick search in this space only', render: () => <SettingsSwitch on={!!prefs.limitToSpace} onChange={(on) => setPref('limitToSpace', on)} /> },
478
+ { id: 'scope', label: 'This space only', render: () => <SettingsSwitch on={!!prefs.limitToSpace} onChange={(on) => setPref('limitToSpace', on)} /> },
441
479
  ] },
442
480
  ...settings,
443
481
  { label: 'Keys', rowGap: 1, rows: SHORTCUTS.flatMap((sec) => sec.items).map((k) => ({
@@ -549,6 +587,10 @@ const ShellLayout = ({ routes = [], basePath = '/', brand: brandProp, brandLogoS
549
587
  * it. Without a `searchPath` Enter keeps the older in-place browser. */
550
588
  onExpand={searchPath ? openResultsPage : () => tagMode.setExpanded?.(true)}
551
589
  enterLabel={searchPath ? `All results for “${searchQuery.trim()}”` : undefined}
590
+ /* ⌘ENTER AND THE FOOTER LINK open the results page (2026-10-01); plain Enter opens
591
+ * the highlighted row and only falls through to the page when nothing matched. */
592
+ onOpenResults={searchPath ? openResultsPage : undefined}
593
+ resultsLabel={`All results for “${searchQuery.trim()}”`}
552
594
  chips={tagMode.isProvided ? tagMode.activeTags : []}
553
595
  onRemoveChip={tagMode.removeTag}
554
596
  placeholder="Search…"
@@ -39,16 +39,44 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
39
39
 
40
40
  /* THE RAIL FOLLOWS YOU — INTO A CHILD ONLY (2026-09-30, reversing the 2026-09-28 "open the
41
41
  * group you are in" on its landing page too). Landing on a page INSIDE a chapter opens that
42
- * chapter and folds the rest; a chapter's own home opens nothing. Every other chapter starts
42
+ * chapter and folds the rest; a chapter's own home keeps that chapter open (2026-10-01, see
43
+ * `chainTo`). Every other chapter starts
43
44
  * folded — including ones this rail has never seen (a Group-by switch hands it new ids, and an
44
- * unknown id used to read as open, so every chapter sprang open). */
45
- const holdsPage = (route) => (route.children ?? []).some((c) => {
46
- const cp = getChildPath(c, basePath).replace(/\/$/, '')
45
+ * unknown id used to read as open, so every chapter sprang open).
46
+ *
47
+ * ANY DEPTH (2026-09-30, the showcase review W1 — user: Library › Composition › Components ›
48
+ * Atoms › Button). A chapter may hold chapters; every group at every depth is a fold of its own,
49
+ * and the chain down to where you are opens and everything off it folds. */
50
+ const isHere = (route) => {
51
+ /* a pathless group is not a place — without this its basePath fallback matched every URL */
52
+ if (!route.path) return false
53
+ const cp = getChildPath(route, basePath).replace(/\/$/, '')
47
54
  return normalizedPath === cp || normalizedPath.startsWith(cp + '/')
48
- })
55
+ }
56
+ const groupsOf = (list) => list.flatMap((r) => (r.children?.length ? [r, ...groupsOf(r.children)] : []))
57
+ /* ONE PLACE YOU ARE (W4, 2026-09-30). A page can sit in more than one group — Button is an
58
+ * atom AND a member of every set that uses it — and opening every group that holds it would
59
+ * unfold half the rail. The first place in tree order wins: its chain opens, the rest fold. */
60
+ const chainTo = (list, trail = []) => {
61
+ for (const r of list) {
62
+ if (!r.children?.length) continue
63
+ /* deepest first: `/sets` holds `/sets/app-shell` by prefix, but the set's own row is the place */
64
+ const deeper = chainTo(r.children, [...trail, r])
65
+ if (deeper) return deeper
66
+ if (r.children.some(isHere)) return [...trail, r]
67
+ /* A GROUP'S OWN HOME KEEPS IT OPEN (user 2026-10-01: *"I dont want to collapse the list
68
+ * like that when I just want to go to atoms home and still see the atoms list"* — reverses
69
+ * the 2026-09-30 "a chapter's own home opens nothing"). The label is a link; only the
70
+ * chevron folds. */
71
+ if (r.path && getChildPath(r, basePath).replace(/\/$/, '') === normalizedPath) return [...trail, r]
72
+ }
73
+ return null
74
+ }
49
75
  const followed = () => {
50
- const hit = routes.find(holdsPage)
51
- return hit ? Object.fromEntries(routes.map((r) => [r.id, r !== hit])) : null
76
+ const chain = chainTo(routes)
77
+ if (!chain) return null
78
+ const open = new Set(chain.map((g) => g.id))
79
+ return Object.fromEntries(groupsOf(routes).map((g) => [g.id, !open.has(g.id)]))
52
80
  }
53
81
  const [collapsedSections, setCollapsedSections] = useState(() => followed() ?? {})
54
82
  useEffect(() => {
@@ -61,7 +89,7 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
61
89
  /* FOLD ALL (2026-09-30, the names audit: *"shortcut collapse/expand the categories"*). The
62
90
  * shell's `C` key broadcasts one target state; every rail's chapters take it. */
63
91
  useEffect(() => {
64
- const onFold = (e) => setCollapsedSections(Object.fromEntries(routes.map((r) => [r.id, !!e.detail?.collapsed])))
92
+ const onFold = (e) => setCollapsedSections(Object.fromEntries(groupsOf(routes).map((r) => [r.id, !!e.detail?.collapsed])))
65
93
  window.addEventListener(RAIL_FOLD_EVENT, onFold)
66
94
  return () => window.removeEventListener(RAIL_FOLD_EVENT, onFold)
67
95
  }, [routes])
@@ -71,8 +99,37 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
71
99
  }
72
100
 
73
101
  /* The L1 count is every leaf row under it — RailSection shows it only while
74
- * the section is folded (2026-09-28). */
75
- const leafCount = routes.reduce((n, r) => n + (r.children?.length ? r.children.length : 1), 0)
102
+ * the section is folded (2026-09-28). At any depth: leaves, not groups. */
103
+ const leavesOf = (list) => list.reduce((n, r) => n + (r.children?.length ? leavesOf(r.children) : 1), 0)
104
+ const leafCount = leavesOf(routes)
105
+
106
+ /* ONE GROUP, AT ANY DEPTH. A chapter's child with children of its own is a chapter again — the
107
+ * same RailSection rung, the same count and chevron, stepped in one row-indent by `.shell-nav-nest`
108
+ * so its caret sits on its siblings' text edge. A leaf is a RailRow wherever it lands. */
109
+ const renderGroup = (route) => (
110
+ <RailSection
111
+ level={2}
112
+ label={route.label}
113
+ count={route.children.length}
114
+ /* THE HEADER ALWAYS LINKS (2026-08-02): the label opens the chapter's home,
115
+ * the chevron folds. */
116
+ to={route.path ?? getSectionRootPath(route, basePath)}
117
+ collapsed={isFolded(route)}
118
+ onToggle={() => handleSectionClick(route)}
119
+ onNavigate={onNavigate}
120
+ icon={Icon}
121
+ >
122
+ <nav className="shell-nav-items">
123
+ {route.children.map((child) => (child.children?.length ? (
124
+ <div key={child.id} className="shell-nav-nest">{renderGroup(child)}</div>
125
+ ) : (
126
+ <RailRow key={child.id} to={getChildPath(child, basePath)} onNavigate={onNavigate}>
127
+ {child.label}
128
+ </RailRow>
129
+ )))}
130
+ </nav>
131
+ </RailSection>
132
+ )
76
133
  return (
77
134
  /* One rail layout, one class (2026-08-01). This was `space-y-4` against the
78
135
  * right rail's `space-y-6` against the outer `flex flex-col gap-6` — three
@@ -90,55 +147,30 @@ const ShellSidebar = ({ routes = [], basePath = '/', onNavigate, label = 'Naviga
90
147
  icon={Icon}
91
148
  >
92
149
  <div className="shell-rail-stack-inner">
93
- {routes.map((route) => {
94
- /* A group with no children is not a group — it is a link. It used
95
- * to render as a header anyway: a chevron that rotated over an
96
- * empty body, no count, and no navigation, so clicking "Icons" or
97
- * "Components" in the tree did nothing at all while looking like
98
- * it should. `collapsible` is what that distinction is now. */
99
- const hasChildren = route.children?.length > 0
100
-
150
+ {/* LOOSE ROWS SHARE ONE LIST (the showcase review W12, 2026-09-30 — user: "the spaces are
151
+ * incorrect in the left sidebar"). Each childless entry rendered its own <nav>, and the
152
+ * stack's 16px gap landed between every pair, so a rail of four rows (Search) read 46px
153
+ * apart instead of a list's 26. Consecutive rows are one run now; a chapter breaks it. */}
154
+ {routes.reduce((runs, route) => {
155
+ const last = runs[runs.length - 1]
156
+ if (!route.children?.length && last && !last.group) last.rows.push(route)
157
+ else runs.push(route.children?.length ? { group: route } : { rows: [route] })
158
+ return runs
159
+ }, []).map((run) => (run.group ? (
160
+ <div key={run.group.id} className="shell-nav-group">
161
+ {renderGroup(run.group)}
162
+ </div>
163
+ ) : (
101
164
  /* A CHILDLESS ENTRY IS A ROW, not a chapter (2026-09-30): drawn at L2 it read as an
102
165
  * empty category with a blank caret slot. */
103
- if (!hasChildren) {
104
- return (
105
- <nav key={route.id} className="shell-nav-items">
106
- <RailRow to={route.path ?? getSectionRootPath(route, basePath)} onNavigate={onNavigate}>
107
- {route.label}
108
- </RailRow>
109
- </nav>
110
- )
111
- }
112
-
113
- return (
114
- <div key={route.id} className="shell-nav-group">
115
- <RailSection
116
- level={2}
117
- label={route.label}
118
- count={route.children.length}
119
- /* THE HEADER ALWAYS LINKS (2026-08-02): the label opens the chapter's home,
120
- * the chevron folds. */
121
- to={route.path ?? getSectionRootPath(route, basePath)}
122
- collapsed={isFolded(route)}
123
- onToggle={() => handleSectionClick(route)}
124
- onNavigate={onNavigate}
125
- icon={Icon}
126
- >
127
- <nav className="shell-nav-items">
128
- {route.children.map((child) => (
129
- <RailRow
130
- key={child.id}
131
- to={getChildPath(child, basePath)}
132
- onNavigate={onNavigate}
133
- >
134
- {child.label}
135
- </RailRow>
136
- ))}
137
- </nav>
138
- </RailSection>
139
- </div>
140
- )
141
- })}
166
+ <nav key={run.rows[0].id} className="shell-nav-items">
167
+ {run.rows.map((route) => (
168
+ <RailRow key={route.id} to={route.path ?? getSectionRootPath(route, basePath)} onNavigate={onNavigate}>
169
+ {route.label}
170
+ </RailRow>
171
+ ))}
172
+ </nav>
173
+ )))}
142
174
  </div>
143
175
  </RailSection>
144
176
  </div>