@kolkrabbi/kol-component 0.187.0 → 0.189.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 +1 -1
- package/src/molecules/ColorInputRow.jsx +108 -28
- package/src/molecules/DocsToc.jsx +198 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.189.0",
|
|
4
4
|
"description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,8 +41,22 @@ import { usePopover, PopoverPanel } from '../utilities/Popover'
|
|
|
41
41
|
* entry.value on a palette pick
|
|
42
42
|
* label — row label (kol-helper-12); also prefixes aria-labels
|
|
43
43
|
* hideLabel — suppress the visible label (aria keeps it)
|
|
44
|
-
* refs — [{ value, label, hex }]
|
|
45
|
-
*
|
|
44
|
+
* refs — [{ value, label, hex? }] palette entries → popover mode.
|
|
45
|
+
* `hex` may be omitted when `resolveRef` is supplied
|
|
46
|
+
* resolveRef — (value) => hex — the RESOLVER SEAM. Without it, every
|
|
47
|
+
* entry must arrive pre-resolved and a `palette:accent`
|
|
48
|
+
* value cannot be shown at all: the swatch has no hex and
|
|
49
|
+
* the subtitle prints the raw ref. With it, a consumer
|
|
50
|
+
* keeps its own palette and this row renders live against
|
|
51
|
+
* it (editor-set-is-behind-its-source, kol-fxr 2026-09-03 —
|
|
52
|
+
* its ColorField takes `palette` and calls `resolveColor`)
|
|
53
|
+
* autoValue — the THEME state's value, typically a `var(--kol-*)`
|
|
54
|
+
* token that flips with light/dark. Set, the popover
|
|
55
|
+
* offers a Theme button; unset, it does not — a field with
|
|
56
|
+
* no auto value has no theme to fall back to
|
|
57
|
+
* size — control rung for the hex input, 'xs'|'sm'|'md'|'lg'
|
|
58
|
+
* (default 'sm'). A rail renders a dozen of these and the
|
|
59
|
+
* rung is the rail's decision, not each row's
|
|
46
60
|
* locked — lock overlay pinned visible, aria-pressed on the swatch
|
|
47
61
|
* onToggleLock — () => void — swatch click toggles the lock
|
|
48
62
|
* tokenName — resolved token readout (kol-helper-10) → grid mode
|
|
@@ -58,6 +72,9 @@ export default function ColorInputRow({
|
|
|
58
72
|
label,
|
|
59
73
|
hideLabel = false,
|
|
60
74
|
refs,
|
|
75
|
+
resolveRef,
|
|
76
|
+
autoValue,
|
|
77
|
+
size = 'sm',
|
|
61
78
|
locked = false,
|
|
62
79
|
onToggleLock,
|
|
63
80
|
tokenName,
|
|
@@ -67,25 +84,52 @@ export default function ColorInputRow({
|
|
|
67
84
|
className = '',
|
|
68
85
|
}) {
|
|
69
86
|
const hasRefs = Array.isArray(refs) && refs.length > 0
|
|
70
|
-
|
|
87
|
+
/* The popover carries the ref grid AND the quick states, so a field with no
|
|
88
|
+
* palette but an `autoValue` still gets one — that is the whole Theme/None
|
|
89
|
+
* affordance and it has nowhere else to live. */
|
|
90
|
+
const hasPopover = hasRefs || autoValue != null
|
|
91
|
+
const isLockToggle = !hasPopover && typeof onToggleLock === 'function'
|
|
71
92
|
const isGrid = tokenName != null
|
|
72
93
|
const labelVisible = label != null && !hideLabel
|
|
73
94
|
|
|
74
|
-
/*
|
|
75
|
-
*
|
|
95
|
+
/* A value is one of FOUR kinds, and the row has to tell them apart before it
|
|
96
|
+
* can render anything: a literal hex, a palette REF the consumer resolves, a
|
|
97
|
+
* themed `var(--kol-*)` token that flips with light/dark, or null — None.
|
|
98
|
+
* The first port only understood the first and the last. */
|
|
99
|
+
const isVar = typeof value === 'string' && value.startsWith('var(')
|
|
100
|
+
const isNone = value == null
|
|
101
|
+
|
|
102
|
+
/* Display resolution: a refs entry shows its own hex, `resolveRef` resolves
|
|
103
|
+
* anything else the consumer owns (a `palette:` ref, a token), and a bare
|
|
104
|
+
* hex IS the value. `hex` on the entry still wins, so a pre-resolved list
|
|
105
|
+
* needs no resolver and nothing existing moves. */
|
|
106
|
+
const resolve = (v) => {
|
|
107
|
+
if (v == null) return null
|
|
108
|
+
const entry = hasRefs ? refs.find((r) => r.value === v) : undefined
|
|
109
|
+
return entry?.hex ?? resolveRef?.(v) ?? (typeof v === 'string' && v.startsWith('#') ? v : null)
|
|
110
|
+
}
|
|
76
111
|
const activeRef = hasRefs ? refs.find((r) => r.value === value) : undefined
|
|
77
|
-
const displayHex =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
112
|
+
const displayHex = resolve(value)
|
|
113
|
+
/* A themed token renders LIVE in the swatch but has no meaningful hex to
|
|
114
|
+
* print, so the field shows its placeholder rather than a resolved literal
|
|
115
|
+
* the user cannot have typed. */
|
|
116
|
+
const digits = isVar || isNone ? '' : (displayHex ?? '').replace(/^#/, '').toUpperCase()
|
|
117
|
+
const showTransparent = unused || (isNone && !isVar)
|
|
118
|
+
const subtitle = isNone
|
|
119
|
+
? 'None'
|
|
120
|
+
: isVar
|
|
121
|
+
? 'Theme'
|
|
122
|
+
: (activeRef?.label ?? (displayHex ? '#' + digits : String(value)))
|
|
81
123
|
|
|
82
124
|
const [open, setOpen] = useState(false)
|
|
83
125
|
const popover = usePopover({ open, onOpenChange: setOpen, placement: 'bottom-start', offset: 4 })
|
|
84
126
|
|
|
85
|
-
const chip = (
|
|
127
|
+
const chip = (swatchSize) => (
|
|
86
128
|
<ColorSwatch
|
|
87
|
-
|
|
88
|
-
|
|
129
|
+
/* a themed token goes STRAIGHT to the swatch — `var(--kol-x)` is a live
|
|
130
|
+
* paint, and resolving it to a literal would freeze it out of the theme */
|
|
131
|
+
hex={showTransparent ? null : (isVar ? value : displayHex)}
|
|
132
|
+
size={swatchSize}
|
|
89
133
|
showTransparent={showTransparent}
|
|
90
134
|
transparentTone={transparentTone}
|
|
91
135
|
hoverable={false}
|
|
@@ -95,7 +139,7 @@ export default function ColorInputRow({
|
|
|
95
139
|
/* Swatch cell — popover trigger (refs), lock toggle (onToggleLock), or a
|
|
96
140
|
* plain preview chip. The lock overlay is a SIBLING of the swatch: inside
|
|
97
141
|
* it, ColorSwatch's overflow-hidden radius clip would cut the glyph off. */
|
|
98
|
-
const swatchCell =
|
|
142
|
+
const swatchCell = hasPopover ? (
|
|
99
143
|
<button
|
|
100
144
|
type="button"
|
|
101
145
|
ref={popover.refs.setReference}
|
|
@@ -134,10 +178,11 @@ export default function ColorInputRow({
|
|
|
134
178
|
const hexInput = (
|
|
135
179
|
<Input
|
|
136
180
|
variant="filled"
|
|
137
|
-
size=
|
|
181
|
+
size={size}
|
|
138
182
|
prefix="#"
|
|
139
183
|
chars={6}
|
|
140
184
|
maxLength={6}
|
|
185
|
+
placeholder={isVar ? 'auto' : '–'}
|
|
141
186
|
value={digits}
|
|
142
187
|
onChange={(e) => onChange?.('#' + e.target.value.replace(/^#/, '').toUpperCase())}
|
|
143
188
|
disabled={disabled}
|
|
@@ -170,28 +215,63 @@ export default function ColorInputRow({
|
|
|
170
215
|
{hexInput}
|
|
171
216
|
</div>
|
|
172
217
|
)}
|
|
173
|
-
{
|
|
218
|
+
{hasPopover && (
|
|
174
219
|
<PopoverPanel
|
|
175
220
|
popover={popover}
|
|
176
221
|
panel={false}
|
|
177
222
|
focus={false}
|
|
178
|
-
className="bg-surface-secondary border border-fg-08 rounded p-2 shadow-lg"
|
|
223
|
+
className="bg-surface-secondary border border-fg-08 rounded p-2 flex flex-col gap-2 shadow-lg"
|
|
179
224
|
style={{ minWidth: 200 }}
|
|
180
225
|
>
|
|
181
|
-
|
|
182
|
-
|
|
226
|
+
{hasRefs && (
|
|
227
|
+
<div className="grid grid-cols-6 gap-1">
|
|
228
|
+
{refs.map((entry) => (
|
|
229
|
+
<ColorSwatch
|
|
230
|
+
key={entry.value}
|
|
231
|
+
hex={entry.hex ?? resolveRef?.(entry.value) ?? null}
|
|
232
|
+
size="fill"
|
|
233
|
+
selected={entry.value === value}
|
|
234
|
+
title={entry.label}
|
|
235
|
+
onClick={() => {
|
|
236
|
+
onChange?.(entry.value)
|
|
237
|
+
setOpen(false)
|
|
238
|
+
}}
|
|
239
|
+
/>
|
|
240
|
+
))}
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
{/* QUICK STATES. Theme (the auto value — a token that flips with
|
|
244
|
+
light/dark) is offered only where the field HAS one; None is
|
|
245
|
+
always available, because clearing a colour is not a palette
|
|
246
|
+
decision. Both were dropped in the first port, which is what left
|
|
247
|
+
`value == null` renderable but unreachable. */}
|
|
248
|
+
<div className="flex items-center gap-2">
|
|
249
|
+
{autoValue != null && (
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
onClick={() => { onChange?.(autoValue); setOpen(false) }}
|
|
253
|
+
aria-pressed={isVar}
|
|
254
|
+
className="flex items-center gap-1.5 kol-helper-12 text-fg-64 rounded px-1.5 h-6 border border-fg-08"
|
|
255
|
+
>
|
|
256
|
+
<ColorSwatch hex={resolve(autoValue) ?? autoValue} size={14} hoverable={false} />
|
|
257
|
+
Theme
|
|
258
|
+
</button>
|
|
259
|
+
)}
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
onClick={() => { onChange?.(null); setOpen(false) }}
|
|
263
|
+
aria-pressed={isNone}
|
|
264
|
+
className="flex items-center gap-1.5 kol-helper-12 text-fg-64 rounded px-1.5 h-6 border border-fg-08"
|
|
265
|
+
>
|
|
183
266
|
<ColorSwatch
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
onClick={() => {
|
|
190
|
-
onChange?.(entry.value)
|
|
191
|
-
setOpen(false)
|
|
192
|
-
}}
|
|
267
|
+
hex="#FFFFFF"
|
|
268
|
+
size={14}
|
|
269
|
+
showTransparent
|
|
270
|
+
transparentTone={transparentTone}
|
|
271
|
+
hoverable={false}
|
|
193
272
|
/>
|
|
194
|
-
|
|
273
|
+
None
|
|
274
|
+
</button>
|
|
195
275
|
</div>
|
|
196
276
|
</PopoverPanel>
|
|
197
277
|
)}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
1
2
|
import useScrollSpy from '../hooks/useScrollSpy.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -12,16 +13,101 @@ import useScrollSpy from '../hooks/useScrollSpy.js'
|
|
|
12
13
|
* default, smooth-scroll, close a mobile drawer). Labels render verbatim —
|
|
13
14
|
* casing is authored at the call site.
|
|
14
15
|
*
|
|
16
|
+
* TWO RENDERS, ONE CONTRACT (`variant`, 2026-09-03 — `docstoc-rail-variant`,
|
|
17
|
+
* kol-client-olina, which built and ran it before filing):
|
|
18
|
+
*
|
|
19
|
+
* - **`list`** (default) — the docs-sidebar column. No existing call moves.
|
|
20
|
+
* - **`rail`** — a fisheye rail pinned to a page edge (Bederson, *Fisheye
|
|
21
|
+
* Menus*, UIST 2000). One hairline per heading with its label beside it,
|
|
22
|
+
* magnified under the pointer on a gaussian curve, with inert graduations
|
|
23
|
+
* between the headings. It reads as a ruler and behaves as a dial.
|
|
24
|
+
*
|
|
25
|
+
* WHY THE RAIL IS NOT A STYLESHEET OVER THE LIST — three behaviours a CSS file
|
|
26
|
+
* over the flat render cannot express:
|
|
27
|
+
*
|
|
28
|
+
* 1. **The curve.** Each row's magnitude is `exp(-((focus − centre)/falloff)²)`,
|
|
29
|
+
* written to the row as `--m`; every visual derives from it in CSS (scale,
|
|
30
|
+
* opacity, rule width), so JS sets ONE number per row and the cascade does
|
|
31
|
+
* the rest. Gaussian, not linear: linear leaves the pointer a visible cone
|
|
32
|
+
* edge, this has none.
|
|
33
|
+
* 2. **The lens SNAPS.** It never rests between two marks — it locks to the
|
|
34
|
+
* nearest graduation and holds across that mark's whole band, so travelling
|
|
35
|
+
* the rail is a run of countable clicks rather than a smear that settles
|
|
36
|
+
* nowhere. The filer's test, verbatim: *"think about a lock picking thief,
|
|
37
|
+
* he counts the ticks right"*. Snapping to the labelled rows alone was tried
|
|
38
|
+
* and rejected — seven coarse stops, and it throws the ruler away. **The
|
|
39
|
+
* graduations are the clicks; the labels are where the numbers happen to be
|
|
40
|
+
* printed.**
|
|
41
|
+
* 3. **The graduations.** `minors` inert ticks between each pair of headings,
|
|
42
|
+
* carrying the same `--m` — which is what makes the movement read as a lens
|
|
43
|
+
* rather than rows blinking, and gives the rail a scale that seven bare
|
|
44
|
+
* strokes have none of. They are `aria-hidden`, not links, no tab stop: a
|
|
45
|
+
* screen reader wants the headings, not the seventy-seven marks.
|
|
46
|
+
*
|
|
47
|
+
* TWO THINGS THAT BIT THE FILER, kept here so they do not bite twice.
|
|
48
|
+
* `transform` does nothing on a non-replaced INLINE element — the label is a
|
|
49
|
+
* `<span>` and its scale was silently ignored until `display:inline-block`
|
|
50
|
+
* (which is why `.kol-toc-label` sets it in the theme). And transitions are OFF
|
|
51
|
+
* while the pointer drives: the rail carries `data-live` on pointer-enter and
|
|
52
|
+
* the CSS drops every transition under it, because a 120ms ease on top of a
|
|
53
|
+
* continuous input lags the cursor and reads as sluggish. They come back on
|
|
54
|
+
* leave so the wave settles instead of snapping.
|
|
55
|
+
*
|
|
56
|
+
* Row centres are measured on ENTER and on resize, never per pointer-move, and
|
|
57
|
+
* `--m` is written inside one rAF straight to the DOM. Reading geometry in a
|
|
58
|
+
* move handler is what makes this pattern jank; so is re-rendering React seven
|
|
59
|
+
* times a frame to animate seven numbers.
|
|
60
|
+
*
|
|
61
|
+
* HEADING DISCOVERY STAYS THE CONSUMER'S. `toc` is the contract for both
|
|
62
|
+
* variants — reading `<section id>` and its `<h2>` out of the DOM is app
|
|
63
|
+
* knowledge and does not belong in the package.
|
|
64
|
+
*
|
|
15
65
|
* @param {Array<{id: string, label: string}>} toc headings to render + observe
|
|
66
|
+
* @param {'list'|'rail'} variant render (default 'list' — the docs-sidebar column)
|
|
16
67
|
* @param {Function} onNavigate (event) => void — optional click handler on every link
|
|
17
68
|
* @param {string} rootMargin IntersectionObserver rootMargin passed to the
|
|
18
69
|
* spy. Default keeps the ported source's tighter
|
|
19
70
|
* top band (useScrollSpy's own default is
|
|
20
71
|
* '-30% 0px -60% 0px')
|
|
72
|
+
* @param {Element} root IntersectionObserver root passed to the spy
|
|
73
|
+
* @param {number} minors RAIL: graduations between one heading and the next — the dial's resolution, so literally how many clicks a heading is worth (default 10)
|
|
74
|
+
* @param {number} falloff RAIL: the gaussian's sigma in px, measured against the TICK pitch not the label pitch — at 10 graduations of 3px a section spans ~52px, so 14 crosses four or five clicks. Widen it and the whole column swells together, which is a column getting bigger rather than a lens moving over it (default 14)
|
|
75
|
+
* @param {'left'|'right'} position RAIL: which edge it pins to (default 'right')
|
|
76
|
+
* @param {number} minSections RAIL: render nothing under this many headings — an index of one is noise (default 2)
|
|
77
|
+
* @param {string} ariaLabel RAIL: the nav's accessible name (default 'On this page')
|
|
78
|
+
* @param {string} className RAIL: extra classes on the nav — where a consumer puts its own breakpoint (olina hides it under `xl`, where the sidenav is the navigation)
|
|
21
79
|
*/
|
|
22
|
-
export default function DocsToc({
|
|
80
|
+
export default function DocsToc({
|
|
81
|
+
toc,
|
|
82
|
+
variant = 'list',
|
|
83
|
+
onNavigate,
|
|
84
|
+
rootMargin = '-80px 0px -80% 0px',
|
|
85
|
+
root = null,
|
|
86
|
+
minors = 10,
|
|
87
|
+
falloff = 14,
|
|
88
|
+
position = 'right',
|
|
89
|
+
minSections = 2,
|
|
90
|
+
ariaLabel = 'On this page',
|
|
91
|
+
className = '',
|
|
92
|
+
}) {
|
|
23
93
|
const activeId = useScrollSpy(toc.map((item) => item.id), { rootMargin, root })
|
|
24
94
|
|
|
95
|
+
if (variant === 'rail') {
|
|
96
|
+
return (
|
|
97
|
+
<TocRail
|
|
98
|
+
toc={toc}
|
|
99
|
+
activeId={activeId}
|
|
100
|
+
onNavigate={onNavigate}
|
|
101
|
+
minors={minors}
|
|
102
|
+
falloff={falloff}
|
|
103
|
+
position={position}
|
|
104
|
+
minSections={minSections}
|
|
105
|
+
ariaLabel={ariaLabel}
|
|
106
|
+
className={className}
|
|
107
|
+
/>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
25
111
|
return (
|
|
26
112
|
<nav>
|
|
27
113
|
<ul className="shell-nav-items">
|
|
@@ -52,3 +138,114 @@ export default function DocsToc({ toc, onNavigate, rootMargin = '-80px 0px -80%
|
|
|
52
138
|
</nav>
|
|
53
139
|
)
|
|
54
140
|
}
|
|
141
|
+
|
|
142
|
+
/* TocRail — the fisheye render. A sibling component rather than a branch inside
|
|
143
|
+
* the default export, because it owns refs, three pointer handlers and an
|
|
144
|
+
* effect that the list has no use for; hooks that only ever run for one variant
|
|
145
|
+
* do not belong in the other's render path.
|
|
146
|
+
*
|
|
147
|
+
* The magnitudes go to the DOM as `--m`, never to state — see the header. */
|
|
148
|
+
function TocRail({ toc, activeId, onNavigate, minors, falloff, position, minSections, ariaLabel, className }) {
|
|
149
|
+
const navRef = useRef(null)
|
|
150
|
+
const centresRef = useRef([])
|
|
151
|
+
const frameRef = useRef(0)
|
|
152
|
+
|
|
153
|
+
/* Row centres in viewport coords. Measured on enter and on resize — the rail
|
|
154
|
+
* is fixed and its rows do not move while the pointer is inside it. EVERY
|
|
155
|
+
* child is a detent: labels and graduations alike. */
|
|
156
|
+
const measure = useCallback(() => {
|
|
157
|
+
const nav = navRef.current
|
|
158
|
+
if (!nav) return
|
|
159
|
+
centresRef.current = [...nav.children].map((el) => {
|
|
160
|
+
const r = el.getBoundingClientRect()
|
|
161
|
+
return r.top + r.height / 2
|
|
162
|
+
})
|
|
163
|
+
}, [])
|
|
164
|
+
|
|
165
|
+
const paint = useCallback((y) => {
|
|
166
|
+
const nav = navRef.current
|
|
167
|
+
if (!nav) return
|
|
168
|
+
|
|
169
|
+
/* DETENT, on EVERY graduation — the safecracker's dial. The lens locks to
|
|
170
|
+
* the nearest mark and holds across that mark's whole band, so dragging
|
|
171
|
+
* down the rail is a run of discrete clicks rather than a smear. */
|
|
172
|
+
const marks = centresRef.current
|
|
173
|
+
const focus = y == null || !marks.length
|
|
174
|
+
? null
|
|
175
|
+
: marks.reduce((best, c) => (Math.abs(c - y) < Math.abs(best - y) ? c : best), marks[0])
|
|
176
|
+
|
|
177
|
+
marks.forEach((c, i) => {
|
|
178
|
+
const m = focus == null ? 0 : Math.exp(-(((focus - c) / falloff) ** 2))
|
|
179
|
+
nav.children[i]?.style.setProperty('--m', m.toFixed(3))
|
|
180
|
+
})
|
|
181
|
+
}, [falloff])
|
|
182
|
+
|
|
183
|
+
const onMove = useCallback((e) => {
|
|
184
|
+
/* Reduced motion: the curve is the animation, so there is nothing to damp —
|
|
185
|
+
* the lens simply does not run, and the theme's reduced-motion block gives
|
|
186
|
+
* every row its resting size. */
|
|
187
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
|
188
|
+
const y = e.clientY
|
|
189
|
+
cancelAnimationFrame(frameRef.current)
|
|
190
|
+
frameRef.current = requestAnimationFrame(() => paint(y))
|
|
191
|
+
}, [paint])
|
|
192
|
+
|
|
193
|
+
const onLeave = useCallback(() => {
|
|
194
|
+
cancelAnimationFrame(frameRef.current)
|
|
195
|
+
frameRef.current = requestAnimationFrame(() => paint(null))
|
|
196
|
+
}, [paint])
|
|
197
|
+
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
paint(null)
|
|
200
|
+
window.addEventListener('resize', measure)
|
|
201
|
+
return () => {
|
|
202
|
+
window.removeEventListener('resize', measure)
|
|
203
|
+
cancelAnimationFrame(frameRef.current)
|
|
204
|
+
}
|
|
205
|
+
}, [measure, paint, toc])
|
|
206
|
+
|
|
207
|
+
if (toc.length < minSections) return null
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<nav
|
|
211
|
+
ref={navRef}
|
|
212
|
+
aria-label={ariaLabel}
|
|
213
|
+
data-position={position}
|
|
214
|
+
onPointerEnter={(e) => { measure(); e.currentTarget.dataset.live = '' }}
|
|
215
|
+
onPointerMove={onMove}
|
|
216
|
+
onPointerLeave={(e) => { delete e.currentTarget.dataset.live; onLeave() }}
|
|
217
|
+
className={`kol-toc kol-toc--${position} ${className}`.trim()}
|
|
218
|
+
>
|
|
219
|
+
{toc.flatMap(({ id, label }, i) => {
|
|
220
|
+
const active = id === activeId
|
|
221
|
+
const row = (
|
|
222
|
+
<a
|
|
223
|
+
key={id}
|
|
224
|
+
href={`#${id}`}
|
|
225
|
+
onClick={onNavigate}
|
|
226
|
+
aria-current={active ? 'true' : undefined}
|
|
227
|
+
data-major=""
|
|
228
|
+
data-active={active ? '' : undefined}
|
|
229
|
+
className="kol-toc-row"
|
|
230
|
+
>
|
|
231
|
+
{/* ONE rail voice (validate:rails R1). The filer's fork ran
|
|
232
|
+
* `kol-helper-12`, which is a second row ramp inside a rail and
|
|
233
|
+
* exactly what that gate exists to stop — the rung is the rail's,
|
|
234
|
+
* and the fisheye scales FROM it rather than replacing it. If 14
|
|
235
|
+
* proves too heavy under the lens, that is a change to the rail
|
|
236
|
+
* law and belongs in the docs before it belongs here. */}
|
|
237
|
+
<span className="kol-toc-label kol-mono-14 text-meta">{label}</span>
|
|
238
|
+
<span aria-hidden="true" className="kol-toc-rule" />
|
|
239
|
+
</a>
|
|
240
|
+
)
|
|
241
|
+
/* No graduations after the last heading — a ruler ends on a mark. */
|
|
242
|
+
if (i === toc.length - 1) return [row]
|
|
243
|
+
return [row, ...Array.from({ length: minors }, (_, k) => (
|
|
244
|
+
<span key={`${id}-tick-${k}`} aria-hidden="true" className="kol-toc-row kol-toc-tick">
|
|
245
|
+
<span className="kol-toc-rule" />
|
|
246
|
+
</span>
|
|
247
|
+
))]
|
|
248
|
+
})}
|
|
249
|
+
</nav>
|
|
250
|
+
)
|
|
251
|
+
}
|