@kolkrabbi/kol-component 0.316.0 → 0.317.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 +2 -2
- package/src/atoms/Button.jsx +2 -2
- package/src/atoms/SegmentedToggle.jsx +8 -3
- package/src/molecules/PaletteHarmonyWheel.jsx +8 -5
- package/src/molecules/Stepper.jsx +3 -1
- package/src/molecules/TabsRow.jsx +18 -11
- package/src/organisms/ColorPickerPopover.jsx +164 -26
- package/src/organisms/NavRailSite.jsx +155 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.317.1",
|
|
4
4
|
"description": "The core component library",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@kolkrabbi/kol-icons": "^0.
|
|
41
|
+
"@kolkrabbi/kol-icons": "^0.53.1"
|
|
42
42
|
},
|
|
43
43
|
"files": [
|
|
44
44
|
"src",
|
package/src/atoms/Button.jsx
CHANGED
|
@@ -12,7 +12,7 @@ import { glyphSize } from '../hooks/glyphLadders.js'
|
|
|
12
12
|
*
|
|
13
13
|
* @param {Object} props
|
|
14
14
|
* @param {ReactNode} props.children - Button content
|
|
15
|
-
* @param {'accent'|'danger'|'nav'|'tab'} props.variant - The intent; the GROUND is `tone`. `tab` is the tab chip (2026-10-02): quiet text at rest, a soft filled chip while `pressed`. `primary · secondary · outline · ghost · grey · control` are deprecated aliases of tones (2026-10-01, on the retirement ledger): pass `tone` — `secondary` is `tone="inverted"`, `control` is `tone="ghost"`, the rest keep their name. Visual variant. UNSET (the default) inherits the tone of the nearest `kol-tone-*` wrapper, else renders primary; the five ground variants are aliases of `tone`. `danger` is the destructive treatment (--ui-error fill); `nav` is the chrome rung — transparent, oq-64 ink, one step brighter than `ghost`; `control` is an alias for `ghost` (legacy call-sites).
|
|
15
|
+
* @param {'accent'|'danger'|'nav'|'tab'|'tab-filled'} props.variant - The intent; the GROUND is `tone`. `tab` is the tab chip (2026-10-02): quiet text at rest, a soft filled chip while `pressed`. `tab-filled` (2026-10-29) shows its chip at rest (oq-04), brighter on hover (oq-08) and brightest selected (oq-16). `primary · secondary · outline · ghost · grey · control` are deprecated aliases of tones (2026-10-01, on the retirement ledger): pass `tone` — `secondary` is `tone="inverted"`, `control` is `tone="ghost"`, the rest keep their name. Visual variant. UNSET (the default) inherits the tone of the nearest `kol-tone-*` wrapper, else renders primary; the five ground variants are aliases of `tone`. `danger` is the destructive treatment (--ui-error fill); `nav` is the chrome rung — transparent, oq-64 ink, one step brighter than `ghost`; `control` is an alias for `ghost` (legacy call-sites).
|
|
16
16
|
* @param {'xs'|'sm'|'md'|'lg'} props.size - Button size (default: 'md'); xs is the panel rung (ControlsXsRung, 2026-09-01) — kol-mono-8 in a 22px shell (20 icon-only), opt-in by prop
|
|
17
17
|
* @param {string} props.iconLeft - Icon name to display on the left
|
|
18
18
|
* @param {string} props.iconRight - Icon name to display on the right
|
|
@@ -93,7 +93,7 @@ const Button = ({
|
|
|
93
93
|
warned.add(variant)
|
|
94
94
|
console.warn(`Button: variant="${variant}" is deprecated — use tone="${VARIANT_TONE[variant]}"`)
|
|
95
95
|
}
|
|
96
|
-
const KNOWN = ['primary', 'secondary', 'accent', 'outline', 'ghost', 'nav', 'tab', 'danger', 'grey']
|
|
96
|
+
const KNOWN = ['primary', 'secondary', 'accent', 'outline', 'ghost', 'nav', 'tab', 'tab-filled', 'danger', 'grey']
|
|
97
97
|
const known = !resolvedVariant || KNOWN.includes(resolvedVariant)
|
|
98
98
|
if (!known && import.meta.env.DEV) console.warn(`Button: unknown variant "${resolvedVariant}" — rendering the inherited tone (primary)`)
|
|
99
99
|
const variantClass = resolvedVariant && known ? `kol-btn-${resolvedVariant}` : ''
|
|
@@ -35,8 +35,11 @@
|
|
|
35
35
|
* (`aria-disabled`, not the attribute — the tooltip still gives the reason),
|
|
36
36
|
* refuses the press, and ←/→ step over it. It may still be the current `value`
|
|
37
37
|
* (the ground changed under it): selected + disabled, no throw.
|
|
38
|
-
* variant — 'default' (
|
|
39
|
-
*
|
|
38
|
+
* variant — 'default' — BORDERLESS (2026-10-29, the user: *"WE DONT USE BORDER, almost NEVER … DONT USE FUCKING
|
|
39
|
+
* BORDER unless I APPROVE it"*): the `sunken` tone, no outer stroke, the selected cell on the sunken
|
|
40
|
+
* ground. A choice is a Dropdown or text tabs first; a toggle that stays is this one |
|
|
41
|
+
* 'outlined' (the old default: shared outer stroke + dividers, filled active cell — only where he has
|
|
42
|
+
* approved a border) | 'filled' (the state law's tiles: every
|
|
40
43
|
* cell a surface-secondary tile with 1px transparent gaps, NO
|
|
41
44
|
* outline shell; the inset ring marks ONLY the selected cell) |
|
|
42
45
|
* 'tonal' (filled tiles, but the clicked cell marks itself by
|
|
@@ -80,6 +83,8 @@ export default function SegmentedToggle({ value, onChange, options = [], variant
|
|
|
80
83
|
e.currentTarget.children[next]?.focus()
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
/* the default draws borderless — the sunken tone — unless a tone is asked for; `outlined` keeps the stroke */
|
|
87
|
+
const resolvedTone = variant === 'default' && tone === 'default' ? 'sunken' : tone
|
|
83
88
|
return (
|
|
84
89
|
<div
|
|
85
90
|
role={stateless ? 'group' : 'radiogroup'}
|
|
@@ -90,7 +95,7 @@ export default function SegmentedToggle({ value, onChange, options = [], variant
|
|
|
90
95
|
(variant === 'filled' || variant === 'tonal') && 'kol-seg--filled',
|
|
91
96
|
variant === 'tonal' && 'kol-seg--tonal',
|
|
92
97
|
size !== 'md' && `kol-seg--${size}`,
|
|
93
|
-
toneClass(
|
|
98
|
+
toneClass(resolvedTone),
|
|
94
99
|
className,
|
|
95
100
|
].filter(Boolean).join(' ')}
|
|
96
101
|
>
|
|
@@ -86,8 +86,11 @@ export default function PaletteHarmonyWheel({
|
|
|
86
86
|
onHueChange?.(h)
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
/* the ring, its margin and the dots were pixels tuned at the 248 default; they scale with `size` so a small wheel (the lab's
|
|
90
|
+
* harmony tiles, 2026-10-29) draws the same wheel instead of a negative radius. 248 is unchanged */
|
|
91
|
+
const k = size / 248
|
|
92
|
+
const outerR = size / 2 - 8 * k
|
|
93
|
+
const innerR = outerR - 22 * k
|
|
91
94
|
const midR = (outerR + innerR) / 2
|
|
92
95
|
|
|
93
96
|
useEffect(() => {
|
|
@@ -134,7 +137,7 @@ export default function PaletteHarmonyWheel({
|
|
|
134
137
|
const x = cx + Math.cos(a) * midR
|
|
135
138
|
const y = cy + Math.sin(a) * midR
|
|
136
139
|
ctx.beginPath()
|
|
137
|
-
ctx.arc(x, y, 5, 0, Math.PI * 2)
|
|
140
|
+
ctx.arc(x, y, 5 * k, 0, Math.PI * 2)
|
|
138
141
|
ctx.fillStyle = `hsl(${h}, 100%, 50%)`
|
|
139
142
|
ctx.fill()
|
|
140
143
|
ctx.strokeStyle = MARKER_STROKE
|
|
@@ -147,14 +150,14 @@ export default function PaletteHarmonyWheel({
|
|
|
147
150
|
const bx = cx + Math.cos(ba) * midR
|
|
148
151
|
const by = cy + Math.sin(ba) * midR
|
|
149
152
|
ctx.beginPath()
|
|
150
|
-
ctx.arc(bx, by, 9, 0, Math.PI * 2)
|
|
153
|
+
ctx.arc(bx, by, 9 * k, 0, Math.PI * 2)
|
|
151
154
|
ctx.fillStyle = `hsl(${baseHue}, 100%, 50%)`
|
|
152
155
|
ctx.fill()
|
|
153
156
|
ctx.strokeStyle = MARKER_STROKE
|
|
154
157
|
ctx.lineWidth = 2
|
|
155
158
|
ctx.stroke()
|
|
156
159
|
ctx.beginPath()
|
|
157
|
-
ctx.arc(bx, by, 10.5, 0, Math.PI * 2)
|
|
160
|
+
ctx.arc(bx, by, 10.5 * k, 0, Math.PI * 2)
|
|
158
161
|
ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)'
|
|
159
162
|
ctx.lineWidth = 1
|
|
160
163
|
ctx.stroke()
|
|
@@ -92,7 +92,9 @@ export default function Stepper({
|
|
|
92
92
|
|
|
93
93
|
const shellCls = [
|
|
94
94
|
'kol-control',
|
|
95
|
-
|
|
95
|
+
/* NO MODIFIER, as Input (2026-09-03): the shell takes a `kol-tone-*` wrapper's tone, else the filled default. It
|
|
96
|
+
* forced `--filled`, so beside an Input or a Dropdown in a toned rail it drew a second tone (BrandLabToolWindow,
|
|
97
|
+
* 2026-10-29 — the user: *"2 different dropdown tones"*) */
|
|
96
98
|
`kol-control-${size}`,
|
|
97
99
|
SIZE_TYPE[size],
|
|
98
100
|
'relative',
|
|
@@ -31,7 +31,9 @@ import { RAIL_ROW } from '../hooks/railRow.js'
|
|
|
31
31
|
* active tab scrolled into view when it changes — arrow keys included; `wrap` breaks the tabs onto a second line.
|
|
32
32
|
* Every look but `stacked`, which has its phone bar
|
|
33
33
|
* @param {'underline'|'chip'|'text'|'stacked'} [look='underline']
|
|
34
|
-
*
|
|
34
|
+
* @param {boolean} [filled=false] - `chip` only: every chip shows at rest (Button `tab-filled`), so the row reads as choices
|
|
35
|
+
* underline — this row, as it always was; a tab's `icon` sits before its label. `size="md"` is the sans body
|
|
36
|
+
* face with a 20px glyph in a 48px row (the user's ref, Adobe's tab strip, 2026-10-29)
|
|
35
37
|
* chip — the active tab a filled chip, the rest quiet (was `TabChips`); `size` is the control rung
|
|
36
38
|
* text — flat helper-type tabs, the active one bright (was kol-shell's `TabStrip`); `size` is the
|
|
37
39
|
* helper size (default 14), `tracked` adds 1px tracking
|
|
@@ -69,13 +71,13 @@ export const tabbarPillWidth = (count) => count * TABBAR_PILL_TAB + 14
|
|
|
69
71
|
/* the collapse's motion — one duration for the width and the glyph swap (300, user 2026-10-23: 180 read as abrupt) */
|
|
70
72
|
const COLLAPSE_MS = 300
|
|
71
73
|
|
|
72
|
-
export default function TabsRow({ tabs: raw = [], value, onChange, onClose, onMinimise, look = 'underline', placement = 'inline', shape = 'bar', collapsed = false, onExpand, edge, border, shadow, dock, active, foldable, folded, onFoldChange, foldSide, size, tracked = false, overflow = 'scroll', ariaLabel, className, style }) {
|
|
74
|
+
export default function TabsRow({ tabs: raw = [], value, onChange, onClose, onMinimise, look = 'underline', filled = false, placement = 'inline', shape = 'bar', collapsed = false, onExpand, edge, border, shadow, dock, active, foldable, folded, onFoldChange, foldSide, size, tracked = false, overflow = 'scroll', ariaLabel, className, style }) {
|
|
73
75
|
const tabs = raw.map((t) => ({ ...t, id: t.id ?? t.value }))
|
|
74
76
|
const wrap = overflow === 'wrap'
|
|
75
|
-
if (look === 'chip') return <Overflow mode={overflow} value={value}><ChipTabs tabs={tabs} value={value} onChange={onChange} size={size ?? 'sm'} ariaLabel={ariaLabel} className={`${wrap ? 'flex-wrap ' : ''}${className ?? ''}`} /></Overflow>
|
|
77
|
+
if (look === 'chip') return <Overflow mode={overflow} value={value}><ChipTabs tabs={tabs} value={value} onChange={onChange} size={size ?? 'sm'} filled={filled} ariaLabel={ariaLabel} className={`${wrap ? 'flex-wrap ' : ''}${className ?? ''}`} /></Overflow>
|
|
76
78
|
if (look === 'text') return <Overflow mode={overflow} value={value}><TextTabs tabs={tabs} value={value} onChange={onChange} size={size ?? 14} tracked={tracked} className={`${wrap ? 'flex-wrap gap-y-2 ' : ''}${className ?? 'gap-6'}`} style={style} /></Overflow>
|
|
77
79
|
if (look === 'stacked') return <StackedTabs tabs={tabs} value={value} onChange={onChange} placement={placement} shape={shape} collapsed={collapsed} onExpand={onExpand} edge={edge} border={border} shadow={shadow} dock={dock} active={active} foldable={foldable} folded={folded} onFoldChange={onFoldChange} foldSide={foldSide} ariaLabel={ariaLabel} className={className ?? ''} />
|
|
78
|
-
return <Overflow mode={overflow} value={value}><UnderlineTabs tabs={tabs} value={value} onChange={onChange} onClose={onClose} onMinimise={onMinimise} wrap={wrap} /></Overflow>
|
|
80
|
+
return <Overflow mode={overflow} value={value}><UnderlineTabs tabs={tabs} value={value} onChange={onChange} onClose={onClose} onMinimise={onMinimise} wrap={wrap} size={size} /></Overflow>
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
/* `overflow="scroll"`: the row in a sideways scroller that fades its clipped edges and keeps the active tab (`data-active`)
|
|
@@ -98,14 +100,14 @@ function Overflow({ mode, value, children }) {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
/* chip — TabChips verbatim */
|
|
101
|
-
function ChipTabs({ tabs, value, onChange, size, ariaLabel, className }) {
|
|
103
|
+
function ChipTabs({ tabs, value, onChange, size, filled, ariaLabel, className }) {
|
|
102
104
|
return (
|
|
103
|
-
<div className={`flex items-center gap-1 ${className}`.trim()} role="tablist" aria-label={ariaLabel}>
|
|
105
|
+
<div className={`flex items-center ${filled ? 'gap-2' : 'gap-1'} ${className}`.trim()} role="tablist" aria-label={ariaLabel}>
|
|
104
106
|
{tabs.map((t) => {
|
|
105
107
|
const active = t.id === value
|
|
106
108
|
return (
|
|
107
109
|
/* a tab is selected, not pressed — the role's own attribute replaces Button's aria-pressed */
|
|
108
|
-
<Button key={t.id} variant=
|
|
110
|
+
<Button key={t.id} variant={filled ? 'tab-filled' : 'tab'} size={size} pressed={active} role="tab" aria-selected={active} aria-pressed={undefined} data-active={active || undefined} onClick={() => onChange?.(t.id)}>
|
|
109
111
|
{t.label}
|
|
110
112
|
</Button>
|
|
111
113
|
)
|
|
@@ -263,7 +265,9 @@ function StackedTabs({ tabs, value, onChange, placement, shape = 'bar', collapse
|
|
|
263
265
|
)
|
|
264
266
|
}
|
|
265
267
|
|
|
266
|
-
function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap }) {
|
|
268
|
+
function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap, size }) {
|
|
269
|
+
/* `size="md"` (2026-10-29, the user's ref — Adobe's tab strip): the sans body face, a 20px glyph, a 48px row, 24 between tabs */
|
|
270
|
+
const md = size === 'md'
|
|
267
271
|
const listRef = useRef(null)
|
|
268
272
|
const activeIdx = tabs.findIndex((t) => t.id === value)
|
|
269
273
|
|
|
@@ -277,14 +281,14 @@ function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap }) {
|
|
|
277
281
|
}
|
|
278
282
|
|
|
279
283
|
return (
|
|
280
|
-
<div className={`flex items-stretch gap-4 ${wrap ? 'min-h-10' : 'h-10'}`}>
|
|
284
|
+
<div className={`flex items-stretch gap-4 ${wrap ? (md ? 'min-h-12' : 'min-h-10') : md ? 'h-12' : 'h-10'}`}>
|
|
281
285
|
{onClose && (
|
|
282
286
|
/* the one close, at the rung whose glyph is the 12 this hand-rolled
|
|
283
287
|
* (2026-09-03) — xs SOLO is 12, so the drawing does not move */
|
|
284
288
|
<CloseButton onClick={onClose} size="xs" className="self-center" />
|
|
285
289
|
)}
|
|
286
290
|
|
|
287
|
-
<div ref={listRef} role="tablist" onKeyDown={onKeyDown} className={`flex items-stretch gap-4${wrap ? ' flex-wrap gap-y-1' : ''}`}>
|
|
291
|
+
<div ref={listRef} role="tablist" onKeyDown={onKeyDown} className={`flex items-stretch ${md ? 'gap-6' : 'gap-4'}${wrap ? ' flex-wrap gap-y-1' : ''}`}>
|
|
288
292
|
{tabs.map((t, i) => {
|
|
289
293
|
const isActive = t.id === value
|
|
290
294
|
return (
|
|
@@ -297,12 +301,15 @@ function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap }) {
|
|
|
297
301
|
tabIndex={isActive || (activeIdx === -1 && i === 0) ? 0 : -1}
|
|
298
302
|
onClick={() => onChange?.(t.id)}
|
|
299
303
|
className={[
|
|
300
|
-
'kol-mono-12 flex items-center cursor-pointer border-b-2
|
|
304
|
+
`group ${md ? 'kol-sans-body-02 gap-2.5' : 'kol-mono-12 gap-2'} flex items-center cursor-pointer border-b-2`,
|
|
301
305
|
isActive
|
|
302
306
|
? 'text-emphasis border-fg'
|
|
303
307
|
: 'text-meta hover:text-emphasis border-transparent',
|
|
304
308
|
].join(' ')}
|
|
305
309
|
>
|
|
310
|
+
{/* a tab may carry its glyph before the label (2026-10-29, Adobe's Base color · Image · Color wheel) */}
|
|
311
|
+
{/* the glyph in OPAQUE ink (icons take oq, never fg) — full when active, meta otherwise, full under the pointer */}
|
|
312
|
+
{t.icon && <Icon name={t.icon} size={md ? 20 : 16} className={isActive ? 'text-oq-96' : 'text-oq-48 group-hover:text-oq-96'} />}
|
|
306
313
|
{t.label}
|
|
307
314
|
</button>
|
|
308
315
|
)
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
2
|
import ColorSwatch from '../molecules/ColorSwatch.jsx'
|
|
3
3
|
import Input from '../atoms/Input.jsx'
|
|
4
|
+
import Slider from '../molecules/Slider.jsx'
|
|
5
|
+
import Dropdown from '../molecules/Dropdown.jsx'
|
|
6
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
7
|
import { usePopover, PopoverPanel } from '../utilities/Popover.jsx'
|
|
5
8
|
import { HueStrip, SBSquare, AlphaStrip } from './SpectrumControls.jsx'
|
|
6
9
|
import { hexToHsva, hsvaToHex } from '../hooks/colorMath.js'
|
|
@@ -9,6 +12,74 @@ import { resolveCssColor } from '../hooks/cssVar.js'
|
|
|
9
12
|
/* taxonomy-ok: organism — composes the SpectrumControls family (an organism) with a swatch and the hex Input */
|
|
10
13
|
|
|
11
14
|
const SWATCH = { xs: 16, sm: 24 }
|
|
15
|
+
|
|
16
|
+
/* ── THE MODES (review 3, 2026-10-29, Adobe's — the user: *"which mode? HEX HSL HSB GREYSCALE? like everything is
|
|
17
|
+
* missing?"*): HEX · RGB · HSB · Lab, each channel a gradient strip with its thumb and its number ── */
|
|
18
|
+
export const PICKER_MODES = [{ value: 'hex', label: 'HEX' }, { value: 'rgb', label: 'RGB' }, { value: 'hsb', label: 'HSB' }, { value: 'lab', label: 'Lab' }]
|
|
19
|
+
const rgbOf = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16))
|
|
20
|
+
const hexOf = (rgb) => '#' + rgb.map((v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, '0')).join('').toUpperCase()
|
|
21
|
+
/* CIE Lab (D65) ↔ sRGB */
|
|
22
|
+
const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 }
|
|
23
|
+
const gam = (c) => 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055)
|
|
24
|
+
const W = [0.95047, 1, 1.08883]
|
|
25
|
+
const f = (t) => (t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116)
|
|
26
|
+
const fi = (t) => (t ** 3 > 216 / 24389 ? t ** 3 : (116 * t - 16) / (24389 / 27))
|
|
27
|
+
function hexToLab(hex) {
|
|
28
|
+
const [r, g, b] = rgbOf(hex).map(lin)
|
|
29
|
+
const xyz = [0.4124 * r + 0.3576 * g + 0.1805 * b, 0.2126 * r + 0.7152 * g + 0.0722 * b, 0.0193 * r + 0.1192 * g + 0.9505 * b].map((v, i) => f(v / W[i]))
|
|
30
|
+
return [116 * xyz[1] - 16, 500 * (xyz[0] - xyz[1]), 200 * (xyz[1] - xyz[2])]
|
|
31
|
+
}
|
|
32
|
+
function labToHex([L, a, bb]) {
|
|
33
|
+
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - bb / 200
|
|
34
|
+
const [x, y, z] = [fi(fx) * W[0], fi(fy) * W[1], fi(fz) * W[2]]
|
|
35
|
+
return hexOf([3.2406 * x - 1.5372 * y - 0.4986 * z, -0.9689 * x + 1.8758 * y + 0.0415 * z, 0.0557 * x - 0.2040 * y + 1.0570 * z].map(gam))
|
|
36
|
+
}
|
|
37
|
+
/* a mode's three channels: name, range, read from the hsva, write back as a hex */
|
|
38
|
+
const CHANNELS = {
|
|
39
|
+
rgb: [0, 1, 2].map((i) => ({ key: 'RGB'[i], min: 0, max: 255, read: (hex) => rgbOf(hex)[i], write: (hex, v) => { const c = rgbOf(hex); c[i] = v; return hexOf(c) } })),
|
|
40
|
+
hsb: [
|
|
41
|
+
{ key: 'H', min: 0, max: 360, read: (_, h) => h.hue, write: null, hsva: (h, v) => ({ ...h, hue: v }) },
|
|
42
|
+
{ key: 'S', min: 0, max: 100, read: (_, h) => h.sat, write: null, hsva: (h, v) => ({ ...h, sat: v }) },
|
|
43
|
+
{ key: 'B', min: 0, max: 100, read: (_, h) => h.val, write: null, hsva: (h, v) => ({ ...h, val: v }) },
|
|
44
|
+
],
|
|
45
|
+
lab: [
|
|
46
|
+
{ key: 'L', min: 0, max: 100, read: (hex) => hexToLab(hex)[0], write: (hex, v) => { const l = hexToLab(hex); l[0] = v; return labToHex(l) } },
|
|
47
|
+
{ key: 'a', min: -128, max: 127, read: (hex) => hexToLab(hex)[1], write: (hex, v) => { const l = hexToLab(hex); l[1] = v; return labToHex(l) } },
|
|
48
|
+
{ key: 'b', min: -128, max: 127, read: (hex) => hexToLab(hex)[2], write: (hex, v) => { const l = hexToLab(hex); l[2] = v; return labToHex(l) } },
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* one channel: its letter, a strip painted with the channel's own range at the current colour, a thumb, its number */
|
|
53
|
+
/* brightness — Adobe's ☀ ramp under the hue strip in HEX and RGB: HSV value, black to the colour at full brightness */
|
|
54
|
+
const BRIGHTNESS = { key: 'B', label: 'Brightness', icon: 'brightness', min: 0, max: 100, read: (_, h) => h.val, write: null, hsva: (h, v) => ({ ...h, val: v }) }
|
|
55
|
+
|
|
56
|
+
function ChannelStrip({ ch, hex, hsva, onHsva }) {
|
|
57
|
+
const ref = useRef(null)
|
|
58
|
+
const value = ch.read(hex, hsva)
|
|
59
|
+
const at = (v) => (ch.hsva ? hsvaToHex({ ...ch.hsva(hsva, v), alpha: 1 }) : ch.write(hex, v))
|
|
60
|
+
const stops = Array.from({ length: 9 }, (_, i) => at(ch.min + ((ch.max - ch.min) * i) / 8).slice(0, 7))
|
|
61
|
+
const set = (v) => {
|
|
62
|
+
const n = Math.min(ch.max, Math.max(ch.min, v))
|
|
63
|
+
onHsva(ch.hsva ? ch.hsva(hsva, n) : { ...hexToHsva(ch.write(hex, n)), alpha: hsva.alpha })
|
|
64
|
+
}
|
|
65
|
+
const fromPointer = (e) => { const r = ref.current.getBoundingClientRect(); set(ch.min + ((e.clientX - r.left) / r.width) * (ch.max - ch.min)) }
|
|
66
|
+
const pct = ((value - ch.min) / (ch.max - ch.min)) * 100
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex items-center gap-2">
|
|
69
|
+
<span className="flex w-4 shrink-0 items-center kol-mono-12 text-meta">{ch.icon ? <Icon name={ch.icon} size={16} className="text-oq-64" /> : ch.key}</span>
|
|
70
|
+
<div ref={ref} role="slider" aria-label={ch.label ?? ch.key} aria-valuemin={ch.min} aria-valuemax={ch.max} aria-valuenow={Math.round(value)} tabIndex={0}
|
|
71
|
+
className="relative h-4 flex-1 cursor-pointer rounded-full touch-none" style={{ background: `linear-gradient(to right, ${stops.join(', ')})` }}
|
|
72
|
+
onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); fromPointer(e) }}
|
|
73
|
+
onPointerMove={(e) => { if (e.currentTarget.hasPointerCapture(e.pointerId)) fromPointer(e) }}
|
|
74
|
+
onKeyDown={(e) => { const d = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }[e.key]; if (d) { e.preventDefault(); set(value + d * (e.shiftKey ? 10 : 1)) } }}>
|
|
75
|
+
<span className="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full" style={{ left: `${pct}%`, boxShadow: '0 0 0 2px #fff, 0 0 0 3px rgba(0,0,0,0.35)', background: hex.slice(0, 7) }} />
|
|
76
|
+
</div>
|
|
77
|
+
<div className="w-14 shrink-0">
|
|
78
|
+
<Input size="sm" type="number" value={Math.round(value)} aria-label={`${ch.label ?? ch.key} value`} onChange={(e) => { const v = Number(e.target.value); if (!Number.isNaN(v)) set(v) }} />
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
12
83
|
const PANEL_W = 180
|
|
13
84
|
|
|
14
85
|
/* the browser's computed `rgb()` / `rgba()` → '#RRGGBB(AA)' — for `currentColor` and tokens */
|
|
@@ -35,8 +106,20 @@ function cssToHex(css) {
|
|
|
35
106
|
* @param {string} [defaultValue] ⌥-click on the swatch resets to it
|
|
36
107
|
* @param {'xs'|'sm'} [size='xs'] swatch 16 · 24
|
|
37
108
|
* @param {string} [label='Color'] the swatch's accessible name
|
|
109
|
+
* @param {ReactNode} [trigger] what opens the panel instead of the swatch — any node, drawn inside the button (the lab's
|
|
110
|
+
* hex, Adobe's "Edit color": BrandLabToolWindow 2026-10-29). The panel is the same
|
|
111
|
+
* @param {string} [placement='bottom-end'] where the panel opens
|
|
112
|
+
* @param {boolean} [alpha=true] the opacity strip
|
|
113
|
+
* @param {boolean} [brightness=false] a brightness row under the hue (HSV value, 0–100, with its number) — Adobe's
|
|
114
|
+
* @param {boolean} [inline=false] the panel IN the page, no swatch and no popover — the same square, strips and hex,
|
|
115
|
+
* filling its column (the lab's Base color tab, 2026-10-29)
|
|
116
|
+
* @param {ReactNode} [hexEnd] inline only: a control at the end of the hex row (the lab's base lock)
|
|
117
|
+
* @param {boolean} [modes=false] a head row — `title` and a mode Dropdown, HEX · RGB · HSB · Lab — and, under the square and
|
|
118
|
+
* the hue, that mode's fields: the hex, or three channel strips with their numbers (Adobe's)
|
|
119
|
+
* @param {string} [title] the head row's name ("Base", "Edit color")
|
|
38
120
|
*/
|
|
39
|
-
export default function ColorPickerPopover({ color, onChange, defaultValue, size = 'xs', label = 'Color', className = '' }) {
|
|
121
|
+
export default function ColorPickerPopover({ color, onChange, defaultValue, size = 'xs', label = 'Color', trigger, placement = 'bottom-end', alpha = true, brightness = false, inline = false, hexEnd, modes = false, title, className = '' }) {
|
|
122
|
+
const [mode, setMode] = useState('hex')
|
|
40
123
|
const isNone = !color || color === 'transparent'
|
|
41
124
|
const [open, setOpen] = useState(false)
|
|
42
125
|
const [hsva, setHsva] = useState(null)
|
|
@@ -44,7 +127,7 @@ export default function ColorPickerPopover({ color, onChange, defaultValue, size
|
|
|
44
127
|
|
|
45
128
|
const popover = usePopover({
|
|
46
129
|
open,
|
|
47
|
-
placement
|
|
130
|
+
placement,
|
|
48
131
|
offset: 4,
|
|
49
132
|
onOpenChange: (next, event) => {
|
|
50
133
|
if (next && event?.altKey) {
|
|
@@ -80,6 +163,70 @@ export default function ColorPickerPopover({ color, onChange, defaultValue, size
|
|
|
80
163
|
|
|
81
164
|
const opaque = hsva && hsvaToHex({ ...hsva, alpha: 1 })
|
|
82
165
|
|
|
166
|
+
/* INLINE: the working copy follows `color` whenever it changes from outside (a roll, an undo) */
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!inline) return
|
|
169
|
+
const literal = isNone ? '#000000' : hexToHsva(color) ? color : '#000000'
|
|
170
|
+
if (hsva && hsvaToHex(hsva).slice(0, 7).toUpperCase() === literal.slice(0, 7).toUpperCase()) return
|
|
171
|
+
const h = hexToHsva(literal) ?? { hue: 0, sat: 0, val: 0, alpha: 1 }
|
|
172
|
+
setHsva(h)
|
|
173
|
+
setDraft(hsvaToHex(h).slice(1))
|
|
174
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
175
|
+
}, [inline, color])
|
|
176
|
+
|
|
177
|
+
const hexField = (
|
|
178
|
+
/* no variant: the field takes the nearest `kol-tone-*` wrapper (a toned rail), filled where there is none (review 3 —
|
|
179
|
+
* *"input is invisible, just set tone to sunken … so you dont have to set it individually"*) */
|
|
180
|
+
<Input size="sm" prefix="#" maxLength={8} value={draft} onChange={onHex} aria-label={`${label} hex`} />
|
|
181
|
+
)
|
|
182
|
+
const strips = hsva && (
|
|
183
|
+
<>
|
|
184
|
+
<div className="rounded-[var(--kol-radius-xs)] overflow-hidden" style={{ width: inline || modes ? '100%' : PANEL_W, height: inline || modes ? 160 : 140 }}>
|
|
185
|
+
<SBSquare hue={hsva.hue} sat={hsva.sat} val={hsva.val} onChange={(sat, val) => commit({ ...hsva, sat, val })} />
|
|
186
|
+
</div>
|
|
187
|
+
<HueStrip hue={hsva.hue} onChange={(hue) => commit({ ...hsva, hue })} />
|
|
188
|
+
{modes && mode !== 'hex' && CHANNELS[mode].map((ch) => <ChannelStrip key={ch.key} ch={ch} hex={hsvaToHex({ ...hsva, alpha: 1 })} hsva={hsva} onHsva={commit} />)}
|
|
189
|
+
{!modes && brightness && (
|
|
190
|
+
<Slider min={0} max={100} value={Math.round(hsva.val)} onChange={(v) => commit({ ...hsva, val: v })} formatValue={(v) => `${v}`} displayWidth={3} aria-label="Brightness" />
|
|
191
|
+
)}
|
|
192
|
+
{alpha && <AlphaStrip alpha={hsva.alpha} color={opaque} onChange={(a) => commit({ ...hsva, alpha: a })} />}
|
|
193
|
+
</>
|
|
194
|
+
)
|
|
195
|
+
/* the head row: the picker's name and its mode */
|
|
196
|
+
const head = modes && (
|
|
197
|
+
<div className="flex items-center justify-between gap-2">
|
|
198
|
+
<span className="truncate kol-helper-12 text-emphasis">{title ?? label}</span>
|
|
199
|
+
<Dropdown size="sm" value={mode} onChange={setMode} options={PICKER_MODES} />
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if (inline) {
|
|
204
|
+
/* ADOBE'S BASE COLOR PANEL (review 7, refs 68–72): the label row — the name, the mode as plain text at the right;
|
|
205
|
+
* in HEX, ONE field: the swatch, the hex, the lock (Input's slotLeft · slotRight); the square, THEN the hue strip;
|
|
206
|
+
* the mode's channels under them (RGB · HSB · Lab), and brightness — the ☀ ramp — in HEX and RGB */
|
|
207
|
+
const hexField = (!modes || mode === 'hex') && (
|
|
208
|
+
<Input size="md" prefix="#" maxLength={8} value={draft} onChange={onHex} aria-label={`${label} hex`} width="100%"
|
|
209
|
+
slotLeft={<ColorSwatch hex={isNone ? null : color} size={SWATCH.xs} radius="full" hoverable={false} />}
|
|
210
|
+
slotRight={hexEnd} />
|
|
211
|
+
)
|
|
212
|
+
return hsva && (
|
|
213
|
+
<div className={`flex w-full flex-col gap-3 ${className}`.trim()}>
|
|
214
|
+
<div className="flex items-center justify-between gap-3">
|
|
215
|
+
<span className="kol-sans-body-02 text-emphasis">{title ?? label}</span>
|
|
216
|
+
{modes && <Dropdown variant="nav" size="sm" value={mode} onChange={setMode} options={PICKER_MODES} ariaLabel={`${label} mode`} />}
|
|
217
|
+
</div>
|
|
218
|
+
{hexField}
|
|
219
|
+
<div className="w-full overflow-hidden rounded-[var(--kol-radius-xs)]" style={{ height: 200 }}>
|
|
220
|
+
<SBSquare hue={hsva.hue} sat={hsva.sat} val={hsva.val} onChange={(sat, val) => commit({ ...hsva, sat, val })} />
|
|
221
|
+
</div>
|
|
222
|
+
<HueStrip hue={hsva.hue} onChange={(hue) => commit({ ...hsva, hue })} />
|
|
223
|
+
{modes && mode !== 'hex' && CHANNELS[mode].map((ch) => <ChannelStrip key={ch.key} ch={ch} hex={hsvaToHex({ ...hsva, alpha: 1 })} hsva={hsva} onHsva={commit} />)}
|
|
224
|
+
{(!modes || mode === 'hex' || mode === 'rgb') && <ChannelStrip ch={BRIGHTNESS} hex={hsvaToHex({ ...hsva, alpha: 1 })} hsva={hsva} onHsva={commit} />}
|
|
225
|
+
{alpha && <AlphaStrip alpha={hsva.alpha} color={opaque} onChange={(a) => commit({ ...hsva, alpha: a })} />}
|
|
226
|
+
</div>
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
83
230
|
return (
|
|
84
231
|
<>
|
|
85
232
|
<button
|
|
@@ -90,36 +237,27 @@ export default function ColorPickerPopover({ color, onChange, defaultValue, size
|
|
|
90
237
|
aria-expanded={open}
|
|
91
238
|
className={`inline-flex shrink-0 rounded focus-visible:outline-1 focus-visible:outline-offset-2 ${className}`}
|
|
92
239
|
>
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
240
|
+
{trigger ?? (
|
|
241
|
+
<ColorSwatch
|
|
242
|
+
hex={isNone ? null : color}
|
|
243
|
+
size={SWATCH[size] ?? SWATCH.xs}
|
|
244
|
+
showTransparent={isNone}
|
|
245
|
+
transparentTone="error"
|
|
246
|
+
hoverable={false}
|
|
247
|
+
className="border border-[var(--kol-border-default)]"
|
|
248
|
+
/>
|
|
249
|
+
)}
|
|
101
250
|
</button>
|
|
102
251
|
{open && hsva && (
|
|
103
252
|
<PopoverPanel
|
|
104
253
|
popover={popover}
|
|
105
254
|
panel={false}
|
|
106
255
|
className="bg-surface-secondary border border-fg-08 rounded p-2 flex flex-col gap-2 shadow-lg"
|
|
107
|
-
style={{ width: PANEL_W + 16 }}
|
|
256
|
+
style={{ width: modes ? 296 : PANEL_W + 16 }}
|
|
108
257
|
>
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
<HueStrip hue={hsva.hue} onChange={(hue) => commit({ ...hsva, hue })} />
|
|
113
|
-
<AlphaStrip alpha={hsva.alpha} color={opaque} onChange={(alpha) => commit({ ...hsva, alpha })} />
|
|
114
|
-
<Input
|
|
115
|
-
variant="filled"
|
|
116
|
-
size="sm"
|
|
117
|
-
prefix="#"
|
|
118
|
-
maxLength={8}
|
|
119
|
-
value={draft}
|
|
120
|
-
onChange={onHex}
|
|
121
|
-
aria-label={`${label} hex`}
|
|
122
|
-
/>
|
|
258
|
+
{head}
|
|
259
|
+
{strips}
|
|
260
|
+
{(!modes || mode === 'hex') && hexField}
|
|
123
261
|
</PopoverPanel>
|
|
124
262
|
)}
|
|
125
263
|
</>
|
|
@@ -136,6 +136,19 @@ import useScrollFade from '../hooks/useScrollFade.js'
|
|
|
136
136
|
* `defaultOpen` category ids open on first render (default none — the rail loads closed, elder ruling 2026-08-06)
|
|
137
137
|
* `openOnRoute` `true` opens the category holding the current route whenever the route changes (default `false`
|
|
138
138
|
* — nothing auto-expands on arrival); it only adds, never closes what you opened
|
|
139
|
+
* EVERY ROW IS OVERRIDABLE (NavRailRowProps, kol-website 2026-10-29 — user: "props are good. we use variants and presets
|
|
140
|
+
* to define how things ship, but props make things flexible"). All opt-in; with none passed every rail renders as before:
|
|
141
|
+
* `case` the label's case on every level — `as-written` · `upper` · `lower` · `title` · `sentence`;
|
|
142
|
+
* `caseCategory` / `caseGroup` / `casePage` per level (defaults today's: upper, upper, as-written)
|
|
143
|
+
* and a node's own `case` wins. CSS only (text-transform, `::first-letter` for sentence), so the
|
|
144
|
+
* label stays the authored string for screen readers and search
|
|
145
|
+
* `icon` on any node at any depth (page, group label, action row, nested child), through `iconComponent`
|
|
146
|
+
* `iconSize` `{ category, group, page }` (defaults 16, 12, 12); a node's own `iconSize` wins
|
|
147
|
+
* `alignIcons` `true` (default): a level where any row has an icon reserves the icon column for all its rows
|
|
148
|
+
* `collapsedIcons` `'category'` (default) — the collapsed rail shows category glyphs only; `'all'` also shows the
|
|
149
|
+
* icon-bearing pages of an open category under it
|
|
150
|
+
* per node `trailing` (a count, badge or node, right-aligned), `className`, `title` (the tooltip when the
|
|
151
|
+
* label truncates), `external: true` (the external-link glyph, opens a new tab)
|
|
139
152
|
* `background` (2026-08-09, prop): `true` (default) paints the rail surface;
|
|
140
153
|
* `false` renders chromeless so the rail can float over a consumer's media
|
|
141
154
|
* (the brand-hero model — the consumer owns the plane). `hairline` (2026-08-26,
|
|
@@ -177,13 +190,25 @@ const indentOf = (depth) => `calc(var(--kol-pad-chrome-x) + 2rem + ${depth} * va
|
|
|
177
190
|
* router lives in the consumer (kol-framework's `SideNav` wraps this with react-router). A
|
|
178
191
|
* modified click (cmd, ctrl, shift, middle) is left to the browser, as a NavLink left it.
|
|
179
192
|
* Hoisted out of the component so its identity is stable across renders (see `leafProps`). */
|
|
180
|
-
function RouteLeaf({ to, href, active, onNavigate, className, style, children, onPress }) {
|
|
193
|
+
function RouteLeaf({ to, href, active, onNavigate, className, style, title, children, onPress }) {
|
|
181
194
|
return (
|
|
182
|
-
<a href={href} style={style} className={className(active)} aria-current={active ? 'page' : undefined}
|
|
195
|
+
<a href={href} style={style} title={title} className={className(active)} aria-current={active ? 'page' : undefined}
|
|
183
196
|
onClick={(e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; e.preventDefault(); onPress?.(); onNavigate(e, to) }}>{children}</a>
|
|
184
197
|
)
|
|
185
198
|
}
|
|
186
199
|
|
|
200
|
+
/* THE ROW PROPS (NavRailRowProps): a case resolves node → level → rail → the level's own look; the level's own look emits
|
|
201
|
+
* NO class, so a rail without the props carries the classes it always did. Tailwind's case utilities — they outrank the
|
|
202
|
+
* hop's `text-transform: uppercase` (kol-theme) from the utilities layer. */
|
|
203
|
+
const CASE_CLS = { 'as-written': 'normal-case', upper: 'uppercase', lower: 'lowercase', title: 'capitalize', sentence: 'lowercase first-letter:uppercase' }
|
|
204
|
+
const LEVEL_CASE = { category: 'upper', group: 'upper', page: 'as-written' }
|
|
205
|
+
const ICON_SIZE = { category: 16, group: 12, page: 12 }
|
|
206
|
+
const withCls = (...parts) => parts.filter(Boolean).join(' ')
|
|
207
|
+
/* `external`: a plain new-tab link, never the router's */
|
|
208
|
+
const ExternalLeaf = ({ to, className, style, title, children }) => (
|
|
209
|
+
<a href={to} target="_blank" rel="noopener noreferrer" style={style} title={title} className={className}>{children}</a>
|
|
210
|
+
)
|
|
211
|
+
|
|
187
212
|
export default function NavRailSite({
|
|
188
213
|
currentPath: pathname = '',
|
|
189
214
|
/* `(to) => href` — the router's basename on the link a modified click opens (kol-framework's
|
|
@@ -212,7 +237,27 @@ export default function NavRailSite({
|
|
|
212
237
|
fade = true,
|
|
213
238
|
defaultOpen = [],
|
|
214
239
|
openOnRoute = false,
|
|
240
|
+
case: railCase,
|
|
241
|
+
caseCategory,
|
|
242
|
+
caseGroup,
|
|
243
|
+
casePage,
|
|
244
|
+
iconSize: iconSizes,
|
|
245
|
+
alignIcons = true,
|
|
246
|
+
collapsedIcons = 'category',
|
|
215
247
|
}) {
|
|
248
|
+
/* the row props' resolvers (NavRailRowProps) — '' at a level's own look, so the classes stay as they were */
|
|
249
|
+
const caseCls = (node, level) => {
|
|
250
|
+
const c = node.case ?? { category: caseCategory, group: caseGroup, page: casePage }[level] ?? railCase ?? LEVEL_CASE[level]
|
|
251
|
+
return c === LEVEL_CASE[level] ? '' : (CASE_CLS[c] ?? '')
|
|
252
|
+
}
|
|
253
|
+
const sizeOf = (node, level) => node.iconSize ?? iconSizes?.[level] ?? ICON_SIZE[level]
|
|
254
|
+
/* what rides a row's right end: its `trailing`, then the external glyph */
|
|
255
|
+
const tailOf = (node) => (node.trailing != null || node.external) && (
|
|
256
|
+
<>
|
|
257
|
+
{node.trailing != null && <span className="shrink-0 text-subtle">{node.trailing}</span>}
|
|
258
|
+
{node.external && <span className="shrink-0 inline-flex text-subtle" aria-hidden="true"><Icon name="external-link" size={10} /></span>}
|
|
259
|
+
</>
|
|
260
|
+
)
|
|
216
261
|
/* the tree fades where it clips — the head once scrolled, the foot while more lies below (kol-theme `.kol-scroll-fade`;
|
|
217
262
|
* TimeSetAndSiteRailFade, 2026-10-29) */
|
|
218
263
|
const scrollRef = useRef(null)
|
|
@@ -299,19 +344,50 @@ export default function NavRailSite({
|
|
|
299
344
|
/* Rows under a category: a leaf is a route link; a group is a non-routing
|
|
300
345
|
* header over its own rows, one step further in (recursive). A group lights
|
|
301
346
|
* up the way a category does — when one of its leaves is the current route. */
|
|
302
|
-
const renderRows = (rows, depth) =>
|
|
347
|
+
const renderRows = (rows, depth) => {
|
|
348
|
+
/* THE ICON COLUMN (NavRailRowProps): with `alignIcons`, a level where any row has an icon reserves the column for all
|
|
349
|
+
* its rows, as wide as its widest glyph; a level with no icon draws exactly as before */
|
|
350
|
+
const levelOf = (r) => (r.children ? 'group' : 'page')
|
|
351
|
+
const iconW = rows.reduce((w, r) => (r.icon ? Math.max(w, sizeOf(r, levelOf(r))) : w), 0)
|
|
352
|
+
const aligned = alignIcons && iconW > 0
|
|
353
|
+
/* a row with an icon, a reserved column or a tail lays out as glyph · label · tail; its case rides the label span
|
|
354
|
+
* (a flex item, so `::first-letter` applies). A plain row keeps its text bare and the case rides the row. */
|
|
355
|
+
const body = (row, cc) => {
|
|
356
|
+
const tail = tailOf(row)
|
|
357
|
+
if (!row.icon && !aligned && !tail) return { rowCase: cc, content: row.label }
|
|
358
|
+
const size = sizeOf(row, levelOf(row))
|
|
359
|
+
return {
|
|
360
|
+
rowCase: '',
|
|
361
|
+
content: (
|
|
362
|
+
<span className="flex items-center gap-2 min-w-0">
|
|
363
|
+
{(row.icon || aligned) && (
|
|
364
|
+
/* zero-height slot: the 12px glyph centres on the 10px line without growing the row */
|
|
365
|
+
<span className="kol-sidenav-hop-icon inline-flex items-center justify-center shrink-0" style={{ width: aligned ? iconW : size, height: 0 }} aria-hidden="true">
|
|
366
|
+
{row.icon && <Glyph name={row.icon} size={size} />}
|
|
367
|
+
</span>
|
|
368
|
+
)}
|
|
369
|
+
<span className={withCls('flex-1 min-w-0 truncate', cc)}>{row.label}</span>
|
|
370
|
+
{tail}
|
|
371
|
+
</span>
|
|
372
|
+
),
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return rows.map((row, i) => {
|
|
303
376
|
if (row.children) {
|
|
377
|
+
const { rowCase, content } = body(row, caseCls(row, 'group'))
|
|
304
378
|
return (
|
|
305
379
|
<li key={row.id ?? `${row.label}-${i}`}>
|
|
306
380
|
<div
|
|
307
381
|
/* UPPERCASE (user 2026-10-24, the another-creation reference: "look at uppercase sections") — the fork's
|
|
308
382
|
* `uppercase` utility returns, as the reference carries it; the group is a section label in the rows' voice.
|
|
309
383
|
* ONE INK (user 2026-10-24: "just keep them same opacity"): the label never lights — the row's dot says
|
|
310
|
-
* which row is active, and a label that brightened said it twice.
|
|
311
|
-
|
|
384
|
+
* which row is active, and a label that brightened said it twice. `caseGroup` / a node's `case` swap the
|
|
385
|
+
* utility (NavRailRowProps). */
|
|
386
|
+
className={withCls('kol-sidenav-group kol-helper-10', rowCase || 'uppercase', 'text-subtle', row.className)}
|
|
312
387
|
style={depth ? { paddingLeft: indentOf(depth) } : undefined}
|
|
388
|
+
title={row.title}
|
|
313
389
|
>
|
|
314
|
-
{
|
|
390
|
+
{content}
|
|
315
391
|
</div>
|
|
316
392
|
<ul className="kol-sidenav-list">{renderRows(rowsOf(row.children), depth + 1)}</ul>
|
|
317
393
|
</li>
|
|
@@ -321,29 +397,65 @@ export default function NavRailSite({
|
|
|
321
397
|
/* a row under a group sits 2px further in than the step, dot and text together, so the dot's edge lines up under
|
|
322
398
|
* the group label's first letter (user 2026-10-24: "just indent the dot+text by 2px to right") */
|
|
323
399
|
const leafStyle = depth ? { paddingLeft: `calc(${indentOf(depth)} + 2px)`, '--kol-sidenav-dot-left': `calc(var(--kol-pad-chrome-x) + 1.125rem + 2px + ${depth} * var(--kol-spacing-3))` } : pageStyle
|
|
400
|
+
const { rowCase, content } = body(row, caseCls(row, 'page'))
|
|
324
401
|
if (row.onSelect) {
|
|
325
402
|
return (
|
|
326
403
|
<li key={row.id ?? `${row.label}-${i}`}>
|
|
327
404
|
<button
|
|
328
405
|
type="button"
|
|
329
406
|
style={leafStyle}
|
|
330
|
-
className={`${row.active ? pageActiveCls : pageCls} w-full bg-transparent border-0 cursor-pointer text-left truncate
|
|
407
|
+
className={withCls(`${row.active ? pageActiveCls : pageCls} w-full bg-transparent border-0 cursor-pointer text-left truncate`, rowCase, row.className)}
|
|
331
408
|
aria-pressed={!!row.active}
|
|
409
|
+
title={row.title}
|
|
332
410
|
onClick={(e) => handleSelect(e, row)}
|
|
333
411
|
>
|
|
334
|
-
{
|
|
412
|
+
{content}
|
|
335
413
|
</button>
|
|
336
414
|
</li>
|
|
337
415
|
)
|
|
338
416
|
}
|
|
417
|
+
if (row.external) {
|
|
418
|
+
return (
|
|
419
|
+
<li key={row.to}>
|
|
420
|
+
<ExternalLeaf to={row.to} style={leafStyle} title={row.title} className={withCls(pageCls, rowCase, row.className)}>{content}</ExternalLeaf>
|
|
421
|
+
</li>
|
|
422
|
+
)
|
|
423
|
+
}
|
|
339
424
|
return (
|
|
340
425
|
<li key={row.to}>
|
|
341
|
-
<RouteLeaf {...leafProps(row.to)} style={leafStyle} className={(active) => (active ? pageActiveCls : pageCls)}>
|
|
342
|
-
{
|
|
426
|
+
<RouteLeaf {...leafProps(row.to)} style={leafStyle} title={row.title} className={(active) => withCls(active ? pageActiveCls : pageCls, rowCase, row.className)}>
|
|
427
|
+
{content}
|
|
343
428
|
</RouteLeaf>
|
|
344
429
|
</li>
|
|
345
430
|
)
|
|
346
|
-
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/* a page as a collapsed glyph row (`collapsedIcons="all"`) — the hop's own classes, so the collapse geometry centres it */
|
|
435
|
+
const renderIconLeaf = (leaf, i) => {
|
|
436
|
+
const cls = 'kol-sidenav-hop kol-helper-12 text-emphasis no-underline'
|
|
437
|
+
const inner = (
|
|
438
|
+
<>
|
|
439
|
+
<span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
|
|
440
|
+
<Glyph name={leaf.icon} size={sizeOf(leaf, 'page')} />
|
|
441
|
+
</span>
|
|
442
|
+
<span className="kol-sidenav-hop-label flex-1 min-w-0">{leaf.label}</span>
|
|
443
|
+
</>
|
|
444
|
+
)
|
|
445
|
+
return (
|
|
446
|
+
<li key={leaf.to ?? leaf.id ?? `${leaf.label}-${i}`}>
|
|
447
|
+
<Tooltip label={leaf.label} placement="right" triggerClassName="block">
|
|
448
|
+
{leaf.onSelect ? (
|
|
449
|
+
<button type="button" className={`${cls} bg-transparent border-0 cursor-pointer text-left${leaf.active ? ' is-active' : ''}`} aria-pressed={!!leaf.active} onClick={(e) => handleSelect(e, leaf)}>{inner}</button>
|
|
450
|
+
) : leaf.external ? (
|
|
451
|
+
<ExternalLeaf to={leaf.to} className={cls}>{inner}</ExternalLeaf>
|
|
452
|
+
) : (
|
|
453
|
+
<RouteLeaf {...leafProps(leaf.to)} className={(active) => `${cls}${active ? ' is-active' : ''}`}>{inner}</RouteLeaf>
|
|
454
|
+
)}
|
|
455
|
+
</Tooltip>
|
|
456
|
+
</li>
|
|
457
|
+
)
|
|
458
|
+
}
|
|
347
459
|
|
|
348
460
|
/* one renderer for the tree and the pinned rows (RailSideNavPixelParity) */
|
|
349
461
|
const renderCat = (cat, ci) => {
|
|
@@ -368,12 +480,21 @@ export default function NavRailSite({
|
|
|
368
480
|
/* Box utilities deliberately absent — the hop's layout lives in
|
|
369
481
|
* .kol-sidenav-hop (kol-framework.css): a utility here would
|
|
370
482
|
* outrank the collapsed-state overrides (2026-08-09 defect). */
|
|
371
|
-
const rowCls = 'kol-sidenav-hop kol-helper-12 text-emphasis no-underline'
|
|
483
|
+
const rowCls = withCls('kol-sidenav-hop kol-helper-12 text-emphasis no-underline', cat.className)
|
|
372
484
|
const glyph = (
|
|
373
485
|
<span className="kol-sidenav-hop-icon inline-flex items-center justify-center w-5 h-5 shrink-0" aria-hidden="true">
|
|
374
|
-
<Glyph name={cat.icon} size={
|
|
486
|
+
<Glyph name={cat.icon} size={sizeOf(cat, 'category')} />
|
|
375
487
|
</span>
|
|
376
488
|
)
|
|
489
|
+
/* the label slot: the case on it (the hop's uppercase is inherited, so a class here wins); with a tail it
|
|
490
|
+
* lays out label · tail, the case moving onto the label's own span (NavRailRowProps) */
|
|
491
|
+
const cc = caseCls(cat, 'category')
|
|
492
|
+
const tail = tailOf(cat)
|
|
493
|
+
const label = (inner) => (tail ? (
|
|
494
|
+
<span className="kol-sidenav-hop-label flex-1 min-w-0 flex items-center gap-2">
|
|
495
|
+
<span className={withCls('flex-1 min-w-0 truncate', cc)}>{inner}</span>{tail}
|
|
496
|
+
</span>
|
|
497
|
+
) : <span className={withCls('kol-sidenav-hop-label flex-1 min-w-0', cc)}>{inner}</span>)
|
|
377
498
|
/* A category is a <button>, not a link — it has no route by
|
|
378
499
|
definition, and an <a href> would promise a page that does
|
|
379
500
|
not exist. Clicking it discloses its pages. */
|
|
@@ -396,15 +517,20 @@ export default function NavRailSite({
|
|
|
396
517
|
>
|
|
397
518
|
<span className={`kol-sidenav-hop-chevron${isOpen ? '' : ' -rotate-90'}`}><Icon name="chevron-down" size={12} /></span>
|
|
398
519
|
</button>
|
|
399
|
-
<RouteLeaf {...leafProps(cat.to)} onPress={discloseOnTitle ? () => discloseCat(cat.id) : undefined} className={(active) => `${rowCls} kol-sidenav-hop--split${(active || isActiveCat(cat)) ? ' is-active' : ''}`}>
|
|
520
|
+
<RouteLeaf {...leafProps(cat.to)} title={cat.title} onPress={discloseOnTitle ? () => discloseCat(cat.id) : undefined} className={(active) => `${rowCls} kol-sidenav-hop--split${(active || isActiveCat(cat)) ? ' is-active' : ''}`}>
|
|
400
521
|
{glyph}
|
|
401
|
-
<span className="kol-sidenav-hop-
|
|
522
|
+
{label(<span className="kol-sidenav-hop-title">{cat.label}</span>)}
|
|
402
523
|
</RouteLeaf>
|
|
403
524
|
</>
|
|
525
|
+
) : isLink && cat.external ? (
|
|
526
|
+
<ExternalLeaf to={cat.to} title={cat.title} className={rowCls}>
|
|
527
|
+
{glyph}
|
|
528
|
+
{label(cat.label)}
|
|
529
|
+
</ExternalLeaf>
|
|
404
530
|
) : isLink ? (
|
|
405
|
-
<RouteLeaf {...leafProps(cat.to)} className={(active) => `${rowCls}${active ? ' is-active' : ''}`}>
|
|
531
|
+
<RouteLeaf {...leafProps(cat.to)} title={cat.title} className={(active) => `${rowCls}${active ? ' is-active' : ''}`}>
|
|
406
532
|
{glyph}
|
|
407
|
-
|
|
533
|
+
{label(cat.label)}
|
|
408
534
|
</RouteLeaf>
|
|
409
535
|
) : isPanel ? (
|
|
410
536
|
<button
|
|
@@ -412,26 +538,29 @@ export default function NavRailSite({
|
|
|
412
538
|
data-panel-trigger={cat.id}
|
|
413
539
|
className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${openPanel === cat.id ? ' is-active' : ''}`}
|
|
414
540
|
aria-expanded={openPanel === cat.id}
|
|
541
|
+
title={cat.title}
|
|
415
542
|
onClick={() => setOpenPanel((o) => (o === cat.id ? null : cat.id))}
|
|
416
543
|
>
|
|
417
544
|
{glyph}
|
|
418
|
-
|
|
545
|
+
{label(cat.label)}
|
|
419
546
|
</button>
|
|
420
547
|
) : isAction ? (
|
|
421
548
|
<button
|
|
422
549
|
type="button"
|
|
423
550
|
className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${cat.active ? ' is-active' : ''}`}
|
|
424
551
|
aria-pressed={!!cat.active}
|
|
552
|
+
title={cat.title}
|
|
425
553
|
onClick={(e) => handleSelect(e, cat)}
|
|
426
554
|
>
|
|
427
555
|
{glyph}
|
|
428
|
-
|
|
556
|
+
{label(cat.label)}
|
|
429
557
|
</button>
|
|
430
558
|
) : (
|
|
431
559
|
<button
|
|
432
560
|
type="button"
|
|
433
561
|
className={`${rowCls} bg-transparent border-0 cursor-pointer text-left${isActiveCat(cat) ? ' is-active' : ''}`}
|
|
434
562
|
aria-expanded={isOpen}
|
|
563
|
+
title={cat.title}
|
|
435
564
|
/* Collapsed rail: the click meant "open this" — expand the
|
|
436
565
|
* rail AND disclose the category, or the tap is wasted on
|
|
437
566
|
* an expand that lands on a closed tree. */
|
|
@@ -445,7 +574,7 @@ export default function NavRailSite({
|
|
|
445
574
|
}}
|
|
446
575
|
>
|
|
447
576
|
{glyph}
|
|
448
|
-
|
|
577
|
+
{label(cat.label)}
|
|
449
578
|
{/* plain wrapper span — Icon stamps inline-flex on its own
|
|
450
579
|
* element, which would outrank the rail's display:none */}
|
|
451
580
|
<span
|
|
@@ -470,6 +599,12 @@ export default function NavRailSite({
|
|
|
470
599
|
{!isLink && !isAction && !isPanel && isOpen && (
|
|
471
600
|
<ul className="kol-sidenav-list">{renderRows(pages, 0)}</ul>
|
|
472
601
|
)}
|
|
602
|
+
{/* `collapsedIcons="all"` (NavRailRowProps): collapsed, an open category's icon-bearing pages show
|
|
603
|
+
* under it as glyph rows in the hop's geometry, each named by its tooltip; expanded they are the
|
|
604
|
+
* list's rows above. Only mounted collapsed, so the expanded tree is untouched. */}
|
|
605
|
+
{collapsed && collapsedIcons === 'all' && isOpen && pages.length > 0 && (
|
|
606
|
+
<ul className="kol-sidenav-tree flex flex-col gap-[2px]">{leavesOf(pages).filter((l) => l.icon).map(renderIconLeaf)}</ul>
|
|
607
|
+
)}
|
|
473
608
|
</li>
|
|
474
609
|
)
|
|
475
610
|
}
|