@kolkrabbi/kol-component 0.317.6 → 0.317.7
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 +1 -1
- package/src/hooks/useScrollSpy.js +14 -3
- package/src/molecules/DocsToc.jsx +49 -10
package/package.json
CHANGED
|
@@ -46,13 +46,24 @@ export default function useScrollSpy(ids, { rootMargin = '-30% 0px -60% 0px', ed
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/* NESTED IDS (DocsToc `children`, 2026-10-30): a section that contains its
|
|
50
|
+
* entries intersects for as long as any of them does, so the innermost
|
|
51
|
+
* visible element wins, and when an entry leaves while its section is
|
|
52
|
+
* still in the band the section takes it back. Flat ids never contain one
|
|
53
|
+
* another, so both rules are inert for them. */
|
|
54
|
+
const live = new Set()
|
|
49
55
|
const observer = new IntersectionObserver(
|
|
50
56
|
(entries) => {
|
|
57
|
+
entries.forEach((e) => (e.isIntersecting ? live.add(e.target) : live.delete(e.target)))
|
|
51
58
|
if (edgeLockRef.current) return
|
|
52
|
-
const
|
|
53
|
-
|
|
59
|
+
const hits = entries.filter((e) => e.isIntersecting)
|
|
60
|
+
const visible = hits
|
|
61
|
+
.filter((e) => !hits.some((o) => o !== e && e.target.contains(o.target)))
|
|
54
62
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
|
55
|
-
if (visible[0]) setActiveId(visible[0].target.id)
|
|
63
|
+
if (visible[0]) { setActiveId(visible[0].target.id); return }
|
|
64
|
+
const left = entries.find((e) => !e.isIntersecting)
|
|
65
|
+
const owner = left && [...live].find((el) => el.contains(left.target))
|
|
66
|
+
if (owner) setActiveId(owner.id)
|
|
56
67
|
},
|
|
57
68
|
{ root: scroller ?? null, rootMargin, threshold: 0 },
|
|
58
69
|
)
|
|
@@ -58,11 +58,22 @@ import useScrollSpy from '../hooks/useScrollSpy.js'
|
|
|
58
58
|
* move handler is what makes this pattern jank; so is re-rendering React seven
|
|
59
59
|
* times a frame to animate seven numbers.
|
|
60
60
|
*
|
|
61
|
+
* SECTIONED RAIL (`children`, DocsTocRailChildren, kol-atlas 2026-10-30 — the
|
|
62
|
+
* type-scale page wanted each section's ticks to BE its styles). An entry may
|
|
63
|
+
* carry `children: [{ id, label }]`; under it the gap is one tick per child
|
|
64
|
+
* instead of `minors` inert graduations, so the rail's height follows the
|
|
65
|
+
* content. A child tick is today's graduation at rest — 3px, no label — but it
|
|
66
|
+
* is a real link (tab stop, not aria-hidden), its label appears only near the
|
|
67
|
+
* lens's peak (a steeper curve than the section labels, because at a 3px pitch
|
|
68
|
+
* the section curve would print five labels on top of one another), and the
|
|
69
|
+
* spy observes its id: the active child takes the active ink and its section
|
|
70
|
+
* stays lit as the parent. No `children`, no change.
|
|
71
|
+
*
|
|
61
72
|
* HEADING DISCOVERY STAYS THE CONSUMER'S. `toc` is the contract for both
|
|
62
73
|
* variants — reading `<section id>` and its `<h2>` out of the DOM is app
|
|
63
74
|
* knowledge and does not belong in the package.
|
|
64
75
|
*
|
|
65
|
-
* @param {Array<{id: string, label: string}>} toc headings to render + observe
|
|
76
|
+
* @param {Array<{id: string, label: string, children?: Array<{id: string, label: string}>}>} toc headings to render + observe; RAIL: `children` are the section's entries, one tick each in place of `minors`
|
|
66
77
|
* @param {'list'|'rail'} variant render (default 'list' — the docs-sidebar column)
|
|
67
78
|
* @param {Function} onNavigate (event) => void — optional click handler on every link
|
|
68
79
|
* @param {string} rootMargin IntersectionObserver rootMargin passed to the
|
|
@@ -92,13 +103,20 @@ export default function DocsToc({
|
|
|
92
103
|
ariaLabel = 'On this page',
|
|
93
104
|
className = '',
|
|
94
105
|
}) {
|
|
95
|
-
|
|
106
|
+
/* Children are observed too, in document order; the active SECTION is the
|
|
107
|
+
* active id itself or the entry that owns it. */
|
|
108
|
+
const activeId = useScrollSpy(
|
|
109
|
+
toc.flatMap((item) => [item.id, ...(item.children ?? []).map((c) => c.id)]),
|
|
110
|
+
{ rootMargin, root },
|
|
111
|
+
)
|
|
112
|
+
const activeSection = toc.find((item) => item.id === activeId || item.children?.some((c) => c.id === activeId))?.id
|
|
96
113
|
|
|
97
114
|
if (variant === 'rail') {
|
|
98
115
|
return (
|
|
99
116
|
<TocRail
|
|
100
117
|
toc={toc}
|
|
101
118
|
activeId={activeId}
|
|
119
|
+
activeSection={activeSection}
|
|
102
120
|
onNavigate={onNavigate}
|
|
103
121
|
minors={minors}
|
|
104
122
|
falloff={falloff}
|
|
@@ -131,7 +149,7 @@ export default function DocsToc({
|
|
|
131
149
|
* Layout, color, hover and focus live in `.shell-nav-item` now;
|
|
132
150
|
* active is the shared `is-active` marker, not `text-emphasis`
|
|
133
151
|
* typed at one call site. Matches RailRow exactly. */
|
|
134
|
-
className={`shell-nav-item kol-mono-14${
|
|
152
|
+
className={`shell-nav-item kol-mono-14${activeSection === item.id ? ' is-active' : ''}`}
|
|
135
153
|
>
|
|
136
154
|
{item.label}
|
|
137
155
|
</a>
|
|
@@ -148,7 +166,7 @@ export default function DocsToc({
|
|
|
148
166
|
* do not belong in the other's render path.
|
|
149
167
|
*
|
|
150
168
|
* The magnitudes go to the DOM as `--m`, never to state — see the header. */
|
|
151
|
-
function TocRail({ toc, activeId, onNavigate, minors, falloff, snap, position, minSections, ariaLabel, className }) {
|
|
169
|
+
function TocRail({ toc, activeId, activeSection, onNavigate, minors, falloff, snap, position, minSections, ariaLabel, className }) {
|
|
152
170
|
const navRef = useRef(null)
|
|
153
171
|
const centresRef = useRef([])
|
|
154
172
|
const frameRef = useRef(0)
|
|
@@ -182,13 +200,14 @@ function TocRail({ toc, activeId, onNavigate, minors, falloff, snap, position, m
|
|
|
182
200
|
* off no snap at all — the honest fisheye, and better on a long rail
|
|
183
201
|
* with many headings and no graduations
|
|
184
202
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
203
|
+
* The labelled marks are the rail's links — headings and, under a
|
|
204
|
+
* sectioned entry, its children — read off the DOM so the count no longer
|
|
205
|
+
* has to be every (minors + 1)th row. */
|
|
187
206
|
const marks = centresRef.current
|
|
188
207
|
const targets = snap === 'off'
|
|
189
208
|
? null
|
|
190
209
|
: snap === 'label'
|
|
191
|
-
? marks.filter((_, i) => i
|
|
210
|
+
? marks.filter((_, i) => nav.children[i]?.tagName === 'A')
|
|
192
211
|
: marks
|
|
193
212
|
const focus = y == null
|
|
194
213
|
? null
|
|
@@ -200,7 +219,7 @@ function TocRail({ toc, activeId, onNavigate, minors, falloff, snap, position, m
|
|
|
200
219
|
const m = focus == null ? 0 : Math.exp(-(((focus - c) / falloff) ** 2))
|
|
201
220
|
nav.children[i]?.style.setProperty('--m', m.toFixed(3))
|
|
202
221
|
})
|
|
203
|
-
}, [falloff, snap
|
|
222
|
+
}, [falloff, snap])
|
|
204
223
|
|
|
205
224
|
const onMove = useCallback((e) => {
|
|
206
225
|
/* Reduced motion: the curve is the animation, so there is nothing to damp —
|
|
@@ -238,8 +257,8 @@ function TocRail({ toc, activeId, onNavigate, minors, falloff, snap, position, m
|
|
|
238
257
|
onPointerLeave={(e) => { delete e.currentTarget.dataset.live; onLeave() }}
|
|
239
258
|
className={`kol-toc kol-toc--${position} ${className}`.trim()}
|
|
240
259
|
>
|
|
241
|
-
{toc.flatMap(({ id, label }, i) => {
|
|
242
|
-
const active = id ===
|
|
260
|
+
{toc.flatMap(({ id, label, children }, i) => {
|
|
261
|
+
const active = id === activeSection
|
|
243
262
|
const row = (
|
|
244
263
|
<a
|
|
245
264
|
key={id}
|
|
@@ -275,6 +294,26 @@ function TocRail({ toc, activeId, onNavigate, minors, falloff, snap, position, m
|
|
|
275
294
|
<span aria-hidden="true" className="kol-toc-rule" />
|
|
276
295
|
</a>
|
|
277
296
|
)
|
|
297
|
+
/* A sectioned entry's gap is its children, one tick each — a link with
|
|
298
|
+
* its label, not an inert graduation. */
|
|
299
|
+
if (children?.length) {
|
|
300
|
+
return [row, ...children.map((c) => {
|
|
301
|
+
const on = c.id === activeId
|
|
302
|
+
return (
|
|
303
|
+
<a
|
|
304
|
+
key={c.id}
|
|
305
|
+
href={`#${c.id}`}
|
|
306
|
+
onClick={onNavigate}
|
|
307
|
+
aria-current={on ? 'true' : undefined}
|
|
308
|
+
data-active={on ? '' : undefined}
|
|
309
|
+
className="kol-toc-row kol-toc-tick kol-toc-child"
|
|
310
|
+
>
|
|
311
|
+
<span className="kol-toc-label kol-helper-10 text-meta">{c.label}</span>
|
|
312
|
+
<span aria-hidden="true" className="kol-toc-rule" />
|
|
313
|
+
</a>
|
|
314
|
+
)
|
|
315
|
+
})]
|
|
316
|
+
}
|
|
278
317
|
/* No graduations after the last heading — a ruler ends on a mark. */
|
|
279
318
|
if (i === toc.length - 1) return [row]
|
|
280
319
|
return [row, ...Array.from({ length: minors }, (_, k) => (
|