@kolkrabbi/kol-component 0.132.0 → 0.134.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.132.0",
3
+ "version": "0.134.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",
@@ -30,6 +30,7 @@ const snapTo = (v, min, max, step) => {
30
30
  * @param {number} size dial px size (default 80); derives ring + disc radii
31
31
  * @param {boolean} disabled blocks drag + keyboard and dims the control (default false)
32
32
  * @param {Function} formatValue optional readout formatter (value: number) => string; default `${value}%`
33
+ * @param {number} defaultValue alt-click the dial resets to this (falls back to `min`) — Slider carries the same gesture
33
34
  */
34
35
  export default function RotaryDial({
35
36
  label,
@@ -41,6 +42,7 @@ export default function RotaryDial({
41
42
  size = 80,
42
43
  disabled = false,
43
44
  formatValue,
45
+ defaultValue,
44
46
  }) {
45
47
  const [isDragging, setIsDragging] = useState(false)
46
48
  const [localValue, setLocalValue] = useState(value) // visual buffer — updates every move
@@ -102,6 +104,21 @@ export default function RotaryDial({
102
104
  onChange?.(next)
103
105
  }
104
106
 
107
+ /* alt-click resets — the same gesture Slider carries (SliderDualThumbAndPlayhead,
108
+ * 2026-08-30). The two components share one value-control contract, and a
109
+ * reset that worked on the fader but not the knob would split it. Handled on
110
+ * pointer-down BEFORE the drag starts, so an alt-click never also nudges. */
111
+ const handlePointerDownOrReset = (e) => {
112
+ if (e.altKey) {
113
+ e.preventDefault()
114
+ const next = defaultValue ?? min
115
+ setLocalValue(next)
116
+ onChange?.(next)
117
+ return
118
+ }
119
+ handlePointerDown(e)
120
+ }
121
+
105
122
  const outerRadius = size / 2
106
123
  const innerRadius = (size * 0.7) / 2
107
124
  const strokeWidth = 2
@@ -124,7 +141,7 @@ export default function RotaryDial({
124
141
  transform: `rotate(${angle}deg)`,
125
142
  willChange: isDragging ? 'transform' : 'auto',
126
143
  }}
127
- onPointerDown={disabled ? undefined : handlePointerDown}
144
+ onPointerDown={disabled ? undefined : handlePointerDownOrReset}
128
145
  onKeyDown={disabled ? undefined : handleKeyDown}
129
146
  >
130
147
  <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ overflow: 'visible' }}>
@@ -1,22 +1,30 @@
1
- import { useEffect, useId, useMemo, useState } from 'react'
1
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
2
2
  import Input from '../atoms/Input.jsx'
3
3
 
4
4
  /**
5
- * Slider — range slider with label and an editable value readout. The LINEAR
6
- * VARIANT of RotaryDial (atoms/RotaryDial.jsx): both implement the shared
7
- * value-control contract — `value` / `min` / `max` / `step` /
8
- * `onChange(next: number)` / `label` / `size` / `disabled` / `formatValue`.
5
+ * Slider — range slider with label and a value readout. The LINEAR VARIANT of
6
+ * RotaryDial (atoms/RotaryDial.jsx): both implement the shared value-control
7
+ * contract — `value` / `min` / `max` / `step` / `onChange(next: number)` /
8
+ * `label` / `size` / `disabled` / `formatValue` / `defaultValue`.
9
9
  * Controlled; onChange always fires with the plain number.
10
10
  *
11
- * One bare inline row: label · track · editable readout. (Bordered
12
- * `default` and chip `subtle` variants retired 2026-07-08 — minimal is
13
- * the only slider.)
11
+ * One bare inline row: label · track · readout. (Bordered `default` and chip
12
+ * `subtle` variants retired 2026-07-08 — minimal is the only single slider.)
14
13
  *
15
- * The value readout is an editable <Input> (type a value, commit on
16
- * blur / Enter, revert on Escape) a Slider-only extra; RotaryDial's
17
- * readout is display-only (atoms nest no KOL component). Track color is
18
- * exposed as the `--kol-slider-track` CSS variable on `.slider-black`;
19
- * override per-instance via style={{ '--kol-slider-track': '...' }}.
14
+ * Track color is `--kol-slider-track`. Set it per-instance via
15
+ * `style={{ '--kol-slider-track': '…' }}` on this component, or from ANY
16
+ * ancestor `.slider-black` reads it as a fallback rather than declaring it,
17
+ * so it inherits (SliderTrackVariableNotForwarded, kol-mirror 2026-08-30: both
18
+ * routes were documented and neither was wired, so consumers were reaching into
19
+ * `.slider-black` by specificity from their own stylesheets).
20
+ * `--kol-slider-playhead` works the same way on the dual rail.
21
+ *
22
+ * DUAL + PLAYHEAD (SliderDualThumbAndPlayhead, kol-mirror 2026-08-30):
23
+ * `variant="dual"` stacks two native ranges on one rail for an in/out pair,
24
+ * with an optional draggable playhead. Built because mirror was maintaining a
25
+ * verbatim copy of this component's CSS at 10 call sites to get it — 8 of which
26
+ * needed nothing else. The clamp lives HERE, not in the consumer: an in-thumb
27
+ * that can cross its out-thumb is a bug every caller would re-fix.
20
28
  *
21
29
  * @param {Object} props
22
30
  * @param {string} props.label - Slider label text
@@ -31,6 +39,16 @@ import Input from '../atoms/Input.jsx'
31
39
  * @param {string} props.className - Additional wrapper classes
32
40
  * @param {number} props.displayWidth - Width of the value readout, in characters (default: 6)
33
41
  * @param {string} props.fontSize - Font size for label/value (e.g., '11px')
42
+ * @param {Object} props.style - forwarded to the wrapper; the seam for `--kol-slider-track` / `--kol-slider-playhead`
43
+ * @param {number} props.defaultValue - alt-click the control resets to this (falls back to `min`). Same contract on RotaryDial
44
+ * @param {'input'|'value'|'none'} props.readout - `input` (default) an editable Input · `value` a plain right-aligned span, RotaryDial's own display-only readout · `none`
45
+ * @param {'minimal'|'dual'} props.variant - `dual` = two thumbs on one rail
46
+ * @param {number} props.value2 - dual only — the out value
47
+ * @param {Function} props.onChange2 - dual only — (next: number) => void for the out thumb
48
+ * @param {string} props.label1 - dual only — replaces the formatted in value above the rail
49
+ * @param {string} props.label2 - dual only — replaces the formatted out value
50
+ * @param {number|null} props.playhead - dual only — marker position in min…max; null renders nothing and attaches no listeners
51
+ * @param {Function} props.onPlayheadChange - dual only — (next: number) => void; omit and the marker is not draggable and the rail does not seek
34
52
  */
35
53
  const Slider = ({
36
54
  label,
@@ -45,6 +63,16 @@ const Slider = ({
45
63
  className = '',
46
64
  displayWidth = 6,
47
65
  fontSize,
66
+ defaultValue,
67
+ readout = 'input',
68
+ variant = 'minimal',
69
+ value2,
70
+ onChange2,
71
+ label1,
72
+ label2,
73
+ playhead = null,
74
+ onPlayheadChange,
75
+ style,
48
76
  }) => {
49
77
  /* Label ↔ input pairing — the <label> is a sibling of the range input, so
50
78
  * without an htmlFor/id pair the visible label confers no accessible name. */
@@ -64,13 +92,16 @@ const Slider = ({
64
92
  return decimalPart ? decimalPart.length : 2
65
93
  }, [formatValue, step])
66
94
 
67
- const displayValue = useMemo(() => {
68
- if (formatValue) return String(formatValue(value))
69
- if (decimals && decimals > 0) {
70
- return Number(value).toFixed(decimals)
71
- }
72
- return String(Math.round(value))
73
- }, [decimals, formatValue, value])
95
+ const fmt = useCallback(
96
+ (v) => {
97
+ if (formatValue) return String(formatValue(v))
98
+ if (decimals && decimals > 0) return Number(v).toFixed(decimals)
99
+ return String(Math.round(v))
100
+ },
101
+ [decimals, formatValue],
102
+ )
103
+
104
+ const displayValue = useMemo(() => fmt(value), [fmt, value])
74
105
 
75
106
  /* Editable readout — local string state lets the user type intermediate
76
107
  * values (e.g. "-" while entering a negative) without clamping mid-keystroke.
@@ -79,6 +110,39 @@ const Slider = ({
79
110
  const [editing, setEditing] = useState(false)
80
111
  useEffect(() => { if (!editing) setDraft(displayValue) }, [displayValue, editing])
81
112
 
113
+ /* EVERY hook runs before the dual branch returns. The prior art in kol-mirror
114
+ * called useMemo *after* its early return, so hook order changed with the
115
+ * variant — it survived only because no call site switched variant at
116
+ * runtime. Not carried. */
117
+ const trackRef = useRef(null)
118
+
119
+ const seekTo = useCallback(
120
+ (clientX) => {
121
+ const el = trackRef.current
122
+ if (!onPlayheadChange || !el) return
123
+ const rect = el.getBoundingClientRect()
124
+ const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
125
+ onPlayheadChange(min + ratio * (max - min))
126
+ },
127
+ [min, max, onPlayheadChange],
128
+ )
129
+
130
+ const handlePlayheadDrag = useCallback(
131
+ (e) => {
132
+ if (!onPlayheadChange) return
133
+ e.preventDefault()
134
+ seekTo(e.clientX)
135
+ const onMove = (me) => seekTo(me.clientX)
136
+ const onUp = () => {
137
+ window.removeEventListener('pointermove', onMove)
138
+ window.removeEventListener('pointerup', onUp)
139
+ }
140
+ window.addEventListener('pointermove', onMove)
141
+ window.addEventListener('pointerup', onUp)
142
+ },
143
+ [onPlayheadChange, seekTo],
144
+ )
145
+
82
146
  const commit = () => {
83
147
  setEditing(false)
84
148
  const parsed = Number(draft)
@@ -96,8 +160,84 @@ const Slider = ({
96
160
  if (e.key === 'Escape') { setDraft(displayValue); setEditing(false); e.currentTarget.blur() }
97
161
  }
98
162
 
163
+ /* alt-click anywhere on the control resets — RotaryDial carries the same
164
+ * gesture, so a reset that worked on the knob and not the fader would be the
165
+ * shared value-control contract splitting again. */
166
+ const onAltReset = (e) => {
167
+ if (!e.altKey || !onChange || disabled) return
168
+ e.preventDefault()
169
+ onChange(defaultValue ?? min)
170
+ }
171
+
172
+ if (variant === 'dual') {
173
+ const v1 = value ?? min
174
+ const v2 = value2 ?? max
175
+ const showLabels = label1 != null || label2 != null
176
+ const ratio = max > min ? (playhead - min) / (max - min) : 0
177
+ return (
178
+ <div className={`control-slider gap-3 shadow-none ${className}`} style={style}>
179
+ {label && (
180
+ <label
181
+ className={`kol-helper-12 whitespace-nowrap shrink-0 w-fit ${disabled ? 'opacity-50' : ''}`}
182
+ style={fontSize ? { fontSize } : undefined}
183
+ >
184
+ {label}
185
+ </label>
186
+ )}
187
+ <div className="flex-1">
188
+ {showLabels && (
189
+ <div
190
+ className="kol-helper-12 text-fg-32 flex items-center justify-between"
191
+ style={{ marginBottom: '-2px' }}
192
+ >
193
+ <span>{label1 ?? fmt(v1)}</span>
194
+ <span>{label2 ?? fmt(v2)}</span>
195
+ </div>
196
+ )}
197
+ <div
198
+ ref={trackRef}
199
+ className="kol-slider-dual"
200
+ style={onPlayheadChange ? { cursor: 'pointer' } : undefined}
201
+ /* the whole rail seeks, not just the marker — hunting a 3px target
202
+ * to scrub is the thing that makes a playhead feel broken */
203
+ onPointerDown={(e) => { if (e.target === e.currentTarget) seekTo(e.clientX) }}
204
+ >
205
+ <div className="kol-slider-dual-rail" />
206
+ {playhead != null && max > min && (
207
+ <div
208
+ className="kol-slider-dual-playhead"
209
+ /* half a thumb in from each end, so the marker lines up with
210
+ * where a thumb CENTRE can actually reach */
211
+ style={{ left: `calc(6px + (100% - 12px) * ${ratio})` }}
212
+ onPointerDown={handlePlayheadDrag}
213
+ />
214
+ )}
215
+ <input
216
+ type="range"
217
+ min={min} max={max} step={step} value={v1}
218
+ disabled={disabled}
219
+ aria-label={label1 ?? 'In'}
220
+ onChange={(e) => onChange?.(Math.min(Number(e.target.value), v2))}
221
+ className="kol-slider-range kol-slider-range--in"
222
+ style={{ zIndex: 1 }}
223
+ />
224
+ <input
225
+ type="range"
226
+ min={min} max={max} step={step} value={v2}
227
+ disabled={disabled}
228
+ aria-label={label2 ?? 'Out'}
229
+ onChange={(e) => onChange2?.(Math.max(Number(e.target.value), v1))}
230
+ className="kol-slider-range kol-slider-range--out"
231
+ style={{ zIndex: 2 }}
232
+ />
233
+ </div>
234
+ </div>
235
+ </div>
236
+ )
237
+ }
238
+
99
239
  return (
100
- <div className={`control-slider gap-3 shadow-none ${className}`}>
240
+ <div className={`control-slider gap-3 shadow-none ${className}`} style={style} onClick={onAltReset}>
101
241
  {label && (
102
242
  <label
103
243
  htmlFor={sliderId}
@@ -119,20 +259,33 @@ const Slider = ({
119
259
  className={`slider-black cursor-pointer disabled:cursor-default disabled:opacity-50 ${size ? 'flex-none' : 'flex-1 w-full'}`}
120
260
  style={size ? { width: size } : undefined}
121
261
  />
122
- <Input
123
- type="text"
124
- inputMode="decimal"
125
- variant="filled"
126
- size="sm"
127
- chars={displayWidth}
128
- value={draft}
129
- disabled={disabled}
130
- onFocus={(e) => { setEditing(true); e.target.select() }}
131
- onChange={(e) => setDraft(e.target.value)}
132
- onBlur={commit}
133
- onKeyDown={onKeyDown}
134
- inputClassName="text-center"
135
- />
262
+ {readout === 'input' && (
263
+ <Input
264
+ type="text"
265
+ inputMode="decimal"
266
+ variant="filled"
267
+ size="sm"
268
+ chars={displayWidth}
269
+ value={draft}
270
+ disabled={disabled}
271
+ onFocus={(e) => { setEditing(true); e.target.select() }}
272
+ onChange={(e) => setDraft(e.target.value)}
273
+ onBlur={commit}
274
+ onKeyDown={onKeyDown}
275
+ inputClassName="text-center"
276
+ />
277
+ )}
278
+ {/* `value` is RotaryDial's readout, not a second design — a mixer running
279
+ * ~23 faders in a 24px row cannot afford an input chip on every one, and
280
+ * that is why the easy call sites could not move. */}
281
+ {readout === 'value' && (
282
+ <span
283
+ className={`kol-helper-12 shrink-0 w-fit text-right ${disabled ? 'opacity-50' : ''}`}
284
+ style={fontSize ? { fontSize } : undefined}
285
+ >
286
+ {displayValue}
287
+ </span>
288
+ )}
136
289
  </div>
137
290
  )
138
291
  }