tweakit 0.6.0 → 0.7.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/AGENTS.md +5 -5
- package/README.md +3 -3
- package/dist/tweaks/auto.js +1 -1
- package/dist/tweaks/auto.js.map +1 -1
- package/dist/tweaks/bezier-S2BOXQBM.js +2 -0
- package/dist/tweaks/bezier-S2BOXQBM.js.map +6 -0
- package/dist/tweaks/chunk-3MZAAMC2.js +2 -0
- package/dist/tweaks/chunk-3MZAAMC2.js.map +6 -0
- package/dist/tweaks/chunk-ETB35KFV.js +2 -0
- package/dist/tweaks/chunk-ETB35KFV.js.map +6 -0
- package/dist/tweaks/chunk-YB5GKWZI.js +2 -0
- package/dist/tweaks/chunk-YB5GKWZI.js.map +6 -0
- package/dist/tweaks/clip-A3VI53BK.js +2 -0
- package/dist/tweaks/clip-A3VI53BK.js.map +6 -0
- package/dist/tweaks/color-UGYMAF3W.js +2 -0
- package/dist/tweaks/core.js +1 -1
- package/dist/tweaks/core.js.map +3 -3
- package/dist/tweaks/fold-GY47HIQA.js +2 -0
- package/dist/tweaks/fold-GY47HIQA.js.map +6 -0
- package/dist/tweaks/gradient-BTX25WSH.js +2 -0
- package/dist/tweaks/gradient-BTX25WSH.js.map +6 -0
- package/dist/tweaks/{heavy-CLD7UAVI.js → heavy-NPFIGHRM.js} +2 -2
- package/dist/tweaks/image-HTQQ4WX6.js +4 -0
- package/dist/tweaks/image-HTQQ4WX6.js.map +6 -0
- package/dist/tweaks/{image-store-WRKY2NPU.js → image-store-QD5SMZXG.js} +1 -1
- package/dist/tweaks/image-store-QD5SMZXG.js.map +6 -0
- package/dist/tweaks/interval-BHPI4LRN.js +2 -0
- package/dist/tweaks/interval-BHPI4LRN.js.map +6 -0
- package/dist/tweaks/markup-QM53TQJZ.js +2 -0
- package/dist/tweaks/markup-QM53TQJZ.js.map +6 -0
- package/dist/tweaks/monitor-722WIYGW.js +3 -0
- package/dist/tweaks/monitor-722WIYGW.js.map +6 -0
- package/dist/tweaks/motion-HMCZK6HJ.js +2 -0
- package/dist/tweaks/motion-HMCZK6HJ.js.map +6 -0
- package/dist/tweaks/plot-D3NQ6XNN.js +3 -0
- package/dist/tweaks/plot-D3NQ6XNN.js.map +6 -0
- package/dist/tweaks/point-XDXKMDXH.js +2 -0
- package/dist/tweaks/point-XDXKMDXH.js.map +6 -0
- package/dist/tweaks/presets-EUWNSCUW.js +2 -0
- package/dist/tweaks/presets-EUWNSCUW.js.map +6 -0
- package/dist/tweaks/shadow-AM7VSRNS.js +2 -0
- package/dist/tweaks/shadow-AM7VSRNS.js.map +6 -0
- package/dist/tweaks/shadow-root-RYUV3AGS.js +3 -0
- package/dist/tweaks/shadow-root-RYUV3AGS.js.map +6 -0
- package/dist/tweaks/sides-JWIURDA4.js +2 -0
- package/dist/tweaks/sides-JWIURDA4.js.map +6 -0
- package/dist/tweaks/spring-BF5GBTPM.js +2 -0
- package/dist/tweaks/spring-BF5GBTPM.js.map +6 -0
- package/dist/tweaks/tabs-PRVUUZXZ.js +2 -0
- package/dist/tweaks/tabs-PRVUUZXZ.js.map +6 -0
- package/dist/tweaks.css +1 -1
- package/dist/tweaks.js +5 -4
- package/dist/tweaks.js.map +3 -3
- package/dist/types/tweaks/enhance.d.ts +3 -2
- package/dist/types/tweaks/types.d.ts +278 -72
- package/package.json +5 -2
- package/src/tweaks/auto.ts +9 -5
- package/src/tweaks/clip.ts +84 -22
- package/src/tweaks/controls/basic.ts +463 -139
- package/src/tweaks/controls/bezier.ts +152 -47
- package/src/tweaks/controls/color.ts +610 -161
- package/src/tweaks/controls/gradient.ts +256 -77
- package/src/tweaks/controls/image.ts +388 -132
- package/src/tweaks/controls/interval.ts +126 -41
- package/src/tweaks/controls/monitor.ts +142 -36
- package/src/tweaks/controls/motion.ts +231 -56
- package/src/tweaks/controls/plot.ts +321 -101
- package/src/tweaks/controls/point.ts +134 -45
- package/src/tweaks/controls/shadow.ts +103 -30
- package/src/tweaks/controls/sides.ts +150 -40
- package/src/tweaks/controls/spring.ts +220 -60
- package/src/tweaks/controls/tabs.ts +39 -18
- package/src/tweaks/core.ts +1 -1
- package/src/tweaks/easing.ts +45 -20
- package/src/tweaks/enhance.ts +21 -6
- package/src/tweaks/feedback.ts +133 -34
- package/src/tweaks/fold.ts +22 -10
- package/src/tweaks/heavy.ts +461 -137
- package/src/tweaks/icons.ts +12 -5
- package/src/tweaks/image-store.ts +131 -43
- package/src/tweaks/lazy.ts +36 -19
- package/src/tweaks/markup.ts +255 -92
- package/src/tweaks/panel.ts +1262 -438
- package/src/tweaks/presets.ts +85 -0
- package/src/tweaks/schema.ts +213 -51
- package/src/tweaks/shadow-root.ts +178 -0
- package/src/tweaks/shared.ts +802 -190
- package/src/tweaks/standalone.ts +120 -30
- package/src/tweaks/types.ts +644 -143
- package/src/tweaks/undo.ts +104 -0
- package/src/tweaks.css +556 -550
- package/src/wide-gamut.ts +473 -125
- package/dist/tweaks/bezier-JFEZMCLP.js +0 -2
- package/dist/tweaks/bezier-JFEZMCLP.js.map +0 -6
- package/dist/tweaks/chunk-6UO4DHBN.js +0 -2
- package/dist/tweaks/chunk-6UO4DHBN.js.map +0 -6
- package/dist/tweaks/chunk-DIQMLBAS.js +0 -2
- package/dist/tweaks/chunk-DIQMLBAS.js.map +0 -6
- package/dist/tweaks/chunk-XE6OUJBG.js +0 -2
- package/dist/tweaks/chunk-XE6OUJBG.js.map +0 -6
- package/dist/tweaks/clip-WW7JYO4X.js +0 -2
- package/dist/tweaks/clip-WW7JYO4X.js.map +0 -6
- package/dist/tweaks/color-YMMFURP6.js +0 -2
- package/dist/tweaks/fold-U6YRZLCJ.js +0 -2
- package/dist/tweaks/fold-U6YRZLCJ.js.map +0 -6
- package/dist/tweaks/gradient-JER7PW4D.js +0 -2
- package/dist/tweaks/gradient-JER7PW4D.js.map +0 -6
- package/dist/tweaks/image-H6QSLGO3.js +0 -4
- package/dist/tweaks/image-H6QSLGO3.js.map +0 -6
- package/dist/tweaks/image-store-WRKY2NPU.js.map +0 -6
- package/dist/tweaks/interval-TEUHQGZN.js +0 -2
- package/dist/tweaks/interval-TEUHQGZN.js.map +0 -6
- package/dist/tweaks/markup-NIBJD6E2.js +0 -2
- package/dist/tweaks/markup-NIBJD6E2.js.map +0 -6
- package/dist/tweaks/monitor-3BY7YCGX.js +0 -3
- package/dist/tweaks/monitor-3BY7YCGX.js.map +0 -6
- package/dist/tweaks/motion-FQPUBKKM.js +0 -2
- package/dist/tweaks/motion-FQPUBKKM.js.map +0 -6
- package/dist/tweaks/plot-2HBNBZSG.js +0 -3
- package/dist/tweaks/plot-2HBNBZSG.js.map +0 -6
- package/dist/tweaks/point-VV6XV3I6.js +0 -2
- package/dist/tweaks/point-VV6XV3I6.js.map +0 -6
- package/dist/tweaks/shadow-5E4E55I2.js +0 -2
- package/dist/tweaks/shadow-5E4E55I2.js.map +0 -6
- package/dist/tweaks/sides-P3STEVYY.js +0 -2
- package/dist/tweaks/sides-P3STEVYY.js.map +0 -6
- package/dist/tweaks/spring-HUVSXNVN.js +0 -2
- package/dist/tweaks/spring-HUVSXNVN.js.map +0 -6
- package/dist/tweaks/tabs-76FW7LQ7.js +0 -2
- package/dist/tweaks/tabs-76FW7LQ7.js.map +0 -6
- /package/dist/tweaks/{color-YMMFURP6.js.map → color-UGYMAF3W.js.map} +0 -0
- /package/dist/tweaks/{heavy-CLD7UAVI.js.map → heavy-NPFIGHRM.js.map} +0 -0
|
@@ -1,10 +1,40 @@
|
|
|
1
1
|
/* The core controls — always-registered constructors (slider, toggle, radio grid, select,
|
|
2
2
|
* buttons, text, folder) plus createControl. Statically imported by the entry, so basic panels build synchronously. */
|
|
3
3
|
import {
|
|
4
|
-
el,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
el,
|
|
5
|
+
btn,
|
|
6
|
+
txt,
|
|
7
|
+
clamp,
|
|
8
|
+
stepPrecision,
|
|
9
|
+
gridEnds,
|
|
10
|
+
roundToStep,
|
|
11
|
+
normalizeRange,
|
|
12
|
+
rangeStep,
|
|
13
|
+
overlapsText,
|
|
14
|
+
optValue,
|
|
15
|
+
optLabel,
|
|
16
|
+
json,
|
|
17
|
+
popover,
|
|
18
|
+
radioButton,
|
|
19
|
+
setRadioActive,
|
|
20
|
+
activeIndex,
|
|
21
|
+
navIndex,
|
|
22
|
+
createSegmented,
|
|
23
|
+
blade,
|
|
24
|
+
foldBody,
|
|
25
|
+
quietFocus,
|
|
26
|
+
activeIn,
|
|
27
|
+
words,
|
|
28
|
+
wireHoverClass,
|
|
29
|
+
onReady,
|
|
30
|
+
onLive,
|
|
31
|
+
registerControl,
|
|
32
|
+
getControl,
|
|
33
|
+
rubberBand,
|
|
34
|
+
rubberRelease,
|
|
35
|
+
EASE_SPRING,
|
|
36
|
+
EASE_GLIDE,
|
|
37
|
+
REDUCE_MOTION,
|
|
8
38
|
} from "../shared.js";
|
|
9
39
|
import { ICON_CHEVRON, chevronIcon } from "../icons.js";
|
|
10
40
|
import type { OnChange } from "../shared.js";
|
|
@@ -17,28 +47,41 @@ function createSlider(meta: Meta, onChange: OnChange): Control {
|
|
|
17
47
|
const label = meta.label;
|
|
18
48
|
let { min, max, step } = normalizeRange(meta.min, meta.max, meta.step, [+(meta.value as number)], label);
|
|
19
49
|
// A hard slider's range is its reachable grid: the ends move in to the first/last grid point,
|
|
20
|
-
// so notches, hashmarks, keyboard ends, aria bounds and the emitted value agree. A soft slider keeps its authored
|
|
50
|
+
// so notches, hashmarks, keyboard ends, aria bounds and the emitted value agree. A soft slider keeps its authored
|
|
51
|
+
// range.
|
|
21
52
|
if (!meta.soft) [min, max] = gridEnds(min, max, step);
|
|
22
53
|
const snap = (max - min) / step <= 6; // past ~6 stops, snapping at every step felt notchy
|
|
23
54
|
const seed = Number.isFinite(+meta.value) ? +meta.value : min; // a non-finite seed → min
|
|
24
55
|
// A soft slider keeps an out-of-range default, as set() does; pull is the detent's tension offset read by render().
|
|
25
|
-
let value = meta.soft && !snap ? seed : clamp(seed, min, max),
|
|
56
|
+
let value = meta.soft && !snap ? seed : clamp(seed, min, max),
|
|
57
|
+
pull = 0;
|
|
26
58
|
const decimals = stepPrecision(step);
|
|
27
59
|
|
|
28
|
-
const wrap = el("div", "
|
|
29
|
-
const track = el("div", "
|
|
30
|
-
const hashes = el("div", "
|
|
31
|
-
const fill = el("div", "
|
|
32
|
-
const handle = el("div", "
|
|
33
|
-
const labelEl = txt("span", "
|
|
34
|
-
const valueEl = el("span", "
|
|
60
|
+
const wrap = el("div", "twk-slider-wrap");
|
|
61
|
+
const track = el("div", "twk-slider");
|
|
62
|
+
const hashes = el("div", "twk-slider-hashmarks");
|
|
63
|
+
const fill = el("div", "twk-slider-fill");
|
|
64
|
+
const handle = el("div", "twk-slider-handle");
|
|
65
|
+
const labelEl = txt("span", "twk-slider-label", label);
|
|
66
|
+
const valueEl = el("span", "twk-slider-value");
|
|
35
67
|
// The readout is a text node plus a muted unit suffix; the param stays the bare number.
|
|
36
|
-
const unit = typeof meta.unit === "string" ? meta.unit.trim() : "",
|
|
68
|
+
const unit = typeof meta.unit === "string" ? meta.unit.trim() : "",
|
|
69
|
+
numText = document.createTextNode("");
|
|
37
70
|
const joined = !unit ? "" : /^[%°]$/.test(unit) ? unit : " " + unit; // "50%", "90°", but "24 px", "300 ms"
|
|
38
|
-
valueEl.append(numText);
|
|
71
|
+
valueEl.append(numText);
|
|
72
|
+
if (unit) valueEl.append(txt("span", "twk-slider-unit", joined));
|
|
39
73
|
// A format that throws or returns a non-string falls back to the digits, so it can't blank the slider.
|
|
40
|
-
const format = meta.format,
|
|
41
|
-
|
|
74
|
+
const format = meta.format,
|
|
75
|
+
parse = meta.parse;
|
|
76
|
+
const show = (v: number) => {
|
|
77
|
+
if (format) {
|
|
78
|
+
try {
|
|
79
|
+
const s = format(v);
|
|
80
|
+
if (typeof s === "string" && s) return s;
|
|
81
|
+
} catch {}
|
|
82
|
+
}
|
|
83
|
+
return v.toFixed(decimals);
|
|
84
|
+
};
|
|
42
85
|
track.append(hashes, fill, handle, labelEl, valueEl);
|
|
43
86
|
wrap.append(track);
|
|
44
87
|
|
|
@@ -49,22 +92,34 @@ function createSlider(meta: Meta, onChange: OnChange): Control {
|
|
|
49
92
|
track.setAttribute("aria-valuemax", String(max));
|
|
50
93
|
|
|
51
94
|
const q = (v: number) => roundToStep(v, min, step); // min/max sit on the grid, so a clamped value rounds inside
|
|
52
|
-
const marks = snap
|
|
53
|
-
|
|
95
|
+
const marks = snap
|
|
96
|
+
? Array.from(
|
|
97
|
+
{ length: Math.max(0, Math.round((max - min) / step) - 1) },
|
|
98
|
+
(_, i) => (((i + 1) * step) / (max - min)) * 100,
|
|
99
|
+
)
|
|
100
|
+
: [];
|
|
101
|
+
for (const pct of marks) {
|
|
102
|
+
const m = el("div", "twk-slider-hashmark");
|
|
103
|
+
m.style.left = pct + "%";
|
|
104
|
+
hashes.append(m);
|
|
105
|
+
}
|
|
54
106
|
|
|
55
107
|
// value stays continuous; only the readout + emitted value round to step.
|
|
56
108
|
const render = () => {
|
|
57
|
-
|
|
109
|
+
// clamp the visual; a soft readout shows its real number
|
|
110
|
+
const pct = clamp(((value - min) / (max - min || 1)) * 100, 0, 100);
|
|
58
111
|
const off = pull ? ` + ${pull.toFixed(1)}px` : "";
|
|
59
112
|
const edge = `${pct}%${off}`;
|
|
60
113
|
fill.style.width = pull ? `calc(${edge})` : pct + "%";
|
|
61
114
|
handle.style.left = `max(5px, calc(${edge} - 9px))`; // the inset hairline rides just inside the fill edge
|
|
62
|
-
const qv = q(value),
|
|
115
|
+
const qv = q(value),
|
|
116
|
+
qvText = show(qv);
|
|
63
117
|
numText.data = qvText;
|
|
64
118
|
track.setAttribute("aria-valuenow", String(qv));
|
|
65
119
|
track.setAttribute("aria-valuetext", qvText + joined);
|
|
66
120
|
// Value-dodge: the handle dims only while its real pixel span overlaps the label or value text.
|
|
67
|
-
const trackW = wrap.offsetWidth,
|
|
121
|
+
const trackW = wrap.offsetWidth,
|
|
122
|
+
valueW = valueEl.offsetWidth;
|
|
68
123
|
if (trackW) {
|
|
69
124
|
// The label's room is what the readout leaves: 12px inset + 8px gap + the readout's 10px inset.
|
|
70
125
|
if (valueW) labelEl.style.maxWidth = Math.max(0, trackW - valueW - 30) + "px";
|
|
@@ -74,7 +129,13 @@ function createSlider(meta: Meta, onChange: OnChange): Control {
|
|
|
74
129
|
};
|
|
75
130
|
render();
|
|
76
131
|
|
|
77
|
-
let rect: DOMRect | null = null,
|
|
132
|
+
let rect: DOMRect | null = null,
|
|
133
|
+
scale = 1,
|
|
134
|
+
downPos: { x: number; y: number } | null = null,
|
|
135
|
+
isClick = true,
|
|
136
|
+
snapTimer: number,
|
|
137
|
+
fineAnchor: { x: number; v: number } | null = null,
|
|
138
|
+
downId: number | null = null;
|
|
78
139
|
const GLIDE_FILL = `width 0.34s ${EASE_GLIDE}`;
|
|
79
140
|
const GLIDE_HANDLE = `left 0.34s ${EASE_GLIDE}, opacity 0.15s, transform 0.2s ${EASE_SPRING}`;
|
|
80
141
|
// Discrete detent: the value commits at 30% of the gap and the handle springs to the notch,
|
|
@@ -86,62 +147,141 @@ function createSlider(meta: Meta, onChange: OnChange): Control {
|
|
|
86
147
|
const pct = clamp((clientX - rect.left) / scale / native, 0, 1);
|
|
87
148
|
return clamp(min + pct * (max - min), min, max);
|
|
88
149
|
};
|
|
89
|
-
|
|
150
|
+
// a non-finite value is ignored; soft may exceed the range
|
|
151
|
+
const set = (v: number | string, fire = true) => {
|
|
152
|
+
v = +v;
|
|
153
|
+
if (!Number.isFinite(v)) return;
|
|
154
|
+
value = snap ? clamp(q(v), min, max) : meta.soft ? v : clamp(v, min, max);
|
|
155
|
+
render();
|
|
156
|
+
if (fire) onChange(q(value));
|
|
157
|
+
};
|
|
90
158
|
|
|
91
159
|
// ── Spring-commit detent (snap sliders) ──
|
|
92
160
|
// Physics in step-index space (notches at integers).
|
|
93
161
|
const STEPS = Math.max(1, Math.round((max - min) / step));
|
|
94
|
-
const C_COMMIT = 0.2,
|
|
162
|
+
const C_COMMIT = 0.2,
|
|
163
|
+
C_LEAN = 0.3,
|
|
164
|
+
C_K = 520,
|
|
165
|
+
C_D = 27; // C_COMMIT = 0.5 − 0.30 (commit fraction)
|
|
95
166
|
const toIdx = (v: number) => (v - min) / step;
|
|
96
|
-
let sx = 0,
|
|
97
|
-
|
|
167
|
+
let sx = 0,
|
|
168
|
+
sv = 0,
|
|
169
|
+
sRAF = 0,
|
|
170
|
+
sDrag = false,
|
|
171
|
+
sPrevEi: number | null = null,
|
|
172
|
+
cursorVal = value,
|
|
173
|
+
sPrevT = 0;
|
|
174
|
+
const springStop = () => {
|
|
175
|
+
if (sRAF) cancelAnimationFrame(sRAF);
|
|
176
|
+
sRAF = 0;
|
|
177
|
+
sPrevT = 0;
|
|
178
|
+
};
|
|
98
179
|
const springFrame = (now: number) => {
|
|
99
|
-
|
|
100
|
-
|
|
180
|
+
// panel torn down mid-drag — bail
|
|
181
|
+
if (!track.isConnected) {
|
|
182
|
+
springStop();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const dt = sPrevT ? Math.min((now - sPrevT) / 1000, 0.05) : 1 / 60;
|
|
186
|
+
sPrevT = now;
|
|
101
187
|
const trackW = wrap.offsetWidth || (rect && rect.width) || 1;
|
|
102
188
|
const ui = toIdx(clamp(cursorVal, min, max));
|
|
103
189
|
const ei = clamp(Math.round(ui + C_COMMIT), 0, STEPS); // eager commit: 30% past the notch
|
|
104
190
|
const tx = sDrag ? ei + C_LEAN * clamp(ui - ei, -0.5, 0.5) : ei; // lean toward the cursor, then settle on the notch
|
|
105
191
|
// Reduced motion: the handle sits on its target every frame. The CSS kill-switch can't reach
|
|
106
192
|
// this, since the detent drives left/width from rAF, not a transition. Read live.
|
|
107
|
-
if (REDUCE_MOTION.matches) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
193
|
+
if (REDUCE_MOTION.matches) {
|
|
194
|
+
sx = tx;
|
|
195
|
+
sv = 0;
|
|
196
|
+
} else {
|
|
197
|
+
let t = dt;
|
|
198
|
+
const h = 1 / 240;
|
|
199
|
+
while (t > 1e-4) {
|
|
200
|
+
const s = Math.min(h, t);
|
|
201
|
+
t -= s;
|
|
202
|
+
const a = C_K * (tx - sx) - C_D * sv;
|
|
203
|
+
sv += a * s;
|
|
204
|
+
sx += sv * s;
|
|
205
|
+
if (sx < 0) {
|
|
206
|
+
sx = 0;
|
|
207
|
+
sv = 0;
|
|
208
|
+
}
|
|
209
|
+
if (sx > STEPS) {
|
|
210
|
+
sx = STEPS;
|
|
211
|
+
sv = 0;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
pull = (((sx - ei) * step) / (max - min || 1)) * trackW; // spring offset from the committed notch, in px
|
|
216
|
+
value = clamp(min + ei * step, min, max);
|
|
217
|
+
render();
|
|
218
|
+
// emit once per commit, not per frame
|
|
219
|
+
if (ei !== sPrevEi) {
|
|
220
|
+
sPrevEi = ei;
|
|
221
|
+
onChange(q(value));
|
|
222
|
+
}
|
|
223
|
+
if (!sDrag && Math.abs(sv) < 0.004 && Math.abs(sx - ei) < 0.004) {
|
|
224
|
+
sx = ei;
|
|
225
|
+
sv = 0;
|
|
226
|
+
pull = 0;
|
|
227
|
+
render();
|
|
228
|
+
springStop();
|
|
229
|
+
track.classList.remove("is-active");
|
|
230
|
+
} else sRAF = requestAnimationFrame(springFrame);
|
|
231
|
+
};
|
|
232
|
+
const springStart = (clientX: number) => {
|
|
233
|
+
sx = toIdx(value);
|
|
234
|
+
sv = 0;
|
|
235
|
+
sDrag = true;
|
|
236
|
+
sPrevEi = Math.round(toIdx(value));
|
|
237
|
+
cursorVal = valFromX(clientX);
|
|
238
|
+
if (!sRAF) sRAF = requestAnimationFrame(springFrame);
|
|
114
239
|
};
|
|
115
|
-
const springStart = (clientX: number) => { sx = toIdx(value); sv = 0; sDrag = true; sPrevEi = Math.round(toIdx(value)); cursorVal = valFromX(clientX); if (!sRAF) sRAF = requestAnimationFrame(springFrame); };
|
|
116
240
|
|
|
117
241
|
track.addEventListener("pointerdown", (e) => {
|
|
118
242
|
if (valueEl.classList.contains("is-editing")) return;
|
|
119
243
|
if (e.button !== 0 || downPos) return; // primary button only; no second-pointer hijack
|
|
120
244
|
e.preventDefault();
|
|
121
|
-
// preventDefault suppressed click-to-focus; restore it without the keyboard ring (programmatic focus otherwise
|
|
245
|
+
// preventDefault suppressed click-to-focus; restore it without the keyboard ring (programmatic focus otherwise
|
|
246
|
+
// reads as keyboard).
|
|
122
247
|
track.focus({ focusVisible: false });
|
|
123
248
|
downId = e.pointerId;
|
|
124
|
-
try {
|
|
125
|
-
|
|
126
|
-
|
|
249
|
+
try {
|
|
250
|
+
(e.target as Element).setPointerCapture(e.pointerId);
|
|
251
|
+
} catch {}
|
|
252
|
+
clearTimeout(snapTimer);
|
|
253
|
+
track.style.transition = "";
|
|
254
|
+
downPos = { x: e.clientX, y: e.clientY };
|
|
255
|
+
isClick = true;
|
|
127
256
|
track.classList.add("is-active");
|
|
128
|
-
rect = wrap.getBoundingClientRect();
|
|
129
|
-
|
|
257
|
+
rect = wrap.getBoundingClientRect();
|
|
258
|
+
scale = rect.width / (wrap.offsetWidth || rect.width);
|
|
259
|
+
// Snap springs to the pressed notch; continuous glides there, the glide cleared on the first move so dragging stays
|
|
260
|
+
// 1:1.
|
|
130
261
|
if (snap) {
|
|
131
|
-
fill.style.transition = "";
|
|
262
|
+
fill.style.transition = "";
|
|
263
|
+
handle.style.transition = "";
|
|
132
264
|
springStart(e.clientX);
|
|
133
265
|
} else {
|
|
134
|
-
fill.style.transition = GLIDE_FILL;
|
|
266
|
+
fill.style.transition = GLIDE_FILL;
|
|
267
|
+
handle.style.transition = GLIDE_HANDLE;
|
|
135
268
|
set(valFromX(e.clientX));
|
|
136
269
|
}
|
|
137
270
|
});
|
|
138
271
|
track.addEventListener("pointermove", (e) => {
|
|
139
272
|
if (!downPos || e.pointerId !== downId) return;
|
|
140
273
|
// Released outside the window: a captured pointerup never reaches us, so bail on the next move.
|
|
141
|
-
if (e.buttons === 0) {
|
|
274
|
+
if (e.buttons === 0) {
|
|
275
|
+
up();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
142
278
|
if (isClick && Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y) > CLICK_THRESHOLD) {
|
|
143
|
-
isClick = false;
|
|
144
|
-
|
|
279
|
+
isClick = false;
|
|
280
|
+
track.classList.add("is-dragging");
|
|
281
|
+
if (!snap) {
|
|
282
|
+
fill.style.transition = "";
|
|
283
|
+
handle.style.transition = "";
|
|
284
|
+
}
|
|
145
285
|
}
|
|
146
286
|
if (!isClick) {
|
|
147
287
|
rubberBand(track, rect, e.clientX);
|
|
@@ -166,15 +306,21 @@ function createSlider(meta: Meta, onChange: OnChange): Control {
|
|
|
166
306
|
if (!downPos || (e && e.pointerId !== downId)) return;
|
|
167
307
|
if (snap) {
|
|
168
308
|
// Let the spring settle; springFrame drops is-active once it lands.
|
|
169
|
-
sDrag = false;
|
|
309
|
+
sDrag = false;
|
|
310
|
+
track.classList.remove("is-dragging");
|
|
170
311
|
if (!sRAF) sRAF = requestAnimationFrame(springFrame);
|
|
171
312
|
} else {
|
|
172
313
|
// Drop the press glide once it's home.
|
|
173
|
-
snapTimer = setTimeout(() => {
|
|
314
|
+
snapTimer = setTimeout(() => {
|
|
315
|
+
fill.style.transition = "";
|
|
316
|
+
handle.style.transition = "";
|
|
317
|
+
}, 360);
|
|
174
318
|
track.classList.remove("is-active", "is-dragging");
|
|
175
319
|
}
|
|
176
320
|
rubberRelease(track);
|
|
177
|
-
downPos = null;
|
|
321
|
+
downPos = null;
|
|
322
|
+
fineAnchor = null;
|
|
323
|
+
downId = null;
|
|
178
324
|
};
|
|
179
325
|
track.addEventListener("pointerup", up);
|
|
180
326
|
track.addEventListener("pointercancel", up);
|
|
@@ -186,38 +332,70 @@ function createSlider(meta: Meta, onChange: OnChange): Control {
|
|
|
186
332
|
track.addEventListener("keydown", (e) => {
|
|
187
333
|
if (e.target !== track) return; // keys typed into the inline editor are its own
|
|
188
334
|
if (downPos) return; // keys don't steer a pointer drag
|
|
189
|
-
if (e.key === "Enter") {
|
|
335
|
+
if (e.key === "Enter") {
|
|
336
|
+
e.preventDefault();
|
|
337
|
+
return openEditor();
|
|
338
|
+
}
|
|
190
339
|
const nv = rangeStep(e, value, step, min, max, (max - min) / 10 || step * 10);
|
|
191
340
|
if (nv == null) return;
|
|
192
341
|
e.preventDefault();
|
|
193
|
-
|
|
342
|
+
// instant step: cancel an in-flight settle + its styling
|
|
343
|
+
if (snap) {
|
|
344
|
+
springStop();
|
|
345
|
+
pull = 0;
|
|
346
|
+
track.classList.remove("is-active");
|
|
347
|
+
}
|
|
194
348
|
set(clamp(nv, min, max));
|
|
195
349
|
});
|
|
196
350
|
|
|
197
351
|
// value: hover 800ms → editable; click → inline input
|
|
198
352
|
let hoverTimer: number;
|
|
199
|
-
valueEl.addEventListener("mouseenter", () => {
|
|
200
|
-
|
|
353
|
+
valueEl.addEventListener("mouseenter", () => {
|
|
354
|
+
hoverTimer = setTimeout(() => valueEl.classList.add("is-editable"), 800);
|
|
355
|
+
});
|
|
356
|
+
valueEl.addEventListener("mouseleave", () => {
|
|
357
|
+
clearTimeout(hoverTimer);
|
|
358
|
+
if (!valueEl.classList.contains("is-editing")) valueEl.classList.remove("is-editable");
|
|
359
|
+
});
|
|
201
360
|
valueEl.addEventListener("pointerdown", (e) => {
|
|
202
361
|
// Touch/pen have no hover to arm the edit gate, so a tap on the readout arms it directly.
|
|
203
|
-
if (e.pointerType && e.pointerType !== "mouse") {
|
|
204
|
-
|
|
362
|
+
if (e.pointerType && e.pointerType !== "mouse") {
|
|
363
|
+
valueEl.classList.add("is-editable");
|
|
364
|
+
e.stopPropagation();
|
|
365
|
+
} else if (valueEl.classList.contains("is-editable")) e.stopPropagation();
|
|
205
366
|
});
|
|
206
367
|
// The inline editor, reached from the armed readout's click or Enter on the focused track. Enter
|
|
207
|
-
// commits and Escape cancels, both returning focus to the track; a blur commits and leaves focus where the user sent
|
|
368
|
+
// commits and Escape cancels, both returning focus to the track; a blur commits and leaves focus where the user sent
|
|
369
|
+
// it.
|
|
208
370
|
const openEditor = () => {
|
|
209
371
|
if (valueEl.classList.contains("is-editing")) return;
|
|
210
|
-
const input = el("input", "
|
|
211
|
-
input.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
372
|
+
const input = el("input", "twk-slider-input");
|
|
373
|
+
input.type = "text";
|
|
374
|
+
input.inputMode = parse ? "text" : "decimal";
|
|
375
|
+
quietFocus(input); /* click-to-type stays ringless; Enter from the track rings it */
|
|
376
|
+
// with a parse, the field edits the formatted text
|
|
377
|
+
input.value = parse ? show(q(value)) : q(value).toFixed(decimals);
|
|
378
|
+
// read back: a text field drops line breaks
|
|
379
|
+
const opened = input.value;
|
|
380
|
+
input.style.width = opened.length + 1 + "ch";
|
|
381
|
+
valueEl.classList.add("is-editing");
|
|
382
|
+
valueEl.replaceWith(input);
|
|
383
|
+
input.focus();
|
|
384
|
+
input.select();
|
|
385
|
+
const restore = () => {
|
|
386
|
+
input.replaceWith(valueEl);
|
|
387
|
+
valueEl.classList.remove("is-editing", "is-editable");
|
|
388
|
+
};
|
|
215
389
|
// Typed text is read by `parse`, or else strictly as a whole number (not parseFloat, which reads
|
|
216
390
|
// "1,000" as 1). Only a finite number commits, and never text left as it opened: a format coarser
|
|
217
391
|
// than the step would move the value just by opening the editor.
|
|
218
392
|
const commit = () => {
|
|
219
|
-
const t = input.value;
|
|
220
|
-
|
|
393
|
+
const t = input.value;
|
|
394
|
+
let p = NaN;
|
|
395
|
+
if (t !== opened)
|
|
396
|
+
try {
|
|
397
|
+
p = parse ? parse(t) : Number(t.replace(unit, "").trim() || NaN);
|
|
398
|
+
} catch {}
|
|
221
399
|
if (Number.isFinite(p)) set(meta.soft ? p : clamp(p, min, max));
|
|
222
400
|
restore();
|
|
223
401
|
};
|
|
@@ -226,119 +404,236 @@ function createSlider(meta: Meta, onChange: OnChange): Control {
|
|
|
226
404
|
// Focus moves to the track BEFORE the input leaves the DOM (blur-commit unhooked so it doesn't commit
|
|
227
405
|
// twice): removing a focused node first lands focus on <body>, and a focus() in the same task was dropped.
|
|
228
406
|
input.addEventListener("keydown", (e) => {
|
|
229
|
-
if (e.key === "Enter") {
|
|
230
|
-
|
|
407
|
+
if (e.key === "Enter") {
|
|
408
|
+
input.removeEventListener("blur", commit);
|
|
409
|
+
track.focus();
|
|
410
|
+
commit();
|
|
411
|
+
}
|
|
412
|
+
// an Escape here mustn't also close a hosting popover
|
|
413
|
+
else if (e.key === "Escape") {
|
|
414
|
+
e.stopPropagation();
|
|
415
|
+
input.removeEventListener("blur", commit);
|
|
416
|
+
track.focus();
|
|
417
|
+
restore();
|
|
418
|
+
}
|
|
231
419
|
});
|
|
232
420
|
};
|
|
233
|
-
valueEl.addEventListener("click", () => {
|
|
421
|
+
valueEl.addEventListener("click", () => {
|
|
422
|
+
if (valueEl.classList.contains("is-editable")) openEditor();
|
|
423
|
+
});
|
|
234
424
|
|
|
235
425
|
return { el: wrap, set: (v: number | string) => set(v, false), get: () => q(value), range: [min, max] };
|
|
236
426
|
}
|
|
237
427
|
|
|
238
428
|
function createToggle(meta: Meta, onChange: OnChange): Control {
|
|
239
|
-
const row = el("div", "
|
|
429
|
+
const row = el("div", "twk-row");
|
|
240
430
|
// The segmented control IS the state; this maps boolean ↔ "on"/"off" at the rim.
|
|
241
|
-
const
|
|
242
|
-
|
|
431
|
+
const tr = words(),
|
|
432
|
+
seg = createSegmented(
|
|
433
|
+
[
|
|
434
|
+
{ value: "off", label: tr("off", "Off") },
|
|
435
|
+
{ value: "on", label: tr("on", "On") },
|
|
436
|
+
],
|
|
437
|
+
meta.value ? "on" : "off",
|
|
438
|
+
(v) => onChange(v === "on"),
|
|
439
|
+
meta.label,
|
|
440
|
+
"on",
|
|
441
|
+
);
|
|
442
|
+
row.append(txt("span", "twk-row-label", meta.label), seg.el);
|
|
243
443
|
return { el: row, set: (v: unknown) => seg.set(v ? "on" : "off"), get: () => seg.get() === "on" };
|
|
244
444
|
}
|
|
245
445
|
|
|
246
446
|
// ── Radio grid — a segmented control wrapped into a 2- or 3-column grid for a small set of short presets. ──
|
|
247
447
|
function createRadiogrid(meta: Meta, onChange: OnChange): Control {
|
|
248
448
|
const options: Option[] = meta.options || [];
|
|
249
|
-
const cols = Math.min(
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
449
|
+
const cols = Math.min(
|
|
450
|
+
3,
|
|
451
|
+
Math.max(2, meta.cols || (options.length <= 3 ? options.length : options.length === 4 ? 2 : 3)),
|
|
452
|
+
);
|
|
453
|
+
// several rows keep equal columns
|
|
454
|
+
const row = el("div", options.length > cols ? "twk-radiogrid is-grid" : "twk-radiogrid");
|
|
455
|
+
const grid = el("div", "twk-radiogrid-grid");
|
|
456
|
+
grid.style.setProperty("--twk-rg-cols", cols);
|
|
457
|
+
grid.setAttribute("role", "radiogroup");
|
|
458
|
+
grid.setAttribute("aria-label", meta.label);
|
|
253
459
|
let value = meta.value ?? optValue(options[0]);
|
|
254
|
-
|
|
460
|
+
// lazy `set` — it's declared below
|
|
461
|
+
const btns = options.map((o) => {
|
|
462
|
+
const b = radioButton("twk-radiogrid-btn", o, (v) => set(v));
|
|
463
|
+
grid.append(b);
|
|
464
|
+
return b;
|
|
465
|
+
});
|
|
255
466
|
const reflect = () => setRadioActive(btns, value);
|
|
256
|
-
|
|
467
|
+
// null clears the selection; an unknown value is ignored, bar the default written off the options, so reset()
|
|
468
|
+
// restores it
|
|
469
|
+
const set = (v: unknown, fire = true) => {
|
|
470
|
+
if (v != null && v !== meta.value && !btns.some((b) => b._twVal === v)) return;
|
|
471
|
+
value = v;
|
|
472
|
+
reflect();
|
|
473
|
+
if (fire) onChange(v);
|
|
474
|
+
};
|
|
257
475
|
grid.addEventListener("keydown", (e) => {
|
|
258
|
-
const i = activeIndex(btns, value);
|
|
259
|
-
|
|
260
|
-
|
|
476
|
+
const i = activeIndex(btns, value);
|
|
477
|
+
if (i < 0) return;
|
|
478
|
+
const j = navIndex(e.key, i, btns.length, cols);
|
|
479
|
+
if (j < 0) return;
|
|
480
|
+
// _twVal, not dataset.value (which stringifies)
|
|
481
|
+
e.preventDefault();
|
|
482
|
+
if (j !== i) {
|
|
483
|
+
set(btns[j]._twVal);
|
|
484
|
+
btns[j].focus();
|
|
485
|
+
}
|
|
261
486
|
});
|
|
262
487
|
reflect();
|
|
263
|
-
row.append(txt("span", "
|
|
488
|
+
row.append(txt("span", "twk-radiogrid-label", meta.label), grid);
|
|
264
489
|
return { el: row, set: (v: unknown) => set(v, false), get: () => value };
|
|
265
490
|
}
|
|
266
491
|
|
|
267
492
|
// ── Select ──
|
|
268
|
-
// An option's color: one token, no nesting, so a `)` can't close it early and splice a url() into the background
|
|
493
|
+
// An option's color: one token, no nesting, so a `)` can't close it early and splice a url() into the background
|
|
494
|
+
// shorthand.
|
|
269
495
|
const COLOR_TOKEN = /^(?:#[0-9a-f]{3,8}|[a-z]+|(?:oklch|oklab|rgba?|hsla?|hwb|lab|lch|color|var)\([^()]*\))$/i;
|
|
270
|
-
const CHEVRON = chevronIcon("
|
|
496
|
+
const CHEVRON = chevronIcon("twk-select-chevron");
|
|
271
497
|
function createSelect(meta: Meta, onChange: OnChange): Control {
|
|
272
498
|
let value = meta.value;
|
|
273
|
-
const opts = meta.options.map((o) => ({
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
499
|
+
const opts = meta.options.map((o) => ({
|
|
500
|
+
value: optValue(o),
|
|
501
|
+
label: optLabel(o),
|
|
502
|
+
color:
|
|
503
|
+
o && typeof o === "object" && typeof o.color === "string" && COLOR_TOKEN.test(o.color.trim())
|
|
504
|
+
? o.color.trim()
|
|
505
|
+
: "",
|
|
506
|
+
}));
|
|
507
|
+
const root = el("div", "twk-select");
|
|
508
|
+
const trigger = btn("twk-select-trigger");
|
|
509
|
+
trigger.setAttribute("aria-haspopup", "listbox");
|
|
510
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
511
|
+
const right = el("span", "twk-select-right");
|
|
277
512
|
// Option swatches sit over a checker so alpha reads.
|
|
278
|
-
const swatchCss = (c: string) => `linear-gradient(${c}, ${c}), var(--
|
|
279
|
-
const swatch = (c: string) => {
|
|
280
|
-
|
|
513
|
+
const swatchCss = (c: string) => `linear-gradient(${c}, ${c}), var(--twk-checker)`;
|
|
514
|
+
const swatch = (c: string) => {
|
|
515
|
+
const s = el("span", "twk-option-swatch");
|
|
516
|
+
s.style.background = swatchCss(c);
|
|
517
|
+
return s;
|
|
518
|
+
};
|
|
519
|
+
const valSwatch = el("span", "twk-option-swatch"),
|
|
520
|
+
valEl = el("span", "twk-select-value");
|
|
281
521
|
right.append(valSwatch, valEl);
|
|
282
522
|
right.insertAdjacentHTML("beforeend", CHEVRON);
|
|
283
|
-
trigger.append(txt("span", "
|
|
284
|
-
const dropdown = el("div", "
|
|
523
|
+
trigger.append(txt("span", "twk-select-label", meta.label), right);
|
|
524
|
+
const dropdown = el("div", "twk-select-dropdown");
|
|
525
|
+
dropdown.setAttribute("role", "listbox");
|
|
285
526
|
const optButtons = opts.map((o) => {
|
|
286
|
-
const b = btn("
|
|
287
|
-
b.
|
|
288
|
-
|
|
527
|
+
const b = btn("twk-select-option");
|
|
528
|
+
b.setAttribute("role", "option");
|
|
529
|
+
if (o.color) b.append(swatch(o.color));
|
|
530
|
+
b.append(txt("span", "twk-option-label", o.label));
|
|
531
|
+
b.dataset.value = String(o.value);
|
|
532
|
+
b.addEventListener("click", () => {
|
|
533
|
+
set(o.value);
|
|
534
|
+
pop.close();
|
|
535
|
+
});
|
|
536
|
+
dropdown.append(b);
|
|
537
|
+
return b;
|
|
289
538
|
});
|
|
290
539
|
root.append(trigger, dropdown);
|
|
291
|
-
|
|
292
|
-
const
|
|
540
|
+
// dataset stringifies, so compare as strings
|
|
541
|
+
const reflect = () => {
|
|
542
|
+
const cur = opts.find((o) => o.value === value);
|
|
543
|
+
valEl.textContent = cur?.label ?? value;
|
|
544
|
+
valSwatch.style.display = cur?.color ? "" : "none";
|
|
545
|
+
if (cur?.color) valSwatch.style.background = swatchCss(cur.color);
|
|
546
|
+
optButtons.forEach((b) => {
|
|
547
|
+
const sel = b.dataset.value === String(value);
|
|
548
|
+
b.dataset.selected = String(sel);
|
|
549
|
+
b.setAttribute("aria-selected", String(sel));
|
|
550
|
+
});
|
|
551
|
+
};
|
|
552
|
+
// null clears the selection; an unknown value is ignored, bar the default written off the options, so reset()
|
|
553
|
+
// restores it
|
|
554
|
+
const set = (v: unknown, fire = true) => {
|
|
555
|
+
if (v != null && v !== meta.value && !opts.some((o) => o.value === v)) return;
|
|
556
|
+
value = v;
|
|
557
|
+
reflect();
|
|
558
|
+
if (fire) onChange(v);
|
|
559
|
+
};
|
|
293
560
|
// The shared popover shell: portaled to <body>, closes on outside-press / Esc / scroll-away,
|
|
294
561
|
// and opening it closes any other popover.
|
|
295
|
-
let typed = "",
|
|
562
|
+
let typed = "",
|
|
563
|
+
typedAt = 0; // the type-ahead buffer; a fresh open starts it over, as a native popup does
|
|
296
564
|
const pop = popover(root, trigger, dropdown, {
|
|
297
|
-
fallbackH: 200,
|
|
298
|
-
|
|
565
|
+
fallbackH: 200,
|
|
566
|
+
movable: false, // a menu: it closes on a pick, so it never stays in the way
|
|
567
|
+
onOpen: () => {
|
|
568
|
+
typed = "";
|
|
569
|
+
(optButtons.find((b) => b.dataset.value === String(value)) || optButtons[0])?.focus();
|
|
570
|
+
},
|
|
299
571
|
});
|
|
300
572
|
// Enter/Space on a focused option selects it natively via click; Tab away closes by the shell's focusout rule.
|
|
301
573
|
trigger.addEventListener("keydown", (e) => {
|
|
302
|
-
if (!pop.isOpen() && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
|
574
|
+
if (!pop.isOpen() && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
|
575
|
+
e.preventDefault();
|
|
576
|
+
pop.open();
|
|
577
|
+
}
|
|
303
578
|
});
|
|
304
579
|
dropdown.addEventListener("keydown", (e) => {
|
|
305
|
-
const i = optButtons.indexOf(
|
|
306
|
-
|
|
580
|
+
const i = optButtons.indexOf(activeIn(dropdown) as HTMLButtonElement);
|
|
581
|
+
let j = i;
|
|
582
|
+
// monotonic, so a wall-clock step can't pin the beat open
|
|
583
|
+
const inBeat = !!typed && performance.now() - typedAt < 600;
|
|
307
584
|
if (e.key === "ArrowDown") j = i < 0 ? 0 : Math.min(optButtons.length - 1, i + 1);
|
|
308
585
|
else if (e.key === "ArrowUp") j = i < 0 ? optButtons.length - 1 : Math.max(0, i - 1);
|
|
309
586
|
else if (e.key === "Home") j = 0;
|
|
310
587
|
else if (e.key === "End") j = optButtons.length - 1;
|
|
311
|
-
else if (
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
588
|
+
else if (
|
|
589
|
+
e.key.length === 1 &&
|
|
590
|
+
(e.key !== " " || inBeat) &&
|
|
591
|
+
!e.ctrlKey &&
|
|
592
|
+
!e.metaKey &&
|
|
593
|
+
!e.altKey &&
|
|
594
|
+
!e.isComposing
|
|
595
|
+
) {
|
|
596
|
+
// Type-ahead, as a native select: keys within a beat form a prefix that moves focus to the first label starting
|
|
597
|
+
// with it, the same key over and over cycles its matches, and no match leaves focus where it is. A space joins a
|
|
598
|
+
// prefix under way and is never also the option's activation; on its own it stays the option's.
|
|
599
|
+
const k = e.key.toLowerCase();
|
|
600
|
+
typed = (inBeat ? typed : "") + k;
|
|
601
|
+
typedAt = performance.now();
|
|
602
|
+
// compared per key, since "İ" lowercases to two code units
|
|
603
|
+
const one = typed === k.repeat(typed.length / k.length),
|
|
604
|
+
needle = one ? k : typed;
|
|
605
|
+
const hit = (lo: number) =>
|
|
606
|
+
opts.findIndex((o, n) => n >= lo && String(o.label).toLowerCase().trimStart().startsWith(needle));
|
|
607
|
+
// a lone key moves past the focused option, a growing prefix re-tests it; the second pass wraps
|
|
608
|
+
j = hit(one ? i + 1 : i);
|
|
609
|
+
if (j < 0) j = hit(0);
|
|
319
610
|
if (j < 0 && e.key !== " ") return; // a prefix's space is swallowed even unmatched, so it never also picks
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
|
|
611
|
+
} else return;
|
|
612
|
+
e.preventDefault();
|
|
613
|
+
optButtons[j]?.focus();
|
|
323
614
|
});
|
|
324
615
|
reflect();
|
|
325
616
|
return { el: root, set: (v: unknown) => set(v, false), get: () => value };
|
|
326
617
|
}
|
|
327
618
|
|
|
328
619
|
function createButton(meta: Meta) {
|
|
329
|
-
const b = txt("button", "
|
|
330
|
-
|
|
620
|
+
const b = txt("button", "twk-button", meta.label);
|
|
621
|
+
// a verbose form can carry a non-function action; as the group does, ignore it
|
|
622
|
+
b.addEventListener("click", () => typeof meta.action === "function" && meta.action());
|
|
331
623
|
return blade(b);
|
|
332
624
|
}
|
|
333
625
|
|
|
334
626
|
// ── Button group — a row of compact actions under one label. `buttons` is { label: fn } or [{label, action}]. ──
|
|
335
627
|
function createButtonGroup(meta: Meta) {
|
|
336
|
-
const row = el("div", "
|
|
337
|
-
if (meta.label) row.append(txt("span", "
|
|
338
|
-
const group = el("div", "
|
|
339
|
-
|
|
628
|
+
const row = el("div", "twk-row twk-buttongroup");
|
|
629
|
+
if (meta.label) row.append(txt("span", "twk-row-label", meta.label));
|
|
630
|
+
const group = el("div", "twk-buttongroup-btns");
|
|
631
|
+
// an entry with no label is still a button, not an empty pill
|
|
632
|
+
const list: [string, () => void][] = Array.isArray(meta.buttons)
|
|
633
|
+
? meta.buttons.map((b) => [b.label ?? words()("button", "Button"), b.action])
|
|
634
|
+
: Object.entries(meta.buttons || {});
|
|
340
635
|
for (const [lab, fn] of list) {
|
|
341
|
-
const b = txt("button", "
|
|
636
|
+
const b = txt("button", "twk-buttongroup-btn", lab);
|
|
342
637
|
b.addEventListener("click", () => typeof fn === "function" && fn());
|
|
343
638
|
group.append(b);
|
|
344
639
|
}
|
|
@@ -347,7 +642,7 @@ function createButtonGroup(meta: Meta) {
|
|
|
347
642
|
}
|
|
348
643
|
|
|
349
644
|
// ── Separator — a thin divider to break a long panel into sections. ──
|
|
350
|
-
const createSeparator = () => blade(el("div", "
|
|
645
|
+
const createSeparator = () => blade(el("div", "twk-separator"));
|
|
351
646
|
|
|
352
647
|
// ── String — a labeled text input ──
|
|
353
648
|
function createString(meta: Meta, onChange: OnChange): Control {
|
|
@@ -355,39 +650,68 @@ function createString(meta: Meta, onChange: OnChange): Control {
|
|
|
355
650
|
const str = (v: unknown) => (v == null ? "" : typeof v === "object" ? json(v) : String(v));
|
|
356
651
|
let value = str(meta.value);
|
|
357
652
|
const multi = meta.rows > 0;
|
|
358
|
-
const row = el("div", multi ? "
|
|
359
|
-
const input = el(multi ? "textarea" : "input", multi ? "
|
|
360
|
-
if (multi) (input as HTMLTextAreaElement).rows = meta.rows;
|
|
653
|
+
const row = el("div", multi ? "twk-row twk-row-multiline" : "twk-row");
|
|
654
|
+
const input = el(multi ? "textarea" : "input", multi ? "twk-text twk-textarea" : "twk-text");
|
|
655
|
+
if (multi) (input as HTMLTextAreaElement).rows = meta.rows;
|
|
656
|
+
else (input as HTMLInputElement).type = "text";
|
|
361
657
|
input.value = value;
|
|
362
658
|
quietFocus(input); // click-to-type stays ringless; Tab rings
|
|
363
659
|
if (meta.placeholder) input.placeholder = meta.placeholder;
|
|
364
|
-
input.addEventListener("input", () => {
|
|
660
|
+
input.addEventListener("input", () => {
|
|
661
|
+
value = input.value;
|
|
662
|
+
onChange(value);
|
|
663
|
+
});
|
|
365
664
|
// Long text clips softly: the edge with more past it fades (a mask, so the fade is the panel's own backdrop).
|
|
366
|
-
const clip = () => {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
665
|
+
const clip = () => {
|
|
666
|
+
const more = input.scrollHeight - input.clientHeight > 1;
|
|
667
|
+
input.classList.toggle("is-clip-top", more && input.scrollTop > 1);
|
|
668
|
+
input.classList.toggle("is-clip-bottom", more && input.scrollTop + input.clientHeight < input.scrollHeight - 1);
|
|
669
|
+
};
|
|
670
|
+
if (multi) {
|
|
671
|
+
input.addEventListener("scroll", clip);
|
|
672
|
+
input.addEventListener("input", clip);
|
|
673
|
+
if (typeof ResizeObserver === "function") new ResizeObserver(clip).observe(input);
|
|
674
|
+
}
|
|
675
|
+
row.append(txt("span", "twk-row-label", meta.label), input);
|
|
676
|
+
// null → "", not the literal "undefined"
|
|
677
|
+
return {
|
|
678
|
+
el: row,
|
|
679
|
+
set: (v: unknown) => {
|
|
680
|
+
value = str(v);
|
|
681
|
+
input.value = value;
|
|
682
|
+
if (multi) clip();
|
|
683
|
+
},
|
|
684
|
+
get: () => value,
|
|
685
|
+
};
|
|
370
686
|
}
|
|
371
687
|
|
|
372
688
|
// ── Folder — a collapsible titled group; `body` is the inner container the caller fills. ──
|
|
373
689
|
function createFolder(meta: { label: string }) {
|
|
374
|
-
const root = el("div", "
|
|
375
|
-
const header = btn("
|
|
376
|
-
header.
|
|
690
|
+
const root = el("div", "twk-folder");
|
|
691
|
+
const header = btn("twk-folder-header");
|
|
692
|
+
header.setAttribute("aria-expanded", "true");
|
|
693
|
+
header.append(txt("span", "twk-folder-title", meta.label));
|
|
377
694
|
header.insertAdjacentHTML("beforeend", ICON_CHEVRON); // chevron trails the title
|
|
378
|
-
const body = el("div", "
|
|
379
|
-
const inner = el("div", "
|
|
695
|
+
const body = el("div", "twk-folder-body");
|
|
696
|
+
const inner = el("div", "twk-controls");
|
|
697
|
+
body.append(inner);
|
|
380
698
|
root.append(header, body);
|
|
381
|
-
|
|
699
|
+
// the shared fold: class + aria-expanded + inert on the body
|
|
700
|
+
const collapse = (c: boolean) => foldBody(root, header, body, c);
|
|
382
701
|
header.addEventListener("click", () => collapse(!root.classList.contains("is-collapsed")));
|
|
383
702
|
return { el: root, body: inner, setCollapsed: collapse };
|
|
384
703
|
}
|
|
385
|
-
/** Builds a control from the registry; a constructor that throws degrades to null so one bad control can't abort the
|
|
704
|
+
/** Builds a control from the registry; a constructor that throws degrades to null so one bad control can't abort the
|
|
705
|
+
* panel. */
|
|
386
706
|
function createControl(meta: Meta, onChange: OnChange): Control | null {
|
|
387
707
|
const make = getControl(meta.type);
|
|
388
708
|
if (!make) return null;
|
|
389
|
-
try {
|
|
390
|
-
|
|
709
|
+
try {
|
|
710
|
+
return make(meta, onChange);
|
|
711
|
+
} catch (e) {
|
|
712
|
+
console.error("[tweaks] control failed to build:", meta && meta.type, e);
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
391
715
|
}
|
|
392
716
|
// Core controls register synchronously; lazy ones register when imported.
|
|
393
717
|
registerControl("slider", createSlider);
|