@xsynaptic/sonic-ui 0.5.0 → 0.6.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 (118) hide show
  1. package/README.md +3 -1
  2. package/dist/default/dat.js +5 -4
  3. package/dist/default/define/button.js +1 -1
  4. package/dist/default/define.js +2 -2
  5. package/dist/default/elements/button.js +3 -3
  6. package/dist/default/elements/envelope.js +32 -16
  7. package/dist/default/elements/meter.js +11 -7
  8. package/dist/default/elements/number.js +1 -1
  9. package/dist/default/elements/radio-group.js +43 -45
  10. package/dist/default/elements/readout.js +1 -1
  11. package/dist/default/elements/sonic-element.js +56 -29
  12. package/dist/default/elements/switch.js +32 -38
  13. package/dist/default/elements/value-element.js +111 -108
  14. package/dist/default/elements/wave-element.js +5 -2
  15. package/dist/default/elements/waveform.js +35 -12
  16. package/dist/default/elements/wavestrip.js +18 -15
  17. package/dist/default/elements/xy.js +32 -18
  18. package/dist/default/index.js +2 -2
  19. package/dist/default/lib/adsr-shape.js +5 -0
  20. package/dist/default/lib/ballistics.js +2 -2
  21. package/dist/default/lib/canvas-surface.js +13 -2
  22. package/dist/default/lib/distribute.js +1 -1
  23. package/dist/default/lib/drag-step.js +4 -4
  24. package/dist/default/lib/frame-timeline.js +2 -1
  25. package/dist/default/lib/marker-band.js +54 -10
  26. package/dist/default/lib/marker-rider.js +10 -4
  27. package/dist/default/lib/math.js +6 -1
  28. package/dist/default/lib/pointer-drag.js +8 -5
  29. package/dist/default/lib/resample-peaks.js +11 -1
  30. package/dist/default/lib/write-attribute.js +1 -0
  31. package/dist/dev/dat.d.ts +2 -2
  32. package/dist/dev/dat.js +5 -4
  33. package/dist/dev/dat.js.map +1 -1
  34. package/dist/dev/define/button.js +1 -1
  35. package/dist/dev/define.d.ts +4 -4
  36. package/dist/dev/define.js +2 -2
  37. package/dist/dev/elements/button.js +3 -3
  38. package/dist/dev/elements/button.js.map +1 -1
  39. package/dist/dev/elements/dial.d.ts +1 -1
  40. package/dist/dev/elements/dial.js.map +1 -1
  41. package/dist/dev/elements/envelope.js +32 -16
  42. package/dist/dev/elements/envelope.js.map +1 -1
  43. package/dist/dev/elements/meter.d.ts +1 -1
  44. package/dist/dev/elements/meter.js +11 -7
  45. package/dist/dev/elements/meter.js.map +1 -1
  46. package/dist/dev/elements/number.js +1 -1
  47. package/dist/dev/elements/number.js.map +1 -1
  48. package/dist/dev/elements/radio-group.js +43 -45
  49. package/dist/dev/elements/radio-group.js.map +1 -1
  50. package/dist/dev/elements/readout.js +1 -1
  51. package/dist/dev/elements/readout.js.map +1 -1
  52. package/dist/dev/elements/slider.d.ts +1 -1
  53. package/dist/dev/elements/slider.js.map +1 -1
  54. package/dist/dev/elements/sonic-element.d.ts +4 -2
  55. package/dist/dev/elements/sonic-element.js +79 -29
  56. package/dist/dev/elements/sonic-element.js.map +1 -1
  57. package/dist/dev/elements/switch.js +32 -38
  58. package/dist/dev/elements/switch.js.map +1 -1
  59. package/dist/dev/elements/value-element.d.ts +3 -3
  60. package/dist/dev/elements/value-element.js +111 -108
  61. package/dist/dev/elements/value-element.js.map +1 -1
  62. package/dist/dev/elements/wave-element.d.ts +1 -0
  63. package/dist/dev/elements/wave-element.js +5 -2
  64. package/dist/dev/elements/wave-element.js.map +1 -1
  65. package/dist/dev/elements/waveform.d.ts +1 -1
  66. package/dist/dev/elements/waveform.js +35 -12
  67. package/dist/dev/elements/waveform.js.map +1 -1
  68. package/dist/dev/elements/wavestrip.js +18 -15
  69. package/dist/dev/elements/wavestrip.js.map +1 -1
  70. package/dist/dev/elements/xy.d.ts +3 -3
  71. package/dist/dev/elements/xy.js +32 -18
  72. package/dist/dev/elements/xy.js.map +1 -1
  73. package/dist/dev/index.d.ts +2 -2
  74. package/dist/dev/index.js +2 -2
  75. package/dist/dev/lib/adsr-shape.js +5 -0
  76. package/dist/dev/lib/adsr-shape.js.map +1 -1
  77. package/dist/dev/lib/ballistics.js +2 -2
  78. package/dist/dev/lib/ballistics.js.map +1 -1
  79. package/dist/dev/lib/canvas-surface.d.ts +1 -0
  80. package/dist/dev/lib/canvas-surface.js +13 -2
  81. package/dist/dev/lib/canvas-surface.js.map +1 -1
  82. package/dist/dev/lib/distribute.js +1 -1
  83. package/dist/dev/lib/distribute.js.map +1 -1
  84. package/dist/dev/lib/drag-step.js +4 -4
  85. package/dist/dev/lib/drag-step.js.map +1 -1
  86. package/dist/dev/lib/frame-timeline.js +2 -1
  87. package/dist/dev/lib/frame-timeline.js.map +1 -1
  88. package/dist/dev/lib/marker-band.js +54 -10
  89. package/dist/dev/lib/marker-band.js.map +1 -1
  90. package/dist/dev/lib/marker-rider.js +10 -4
  91. package/dist/dev/lib/marker-rider.js.map +1 -1
  92. package/dist/dev/lib/math.js +6 -1
  93. package/dist/dev/lib/math.js.map +1 -1
  94. package/dist/dev/lib/pointer-drag.js +8 -5
  95. package/dist/dev/lib/pointer-drag.js.map +1 -1
  96. package/dist/dev/lib/resample-peaks.js +11 -1
  97. package/dist/dev/lib/resample-peaks.js.map +1 -1
  98. package/dist/dev/lib/write-attribute.js +1 -0
  99. package/dist/dev/lib/write-attribute.js.map +1 -1
  100. package/dist/skins/amber.css +8 -0
  101. package/dist/skins/flat.css +25 -0
  102. package/dist/skins/ivory.css +1 -0
  103. package/dist/skins/lime.css +2 -0
  104. package/dist/skins/slate.css +2 -0
  105. package/dist/styles/button.css +38 -10
  106. package/dist/styles/dial.css +24 -16
  107. package/dist/styles/envelope.css +56 -11
  108. package/dist/styles/led.css +3 -3
  109. package/dist/styles/material.css +83 -41
  110. package/dist/styles/meter.css +8 -4
  111. package/dist/styles/panel.css +5 -3
  112. package/dist/styles/segmented.css +27 -5
  113. package/dist/styles/slider.css +2 -1
  114. package/dist/styles/switch.css +59 -20
  115. package/dist/styles/waveform.css +28 -5
  116. package/dist/styles/wavestrip.css +23 -6
  117. package/dist/styles/xy.css +61 -24
  118. package/package.json +2 -5
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @xsynaptic/sonic-ui
2
2
 
3
- Native custom elements for audio interfaces. Each control renders in the light DOM, drawn in CSS, skinned with `--sonic-*` tokens (CSS custom properties). No framework, no runtime dependencies.
3
+ Native custom elements for audio interfaces. Each control renders in the light DOM, skinned with `--sonic-*` tokens (CSS custom properties). No framework, no runtime dependencies.
4
4
 
5
5
  Early and unstable: any 0.x release may break the API.
6
6
 
@@ -23,4 +23,6 @@ import '@xsynaptic/sonic-ui/define';
23
23
  <sonic-dial aria-label="Cutoff" value="40"></sonic-dial>
24
24
  ```
25
25
 
26
+ Under a hydrating framework, register the definitions once hydration is done (in React, from an effect); a control owns one child of its host and uses only the children it documents.
27
+
26
28
  Targets current Chrome and Edge, Safari 26 and Firefox 147. MIT licensed.
@@ -16,6 +16,7 @@ function readDatHeader(buffer) {
16
16
  if (channels < 1) throw new Error("A .dat header names no channels");
17
17
  const sampleRate = view.getInt32(8, true);
18
18
  const samplesPerPixel = view.getInt32(12, true);
19
+ if (samplesPerPixel < 1) throw new Error("A .dat header names no samples per pixel");
19
20
  return {
20
21
  bits: (view.getUint32(4, true) & 1) === 1 ? 8 : 16,
21
22
  channels,
@@ -34,15 +35,15 @@ function datByteRange(header, fromPair, toPair) {
34
35
  const pairBytes = header.channels * 2 * header.bits / 8;
35
36
  return [header.dataOffset + from * pairBytes, header.dataOffset + to * pairBytes - 1];
36
37
  }
37
- function readDatSamples(header, body) {
38
- return header.bits === 8 ? new Int8Array(body) : new Int16Array(body, 0, Math.floor(body.byteLength / 2));
38
+ function readDatSamples(header, body, byteOffset = 0) {
39
+ return header.bits === 8 ? new Int8Array(body, byteOffset) : new Int16Array(body, byteOffset, Math.floor((body.byteLength - byteOffset) / 2));
39
40
  }
40
- function readDatPeaks(header, body) {
41
+ function readDatPeaks(header, body, byteOffset = 0) {
41
42
  return {
42
43
  channels: header.channels,
43
44
  fullScale: header.bits === 8 ? 128 : 32768,
44
45
  pairsPerSecond: header.pairsPerSecond,
45
- samples: readDatSamples(header, body)
46
+ samples: readDatSamples(header, body, byteOffset)
46
47
  };
47
48
  }
48
49
 
@@ -1,5 +1,5 @@
1
- import { defineOnce } from "../elements/define-once.js";
2
1
  import { SonicButton } from "../elements/button.js";
2
+ import { defineOnce } from "../elements/define-once.js";
3
3
 
4
4
  //#region src/define/button.ts
5
5
  defineOnce("sonic-button", SonicButton);
@@ -1,12 +1,12 @@
1
+ import "./define/button.js";
1
2
  import "./define/dial.js";
2
3
  import "./define/envelope.js";
3
- import "./define/button.js";
4
- import "./define/switch.js";
5
4
  import "./define/meter.js";
6
5
  import "./define/number.js";
7
6
  import "./define/segmented.js";
8
7
  import "./define/slider.js";
9
8
  import "./define/split.js";
9
+ import "./define/switch.js";
10
10
  import "./define/waveform.js";
11
11
  import "./define/wavestrip.js";
12
12
  import "./define/xy.js";
@@ -113,12 +113,12 @@ var SonicButton = class extends SonicFormElement {
113
113
  return this.#button;
114
114
  }
115
115
  render() {
116
+ if (!this.isBound()) return;
116
117
  const button = this.#button;
117
- button.disabled = this.isDisabled();
118
+ writeAttribute(button, "disabled", this.isDisabled() ? "" : void 0);
118
119
  writeAttribute(button, "aria-disabled", this.#isSoftDisabled() ? "true" : void 0);
119
120
  writeAttribute(button, "aria-busy", this.busy ? "true" : void 0);
120
- button.removeAttribute("aria-pressed");
121
- if (this.latching) button.setAttribute("aria-pressed", String(this.pressed));
121
+ writeAttribute(button, "aria-pressed", this.latching ? String(this.pressed) : void 0);
122
122
  this.toggleState("pressed", this.pressed);
123
123
  this.forwardNaming(button, true);
124
124
  const submitted = this.latching && this.pressed ? this.value : null;
@@ -4,10 +4,10 @@ import { SonicElement } from "./sonic-element.js";
4
4
  import { ReadoutClaim } from "./readout-claim.js";
5
5
  import { Readout } from "./readout.js";
6
6
  import { dragThresholdPx } from "../lib/drag-step.js";
7
+ import { bindDrag } from "../lib/pointer-drag.js";
7
8
  import { SonicValueElement, linkValue } from "./value-element.js";
8
9
  import { adsrPath, adsrShape, adsrStages, timeStages } from "../lib/adsr-shape.js";
9
10
  import { pointerMove, pointerPosition, startFieldDrag, stepFieldDrag } from "../lib/field.js";
10
- import { bindDrag } from "../lib/pointer-drag.js";
11
11
 
12
12
  //#region src/elements/envelope.ts
13
13
  const curveStages = [
@@ -31,10 +31,22 @@ function flip(point, isFlipped) {
31
31
  function isParent(node) {
32
32
  return "querySelector" in node;
33
33
  }
34
+ const emptyPath = {
35
+ fill: "",
36
+ floor: {
37
+ from: 0,
38
+ to: 0
39
+ },
40
+ stroke: ""
41
+ };
42
+ function stageParts(envelope, hook, stages) {
43
+ return new Map(stages.map((stage) => [stage, requireChild(envelope, `.${hook}[data-sonic-stage="${stage}"]`, HTMLDivElement)]));
44
+ }
34
45
  const renderEnvelope = template(`
35
46
  <div class="sonic-envelope" aria-hidden="true">
36
47
  <svg class="sonic-envelope-graph" preserveAspectRatio="none" viewBox="0 0 1 1">
37
48
  <path class="sonic-envelope-fill" />
49
+ <rect class="sonic-envelope-fill sonic-envelope-floor" y="1" />
38
50
  <path class="sonic-envelope-line" />
39
51
  </svg>
40
52
  <div class="sonic-envelope-handle" data-sonic-stage="delay" hidden></div>
@@ -119,8 +131,12 @@ var SonicEnvelope = class extends SonicElement {
119
131
  this.#renderReadout();
120
132
  });
121
133
  #envelope = renderEnvelope();
134
+ #curveHandles = stageParts(this.#envelope, "sonic-envelope-curve", curveStages);
122
135
  #fill = requireChild(this.#envelope, ".sonic-envelope-fill", SVGElement);
136
+ #floor = requireChild(this.#envelope, ".sonic-envelope-floor", SVGElement);
123
137
  #graph = requireChild(this.#envelope, ".sonic-envelope-graph", SVGElement);
138
+ #handles = stageParts(this.#envelope, "sonic-envelope-handle", timeStages);
139
+ #isDriving = false;
124
140
  #line = requireChild(this.#envelope, ".sonic-envelope-line", SVGElement);
125
141
  #pointerDrag;
126
142
  #readout = new Readout(requireChild(this.#envelope, ".sonic-envelope-readout", HTMLDivElement));
@@ -145,7 +161,7 @@ var SonicEnvelope = class extends SonicElement {
145
161
  connect(signal) {
146
162
  const envelope = this.#envelope;
147
163
  this.#signal = signal;
148
- this.appendOnce(envelope);
164
+ this.keepControl(envelope, signal);
149
165
  this.#bind();
150
166
  this.checkStyles(envelope, "envelope.css");
151
167
  this.#bindPointer(envelope, signal);
@@ -162,7 +178,7 @@ var SonicEnvelope = class extends SonicElement {
162
178
  if (!element) continue;
163
179
  const link = linkValue(element);
164
180
  const unwatch = link.watch(() => {
165
- this.#draw();
181
+ if (!this.#isDriving) this.#draw();
166
182
  });
167
183
  this.#bindings.set(name, {
168
184
  element,
@@ -202,32 +218,30 @@ var SonicEnvelope = class extends SonicElement {
202
218
  }
203
219
  #draw() {
204
220
  const shape = this.#readShape();
205
- const path = this.#bindings.size === 0 ? {
206
- fill: "",
207
- stroke: ""
208
- } : adsrPath(shape);
221
+ const path = this.#bindings.size === 0 ? emptyPath : adsrPath(shape);
209
222
  this.#shape = shape;
210
- this.#line.setAttribute("d", path.stroke);
211
- this.#fill.setAttribute("d", path.fill);
212
- for (const stage of timeStages) this.#drawPoint(`.sonic-envelope-handle[data-sonic-stage="${stage}"]`, {
223
+ writeAttribute(this.#line, "d", path.stroke);
224
+ writeAttribute(this.#fill, "d", path.fill);
225
+ writeAttribute(this.#floor, "x", String(path.floor.from));
226
+ writeAttribute(this.#floor, "width", String(path.floor.to - path.floor.from));
227
+ for (const [stage, part] of this.#handles) this.#drawPoint(part, {
213
228
  at: shape.handles.find((point) => point.stage === stage),
214
229
  drives: this.#drives(stage)
215
230
  });
216
- for (const stage of curveStages) this.#drawPoint(`.sonic-envelope-curve[data-sonic-stage="${stage}"]`, {
231
+ for (const [stage, part] of this.#curveHandles) this.#drawPoint(part, {
217
232
  at: shape.curveHandles.find((point) => point.stage === stage),
218
233
  drives: this.#curveDrives(stage)
219
234
  });
220
235
  this.#renderReadout();
221
236
  }
222
- #drawPoint(selector, point) {
223
- const part = requireChild(this.#envelope, selector, HTMLDivElement);
237
+ #drawPoint(part, point) {
224
238
  const { at, drives } = point;
225
- part.hidden = !at;
239
+ writeAttribute(part, "hidden", at ? void 0 : "");
226
240
  if (!at) return;
227
241
  const isStill = this.isDisabled() || drives.every(({ binding }) => binding.link.isDisabled());
228
242
  part.style.setProperty("--_sonic-envelope-x", String(at.x));
229
243
  part.style.setProperty("--_sonic-envelope-y", String(at.y));
230
- part.dataset.sonicAxes = drives.map(({ axis }) => axis).join("");
244
+ writeAttribute(part, "data-sonic-axes", drives.map(({ axis }) => axis).join(""));
231
245
  writeAttribute(part, "data-sonic-disabled", isStill ? "" : void 0);
232
246
  }
233
247
  #drives(stage) {
@@ -284,11 +298,13 @@ var SonicEnvelope = class extends SonicElement {
284
298
  const step = stepFieldDrag(mappings, drag.state, flip(pointerMove(event), drag.isFlipped));
285
299
  drag.state = step.state;
286
300
  if (step.state.isEngaged) this.#claim.reveal("drag");
301
+ this.#isDriving = true;
287
302
  for (const { axis, binding } of drag.drives) {
288
303
  const next = step[axis];
289
304
  if (next !== void 0) binding.link.input(next);
290
305
  }
291
- this.#renderReadout();
306
+ this.#isDriving = false;
307
+ this.#draw();
292
308
  }
293
309
  #pressed(event) {
294
310
  const part = event.target instanceof Element ? event.target.closest(".sonic-envelope-handle, .sonic-envelope-curve") : void 0;
@@ -1,3 +1,4 @@
1
+ import { toNumber } from "../lib/math.js";
1
2
  import { template } from "../lib/render.js";
2
3
  import { SonicElement } from "./sonic-element.js";
3
4
  import { parseNumberList } from "../lib/number-list.js";
@@ -51,7 +52,8 @@ var SonicMeter = class extends SonicElement {
51
52
  get level() {
52
53
  return this.#level;
53
54
  }
54
- set level(amplitude) {
55
+ set level(level) {
56
+ const amplitude = toNumber(level);
55
57
  if (!Number.isFinite(amplitude)) return;
56
58
  const decibels = toDecibels(amplitude);
57
59
  this.#level = amplitude;
@@ -121,7 +123,8 @@ var SonicMeter = class extends SonicElement {
121
123
  const now = performance.now();
122
124
  if (name === "ballistics") this.#velocity = 0;
123
125
  else if (name === "value") this.#readValue(now);
124
- this.#renderSegments();
126
+ this.#segments = this.segments;
127
+ if (name === "segments") this.#renderSegments();
125
128
  this.#render(now);
126
129
  this.#schedule(now);
127
130
  }
@@ -131,7 +134,7 @@ var SonicMeter = class extends SonicElement {
131
134
  connect(signal) {
132
135
  const now = performance.now();
133
136
  this.upgradeProperties("ballistics", "max", "min", "orientation", "origin", "segments", "value", "level");
134
- this.appendOnce(this.#meter);
137
+ this.keepControl(this.#meter, signal);
135
138
  this.#renderSegments();
136
139
  this.#render(now);
137
140
  this.#schedule(now);
@@ -165,6 +168,7 @@ var SonicMeter = class extends SonicElement {
165
168
  if (value !== void 0) this.#peak = Math.max(this.#peak, value);
166
169
  }
167
170
  #render(now) {
171
+ if (!this.isBound()) return;
168
172
  const meter = this.#meter;
169
173
  const origin = this.origin === void 0 ? 0 : this.#proportionOf(this.origin);
170
174
  meter.style.setProperty("--_sonic-meter-min", String(this.min));
@@ -185,9 +189,9 @@ var SonicMeter = class extends SonicElement {
185
189
  meter.style.setProperty("--_sonic-meter-clipped", now - this.#clipAt < holdMs ? "1" : "0");
186
190
  }
187
191
  #renderSegments() {
192
+ if (!this.isBound()) return;
188
193
  const meter = this.#meter;
189
- const segments = this.segments;
190
- this.#segments = segments;
194
+ const segments = this.#segments;
191
195
  this.toggleState("ladder", segments !== void 0);
192
196
  if (!segments) {
193
197
  for (const name of [
@@ -226,9 +230,9 @@ var SonicMeter = class extends SonicElement {
226
230
  this.#stepNeedle(elapsedMs);
227
231
  return;
228
232
  }
229
- const { fallDecibelsPerSecond, riseMs } = ballisticsRates[mode];
233
+ const { fallDecibelsPerSecond, riseTimeConstantMs } = ballisticsRates[mode];
230
234
  const floor = Math.max(this.#target, this.min);
231
- this.#bar = this.#target > this.#bar ? this.#risen(riseMs, elapsedMs) : fall(this.#bar, {
235
+ this.#bar = this.#target > this.#bar ? this.#risen(riseTimeConstantMs, elapsedMs) : fall(this.#bar, {
232
236
  fallDecibelsPerSecond,
233
237
  floor
234
238
  }, elapsedMs);
@@ -30,7 +30,7 @@ var SonicNumber = class extends SonicValueElement {
30
30
  }
31
31
  connect(signal) {
32
32
  const number = this.#number;
33
- this.appendOnce(number);
33
+ this.keepControl(number, signal);
34
34
  this.render();
35
35
  this.checkStyles(number, "number.css");
36
36
  this.bindGestures(number, signal, () => ({
@@ -1,6 +1,8 @@
1
1
  import { placeChildren } 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";
5
+ import { bindDrag } from "../lib/pointer-drag.js";
4
6
 
5
7
  //#region src/elements/radio-group.ts
6
8
  const keySteps = /* @__PURE__ */ new Map([
@@ -9,10 +11,12 @@ const keySteps = /* @__PURE__ */ new Map([
9
11
  ["ArrowRight", 1],
10
12
  ["ArrowUp", -1]
11
13
  ]);
12
- function isUnder(option, x, y) {
13
- const box = option.getBoundingClientRect();
14
+ function isInside(box, x, y) {
14
15
  return x >= box.left && x < box.right && y >= box.top && y < box.bottom;
15
16
  }
17
+ function isUnder(option, x, y) {
18
+ return isInside(option.getBoundingClientRect(), x, y);
19
+ }
16
20
  function optionValue(option) {
17
21
  return option.querySelector("[data-sonic-value]")?.dataset.sonicValue;
18
22
  }
@@ -26,7 +30,6 @@ var SonicRadioGroupElement = class extends SonicFormElement {
26
30
  this.#value = next;
27
31
  this.render();
28
32
  }
29
- #press;
30
33
  #value = "";
31
34
  attributeChangedCallback(name) {
32
35
  if (name === "value") this.#value = this.getAttribute("value") ?? "";
@@ -86,13 +89,15 @@ var SonicRadioGroupElement = class extends SonicFormElement {
86
89
  return option;
87
90
  }
88
91
  render() {
92
+ if (!this.isBound()) return;
89
93
  const options = this.options();
90
94
  const checked = options.find((option) => optionValue(option) === this.#value);
91
95
  const focusable = checked ?? options[0];
96
+ const isDisabled = this.isDisabled();
92
97
  for (const option of options) {
93
- option.disabled = this.isDisabled();
94
- option.setAttribute("aria-checked", String(option === checked));
95
- option.tabIndex = option === focusable ? 0 : -1;
98
+ writeAttribute(option, "disabled", isDisabled ? "" : void 0);
99
+ writeAttribute(option, "aria-checked", String(option === checked));
100
+ writeAttribute(option, "tabindex", option === focusable ? "0" : "-1");
96
101
  }
97
102
  this.forwardNaming(this.group, true);
98
103
  this.writeFormValue(checked ? this.#value : null, this.#value);
@@ -107,41 +112,32 @@ var SonicRadioGroupElement = class extends SonicFormElement {
107
112
  this.dispatchEvent(new Event("change", { bubbles: true }));
108
113
  }
109
114
  #bindPress(group, signal) {
110
- const isOwn = (event) => event.pointerId === this.#press?.pointerId;
111
- const release = (event) => {
112
- if (isOwn(event)) this.#hold(void 0);
113
- };
114
- group.addEventListener("pointerdown", (event) => {
115
- const option = this.optionOf(event.target);
116
- if (!option || event.button !== 0 || this.isDisabled()) return;
117
- if (this.claimPress(option, event)) return;
118
- group.setPointerCapture(event.pointerId);
119
- this.#hold({
120
- option,
121
- pointerId: event.pointerId
122
- });
123
- }, { signal });
124
- group.addEventListener("pointermove", (event) => {
125
- if (!isOwn(event)) return;
126
- this.#hold({
127
- option: this.options().find((option) => isUnder(option, event.clientX, event.clientY)),
128
- pointerId: event.pointerId
129
- });
130
- }, { signal });
131
- group.addEventListener("pointerup", (event) => {
132
- const pressed = isOwn(event) ? this.#press?.option : void 0;
133
- release(event);
134
- if (!pressed || this.isDisabled()) return;
135
- const option = this.releaseTarget(pressed);
136
- if (!option) return;
137
- this.select(option);
138
- if (group.matches(":focus-within")) option.focus();
139
- }, { signal });
140
- group.addEventListener("pointercancel", release, { signal });
141
- group.addEventListener("lostpointercapture", release, { signal });
142
- signal.addEventListener("abort", () => {
143
- this.#hold(void 0);
144
- }, { once: true });
115
+ bindDrag(group, {
116
+ grab: (event) => {
117
+ const option = this.optionOf(event.target);
118
+ if (!option || this.isDisabled() || this.claimPress(option, event)) return;
119
+ const boxes = this.#optionBoxes();
120
+ this.#markPressed(option);
121
+ return {
122
+ boxes,
123
+ option
124
+ };
125
+ },
126
+ lift: (press) => {
127
+ if (!press.option || this.isDisabled()) return;
128
+ const option = this.releaseTarget(press.option);
129
+ if (!option) return;
130
+ this.select(option);
131
+ if (group.matches(":focus-within")) option.focus();
132
+ },
133
+ move: (press, event) => {
134
+ press.option = press.boxes.find(([, box]) => isInside(box, event.clientX, event.clientY))?.[0];
135
+ this.#markPressed(press.option);
136
+ },
137
+ release: () => {
138
+ this.#markPressed(void 0);
139
+ }
140
+ }, signal);
145
141
  }
146
142
  #copyOptions(copies) {
147
143
  const group = this.group;
@@ -160,10 +156,6 @@ var SonicRadioGroupElement = class extends SonicFormElement {
160
156
  this.render();
161
157
  if (focused) this.#refocus(focused, focusedValue);
162
158
  }
163
- #hold(press) {
164
- this.#press = press;
165
- for (const option of this.options()) option.toggleAttribute("data-sonic-pressed", option === press?.option);
166
- }
167
159
  #keyTarget(option, key) {
168
160
  const options = this.options();
169
161
  if (key === "Home") return options[0];
@@ -174,6 +166,12 @@ var SonicRadioGroupElement = class extends SonicFormElement {
174
166
  if (this.isWrapping()) return options[(index + options.length) % options.length];
175
167
  return options[Math.min(Math.max(index, 0), options.length - 1)];
176
168
  }
169
+ #markPressed(pressed) {
170
+ for (const option of this.options()) option.toggleAttribute("data-sonic-pressed", option === pressed);
171
+ }
172
+ #optionBoxes() {
173
+ return this.options().map((option) => [option, option.getBoundingClientRect()]);
174
+ }
177
175
  #refocus(focused, value) {
178
176
  const options = this.options();
179
177
  const next = options.find((option) => optionValue(option) === value) ?? options.find((option) => option.tabIndex === 0);
@@ -23,7 +23,7 @@ var Readout = class {
23
23
  this.#anchor = anchor;
24
24
  }
25
25
  this.#text.hidden = text === void 0;
26
- if (text !== void 0) this.#text.textContent = text;
26
+ if (isOpen && text !== void 0 && this.#text.textContent !== text) this.#text.textContent = text;
27
27
  this.#toggle(isOpen);
28
28
  }
29
29
  #toggle(isOpen) {
@@ -1,3 +1,4 @@
1
+ import { toNumber } from "../lib/math.js";
1
2
  import { attachSlots } from "../lib/render.js";
2
3
  import { writeAttribute } from "../lib/write-attribute.js";
3
4
 
@@ -30,13 +31,14 @@ var SonicElement = class extends HTMLElement {
30
31
  this.upgradeProperties("disabled");
31
32
  if (this.#connection) return;
32
33
  const connection = new AbortController();
34
+ this.#connection = connection;
33
35
  try {
34
36
  this.connect(connection.signal);
35
37
  } catch (error) {
36
38
  connection.abort();
39
+ this.#connection = void 0;
37
40
  throw error;
38
41
  }
39
- this.#connection = connection;
40
42
  }
41
43
  connectedMoveCallback() {}
42
44
  disconnectedCallback() {
@@ -56,12 +58,6 @@ var SonicElement = class extends HTMLElement {
56
58
  }
57
59
  if (!this.isDisabled() && !this.#focused()) target.focus(options);
58
60
  }
59
- appendOnce(control) {
60
- const [hook] = control.classList;
61
- const stale = [...this.children].filter((child) => child !== control && hook !== void 0 && child.classList.contains(hook));
62
- for (const child of stale) child.remove();
63
- if (control.parentNode !== this) this.append(control);
64
- }
65
61
  checkStyles(control, sheet) {}
66
62
  focusTarget() {}
67
63
  forwardNaming(target, isNamed) {
@@ -73,36 +69,64 @@ var SonicElement = class extends HTMLElement {
73
69
  }
74
70
  if (labels.length > 0) writeLabelledBy(target, labels);
75
71
  }
72
+ isBound() {
73
+ return this.#connection !== void 0;
74
+ }
76
75
  isDisabled() {
77
76
  return this.disabled || this.matches(":disabled");
78
77
  }
79
- mirrorChildren({ control, copy, isCopied = () => true, place }, signal) {
78
+ keepControl(control, signal) {
79
+ const keep = () => {
80
+ this.#appendOnce(control);
81
+ this.#checkChildren([...this.childNodes].filter((child) => child !== control));
82
+ };
83
+ const observer = new MutationObserver(keep);
84
+ keep();
85
+ observer.observe(this, { childList: true });
86
+ signal.addEventListener("abort", () => {
87
+ observer.disconnect();
88
+ }, { once: true });
89
+ }
90
+ mirrorChildren({ control, copy, isCopied = () => true, isPassed = () => false, place }, signal) {
80
91
  const slots = this.#slots ?? attachSlots(this);
81
92
  let copies = /* @__PURE__ */ new Map();
82
93
  this.#slots = slots;
83
94
  const mirror = (touched) => {
84
- this.appendOnce(control);
95
+ this.#appendOnce(control);
85
96
  const children = [...this.childNodes].filter((child) => child !== control);
86
97
  const slotted = children.filter((child) => child instanceof Element || child instanceof Text);
87
98
  const kept = copies;
88
- copies = new Map(children.filter((child) => isCopied(child)).map((original) => [original, touched && !touched.has(original) && kept.has(original) ? kept.get(original) : copy(original)]));
99
+ const originals = children.filter((child) => isCopied(child));
100
+ this.#checkMirrored(originals);
101
+ this.#checkChildren(children.filter((child) => !isCopied(child) && !isPassed(child)));
102
+ copies = new Map(originals.map((original) => [original, touched && !touched.has(original) && kept.has(original) ? kept.get(original) : copy(original)]));
89
103
  place([...copies.values()].filter((made) => made !== void 0));
90
104
  slots.shown.assign(...slotted.filter((child) => !isCopied(child)), control);
91
105
  slots.kept.assign(...slotted.filter((child) => isCopied(child)));
92
106
  };
107
+ const watch = () => {
108
+ observer.disconnect();
109
+ observer.observe(this, { childList: true });
110
+ for (const child of this.childNodes) {
111
+ if (child === control) continue;
112
+ observer.observe(child, {
113
+ attributes: true,
114
+ characterData: true,
115
+ childList: true,
116
+ subtree: true
117
+ });
118
+ }
119
+ };
93
120
  const observer = new MutationObserver((records) => {
94
121
  const changes = records.filter((record) => this.#isCopiedChange(record, control, isCopied));
95
122
  if (changes.length === 0) return;
96
- if (changes.some((record) => record.target === this)) mirror();
97
- else mirror(new Set(changes.map((record) => this.#childHolding(record.target))));
123
+ if (changes.some((record) => record.target === this)) {
124
+ mirror();
125
+ watch();
126
+ } else mirror(new Set(changes.map((record) => this.#childHolding(record.target))));
98
127
  });
99
128
  mirror();
100
- observer.observe(this, {
101
- attributes: true,
102
- characterData: true,
103
- childList: true,
104
- subtree: true
105
- });
129
+ watch();
106
130
  signal.addEventListener("abort", () => {
107
131
  observer.disconnect();
108
132
  }, { once: true });
@@ -111,9 +135,7 @@ var SonicElement = class extends HTMLElement {
111
135
  return [];
112
136
  }
113
137
  numberAttribute(name, fallback) {
114
- const attribute = this.getAttribute(name);
115
- if (attribute === null || attribute.trim() === "") return fallback;
116
- const parsed = Number(attribute);
138
+ const parsed = toNumber(this.getAttribute(name));
117
139
  return Number.isFinite(parsed) ? parsed : fallback;
118
140
  }
119
141
  optionalNumberAttribute(name) {
@@ -121,11 +143,11 @@ var SonicElement = class extends HTMLElement {
121
143
  return Number.isNaN(value) ? void 0 : value;
122
144
  }
123
145
  reflect(name, value) {
124
- if (value === true) {
125
- this.setAttribute(name, "");
146
+ if (typeof value !== "number" && typeof value !== "string" && value !== true) {
147
+ this.removeAttribute(name);
126
148
  return;
127
149
  }
128
- writeAttribute(this, name, value === false || value === null ? void 0 : value?.toString());
150
+ this.setAttribute(name, value === true ? "" : value.toString());
129
151
  }
130
152
  toggleState(state, isOn) {
131
153
  if (isOn) this.internals?.states.add(state);
@@ -139,6 +161,14 @@ var SonicElement = class extends HTMLElement {
139
161
  this[name] = value;
140
162
  }
141
163
  }
164
+ #appendOnce(control) {
165
+ const [hook] = control.classList;
166
+ const stale = [...this.children].filter((child) => child !== control && hook !== void 0 && child.classList.contains(hook));
167
+ for (const child of stale) child.remove();
168
+ if (control.parentNode !== this) this.append(control);
169
+ }
170
+ #checkChildren(undocumented) {}
171
+ #checkMirrored(originals) {}
142
172
  #childHolding(node) {
143
173
  let current = node;
144
174
  while (current && current.parentNode !== this) current = current.parentNode;
@@ -150,12 +180,9 @@ var SonicElement = class extends HTMLElement {
150
180
  return active instanceof HTMLElement && this.contains(active) ? active : void 0;
151
181
  }
152
182
  #isCopiedChange(record, control, isCopied) {
153
- if (record.target === this) {
154
- if (record.type === "attributes") return false;
155
- return record.removedNodes.length > 0 || [...record.addedNodes].some((node) => node !== control);
156
- }
183
+ if (record.target === this) return record.removedNodes.length > 0 || [...record.addedNodes].some((node) => node !== control);
157
184
  const child = this.#childHolding(record.target);
158
- if (child === void 0 || child === control) return false;
185
+ if (child === void 0) return false;
159
186
  return isCopied(child) || record.type === "attributes" && record.target === child;
160
187
  }
161
188
  };