@kolkrabbi/kol-component 0.132.0 → 0.133.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.133.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,26 @@
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 exposed as the `--kol-slider-track` CSS variable on
15
+ * `.slider-black`; override per-instance via
16
+ * style={{ '--kol-slider-track': '...' }}.
17
+ *
18
+ * DUAL + PLAYHEAD (SliderDualThumbAndPlayhead, kol-mirror 2026-08-30):
19
+ * `variant="dual"` stacks two native ranges on one rail for an in/out pair,
20
+ * with an optional draggable playhead. Built because mirror was maintaining a
21
+ * verbatim copy of this component's CSS at 10 call sites to get it — 8 of which
22
+ * needed nothing else. The clamp lives HERE, not in the consumer: an in-thumb
23
+ * that can cross its out-thumb is a bug every caller would re-fix.
20
24
  *
21
25
  * @param {Object} props
22
26
  * @param {string} props.label - Slider label text
@@ -31,6 +35,15 @@ import Input from '../atoms/Input.jsx'
31
35
  * @param {string} props.className - Additional wrapper classes
32
36
  * @param {number} props.displayWidth - Width of the value readout, in characters (default: 6)
33
37
  * @param {string} props.fontSize - Font size for label/value (e.g., '11px')
38
+ * @param {number} props.defaultValue - alt-click the control resets to this (falls back to `min`). Same contract on RotaryDial
39
+ * @param {'input'|'value'|'none'} props.readout - `input` (default) an editable Input · `value` a plain right-aligned span, RotaryDial's own display-only readout · `none`
40
+ * @param {'minimal'|'dual'} props.variant - `dual` = two thumbs on one rail
41
+ * @param {number} props.value2 - dual only — the out value
42
+ * @param {Function} props.onChange2 - dual only — (next: number) => void for the out thumb
43
+ * @param {string} props.label1 - dual only — replaces the formatted in value above the rail
44
+ * @param {string} props.label2 - dual only — replaces the formatted out value
45
+ * @param {number|null} props.playhead - dual only — marker position in min…max; null renders nothing and attaches no listeners
46
+ * @param {Function} props.onPlayheadChange - dual only — (next: number) => void; omit and the marker is not draggable and the rail does not seek
34
47
  */
35
48
  const Slider = ({
36
49
  label,
@@ -45,6 +58,15 @@ const Slider = ({
45
58
  className = '',
46
59
  displayWidth = 6,
47
60
  fontSize,
61
+ defaultValue,
62
+ readout = 'input',
63
+ variant = 'minimal',
64
+ value2,
65
+ onChange2,
66
+ label1,
67
+ label2,
68
+ playhead = null,
69
+ onPlayheadChange,
48
70
  }) => {
49
71
  /* Label ↔ input pairing — the <label> is a sibling of the range input, so
50
72
  * without an htmlFor/id pair the visible label confers no accessible name. */
@@ -64,13 +86,16 @@ const Slider = ({
64
86
  return decimalPart ? decimalPart.length : 2
65
87
  }, [formatValue, step])
66
88
 
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])
89
+ const fmt = useCallback(
90
+ (v) => {
91
+ if (formatValue) return String(formatValue(v))
92
+ if (decimals && decimals > 0) return Number(v).toFixed(decimals)
93
+ return String(Math.round(v))
94
+ },
95
+ [decimals, formatValue],
96
+ )
97
+
98
+ const displayValue = useMemo(() => fmt(value), [fmt, value])
74
99
 
75
100
  /* Editable readout — local string state lets the user type intermediate
76
101
  * values (e.g. "-" while entering a negative) without clamping mid-keystroke.
@@ -79,6 +104,39 @@ const Slider = ({
79
104
  const [editing, setEditing] = useState(false)
80
105
  useEffect(() => { if (!editing) setDraft(displayValue) }, [displayValue, editing])
81
106
 
107
+ /* EVERY hook runs before the dual branch returns. The prior art in kol-mirror
108
+ * called useMemo *after* its early return, so hook order changed with the
109
+ * variant — it survived only because no call site switched variant at
110
+ * runtime. Not carried. */
111
+ const trackRef = useRef(null)
112
+
113
+ const seekTo = useCallback(
114
+ (clientX) => {
115
+ const el = trackRef.current
116
+ if (!onPlayheadChange || !el) return
117
+ const rect = el.getBoundingClientRect()
118
+ const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
119
+ onPlayheadChange(min + ratio * (max - min))
120
+ },
121
+ [min, max, onPlayheadChange],
122
+ )
123
+
124
+ const handlePlayheadDrag = useCallback(
125
+ (e) => {
126
+ if (!onPlayheadChange) return
127
+ e.preventDefault()
128
+ seekTo(e.clientX)
129
+ const onMove = (me) => seekTo(me.clientX)
130
+ const onUp = () => {
131
+ window.removeEventListener('pointermove', onMove)
132
+ window.removeEventListener('pointerup', onUp)
133
+ }
134
+ window.addEventListener('pointermove', onMove)
135
+ window.addEventListener('pointerup', onUp)
136
+ },
137
+ [onPlayheadChange, seekTo],
138
+ )
139
+
82
140
  const commit = () => {
83
141
  setEditing(false)
84
142
  const parsed = Number(draft)
@@ -96,8 +154,84 @@ const Slider = ({
96
154
  if (e.key === 'Escape') { setDraft(displayValue); setEditing(false); e.currentTarget.blur() }
97
155
  }
98
156
 
157
+ /* alt-click anywhere on the control resets — RotaryDial carries the same
158
+ * gesture, so a reset that worked on the knob and not the fader would be the
159
+ * shared value-control contract splitting again. */
160
+ const onAltReset = (e) => {
161
+ if (!e.altKey || !onChange || disabled) return
162
+ e.preventDefault()
163
+ onChange(defaultValue ?? min)
164
+ }
165
+
166
+ if (variant === 'dual') {
167
+ const v1 = value ?? min
168
+ const v2 = value2 ?? max
169
+ const showLabels = label1 != null || label2 != null
170
+ const ratio = max > min ? (playhead - min) / (max - min) : 0
171
+ return (
172
+ <div className={`control-slider gap-3 shadow-none ${className}`}>
173
+ {label && (
174
+ <label
175
+ className={`kol-helper-12 whitespace-nowrap shrink-0 w-fit ${disabled ? 'opacity-50' : ''}`}
176
+ style={fontSize ? { fontSize } : undefined}
177
+ >
178
+ {label}
179
+ </label>
180
+ )}
181
+ <div className="flex-1">
182
+ {showLabels && (
183
+ <div
184
+ className="kol-helper-12 text-fg-32 flex items-center justify-between"
185
+ style={{ marginBottom: '-2px' }}
186
+ >
187
+ <span>{label1 ?? fmt(v1)}</span>
188
+ <span>{label2 ?? fmt(v2)}</span>
189
+ </div>
190
+ )}
191
+ <div
192
+ ref={trackRef}
193
+ className="kol-slider-dual"
194
+ style={onPlayheadChange ? { cursor: 'pointer' } : undefined}
195
+ /* the whole rail seeks, not just the marker — hunting a 3px target
196
+ * to scrub is the thing that makes a playhead feel broken */
197
+ onPointerDown={(e) => { if (e.target === e.currentTarget) seekTo(e.clientX) }}
198
+ >
199
+ <div className="kol-slider-dual-rail" />
200
+ {playhead != null && max > min && (
201
+ <div
202
+ className="kol-slider-dual-playhead"
203
+ /* half a thumb in from each end, so the marker lines up with
204
+ * where a thumb CENTRE can actually reach */
205
+ style={{ left: `calc(6px + (100% - 12px) * ${ratio})` }}
206
+ onPointerDown={handlePlayheadDrag}
207
+ />
208
+ )}
209
+ <input
210
+ type="range"
211
+ min={min} max={max} step={step} value={v1}
212
+ disabled={disabled}
213
+ aria-label={label1 ?? 'In'}
214
+ onChange={(e) => onChange?.(Math.min(Number(e.target.value), v2))}
215
+ className="kol-slider-range kol-slider-range--in"
216
+ style={{ zIndex: 1 }}
217
+ />
218
+ <input
219
+ type="range"
220
+ min={min} max={max} step={step} value={v2}
221
+ disabled={disabled}
222
+ aria-label={label2 ?? 'Out'}
223
+ onChange={(e) => onChange2?.(Math.max(Number(e.target.value), v1))}
224
+ className="kol-slider-range kol-slider-range--out"
225
+ style={{ zIndex: 2 }}
226
+ />
227
+ </div>
228
+ </div>
229
+ </div>
230
+ )
231
+ }
232
+
99
233
  return (
100
- <div className={`control-slider gap-3 shadow-none ${className}`}>
234
+ <div className={`control-slider gap-3 shadow-none ${className}`} onClick={onAltReset}>
101
235
  {label && (
102
236
  <label
103
237
  htmlFor={sliderId}
@@ -119,20 +253,33 @@ const Slider = ({
119
253
  className={`slider-black cursor-pointer disabled:cursor-default disabled:opacity-50 ${size ? 'flex-none' : 'flex-1 w-full'}`}
120
254
  style={size ? { width: size } : undefined}
121
255
  />
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
- />
256
+ {readout === 'input' && (
257
+ <Input
258
+ type="text"
259
+ inputMode="decimal"
260
+ variant="filled"
261
+ size="sm"
262
+ chars={displayWidth}
263
+ value={draft}
264
+ disabled={disabled}
265
+ onFocus={(e) => { setEditing(true); e.target.select() }}
266
+ onChange={(e) => setDraft(e.target.value)}
267
+ onBlur={commit}
268
+ onKeyDown={onKeyDown}
269
+ inputClassName="text-center"
270
+ />
271
+ )}
272
+ {/* `value` is RotaryDial's readout, not a second design — a mixer running
273
+ * ~23 faders in a 24px row cannot afford an input chip on every one, and
274
+ * that is why the easy call sites could not move. */}
275
+ {readout === 'value' && (
276
+ <span
277
+ className={`kol-helper-12 shrink-0 w-fit text-right ${disabled ? 'opacity-50' : ''}`}
278
+ style={fontSize ? { fontSize } : undefined}
279
+ >
280
+ {displayValue}
281
+ </span>
282
+ )}
136
283
  </div>
137
284
  )
138
285
  }