@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 +1 -1
- package/src/atoms/RotaryDial.jsx +18 -1
- package/src/molecules/Slider.jsx +188 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "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",
|
package/src/atoms/RotaryDial.jsx
CHANGED
|
@@ -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 :
|
|
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' }}>
|
package/src/molecules/Slider.jsx
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
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 ·
|
|
12
|
-
* `
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return Number(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
}
|