@xsynaptic/sonic-ui 0.3.0 → 0.4.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.
Files changed (41) hide show
  1. package/dist/default/elements/dial.js +5 -3
  2. package/dist/default/elements/key.js +29 -4
  3. package/dist/default/elements/meter.js +63 -9
  4. package/dist/default/elements/number.js +1 -1
  5. package/dist/default/elements/range-element.js +134 -21
  6. package/dist/default/elements/slider.js +49 -3
  7. package/dist/default/lib/detent.js +17 -0
  8. package/dist/default/lib/math.js +16 -0
  9. package/dist/default/lib/number-list.js +14 -0
  10. package/dist/default/lib/taper.js +17 -7
  11. package/dist/dev/elements/dial.js +5 -3
  12. package/dist/dev/elements/dial.js.map +1 -1
  13. package/dist/dev/elements/key.d.ts +4 -0
  14. package/dist/dev/elements/key.js +29 -4
  15. package/dist/dev/elements/key.js.map +1 -1
  16. package/dist/dev/elements/meter.d.ts +6 -0
  17. package/dist/dev/elements/meter.js +63 -9
  18. package/dist/dev/elements/meter.js.map +1 -1
  19. package/dist/dev/elements/number.js +1 -1
  20. package/dist/dev/elements/range-element.d.ts +8 -0
  21. package/dist/dev/elements/range-element.js +134 -21
  22. package/dist/dev/elements/range-element.js.map +1 -1
  23. package/dist/dev/elements/slider.d.ts +6 -0
  24. package/dist/dev/elements/slider.js +49 -3
  25. package/dist/dev/elements/slider.js.map +1 -1
  26. package/dist/dev/lib/detent.js +18 -0
  27. package/dist/dev/lib/detent.js.map +1 -0
  28. package/dist/dev/lib/math.js +17 -0
  29. package/dist/dev/lib/math.js.map +1 -0
  30. package/dist/dev/lib/number-list.js +15 -0
  31. package/dist/dev/lib/number-list.js.map +1 -0
  32. package/dist/dev/lib/taper.js +17 -7
  33. package/dist/dev/lib/taper.js.map +1 -1
  34. package/dist/styles/dial.css +43 -3
  35. package/dist/styles/key.css +60 -15
  36. package/dist/styles/led.css +8 -1
  37. package/dist/styles/material.css +50 -1
  38. package/dist/styles/meter.css +101 -5
  39. package/dist/styles/segmented.css +2 -7
  40. package/dist/styles/slider.css +94 -32
  41. package/package.json +1 -1
@@ -1,10 +1,11 @@
1
- import { template } from "../lib/render.js";
2
- import { SonicRangeElement } from "./range-element.js";
1
+ import { requireChild, template } from "../lib/render.js";
3
2
  import { readPxProperty } from "../lib/read-px-property.js";
3
+ import { SonicRangeElement } from "./range-element.js";
4
4
 
5
5
  //#region src/elements/dial.ts
6
6
  const renderDial = template(`
7
7
  <div class="sonic-dial" role="slider" tabindex="0">
8
+ <div class="sonic-dial-scale" aria-hidden="true"></div>
8
9
  <div class="sonic-dial-notches"></div>
9
10
  <div class="sonic-dial-ring"></div>
10
11
  <div class="sonic-dial-modulation"></div>
@@ -49,7 +50,7 @@ var SonicDial = class extends SonicRangeElement {
49
50
  }
50
51
  connect(signal) {
51
52
  const dial = this.#dial;
52
- this.appendOnce(dial);
53
+ this.bindScale(dial, requireChild(dial, ".sonic-dial-scale", HTMLDivElement), signal);
53
54
  this.render();
54
55
  this.checkStyles(dial, "dial.css");
55
56
  this.bindGestures(dial, signal, () => ({
@@ -70,6 +71,7 @@ var SonicDial = class extends SonicRangeElement {
70
71
  dial.style.setProperty("--_sonic-dial-modulation-to", String(modulationTo));
71
72
  if (positions === void 0) dial.style.removeProperty("--_sonic-dial-positions");
72
73
  else dial.style.setProperty("--_sonic-dial-positions", String(positions));
74
+ this.renderScale();
73
75
  this.renderAria(dial);
74
76
  }
75
77
  wraps() {
@@ -1,4 +1,5 @@
1
1
  import { requireChild, template } from "../lib/render.js";
2
+ import { writeAttribute } from "../lib/write-attribute.js";
2
3
  import { SonicFormElement } from "./form-element.js";
3
4
  import { copyNode } from "../lib/copy-node.js";
4
5
 
@@ -10,11 +11,19 @@ const holdKeys = /* @__PURE__ */ new Set([" ", "Enter"]);
10
11
  var SonicKey = class extends SonicFormElement {
11
12
  static observedAttributes = [
12
13
  ...SonicFormElement.observedAttributes,
14
+ "busy",
13
15
  "momentary",
14
16
  "pressed",
17
+ "soft-disabled",
15
18
  "toggle",
16
19
  "value"
17
20
  ];
21
+ get busy() {
22
+ return this.hasAttribute("busy");
23
+ }
24
+ set busy(isBusy) {
25
+ this.reflect("busy", isBusy);
26
+ }
18
27
  get defaultPressed() {
19
28
  return this.hasAttribute("pressed");
20
29
  }
@@ -35,6 +44,12 @@ var SonicKey = class extends SonicFormElement {
35
44
  this.#pressed = isPressed === true;
36
45
  this.render();
37
46
  }
47
+ get softDisabled() {
48
+ return this.hasAttribute("soft-disabled");
49
+ }
50
+ set softDisabled(isSoftDisabled) {
51
+ this.reflect("soft-disabled", isSoftDisabled);
52
+ }
38
53
  get toggle() {
39
54
  return this.hasAttribute("toggle");
40
55
  }
@@ -53,7 +68,7 @@ var SonicKey = class extends SonicFormElement {
53
68
  #pressed = false;
54
69
  attributeChangedCallback(name) {
55
70
  if (name === "pressed") this.#isDirty = false;
56
- if (name === "disabled" && this.isDisabled()) this.#release();
71
+ if (this.isDisabled() || this.#isSoftDisabled()) this.#release();
57
72
  this.render();
58
73
  }
59
74
  formResetCallback() {
@@ -65,7 +80,7 @@ var SonicKey = class extends SonicFormElement {
65
80
  connect(signal) {
66
81
  const key = this.#key;
67
82
  const cap = requireChild(key, ".sonic-key-cap", HTMLSpanElement);
68
- this.upgradeProperties("defaultPressed", "momentary", "toggle", "pressed", "value");
83
+ this.upgradeProperties("busy", "defaultPressed", "momentary", "softDisabled", "toggle", "pressed", "value");
69
84
  this.mirrorChildren({
70
85
  control: key,
71
86
  copy: (originals) => {
@@ -77,7 +92,12 @@ var SonicKey = class extends SonicFormElement {
77
92
  signal.addEventListener("abort", () => {
78
93
  this.#release();
79
94
  }, { once: true });
80
- key.addEventListener("click", () => {
95
+ key.addEventListener("click", (event) => {
96
+ if (this.#isSoftDisabled()) {
97
+ event.preventDefault();
98
+ event.stopImmediatePropagation();
99
+ return;
100
+ }
81
101
  if (!this.toggle) return;
82
102
  this.pressed = !this.pressed;
83
103
  this.dispatchEvent(new Event("change", { bubbles: true }));
@@ -90,6 +110,8 @@ var SonicKey = class extends SonicFormElement {
90
110
  render() {
91
111
  const key = this.#key;
92
112
  key.disabled = this.isDisabled();
113
+ writeAttribute(key, "aria-disabled", this.#isSoftDisabled() ? "true" : void 0);
114
+ writeAttribute(key, "aria-busy", this.busy ? "true" : void 0);
93
115
  key.removeAttribute("aria-pressed");
94
116
  if (this.toggle) key.setAttribute("aria-pressed", String(this.pressed));
95
117
  this.toggleState("pressed", this.pressed);
@@ -124,7 +146,7 @@ var SonicKey = class extends SonicFormElement {
124
146
  }, { signal });
125
147
  }
126
148
  #hold(holder) {
127
- if (this.isDisabled() || this.#holder !== void 0) return;
149
+ if (this.isDisabled() || this.#isSoftDisabled() || this.#holder !== void 0) return;
128
150
  this.#holder = holder;
129
151
  this.pressed = true;
130
152
  this.dispatchEvent(new Event("change", { bubbles: true }));
@@ -132,6 +154,9 @@ var SonicKey = class extends SonicFormElement {
132
154
  #isMomentary() {
133
155
  return this.momentary && !this.toggle;
134
156
  }
157
+ #isSoftDisabled() {
158
+ return this.softDisabled && !this.isDisabled();
159
+ }
135
160
  #release() {
136
161
  if (this.#holder === void 0) return;
137
162
  this.#holder = void 0;
@@ -1,5 +1,7 @@
1
1
  import { template } from "../lib/render.js";
2
2
  import { SonicElement } from "./sonic-element.js";
3
+ import { parseNumberList } from "../lib/number-list.js";
4
+ import { linearTaper } from "../lib/taper.js";
3
5
 
4
6
  //#region src/elements/meter.ts
5
7
  const renderMeter = template(`
@@ -14,20 +16,30 @@ const holdMs = 1500;
14
16
  function toDecibels(amplitude) {
15
17
  return 20 * Math.log10(Math.abs(amplitude));
16
18
  }
19
+ function lightsBelow(lights, zone) {
20
+ return `calc(${lights.map((threshold) => `sign(max(0, var(--_sonic-meter-${zone}-from) - (${String(threshold)})))`).join(" + ")})`;
21
+ }
17
22
  var SonicMeter = class extends SonicElement {
18
23
  static observedAttributes = [
19
24
  "disabled",
25
+ "lights",
20
26
  "max",
21
- "min"
27
+ "min",
28
+ "origin",
29
+ "scale"
22
30
  ];
23
31
  get level() {
24
32
  return this.#level;
25
33
  }
26
34
  set level(amplitude) {
27
35
  if (!Number.isFinite(amplitude)) return;
28
- const decibels = toDecibels(amplitude);
29
36
  const now = performance.now();
30
37
  this.#level = amplitude;
38
+ if (this.scale === "linear") {
39
+ this.#render(now);
40
+ return;
41
+ }
42
+ const decibels = toDecibels(amplitude);
31
43
  this.#target = decibels;
32
44
  this.#bar = Math.max(this.#bar, decibels);
33
45
  if (decibels >= this.#peak) {
@@ -38,6 +50,12 @@ var SonicMeter = class extends SonicElement {
38
50
  this.#render(now);
39
51
  this.#schedule(now);
40
52
  }
53
+ get lights() {
54
+ return parseNumberList(this.getAttribute("lights"));
55
+ }
56
+ set lights(thresholds) {
57
+ this.reflect("lights", thresholds?.join(" "));
58
+ }
41
59
  get max() {
42
60
  return this.numberAttribute("max", 0);
43
61
  }
@@ -56,24 +74,39 @@ var SonicMeter = class extends SonicElement {
56
74
  set orientation(direction) {
57
75
  this.reflect("orientation", direction);
58
76
  }
77
+ get origin() {
78
+ return this.optionalNumberAttribute("origin");
79
+ }
80
+ set origin(value) {
81
+ this.reflect("origin", value);
82
+ }
83
+ get scale() {
84
+ return this.getAttribute("scale") === "linear" ? "linear" : "db";
85
+ }
86
+ set scale(scale) {
87
+ this.reflect("scale", scale);
88
+ }
59
89
  #bar = -Infinity;
60
90
  #clipAt = -Infinity;
61
91
  #frame;
62
92
  #lastFrame;
63
93
  #level = 0;
94
+ #lights;
64
95
  #meter = renderMeter();
65
96
  #peak = -Infinity;
66
97
  #peakAt = -Infinity;
67
98
  #target = -Infinity;
68
99
  attributeChangedCallback() {
69
100
  const now = performance.now();
101
+ this.#renderLights();
70
102
  this.#render(now);
71
103
  this.#schedule(now);
72
104
  }
73
105
  connect(signal) {
74
106
  const now = performance.now();
75
- this.upgradeProperties("max", "min", "orientation", "level");
107
+ this.upgradeProperties("lights", "max", "min", "orientation", "origin", "scale", "level");
76
108
  this.appendOnce(this.#meter);
109
+ this.#renderLights();
77
110
  this.#render(now);
78
111
  this.#schedule(now);
79
112
  this.checkStyles(this.#meter, "meter.css");
@@ -83,21 +116,25 @@ var SonicMeter = class extends SonicElement {
83
116
  this.#lastFrame = void 0;
84
117
  }, { once: true });
85
118
  }
86
- #fraction(decibels) {
87
- const min = this.min;
88
- const range = this.max - min;
89
- return range > 0 ? Math.min(1, Math.max(0, (decibels - min) / range)) : 0;
119
+ #fraction(level) {
120
+ const lights = this.#lights;
121
+ if (lights) return lights.filter((threshold) => threshold <= level).length / lights.length;
122
+ return linearTaper(this.min, this.max).position(level);
90
123
  }
91
124
  #isSettled(now) {
125
+ if (this.scale === "linear") return true;
92
126
  const floor = this.min;
93
127
  return this.#bar <= Math.max(this.#target, floor) && this.#peak <= Math.max(this.#bar, floor) && now - this.#clipAt >= holdMs;
94
128
  }
95
129
  #render(now) {
96
130
  const meter = this.#meter;
131
+ const origin = this.origin === void 0 ? 0 : this.#fraction(this.origin);
97
132
  meter.style.setProperty("--_sonic-meter-min", String(this.min));
98
133
  meter.style.setProperty("--_sonic-meter-max", String(this.max));
99
- if (this.disabled) {
100
- meter.style.setProperty("--_sonic-meter-level", "0");
134
+ meter.style.setProperty("--_sonic-meter-origin", String(origin));
135
+ if (this.disabled || this.scale === "linear") {
136
+ const level = this.disabled ? origin : this.#fraction(this.#level);
137
+ meter.style.setProperty("--_sonic-meter-level", String(level));
101
138
  meter.style.setProperty("--_sonic-meter-peak", "0");
102
139
  meter.style.setProperty("--_sonic-meter-clipped", "0");
103
140
  return;
@@ -106,6 +143,23 @@ var SonicMeter = class extends SonicElement {
106
143
  meter.style.setProperty("--_sonic-meter-peak", String(this.#fraction(this.#peak)));
107
144
  meter.style.setProperty("--_sonic-meter-clipped", now - this.#clipAt < holdMs ? "1" : "0");
108
145
  }
146
+ #renderLights() {
147
+ const meter = this.#meter;
148
+ const lights = this.lights;
149
+ this.#lights = lights;
150
+ this.toggleState("ladder", lights !== void 0);
151
+ if (!lights) {
152
+ for (const name of [
153
+ "count",
154
+ "hot-lights",
155
+ "clip-lights"
156
+ ]) meter.style.removeProperty(`--_sonic-meter-${name}`);
157
+ return;
158
+ }
159
+ meter.style.setProperty("--_sonic-meter-count", String(lights.length));
160
+ meter.style.setProperty("--_sonic-meter-hot-lights", lightsBelow(lights, "hot"));
161
+ meter.style.setProperty("--_sonic-meter-clip-lights", lightsBelow(lights, "clip"));
162
+ }
109
163
  #schedule(now) {
110
164
  if (this.#frame !== void 0 || !this.isConnected || this.#isSettled(now)) return;
111
165
  if (this.#lastFrame === void 0) this.#lastFrame = now;
@@ -1,6 +1,6 @@
1
1
  import { requireChild, template } from "../lib/render.js";
2
- import { SonicRangeElement } from "./range-element.js";
3
2
  import { readPxProperty } from "../lib/read-px-property.js";
3
+ import { SonicRangeElement } from "./range-element.js";
4
4
 
5
5
  //#region src/elements/number.ts
6
6
  const renderNumber = template(`
@@ -2,9 +2,15 @@ import { requireChild } from "../lib/render.js";
2
2
  import { writeAttribute } from "../lib/write-attribute.js";
3
3
  import { SonicFormElement } from "./form-element.js";
4
4
  import { RangeEntry } from "./range-entry.js";
5
- import { clampUnit, linearTaper, logTaper, skewTaper, wrapUnit } from "../lib/taper.js";
5
+ import { copyNode } from "../lib/copy-node.js";
6
+ import { passDetent } from "../lib/detent.js";
7
+ import { clamp, clampUnit, trimFloat, wrapUnit } from "../lib/math.js";
8
+ import { nearestEntry, parseNumberList } from "../lib/number-list.js";
9
+ import { readPxProperty } from "../lib/read-px-property.js";
10
+ import { linearTaper, listTaper, logTaper, skewTaper } from "../lib/taper.js";
6
11
 
7
12
  //#region src/elements/range-element.ts
13
+ const detentZonePx = 8;
8
14
  const fineScale = .1;
9
15
  const keyRevealMs = 1e3;
10
16
  const pageSteps = 10;
@@ -13,6 +19,7 @@ const revealPx = 4;
13
19
  const canPopover = "togglePopover" in HTMLElement.prototype;
14
20
  let instanceCount = 0;
15
21
  const resetKeys = /* @__PURE__ */ new Set(["Backspace", "Delete"]);
22
+ const endKeys = /* @__PURE__ */ new Set(["End", "Home"]);
16
23
  const keySteps = /* @__PURE__ */ new Map([
17
24
  ["ArrowDown", -1],
18
25
  ["ArrowLeft", -1],
@@ -31,9 +38,22 @@ const roleAttributes = [
31
38
  "role",
32
39
  "tabindex"
33
40
  ];
41
+ function scaleValue(mark) {
42
+ const text = mark.dataset.sonicValue?.trim();
43
+ return text ? Number(text) : NaN;
44
+ }
45
+ function scaleMark(original) {
46
+ const mark = copyNode(original);
47
+ if (!(mark instanceof HTMLElement || mark instanceof SVGElement)) return [];
48
+ if (!Number.isFinite(scaleValue(mark))) return [];
49
+ const isTick = mark.childElementCount === 0 && mark.textContent.trim() === "";
50
+ mark.classList.add(isTick ? "sonic-scale-tick" : "sonic-scale-label");
51
+ return [mark];
52
+ }
34
53
  var SonicRangeElement = class extends SonicFormElement {
35
54
  static observedAttributes = [
36
55
  ...SonicFormElement.observedAttributes,
56
+ "detent",
37
57
  "max",
38
58
  "midpoint",
39
59
  "min",
@@ -42,7 +62,8 @@ var SonicRangeElement = class extends SonicFormElement {
42
62
  "readout",
43
63
  "step",
44
64
  "taper",
45
- "value"
65
+ "value",
66
+ "values"
46
67
  ];
47
68
  get default() {
48
69
  return this.optionalNumberAttribute("default");
@@ -50,6 +71,12 @@ var SonicRangeElement = class extends SonicFormElement {
50
71
  set default(value) {
51
72
  this.reflect("default", value);
52
73
  }
74
+ get detent() {
75
+ return this.optionalNumberAttribute("detent");
76
+ }
77
+ set detent(value) {
78
+ this.reflect("detent", value);
79
+ }
53
80
  get dimmed() {
54
81
  return this.hasAttribute("dimmed");
55
82
  }
@@ -130,7 +157,14 @@ var SonicRangeElement = class extends SonicFormElement {
130
157
  if (this.#drag) return;
131
158
  this.#write(next);
132
159
  }
160
+ get values() {
161
+ return parseNumberList(this.getAttribute("values"));
162
+ }
163
+ set values(entries) {
164
+ this.reflect("values", entries?.join(" "));
165
+ }
133
166
  #drag;
167
+ #entries;
134
168
  #entry;
135
169
  #formatValue;
136
170
  #instance = String(instanceCount += 1);
@@ -138,8 +172,10 @@ var SonicRangeElement = class extends SonicFormElement {
138
172
  #keyRevealTimer;
139
173
  #parseValue;
140
174
  #readout;
175
+ #scale;
141
176
  #value = 0;
142
177
  attributeChangedCallback(name) {
178
+ this.#entries = parseNumberList(this.getAttribute("values"));
143
179
  if (name === "value") {
144
180
  if (this.#drag) return;
145
181
  this.#isDirty = false;
@@ -152,7 +188,7 @@ var SonicRangeElement = class extends SonicFormElement {
152
188
  this.render();
153
189
  }
154
190
  connectedCallback() {
155
- this.upgradeProperties("default", "dimmed", "doublePress", "max", "midpoint", "min", "notched", "origin", "readout", "step", "taper", "formatValue", "parseValue", "value");
191
+ this.upgradeProperties("default", "dimmed", "doublePress", "max", "midpoint", "min", "notched", "origin", "readout", "step", "taper", "values", "detent", "formatValue", "parseValue", "value");
156
192
  super.connectedCallback();
157
193
  }
158
194
  bindGestures(control, signal, grab) {
@@ -170,6 +206,7 @@ var SonicRangeElement = class extends SonicFormElement {
170
206
  if (!axis) return;
171
207
  const startPosition = axis.position(event);
172
208
  const drag = {
209
+ detent: this.#detentHold(control, axis.travelPx),
173
210
  fromValue,
174
211
  isRevealed: false,
175
212
  lastPosition: startPosition,
@@ -192,11 +229,10 @@ var SonicRangeElement = class extends SonicFormElement {
192
229
  if (event.pointerId !== drag?.pointerId) return;
193
230
  const position = drag.position(event);
194
231
  const scale = event.shiftKey ? fineScale : 1;
195
- const fraction = drag.rawFraction + (position - drag.lastPosition) / drag.travelPx * scale;
196
- drag.rawFraction = this.wraps() ? wrapUnit(fraction) : clampUnit(fraction);
232
+ const next = this.#move(drag, (position - drag.lastPosition) / drag.travelPx * scale);
197
233
  drag.lastPosition = position;
198
234
  if (Math.abs(position - drag.startPosition) >= revealPx) this.#reveal(drag);
199
- this.input(this.valueAt(drag.rawFraction));
235
+ this.input(next);
200
236
  }, { signal });
201
237
  const endDrag = (event) => {
202
238
  if (event.pointerId === this.#drag?.pointerId) this.#endDrag();
@@ -209,7 +245,7 @@ var SonicRangeElement = class extends SonicFormElement {
209
245
  control.addEventListener("pointerup", (event) => {
210
246
  if (event.pointerId !== this.#drag?.pointerId) return;
211
247
  this.#endDrag();
212
- if (!entry.press(event)) return;
248
+ if (!entry.press(event) || this.springTarget() !== void 0) return;
213
249
  if (this.doublePress === "reset") this.#reset();
214
250
  else entry.open();
215
251
  }, { signal });
@@ -219,13 +255,24 @@ var SonicRangeElement = class extends SonicFormElement {
219
255
  this.#hideForKeys();
220
256
  }, { once: true });
221
257
  }
258
+ bindScale(control, scale, signal) {
259
+ this.#scale = scale;
260
+ this.mirrorChildren({
261
+ control,
262
+ copy: (originals) => {
263
+ scale.replaceChildren(...originals.flatMap((original) => scaleMark(original)));
264
+ this.renderScale();
265
+ },
266
+ isCopied: (child) => child instanceof Element && child.matches("[data-sonic-value]")
267
+ }, signal);
268
+ }
222
269
  clampRange(next) {
223
270
  const min = this.min;
224
- if (!this.wraps()) return Math.min(this.#max(), Math.max(min, next));
271
+ if (!this.wraps()) return clamp(next, min, this.#max());
225
272
  const range = this.range();
226
273
  if (range <= 0) return min;
227
- const offset = Number((((next - min) % range + range) % range).toPrecision(12));
228
- return offset >= range ? min : Number((min + offset).toPrecision(12));
274
+ const offset = trimFloat(((next - min) % range + range) % range);
275
+ return offset >= range ? min : trimFloat(min + offset);
229
276
  }
230
277
  controlRole() {
231
278
  return "slider";
@@ -249,10 +296,10 @@ var SonicRangeElement = class extends SonicFormElement {
249
296
  return [Math.min(value, modulated), Math.max(value, modulated)];
250
297
  }
251
298
  originFraction() {
252
- return this.fraction(this.origin ?? this.min);
299
+ return this.fraction(this.restValue());
253
300
  }
254
301
  positions() {
255
- const positions = Math.round(this.range() / this.step) + (this.wraps() ? 0 : 1);
302
+ const positions = this.#entries?.length ?? Math.round(this.range() / this.step) + (this.wraps() ? 0 : 1);
256
303
  if (!this.notched || !Number.isFinite(positions) || positions < 2) return;
257
304
  return positions;
258
305
  }
@@ -267,9 +314,10 @@ var SonicRangeElement = class extends SonicFormElement {
267
314
  this.#renderReadout();
268
315
  return;
269
316
  }
317
+ const [low, high] = this.#bounds();
270
318
  control.setAttribute("role", this.controlRole());
271
- control.setAttribute("aria-valuemin", String(this.min));
272
- control.setAttribute("aria-valuemax", String(this.#max()));
319
+ control.setAttribute("aria-valuemin", String(low));
320
+ control.setAttribute("aria-valuemax", String(high));
273
321
  control.setAttribute("aria-valuenow", String(this.#value));
274
322
  if (orientation) control.setAttribute("aria-orientation", orientation);
275
323
  writeAttribute(control, "aria-valuetext", this.#formatValue?.(this.#value));
@@ -277,9 +325,21 @@ var SonicRangeElement = class extends SonicFormElement {
277
325
  this.#renderDisabled(control);
278
326
  this.#renderReadout();
279
327
  }
328
+ renderScale() {
329
+ const marks = this.#scale?.children ?? [];
330
+ for (const mark of marks) {
331
+ if (!(mark instanceof HTMLElement || mark instanceof SVGElement)) continue;
332
+ const at = String(this.fraction(this.clampRange(scaleValue(mark))));
333
+ if (mark.style.getPropertyValue("--_sonic-scale-at") !== at) mark.style.setProperty("--_sonic-scale-at", at);
334
+ }
335
+ }
280
336
  restoreState(state) {
281
337
  this.value = Number(state);
282
338
  }
339
+ restValue() {
340
+ return this.origin ?? this.#bounds()[0];
341
+ }
342
+ springTarget() {}
283
343
  valueAt(fraction) {
284
344
  return (this.#taper() ?? linearTaper(this.min, this.#max())).value(fraction);
285
345
  }
@@ -321,6 +381,7 @@ var SonicRangeElement = class extends SonicFormElement {
321
381
  control.addEventListener("keydown", (event) => {
322
382
  if (this.isDisabled() || event.defaultPrevented || event.target !== control) return;
323
383
  if (event.key === "Enter") {
384
+ if (this.springTarget() !== void 0) return;
324
385
  event.preventDefault();
325
386
  entry.open();
326
387
  return;
@@ -331,21 +392,43 @@ var SonicRangeElement = class extends SonicFormElement {
331
392
  this.#commit(next);
332
393
  this.#revealForKeys();
333
394
  }, { signal });
395
+ control.addEventListener("keyup", (event) => {
396
+ if (keySteps.has(event.key) || endKeys.has(event.key)) this.#springBack();
397
+ }, { signal });
334
398
  control.addEventListener("blur", () => {
335
399
  this.#hideForKeys();
400
+ this.#springBack();
336
401
  }, { signal });
337
402
  }
403
+ #bounds() {
404
+ const entries = this.#entries;
405
+ return [entries?.[0] ?? this.min, entries?.at(-1) ?? this.#max()];
406
+ }
338
407
  #clamp(next) {
408
+ const entries = this.#entries;
409
+ if (entries) return nearestEntry(entries, next);
339
410
  const min = this.min;
340
411
  const step = this.step;
341
412
  const stepped = step > 0 ? min + Math.round((next - min) / step) * step : next;
342
- return this.clampRange(Number(stepped.toPrecision(12)));
413
+ return this.clampRange(trimFloat(stepped));
343
414
  }
344
415
  #commit(next) {
345
416
  if (this.input(next)) this.dispatchEvent(new Event("change", { bubbles: true }));
346
417
  }
418
+ #detentHold(control, travelPx) {
419
+ const detent = this.detent;
420
+ if (detent === void 0) return void 0;
421
+ const value = this.#clamp(detent);
422
+ const zonePx = readPxProperty(getComputedStyle(control), "--_sonic-detent-zone", detentZonePx);
423
+ return {
424
+ place: this.fraction(value),
425
+ slack: value === this.#value ? 0 : void 0,
426
+ value,
427
+ zone: zonePx / travelPx
428
+ };
429
+ }
347
430
  #end(step) {
348
- if (!this.wraps()) return this.#max();
431
+ if (!this.wraps()) return this.#bounds()[1];
349
432
  return this.min + (Math.ceil(this.range() / step) - 1) * step;
350
433
  }
351
434
  #endDrag() {
@@ -356,6 +439,7 @@ var SonicRangeElement = class extends SonicFormElement {
356
439
  this.toggleState("dragging", false);
357
440
  this.#renderReadout();
358
441
  if (this.#value !== drag.fromValue) this.dispatchEvent(new Event("change", { bubbles: true }));
442
+ this.#springBack();
359
443
  }
360
444
  #hideForKeys() {
361
445
  clearTimeout(this.#keyRevealTimer);
@@ -367,20 +451,29 @@ var SonicRangeElement = class extends SonicFormElement {
367
451
  return (this.#drag?.isRevealed === true || this.#keyRevealTimer !== void 0) && this.readout;
368
452
  }
369
453
  #keyTarget(key) {
370
- if (key === "Home") return this.min;
454
+ if (key === "Home") return this.#bounds()[0];
371
455
  const step = this.step > 0 ? this.step : this.range() / 100;
372
456
  if (key === "End") return this.#end(step);
373
457
  if (resetKeys.has(key)) return this.default;
374
458
  const steps = keySteps.get(key);
375
459
  if (steps === void 0) return void 0;
376
- const taper = this.#taper();
377
- if (!taper) return this.#value + steps * step;
378
- const next = this.#clamp(taper.value(taper.position(this.#value) + steps / 100));
379
- return next === this.#value ? this.#value + Math.sign(steps) * step : next;
460
+ return this.#stopAtDetent(this.#stepBy(steps, step));
380
461
  }
381
462
  #max() {
382
463
  return Math.max(this.min, this.max);
383
464
  }
465
+ #move(drag, delta) {
466
+ const { detent } = drag;
467
+ const to = drag.rawFraction + delta;
468
+ const next = detent ? passDetent(detent, drag.rawFraction, to) : to;
469
+ if (detent && next === void 0) {
470
+ drag.rawFraction = detent.place;
471
+ return detent.value;
472
+ }
473
+ const place = next ?? to;
474
+ drag.rawFraction = this.wraps() ? wrapUnit(place) : clampUnit(place);
475
+ return this.valueAt(drag.rawFraction);
476
+ }
384
477
  #renderDisabled(control) {
385
478
  if (this.isDisabled()) {
386
479
  control.setAttribute("aria-disabled", "true");
@@ -413,7 +506,27 @@ var SonicRangeElement = class extends SonicFormElement {
413
506
  }, keyRevealMs);
414
507
  this.#renderReadout();
415
508
  }
509
+ #springBack() {
510
+ const target = this.springTarget();
511
+ if (target !== void 0) this.#commit(target);
512
+ }
513
+ #stepBy(steps, step) {
514
+ const entries = this.#entries;
515
+ if (entries) return entries[clamp(entries.indexOf(this.#value) + steps, 0, entries.length - 1)] ?? this.#value;
516
+ const taper = this.#taper();
517
+ if (!taper) return this.#value + steps * step;
518
+ const next = this.#clamp(taper.value(taper.position(this.#value) + steps / 100));
519
+ return next === this.#value ? this.#value + Math.sign(steps) * step : next;
520
+ }
521
+ #stopAtDetent(next) {
522
+ const detent = this.detent;
523
+ if (detent === void 0) return next;
524
+ const value = this.#clamp(detent);
525
+ return (this.#value - value) * (next - value) < 0 ? value : next;
526
+ }
416
527
  #taper() {
528
+ const entries = this.#entries;
529
+ if (entries) return listTaper(entries);
417
530
  if (this.notched || this.wraps()) return void 0;
418
531
  const min = this.min;
419
532
  const max = this.#max();