@xsynaptic/sonic-ui 0.2.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 (59) hide show
  1. package/README.md +2 -2
  2. package/dist/default/define/number.js +7 -0
  3. package/dist/default/define.js +1 -0
  4. package/dist/default/elements/dial.js +24 -10
  5. package/dist/default/elements/key.js +29 -4
  6. package/dist/default/elements/meter.js +63 -9
  7. package/dist/default/elements/number.js +46 -0
  8. package/dist/default/elements/range-element.js +172 -36
  9. package/dist/default/elements/slider.js +63 -3
  10. package/dist/default/index.js +2 -1
  11. package/dist/default/lib/detent.js +17 -0
  12. package/dist/default/lib/math.js +16 -0
  13. package/dist/default/lib/number-list.js +14 -0
  14. package/dist/default/lib/taper.js +17 -4
  15. package/dist/dev/define/number.d.ts +2 -0
  16. package/dist/dev/define/number.js +8 -0
  17. package/dist/dev/define/number.js.map +1 -0
  18. package/dist/dev/define.d.ts +3 -1
  19. package/dist/dev/define.js +1 -0
  20. package/dist/dev/elements/dial.d.ts +3 -0
  21. package/dist/dev/elements/dial.js +24 -10
  22. package/dist/dev/elements/dial.js.map +1 -1
  23. package/dist/dev/elements/key.d.ts +4 -0
  24. package/dist/dev/elements/key.js +29 -4
  25. package/dist/dev/elements/key.js.map +1 -1
  26. package/dist/dev/elements/meter.d.ts +6 -0
  27. package/dist/dev/elements/meter.js +63 -9
  28. package/dist/dev/elements/meter.js.map +1 -1
  29. package/dist/dev/elements/number.d.ts +16 -0
  30. package/dist/dev/elements/number.js +47 -0
  31. package/dist/dev/elements/number.js.map +1 -0
  32. package/dist/dev/elements/range-element.d.ts +12 -0
  33. package/dist/dev/elements/range-element.js +172 -36
  34. package/dist/dev/elements/range-element.js.map +1 -1
  35. package/dist/dev/elements/slider.d.ts +8 -0
  36. package/dist/dev/elements/slider.js +63 -3
  37. package/dist/dev/elements/slider.js.map +1 -1
  38. package/dist/dev/index.d.ts +2 -1
  39. package/dist/dev/index.js +2 -1
  40. package/dist/dev/lib/detent.js +18 -0
  41. package/dist/dev/lib/detent.js.map +1 -0
  42. package/dist/dev/lib/math.js +17 -0
  43. package/dist/dev/lib/math.js.map +1 -0
  44. package/dist/dev/lib/number-list.js +15 -0
  45. package/dist/dev/lib/number-list.js.map +1 -0
  46. package/dist/dev/lib/read-px-property.js.map +1 -1
  47. package/dist/dev/lib/taper.js +17 -4
  48. package/dist/dev/lib/taper.js.map +1 -1
  49. package/dist/styles/controls.css +2 -0
  50. package/dist/styles/dial.css +80 -6
  51. package/dist/styles/key.css +96 -11
  52. package/dist/styles/led.css +82 -0
  53. package/dist/styles/material.css +150 -20
  54. package/dist/styles/meter.css +120 -8
  55. package/dist/styles/number.css +81 -0
  56. package/dist/styles/screen.css +6 -39
  57. package/dist/styles/segmented.css +23 -7
  58. package/dist/styles/slider.css +143 -30
  59. package/package.json +4 -2
@@ -2,10 +2,21 @@ import { requireChild, template } from "../lib/render.js";
2
2
  import { SonicRangeElement } from "./range-element.js";
3
3
 
4
4
  //#region src/elements/slider.ts
5
+ function readRanges(ranges) {
6
+ if ("start" in ranges) return Array.from({ length: ranges.length }, (_entry, index) => [ranges.start(index), ranges.end(index)]);
7
+ return [...ranges].map(([start, end]) => [start, end]);
8
+ }
9
+ function grooveStop(fraction) {
10
+ if (fraction <= 0) return "0%";
11
+ if (fraction >= 1) return "100%";
12
+ return `calc(${String(fraction)} * var(--_sonic-slider-travel) + var(--_sonic-slider-groove) / 2)`;
13
+ }
5
14
  const renderSlider = template(`
6
15
  <div class="sonic-slider" role="slider" tabindex="0">
16
+ <div class="sonic-slider-scale" aria-hidden="true"></div>
7
17
  <div class="sonic-slider-marks"></div>
8
18
  <div class="sonic-slider-groove"></div>
19
+ <div class="sonic-slider-modulation"></div>
9
20
  <div class="sonic-slider-cap"></div>
10
21
  <div class="sonic-slider-readout" popover="manual">
11
22
  <span></span>
@@ -21,28 +32,53 @@ const renderSlider = template(`
21
32
  </div>
22
33
  `, HTMLDivElement);
23
34
  var SonicSlider = class extends SonicRangeElement {
24
- static observedAttributes = [...SonicRangeElement.observedAttributes, "orientation"];
35
+ static observedAttributes = [
36
+ ...SonicRangeElement.observedAttributes,
37
+ "modulation",
38
+ "orientation",
39
+ "spring"
40
+ ];
41
+ get buffered() {
42
+ return this.#buffered.map(([start, end]) => [start, end]);
43
+ }
44
+ set buffered(ranges) {
45
+ this.#buffered = ranges ? readRanges(ranges).toSorted((first, second) => first[0] - second[0]) : [];
46
+ this.render();
47
+ }
25
48
  get groovePress() {
26
49
  return this.getAttribute("groove-press") === "none" ? "none" : "jump";
27
50
  }
28
51
  set groovePress(gesture) {
29
52
  this.reflect("groove-press", gesture);
30
53
  }
54
+ get modulation() {
55
+ return this.numberAttribute("modulation", 0);
56
+ }
57
+ set modulation(amount) {
58
+ this.reflect("modulation", amount);
59
+ }
31
60
  get orientation() {
32
61
  return this.getAttribute("orientation") === "vertical" ? "vertical" : "horizontal";
33
62
  }
34
63
  set orientation(direction) {
35
64
  this.reflect("orientation", direction);
36
65
  }
66
+ get spring() {
67
+ return this.hasAttribute("spring");
68
+ }
69
+ set spring(isSpring) {
70
+ this.reflect("spring", isSpring);
71
+ }
72
+ #buffered = [];
37
73
  #slider = renderSlider();
38
74
  connectedCallback() {
39
- this.upgradeProperties("groovePress", "orientation");
75
+ this.upgradeProperties("buffered", "groovePress", "modulation", "orientation", "spring");
40
76
  super.connectedCallback();
41
77
  }
42
78
  connect(signal) {
43
79
  const slider = this.#slider;
44
80
  const cap = requireChild(slider, ".sonic-slider-cap", HTMLDivElement);
45
- this.appendOnce(slider);
81
+ this.bindScale(slider, requireChild(slider, ".sonic-slider-scale", HTMLDivElement), signal);
46
82
  this.render();
47
83
  this.checkStyles(slider, "slider.css");
48
84
  this.bindGestures(slider, signal, (event) => {
@@ -59,12 +95,20 @@ var SonicSlider = class extends SonicRangeElement {
59
95
  render() {
60
96
  const slider = this.#slider;
61
97
  const positions = this.positions();
98
+ const [modulationFrom, modulationTo] = this.modulationFractions(this.modulation);
62
99
  slider.style.setProperty("--_sonic-slider-value", String(this.fraction(this.value)));
63
100
  slider.style.setProperty("--_sonic-slider-origin", String(this.originFraction()));
101
+ slider.style.setProperty("--_sonic-slider-modulation-from", String(modulationFrom));
102
+ slider.style.setProperty("--_sonic-slider-modulation-to", String(modulationTo));
64
103
  if (positions === void 0) slider.style.removeProperty("--_sonic-slider-positions");
65
104
  else slider.style.setProperty("--_sonic-slider-positions", String(positions));
105
+ this.#renderBuffered(slider);
106
+ this.renderScale();
66
107
  this.renderAria(slider, this.orientation);
67
108
  }
109
+ springTarget() {
110
+ return this.spring ? this.restValue() : void 0;
111
+ }
68
112
  #axis(slider, cap) {
69
113
  const track = slider.getBoundingClientRect();
70
114
  const capBox = cap.getBoundingClientRect();
@@ -79,6 +123,22 @@ var SonicSlider = class extends SonicRangeElement {
79
123
  travelPx: Math.max(1, track.width - capBox.width)
80
124
  };
81
125
  }
126
+ #renderBuffered(slider) {
127
+ if (this.#buffered.length === 0) {
128
+ slider.style.removeProperty("--_sonic-slider-buffered-ranges");
129
+ return;
130
+ }
131
+ const stops = this.#buffered.flatMap(([start, end]) => {
132
+ const from = grooveStop(this.fraction(Math.min(start, end)));
133
+ const to = grooveStop(this.fraction(Math.max(start, end)));
134
+ return [
135
+ `transparent ${from}`,
136
+ `var(--_sonic-slider-buffered) ${from} ${to}`,
137
+ `transparent ${to}`
138
+ ];
139
+ });
140
+ slider.style.setProperty("--_sonic-slider-buffered-ranges", `linear-gradient(var(--_sonic-slider-toward), ${stops.join(", ")})`);
141
+ }
82
142
  };
83
143
 
84
144
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"slider.js","names":[],"sources":["../../../src/elements/slider.ts"],"sourcesContent":["import type { RangeAxis } from '#elements/range-element.ts';\n\nimport { SonicRangeElement } from '#elements/range-element.ts';\nimport { requireChild, template } from '#lib/render.ts';\n\ndeclare global {\n\tinterface HTMLElementTagNameMap {\n\t\t'sonic-slider': SonicSlider;\n\t}\n}\n\ninterface SliderAxis extends RangeAxis {\n\t// The cap's centre at the minimum, on the same scale as `position`\n\tstartPx: number;\n}\n\nconst renderSlider = template(\n\t/* HTML */ `\n\t\t<div class=\"sonic-slider\" role=\"slider\" tabindex=\"0\">\n\t\t\t<div class=\"sonic-slider-marks\"></div>\n\t\t\t<div class=\"sonic-slider-groove\"></div>\n\t\t\t<div class=\"sonic-slider-cap\"></div>\n\t\t\t<div class=\"sonic-slider-readout\" popover=\"manual\">\n\t\t\t\t<span></span>\n\t\t\t\t<input\n\t\t\t\t\tautocomplete=\"off\"\n\t\t\t\t\tclass=\"sonic-slider-entry\"\n\t\t\t\t\tenterkeyhint=\"done\"\n\t\t\t\t\thidden\n\t\t\t\t\tspellcheck=\"false\"\n\t\t\t\t\ttype=\"text\"\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</div>\n\t`,\n\tHTMLDivElement,\n);\n\nexport class SonicSlider extends SonicRangeElement {\n\tstatic override readonly observedAttributes = [\n\t\t...SonicRangeElement.observedAttributes,\n\t\t'orientation',\n\t];\n\n\t// Named for the absent attribute, as `input.type` reads `text`\n\tget groovePress(): 'jump' | 'none' {\n\t\treturn this.getAttribute('groove-press') === 'none' ? 'none' : 'jump';\n\t}\n\n\tset groovePress(gesture: 'jump' | 'none' | undefined) {\n\t\tthis.reflect('groove-press', gesture);\n\t}\n\n\tget orientation(): 'horizontal' | 'vertical' {\n\t\treturn this.getAttribute('orientation') === 'vertical' ? 'vertical' : 'horizontal';\n\t}\n\n\tset orientation(direction: 'horizontal' | 'vertical' | undefined) {\n\t\tthis.reflect('orientation', direction);\n\t}\n\n\treadonly #slider = renderSlider();\n\n\toverride connectedCallback(): void {\n\t\tthis.upgradeProperties('groovePress', 'orientation');\n\t\tsuper.connectedCallback();\n\t}\n\n\tprotected connect(signal: AbortSignal): void {\n\t\tconst slider = this.#slider;\n\t\tconst cap = requireChild(slider, '.sonic-slider-cap', HTMLDivElement);\n\n\t\tthis.appendOnce(slider);\n\t\tthis.render();\n\t\tthis.checkStyles(slider, 'slider.css');\n\t\tthis.bindGestures(slider, signal, (event) => {\n\t\t\tconst axis = this.#axis(slider, cap);\n\t\t\tif (event.target instanceof Node && cap.contains(event.target)) return axis;\n\n\t\t\tif (this.groovePress === 'none') return;\n\n\t\t\tthis.input(this.valueAt((axis.position(event) - axis.startPx) / axis.travelPx));\n\n\t\t\treturn axis;\n\t\t});\n\t}\n\n\tprotected override focusTarget(): HTMLElement {\n\t\treturn this.#slider;\n\t}\n\n\tprotected render(): void {\n\t\tconst slider = this.#slider;\n\t\tconst positions = this.positions();\n\n\t\tslider.style.setProperty('--_sonic-slider-value', String(this.fraction(this.value)));\n\t\tslider.style.setProperty('--_sonic-slider-origin', String(this.originFraction()));\n\t\tif (positions === undefined) slider.style.removeProperty('--_sonic-slider-positions');\n\t\telse slider.style.setProperty('--_sonic-slider-positions', String(positions));\n\t\tthis.renderAria(slider, this.orientation);\n\t}\n\n\t#axis(slider: HTMLElement, cap: HTMLElement): SliderAxis {\n\t\tconst track = slider.getBoundingClientRect();\n\t\tconst capBox = cap.getBoundingClientRect();\n\n\t\tif (this.orientation === 'vertical') {\n\t\t\treturn {\n\t\t\t\tposition: (event) => -event.clientY,\n\t\t\t\tstartPx: capBox.height / 2 - track.bottom,\n\t\t\t\ttravelPx: Math.max(1, track.height - capBox.height),\n\t\t\t};\n\t\t}\n\n\t\treturn {\n\t\t\tposition: (event) => event.clientX,\n\t\t\tstartPx: track.left + capBox.width / 2,\n\t\t\ttravelPx: Math.max(1, track.width - capBox.width),\n\t\t};\n\t}\n}\n"],"mappings":";;;;AAgBA,MAAM,eAAe,SACT;;;;;;;;;;;;;;;;;IAkBX,cACD;AAEA,IAAa,cAAb,cAAiC,kBAAkB;CAClD,OAAyB,qBAAqB,CAC7C,GAAG,kBAAkB,oBACrB,aACD;CAGA,IAAI,cAA+B;EAClC,OAAO,KAAK,aAAa,cAAc,MAAM,SAAS,SAAS;CAChE;CAEA,IAAI,YAAY,SAAsC;EACrD,KAAK,QAAQ,gBAAgB,OAAO;CACrC;CAEA,IAAI,cAAyC;EAC5C,OAAO,KAAK,aAAa,aAAa,MAAM,aAAa,aAAa;CACvE;CAEA,IAAI,YAAY,WAAkD;EACjE,KAAK,QAAQ,eAAe,SAAS;CACtC;CAEA,AAAS,UAAU,aAAa;CAEhC,AAAS,oBAA0B;EAClC,KAAK,kBAAkB,eAAe,aAAa;EACnD,MAAM,kBAAkB;CACzB;CAEA,AAAU,QAAQ,QAA2B;EAC5C,MAAM,SAAS,KAAK;EACpB,MAAM,MAAM,aAAa,QAAQ,qBAAqB,cAAc;EAEpE,KAAK,WAAW,MAAM;EACtB,KAAK,OAAO;EACZ,KAAK,YAAY,QAAQ,YAAY;EACrC,KAAK,aAAa,QAAQ,SAAS,UAAU;GAC5C,MAAM,OAAO,KAAK,MAAM,QAAQ,GAAG;GACnC,IAAI,MAAM,kBAAkB,QAAQ,IAAI,SAAS,MAAM,MAAM,GAAG,OAAO;GAEvE,IAAI,KAAK,gBAAgB,QAAQ;GAEjC,KAAK,MAAM,KAAK,SAAS,KAAK,SAAS,KAAK,IAAI,KAAK,WAAW,KAAK,QAAQ,CAAC;GAE9E,OAAO;EACR,CAAC;CACF;CAEA,AAAmB,cAA2B;EAC7C,OAAO,KAAK;CACb;CAEA,AAAU,SAAe;EACxB,MAAM,SAAS,KAAK;EACpB,MAAM,YAAY,KAAK,UAAU;EAEjC,OAAO,MAAM,YAAY,yBAAyB,OAAO,KAAK,SAAS,KAAK,KAAK,CAAC,CAAC;EACnF,OAAO,MAAM,YAAY,0BAA0B,OAAO,KAAK,eAAe,CAAC,CAAC;EAChF,IAAI,cAAc,QAAW,OAAO,MAAM,eAAe,2BAA2B;OAC/E,OAAO,MAAM,YAAY,6BAA6B,OAAO,SAAS,CAAC;EAC5E,KAAK,WAAW,QAAQ,KAAK,WAAW;CACzC;CAEA,MAAM,QAAqB,KAA8B;EACxD,MAAM,QAAQ,OAAO,sBAAsB;EAC3C,MAAM,SAAS,IAAI,sBAAsB;EAEzC,IAAI,KAAK,gBAAgB,YACxB,OAAO;GACN,WAAW,UAAU,CAAC,MAAM;GAC5B,SAAS,OAAO,SAAS,IAAI,MAAM;GACnC,UAAU,KAAK,IAAI,GAAG,MAAM,SAAS,OAAO,MAAM;EACnD;EAGD,OAAO;GACN,WAAW,UAAU,MAAM;GAC3B,SAAS,MAAM,OAAO,OAAO,QAAQ;GACrC,UAAU,KAAK,IAAI,GAAG,MAAM,QAAQ,OAAO,KAAK;EACjD;CACD;AACD"}
1
+ {"version":3,"file":"slider.js","names":[],"sources":["../../../src/elements/slider.ts"],"sourcesContent":["import type { RangeAxis } from '#elements/range-element.ts';\n\nimport { SonicRangeElement } from '#elements/range-element.ts';\nimport { requireChild, template } from '#lib/render.ts';\n\ndeclare global {\n\tinterface HTMLElementTagNameMap {\n\t\t'sonic-slider': SonicSlider;\n\t}\n}\n\ninterface SliderAxis extends RangeAxis {\n\t// The cap's centre at the minimum, on the same scale as `position`\n\tstartPx: number;\n}\n\nexport type BufferedRanges = Iterable<readonly [number, number]> | TimeRanges;\n\nfunction readRanges(ranges: BufferedRanges): Array<[number, number]> {\n\tif ('start' in ranges) {\n\t\treturn Array.from({ length: ranges.length }, (_entry, index) => [\n\t\t\tranges.start(index),\n\t\t\tranges.end(index),\n\t\t]);\n\t}\n\n\treturn [...ranges].map(([start, end]) => [start, end]);\n}\n\n// Along the groove, which runs past the travel by half its width at each end\nfunction grooveStop(fraction: number): string {\n\tif (fraction <= 0) return '0%';\n\tif (fraction >= 1) return '100%';\n\n\treturn `calc(${String(fraction)} * var(--_sonic-slider-travel) + var(--_sonic-slider-groove) / 2)`;\n}\n\nconst renderSlider = template(\n\t/* HTML */ `\n\t\t<div class=\"sonic-slider\" role=\"slider\" tabindex=\"0\">\n\t\t\t<div class=\"sonic-slider-scale\" aria-hidden=\"true\"></div>\n\t\t\t<div class=\"sonic-slider-marks\"></div>\n\t\t\t<div class=\"sonic-slider-groove\"></div>\n\t\t\t<div class=\"sonic-slider-modulation\"></div>\n\t\t\t<div class=\"sonic-slider-cap\"></div>\n\t\t\t<div class=\"sonic-slider-readout\" popover=\"manual\">\n\t\t\t\t<span></span>\n\t\t\t\t<input\n\t\t\t\t\tautocomplete=\"off\"\n\t\t\t\t\tclass=\"sonic-slider-entry\"\n\t\t\t\t\tenterkeyhint=\"done\"\n\t\t\t\t\thidden\n\t\t\t\t\tspellcheck=\"false\"\n\t\t\t\t\ttype=\"text\"\n\t\t\t\t/>\n\t\t\t</div>\n\t\t</div>\n\t`,\n\tHTMLDivElement,\n);\n\nexport class SonicSlider extends SonicRangeElement {\n\tstatic override readonly observedAttributes = [\n\t\t...SonicRangeElement.observedAttributes,\n\t\t'modulation',\n\t\t'orientation',\n\t\t'spring',\n\t];\n\n\tget buffered(): Array<[number, number]> {\n\t\treturn this.#buffered.map(([start, end]) => [start, end]);\n\t}\n\n\tset buffered(ranges: BufferedRanges | undefined) {\n\t\tthis.#buffered = ranges\n\t\t\t? readRanges(ranges).toSorted((first, second) => first[0] - second[0])\n\t\t\t: [];\n\t\tthis.render();\n\t}\n\n\t// Named for the absent attribute, as `input.type` reads `text`\n\tget groovePress(): 'jump' | 'none' {\n\t\treturn this.getAttribute('groove-press') === 'none' ? 'none' : 'jump';\n\t}\n\n\tset groovePress(gesture: 'jump' | 'none' | undefined) {\n\t\tthis.reflect('groove-press', gesture);\n\t}\n\n\tget modulation(): number {\n\t\treturn this.numberAttribute('modulation', 0);\n\t}\n\n\tset modulation(amount: number | undefined) {\n\t\tthis.reflect('modulation', amount);\n\t}\n\n\tget orientation(): 'horizontal' | 'vertical' {\n\t\treturn this.getAttribute('orientation') === 'vertical' ? 'vertical' : 'horizontal';\n\t}\n\n\tset orientation(direction: 'horizontal' | 'vertical' | undefined) {\n\t\tthis.reflect('orientation', direction);\n\t}\n\n\tget spring(): boolean {\n\t\treturn this.hasAttribute('spring');\n\t}\n\n\tset spring(isSpring: boolean) {\n\t\tthis.reflect('spring', isSpring);\n\t}\n\n\t#buffered: Array<[number, number]> = [];\n\n\treadonly #slider = renderSlider();\n\n\toverride connectedCallback(): void {\n\t\tthis.upgradeProperties('buffered', 'groovePress', 'modulation', 'orientation', 'spring');\n\t\tsuper.connectedCallback();\n\t}\n\n\tprotected connect(signal: AbortSignal): void {\n\t\tconst slider = this.#slider;\n\t\tconst cap = requireChild(slider, '.sonic-slider-cap', HTMLDivElement);\n\n\t\tthis.bindScale(slider, requireChild(slider, '.sonic-slider-scale', HTMLDivElement), signal);\n\t\tthis.render();\n\t\tthis.checkStyles(slider, 'slider.css');\n\t\tthis.bindGestures(slider, signal, (event) => {\n\t\t\tconst axis = this.#axis(slider, cap);\n\t\t\tif (event.target instanceof Node && cap.contains(event.target)) return axis;\n\n\t\t\tif (this.groovePress === 'none') return;\n\n\t\t\tthis.input(this.valueAt((axis.position(event) - axis.startPx) / axis.travelPx));\n\n\t\t\treturn axis;\n\t\t});\n\t}\n\n\tprotected override focusTarget(): HTMLElement {\n\t\treturn this.#slider;\n\t}\n\n\tprotected render(): void {\n\t\tconst slider = this.#slider;\n\t\tconst positions = this.positions();\n\t\tconst [modulationFrom, modulationTo] = this.modulationFractions(this.modulation);\n\n\t\tslider.style.setProperty('--_sonic-slider-value', String(this.fraction(this.value)));\n\t\tslider.style.setProperty('--_sonic-slider-origin', String(this.originFraction()));\n\t\tslider.style.setProperty('--_sonic-slider-modulation-from', String(modulationFrom));\n\t\tslider.style.setProperty('--_sonic-slider-modulation-to', String(modulationTo));\n\t\tif (positions === undefined) slider.style.removeProperty('--_sonic-slider-positions');\n\t\telse slider.style.setProperty('--_sonic-slider-positions', String(positions));\n\t\tthis.#renderBuffered(slider);\n\t\tthis.renderScale();\n\t\tthis.renderAria(slider, this.orientation);\n\t}\n\n\tprotected override springTarget(): number | undefined {\n\t\treturn this.spring ? this.restValue() : undefined;\n\t}\n\n\t#axis(slider: HTMLElement, cap: HTMLElement): SliderAxis {\n\t\tconst track = slider.getBoundingClientRect();\n\t\tconst capBox = cap.getBoundingClientRect();\n\n\t\tif (this.orientation === 'vertical') {\n\t\t\treturn {\n\t\t\t\tposition: (event) => -event.clientY,\n\t\t\t\tstartPx: capBox.height / 2 - track.bottom,\n\t\t\t\ttravelPx: Math.max(1, track.height - capBox.height),\n\t\t\t};\n\t\t}\n\n\t\treturn {\n\t\t\tposition: (event) => event.clientX,\n\t\t\tstartPx: track.left + capBox.width / 2,\n\t\t\ttravelPx: Math.max(1, track.width - capBox.width),\n\t\t};\n\t}\n\n\t// Hard stops in one gradient, so any number of ranges costs one layer\n\t#renderBuffered(slider: HTMLElement): void {\n\t\tif (this.#buffered.length === 0) {\n\t\t\tslider.style.removeProperty('--_sonic-slider-buffered-ranges');\n\t\t\treturn;\n\t\t}\n\n\t\tconst stops = this.#buffered.flatMap(([start, end]) => {\n\t\t\tconst from = grooveStop(this.fraction(Math.min(start, end)));\n\t\t\tconst to = grooveStop(this.fraction(Math.max(start, end)));\n\n\t\t\treturn [\n\t\t\t\t`transparent ${from}`,\n\t\t\t\t`var(--_sonic-slider-buffered) ${from} ${to}`,\n\t\t\t\t`transparent ${to}`,\n\t\t\t];\n\t\t});\n\n\t\tslider.style.setProperty(\n\t\t\t'--_sonic-slider-buffered-ranges',\n\t\t\t`linear-gradient(var(--_sonic-slider-toward), ${stops.join(', ')})`,\n\t\t);\n\t}\n}\n"],"mappings":"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"}
@@ -1,7 +1,8 @@
1
1
  import { SonicDial } from "./elements/dial.js";
2
2
  import { SonicKey } from "./elements/key.js";
3
3
  import { SonicMeter } from "./elements/meter.js";
4
+ import { SonicNumber } from "./elements/number.js";
4
5
  import { SonicSegmented } from "./elements/segmented.js";
5
6
  import { SonicSlider } from "./elements/slider.js";
6
7
  import { formatPercent, parsePercent } from "./lib/percent.js";
7
- export { SonicDial, SonicKey, SonicMeter, SonicSegmented, SonicSlider, formatPercent, parsePercent };
8
+ export { SonicDial, SonicKey, SonicMeter, SonicNumber, SonicSegmented, SonicSlider, formatPercent, parsePercent };
package/dist/dev/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { SonicDial } from "./elements/dial.js";
2
2
  import { SonicKey } from "./elements/key.js";
3
3
  import { SonicMeter } from "./elements/meter.js";
4
+ import { SonicNumber } from "./elements/number.js";
4
5
  import { SonicSegmented } from "./elements/segmented.js";
5
6
  import { SonicSlider } from "./elements/slider.js";
6
7
  import { formatPercent, parsePercent } from "./lib/percent.js";
7
8
 
8
- export { SonicDial, SonicKey, SonicMeter, SonicSegmented, SonicSlider, formatPercent, parsePercent };
9
+ export { SonicDial, SonicKey, SonicMeter, SonicNumber, SonicSegmented, SonicSlider, formatPercent, parsePercent };
@@ -0,0 +1,18 @@
1
+ //#region src/lib/detent.ts
2
+ function isAcross(from, to, place) {
3
+ return from !== place && (from - place) * (to - place) <= 0;
4
+ }
5
+ function passDetent(hold, from, to) {
6
+ if (hold.slack === void 0 && !isAcross(from, to, hold.place)) return to;
7
+ const slack = (hold.slack ?? 0) + to - hold.place;
8
+ if (Math.abs(slack) <= hold.zone) {
9
+ hold.slack = slack;
10
+ return;
11
+ }
12
+ hold.slack = void 0;
13
+ return hold.place + slack - Math.sign(slack) * hold.zone;
14
+ }
15
+
16
+ //#endregion
17
+ export { passDetent };
18
+ //# sourceMappingURL=detent.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detent.js","names":[],"sources":["../../../src/lib/detent.ts"],"sourcesContent":["// Places run 0 to 1 along the travel; `slack` is how far past the detent the pointer has pushed, set while held\nexport interface DetentHold {\n\tplace: number;\n\tslack: number | undefined;\n\tvalue: number;\n\tzone: number;\n}\n\nfunction isAcross(from: number, to: number, place: number): boolean {\n\treturn from !== place && (from - place) * (to - place) <= 0;\n}\n\n// The next place along the travel, or `undefined` while the detent holds\nexport function passDetent(hold: DetentHold, from: number, to: number): number | undefined {\n\tif (hold.slack === undefined && !isAcross(from, to, hold.place)) return to;\n\n\t// Held, `from` is the detent's place, so the move adds to the slack\n\tconst slack = (hold.slack ?? 0) + to - hold.place;\n\n\tif (Math.abs(slack) <= hold.zone) {\n\t\thold.slack = slack;\n\t\treturn undefined;\n\t}\n\n\thold.slack = undefined;\n\n\treturn hold.place + slack - Math.sign(slack) * hold.zone;\n}\n"],"mappings":";AAQA,SAAS,SAAS,MAAc,IAAY,OAAwB;CACnE,OAAO,SAAS,UAAU,OAAO,UAAU,KAAK,UAAU;AAC3D;AAGA,SAAgB,WAAW,MAAkB,MAAc,IAAgC;CAC1F,IAAI,KAAK,UAAU,UAAa,CAAC,SAAS,MAAM,IAAI,KAAK,KAAK,GAAG,OAAO;CAGxE,MAAM,SAAS,KAAK,SAAS,KAAK,KAAK,KAAK;CAE5C,IAAI,KAAK,IAAI,KAAK,KAAK,KAAK,MAAM;EACjC,KAAK,QAAQ;EACb;CACD;CAEA,KAAK,QAAQ;CAEb,OAAO,KAAK,QAAQ,QAAQ,KAAK,KAAK,KAAK,IAAI,KAAK;AACrD"}
@@ -0,0 +1,17 @@
1
+ //#region src/lib/math.ts
2
+ function clamp(value, min, max) {
3
+ return Math.min(max, Math.max(min, value));
4
+ }
5
+ function clampUnit(fraction) {
6
+ return clamp(fraction, 0, 1);
7
+ }
8
+ function wrapUnit(fraction) {
9
+ return fraction - Math.floor(fraction);
10
+ }
11
+ function trimFloat(value) {
12
+ return Number(value.toPrecision(12));
13
+ }
14
+
15
+ //#endregion
16
+ export { clamp, clampUnit, trimFloat, wrapUnit };
17
+ //# sourceMappingURL=math.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"math.js","names":[],"sources":["../../../src/lib/math.ts"],"sourcesContent":["export function clamp(value: number, min: number, max: number): number {\n\treturn Math.min(max, Math.max(min, value));\n}\n\nexport function clampUnit(fraction: number): number {\n\treturn clamp(fraction, 0, 1);\n}\n\nexport function wrapUnit(fraction: number): number {\n\treturn fraction - Math.floor(fraction);\n}\n\nexport function trimFloat(value: number): number {\n\treturn Number(value.toPrecision(12));\n}\n"],"mappings":";AAAA,SAAgB,MAAM,OAAe,KAAa,KAAqB;CACtE,OAAO,KAAK,IAAI,KAAK,KAAK,IAAI,KAAK,KAAK,CAAC;AAC1C;AAEA,SAAgB,UAAU,UAA0B;CACnD,OAAO,MAAM,UAAU,GAAG,CAAC;AAC5B;AAEA,SAAgB,SAAS,UAA0B;CAClD,OAAO,WAAW,KAAK,MAAM,QAAQ;AACtC;AAEA,SAAgB,UAAU,OAAuB;CAChD,OAAO,OAAO,MAAM,YAAY,EAAE,CAAC;AACpC"}
@@ -0,0 +1,15 @@
1
+ //#region src/lib/number-list.ts
2
+ function parseNumberList(text) {
3
+ if (text === null) return void 0;
4
+ const entries = [...new Set(text.split(/[\s,]+/).filter(Boolean).map(Number).filter((entry) => Number.isFinite(entry)))].toSorted((first, second) => first - second);
5
+ return entries.length >= 2 ? entries : void 0;
6
+ }
7
+ function nearestEntry(entries, value) {
8
+ let nearest = entries[0] ?? value;
9
+ for (const entry of entries) if (Math.abs(entry - value) < Math.abs(nearest - value)) nearest = entry;
10
+ return nearest;
11
+ }
12
+
13
+ //#endregion
14
+ export { nearestEntry, parseNumberList };
15
+ //# sourceMappingURL=number-list.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"number-list.js","names":[],"sources":["../../../src/lib/number-list.ts"],"sourcesContent":["export function parseNumberList(text: null | string): Array<number> | undefined {\n\tif (text === null) return undefined;\n\n\tconst entries = [\n\t\t...new Set(\n\t\t\ttext\n\t\t\t\t.split(/[\\s,]+/)\n\t\t\t\t.filter(Boolean)\n\t\t\t\t.map(Number)\n\t\t\t\t.filter((entry) => Number.isFinite(entry)),\n\t\t),\n\t].toSorted((first, second) => first - second);\n\n\treturn entries.length >= 2 ? entries : undefined;\n}\n\nexport function nearestEntry(entries: Array<number>, value: number): number {\n\tlet nearest = entries[0] ?? value;\n\n\tfor (const entry of entries) {\n\t\tif (Math.abs(entry - value) < Math.abs(nearest - value)) nearest = entry;\n\t}\n\n\treturn nearest;\n}\n"],"mappings":";AAAA,SAAgB,gBAAgB,MAAgD;CAC/E,IAAI,SAAS,MAAM,OAAO;CAE1B,MAAM,UAAU,CACf,GAAG,IAAI,IACN,KACE,MAAM,QAAQ,CAAC,CACf,OAAO,OAAO,CAAC,CACf,IAAI,MAAM,CAAC,CACX,QAAQ,UAAU,OAAO,SAAS,KAAK,CAAC,CAC3C,CACD,CAAC,CAAC,UAAU,OAAO,WAAW,QAAQ,MAAM;CAE5C,OAAO,QAAQ,UAAU,IAAI,UAAU;AACxC;AAEA,SAAgB,aAAa,SAAwB,OAAuB;CAC3E,IAAI,UAAU,QAAQ,MAAM;CAE5B,KAAK,MAAM,SAAS,SACnB,IAAI,KAAK,IAAI,QAAQ,KAAK,IAAI,KAAK,IAAI,UAAU,KAAK,GAAG,UAAU;CAGpE,OAAO;AACR"}
@@ -1 +1 @@
1
- {"version":3,"file":"read-px-property.js","names":[],"sources":["../../../src/lib/read-px-property.ts"],"sourcesContent":["// Only px; another unit needs a probe element to convert\nexport function readPxProperty(\n\tstyles: CSSStyleDeclaration,\n\tproperty: string,\n\tfallback: number,\n): number {\n\t// eslint-disable-next-line unicorn/prefer-number-coercion -- parsing has to stop at the unit\n\tconst parsed = Number.parseFloat(styles.getPropertyValue(property));\n\n\treturn Number.isFinite(parsed) ? parsed : fallback;\n}\n"],"mappings":";AACA,SAAgB,eACf,QACA,UACA,UACS;CAET,MAAM,SAAS,OAAO,WAAW,OAAO,iBAAiB,QAAQ,CAAC;CAElE,OAAO,OAAO,SAAS,MAAM,IAAI,SAAS;AAC3C"}
1
+ {"version":3,"file":"read-px-property.js","names":[],"sources":["../../../src/lib/read-px-property.ts"],"sourcesContent":["// Registered `<length>` properties only; their computed value is always px\nexport function readPxProperty(\n\tstyles: CSSStyleDeclaration,\n\tproperty: string,\n\tfallback: number,\n): number {\n\t// eslint-disable-next-line unicorn/prefer-number-coercion -- parsing has to stop at the unit\n\tconst parsed = Number.parseFloat(styles.getPropertyValue(property));\n\n\treturn Number.isFinite(parsed) ? parsed : fallback;\n}\n"],"mappings":";AACA,SAAgB,eACf,QACA,UACA,UACS;CAET,MAAM,SAAS,OAAO,WAAW,OAAO,iBAAiB,QAAQ,CAAC;CAElE,OAAO,OAAO,SAAS,MAAM,IAAI,SAAS;AAC3C"}
@@ -1,7 +1,6 @@
1
+ import { clampUnit } from "./math.js";
2
+
1
3
  //#region src/lib/taper.ts
2
- function clampUnit(fraction) {
3
- return Math.min(1, Math.max(0, fraction));
4
- }
5
4
  function linearTaper(min, max) {
6
5
  const range = max - min;
7
6
  return {
@@ -27,7 +26,21 @@ function skewTaper(min, max, midpoint) {
27
26
  value: (position) => min + range * clampUnit(position) ** (1 / skew)
28
27
  };
29
28
  }
29
+ function listTaper(entries) {
30
+ const last = entries.length - 1;
31
+ return {
32
+ position: (value) => {
33
+ const above = entries.findIndex((entry) => entry >= value);
34
+ if (above === -1) return 1;
35
+ const high = entries[above];
36
+ const low = entries[above - 1];
37
+ if (high === void 0 || low === void 0) return 0;
38
+ return (above - 1 + (value - low) / (high - low)) / last;
39
+ },
40
+ value: (position) => entries[Math.round(clampUnit(position) * last)] ?? NaN
41
+ };
42
+ }
30
43
 
31
44
  //#endregion
32
- export { clampUnit, linearTaper, logTaper, skewTaper };
45
+ export { linearTaper, listTaper, logTaper, skewTaper };
33
46
  //# sourceMappingURL=taper.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"taper.js","names":[],"sources":["../../../src/lib/taper.ts"],"sourcesContent":["// Positions run 0 to 1 along the travel\nexport interface Taper {\n\tposition: (value: number) => number;\n\tvalue: (position: number) => number;\n}\n\nexport function clampUnit(fraction: number): number {\n\treturn Math.min(1, Math.max(0, fraction));\n}\n\nexport function linearTaper(min: number, max: number): Taper {\n\tconst range = max - min;\n\n\treturn {\n\t\tposition: (value) => (range > 0 ? clampUnit((value - min) / range) : 0),\n\t\tvalue: (position) => min + clampUnit(position) * range,\n\t};\n}\n\nexport function logTaper(min: number, max: number): Taper | undefined {\n\tif (min <= 0 || max <= min) return undefined;\n\n\tconst span = Math.log(max / min);\n\n\treturn {\n\t\tposition: (value) => clampUnit(Math.log(Math.max(min, value) / min) / span),\n\t\tvalue: (position) => min * Math.exp(clampUnit(position) * span),\n\t};\n}\n\n// JUCE's skew: a power curve through `midpoint` at half the travel\nexport function skewTaper(min: number, max: number, midpoint: number): Taper | undefined {\n\tconst range = max - min;\n\tconst proportion = (midpoint - min) / range;\n\n\tif (proportion === 0.5 || !(proportion > 0 && proportion < 1)) return undefined;\n\n\tconst skew = Math.log(0.5) / Math.log(proportion);\n\n\treturn {\n\t\tposition: (value) => clampUnit((value - min) / range) ** skew,\n\t\tvalue: (position) => min + range * clampUnit(position) ** (1 / skew),\n\t};\n}\n"],"mappings":";AAMA,SAAgB,UAAU,UAA0B;CACnD,OAAO,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,QAAQ,CAAC;AACzC;AAEA,SAAgB,YAAY,KAAa,KAAoB;CAC5D,MAAM,QAAQ,MAAM;CAEpB,OAAO;EACN,WAAW,UAAW,QAAQ,IAAI,WAAW,QAAQ,OAAO,KAAK,IAAI;EACrE,QAAQ,aAAa,MAAM,UAAU,QAAQ,IAAI;CAClD;AACD;AAEA,SAAgB,SAAS,KAAa,KAAgC;CACrE,IAAI,OAAO,KAAK,OAAO,KAAK,OAAO;CAEnC,MAAM,OAAO,KAAK,IAAI,MAAM,GAAG;CAE/B,OAAO;EACN,WAAW,UAAU,UAAU,KAAK,IAAI,KAAK,IAAI,KAAK,KAAK,IAAI,GAAG,IAAI,IAAI;EAC1E,QAAQ,aAAa,MAAM,KAAK,IAAI,UAAU,QAAQ,IAAI,IAAI;CAC/D;AACD;AAGA,SAAgB,UAAU,KAAa,KAAa,UAAqC;CACxF,MAAM,QAAQ,MAAM;CACpB,MAAM,cAAc,WAAW,OAAO;CAEtC,IAAI,eAAe,MAAO,EAAE,aAAa,KAAK,aAAa,IAAI,OAAO;CAEtE,MAAM,OAAO,KAAK,IAAI,EAAG,IAAI,KAAK,IAAI,UAAU;CAEhD,OAAO;EACN,WAAW,UAAU,WAAW,QAAQ,OAAO,KAAK,KAAK;EACzD,QAAQ,aAAa,MAAM,QAAQ,UAAU,QAAQ,MAAM,IAAI;CAChE;AACD"}
1
+ {"version":3,"file":"taper.js","names":[],"sources":["../../../src/lib/taper.ts"],"sourcesContent":["import { clampUnit } from '#lib/math.ts';\n\n// Positions run 0 to 1 along the travel\nexport interface Taper {\n\tposition: (value: number) => number;\n\tvalue: (position: number) => number;\n}\n\nexport function linearTaper(min: number, max: number): Taper {\n\tconst range = max - min;\n\n\treturn {\n\t\tposition: (value) => (range > 0 ? clampUnit((value - min) / range) : 0),\n\t\tvalue: (position) => min + clampUnit(position) * range,\n\t};\n}\n\nexport function logTaper(min: number, max: number): Taper | undefined {\n\tif (min <= 0 || max <= min) return undefined;\n\n\tconst span = Math.log(max / min);\n\n\treturn {\n\t\tposition: (value) => clampUnit(Math.log(Math.max(min, value) / min) / span),\n\t\tvalue: (position) => min * Math.exp(clampUnit(position) * span),\n\t};\n}\n\n// JUCE's skew: a power curve through `midpoint` at half the travel\nexport function skewTaper(min: number, max: number, midpoint: number): Taper | undefined {\n\tconst range = max - min;\n\tconst proportion = (midpoint - min) / range;\n\n\tif (proportion === 0.5 || !(proportion > 0 && proportion < 1)) return undefined;\n\n\tconst skew = Math.log(0.5) / Math.log(proportion);\n\n\treturn {\n\t\tposition: (value) => clampUnit((value - min) / range) ** skew,\n\t\tvalue: (position) => min + range * clampUnit(position) ** (1 / skew),\n\t};\n}\n\n// Entries sit evenly along the travel, however unevenly they are spaced\nexport function listTaper(entries: Array<number>): Taper {\n\tconst last = entries.length - 1;\n\n\treturn {\n\t\tposition: (value) => {\n\t\t\tconst above = entries.findIndex((entry) => entry >= value);\n\t\t\tif (above === -1) return 1;\n\n\t\t\tconst high = entries[above];\n\t\t\tconst low = entries[above - 1];\n\t\t\tif (high === undefined || low === undefined) return 0;\n\n\t\t\treturn (above - 1 + (value - low) / (high - low)) / last;\n\t\t},\n\t\tvalue: (position) => entries[Math.round(clampUnit(position) * last)] ?? NaN,\n\t};\n}\n"],"mappings":";;;AAQA,SAAgB,YAAY,KAAa,KAAoB;CAC5D,MAAM,QAAQ,MAAM;CAEpB,OAAO;EACN,WAAW,UAAW,QAAQ,IAAI,WAAW,QAAQ,OAAO,KAAK,IAAI;EACrE,QAAQ,aAAa,MAAM,UAAU,QAAQ,IAAI;CAClD;AACD;AAEA,SAAgB,SAAS,KAAa,KAAgC;CACrE,IAAI,OAAO,KAAK,OAAO,KAAK,OAAO;CAEnC,MAAM,OAAO,KAAK,IAAI,MAAM,GAAG;CAE/B,OAAO;EACN,WAAW,UAAU,UAAU,KAAK,IAAI,KAAK,IAAI,KAAK,KAAK,IAAI,GAAG,IAAI,IAAI;EAC1E,QAAQ,aAAa,MAAM,KAAK,IAAI,UAAU,QAAQ,IAAI,IAAI;CAC/D;AACD;AAGA,SAAgB,UAAU,KAAa,KAAa,UAAqC;CACxF,MAAM,QAAQ,MAAM;CACpB,MAAM,cAAc,WAAW,OAAO;CAEtC,IAAI,eAAe,MAAO,EAAE,aAAa,KAAK,aAAa,IAAI,OAAO;CAEtE,MAAM,OAAO,KAAK,IAAI,EAAG,IAAI,KAAK,IAAI,UAAU;CAEhD,OAAO;EACN,WAAW,UAAU,WAAW,QAAQ,OAAO,KAAK,KAAK;EACzD,QAAQ,aAAa,MAAM,QAAQ,UAAU,QAAQ,MAAM,IAAI;CAChE;AACD;AAGA,SAAgB,UAAU,SAA+B;CACxD,MAAM,OAAO,QAAQ,SAAS;CAE9B,OAAO;EACN,WAAW,UAAU;GACpB,MAAM,QAAQ,QAAQ,WAAW,UAAU,SAAS,KAAK;GACzD,IAAI,UAAU,IAAI,OAAO;GAEzB,MAAM,OAAO,QAAQ;GACrB,MAAM,MAAM,QAAQ,QAAQ;GAC5B,IAAI,SAAS,UAAa,QAAQ,QAAW,OAAO;GAEpD,QAAQ,QAAQ,KAAK,QAAQ,QAAQ,OAAO,QAAQ;EACrD;EACA,QAAQ,aAAa,QAAQ,KAAK,MAAM,UAAU,QAAQ,IAAI,IAAI,MAAM;CACzE;AACD"}
@@ -1,7 +1,9 @@
1
1
  @import './material.css';
2
2
  @import './dial.css';
3
3
  @import './key.css';
4
+ @import './led.css';
4
5
  @import './meter.css';
6
+ @import './number.css';
5
7
  @import './screen.css';
6
8
  @import './segmented.css';
7
9
  @import './slider.css';
@@ -1,3 +1,21 @@
1
+ @property --_sonic-dial-size {
2
+ syntax: '<length>';
3
+ inherits: true;
4
+ initial-value: 40px;
5
+ }
6
+
7
+ @property --_sonic-dial-ridge-pitch {
8
+ syntax: '<length>';
9
+ inherits: true;
10
+ initial-value: 4px;
11
+ }
12
+
13
+ @property --_sonic-dial-travel {
14
+ syntax: '<length>';
15
+ inherits: false;
16
+ initial-value: 160px;
17
+ }
18
+
1
19
  @layer sonic {
2
20
  :is(sonic-dial, .sonic-dial) {
3
21
  --_sonic-dial-size: var(--sonic-dial-size, 2.5rem);
@@ -9,9 +27,11 @@
9
27
 
10
28
  sonic-dial:not(:defined, [hidden]) {
11
29
  display: block;
30
+ visibility: hidden;
12
31
  flex-shrink: 0;
13
32
  inline-size: var(--_sonic-dial-size);
14
33
  block-size: var(--_sonic-dial-size);
34
+ overflow: hidden;
15
35
  }
16
36
 
17
37
  .sonic-dial {
@@ -25,21 +45,24 @@
25
45
  /* A fraction of the knob's angle */
26
46
  --_sonic-dial-face-drift: var(--sonic-dial-face-drift, 0.1);
27
47
  --_sonic-dial-notch-ratio: var(--sonic-dial-notch-ratio, 0.1);
48
+ --_sonic-dial-scale-ratio: var(--sonic-dial-scale-ratio, 0.2);
49
+ --_sonic-dial-scale-font-ratio: var(--sonic-dial-scale-font-ratio, 0.175);
28
50
 
29
51
  /* Centred on six o'clock */
30
52
  --_sonic-dial-sweep: var(--sonic-dial-sweep, 300deg);
53
+ --_sonic-dial-segment: var(--sonic-dial-segment, 30deg);
31
54
  --_sonic-dial-ridge-pitch: var(--sonic-dial-ridge-pitch, 4px);
32
-
33
- /* Read by the element as px */
34
55
  --_sonic-dial-travel: var(--sonic-dial-travel, 160px);
35
56
  --_sonic-unit: calc(var(--_sonic-dial-size) / 40);
36
57
  --_sonic-dial-lit: var(--_sonic-lit);
37
58
  --_sonic-dial-notch-band: 0px;
59
+ --_sonic-dial-scale-band: 0px;
60
+ --_sonic-dial-notch-ends: 1;
38
61
  --_sonic-dial-start: calc(180deg + (1turn - var(--_sonic-dial-sweep)) / 2);
39
62
  --_sonic-dial-angle: calc(var(--_sonic-dial-value, 0) * var(--_sonic-dial-sweep));
40
63
  --_sonic-dial-ring-width: calc(var(--_sonic-dial-size) * var(--_sonic-dial-ring-ratio));
41
64
  --_sonic-dial-cap-inset: calc(
42
- var(--_sonic-dial-notch-band) + var(--_sonic-dial-size) *
65
+ var(--_sonic-dial-scale-band) + var(--_sonic-dial-notch-band) + var(--_sonic-dial-size) *
43
66
  (var(--_sonic-dial-ring-ratio) + var(--_sonic-dial-gap-ratio))
44
67
  );
45
68
 
@@ -72,10 +95,19 @@
72
95
  --_sonic-cap: var(--_sonic-cap-hover);
73
96
  }
74
97
 
98
+ [endless] > .sonic-dial {
99
+ --_sonic-dial-sweep: 1turn;
100
+ --_sonic-dial-notch-ends: 0;
101
+ }
102
+
75
103
  [notched] > .sonic-dial {
76
104
  --_sonic-dial-notch-band: calc(var(--_sonic-dial-size) * var(--_sonic-dial-notch-ratio));
77
105
  }
78
106
 
107
+ :has(> [data-sonic-value]) > .sonic-dial {
108
+ --_sonic-dial-scale-band: calc(var(--_sonic-dial-size) * var(--_sonic-dial-scale-ratio));
109
+ }
110
+
79
111
  /* Set on `--_sonic-dial-lit`, since `--_sonic-lit` cannot reference itself */
80
112
  [dimmed] > .sonic-dial {
81
113
  --_sonic-dial-lit: var(--_sonic-lit-off);
@@ -90,11 +122,11 @@
90
122
  .sonic-dial-notches {
91
123
  --_sonic-dial-notch-width: atan2(var(--_sonic-unit), var(--_sonic-dial-size) / 2);
92
124
  --_sonic-dial-notch-pitch: calc(
93
- var(--_sonic-dial-sweep) / (var(--_sonic-dial-positions, 2) - 1)
125
+ var(--_sonic-dial-sweep) / (var(--_sonic-dial-positions, 2) - var(--_sonic-dial-notch-ends))
94
126
  );
95
127
 
96
128
  position: absolute;
97
- inset: 0;
129
+ inset: var(--_sonic-dial-scale-band);
98
130
  background: repeating-conic-gradient(
99
131
  from calc(var(--_sonic-dial-start) - var(--_sonic-dial-notch-width) / 2),
100
132
  var(--_sonic-etch) 0 var(--_sonic-dial-notch-width),
@@ -116,9 +148,40 @@
116
148
  mask-composite: intersect;
117
149
  }
118
150
 
151
+ .sonic-dial-scale {
152
+ font-size: calc(var(--_sonic-dial-size) * var(--_sonic-dial-scale-font-ratio));
153
+
154
+ /* The near edge hugs the band's inner edge, and each mark grows outward along its angle */
155
+ & > * {
156
+ --_sonic-dial-scale-angle: calc(
157
+ var(--_sonic-dial-start) + var(--_sonic-scale-at, 0) * var(--_sonic-dial-sweep)
158
+ );
159
+ --_sonic-dial-scale-sin: sin(var(--_sonic-dial-scale-angle));
160
+ --_sonic-dial-scale-cos: cos(var(--_sonic-dial-scale-angle));
161
+ --_sonic-dial-scale-radius: calc(
162
+ var(--_sonic-dial-size) / 2 - var(--_sonic-dial-scale-band) + var(--_sonic-unit)
163
+ );
164
+
165
+ inset: calc(50% - var(--_sonic-dial-scale-cos) * var(--_sonic-dial-scale-radius)) auto auto
166
+ calc(50% + var(--_sonic-dial-scale-sin) * var(--_sonic-dial-scale-radius));
167
+ translate: calc(-50% + var(--_sonic-dial-scale-sin) * 50%)
168
+ calc(-50% - var(--_sonic-dial-scale-cos) * 50%);
169
+ }
170
+
171
+ & > .sonic-scale-tick {
172
+ --_sonic-dial-tick-length: calc(var(--_sonic-dial-scale-band) * 0.4);
173
+
174
+ inline-size: var(--_sonic-unit);
175
+ block-size: var(--_sonic-dial-tick-length);
176
+ rotate: var(--_sonic-dial-scale-angle);
177
+ translate: calc(-50% + var(--_sonic-dial-scale-sin) * var(--_sonic-dial-tick-length) / 2)
178
+ calc(-50% - var(--_sonic-dial-scale-cos) * var(--_sonic-dial-tick-length) / 2);
179
+ }
180
+ }
181
+
119
182
  :is(.sonic-dial-ring, .sonic-dial-modulation) {
120
183
  position: absolute;
121
- inset: var(--_sonic-dial-notch-band);
184
+ inset: calc(var(--_sonic-dial-scale-band) + var(--_sonic-dial-notch-band));
122
185
  border-radius: 50%;
123
186
  mask: radial-gradient(
124
187
  closest-side,
@@ -155,6 +218,17 @@
155
218
  );
156
219
  }
157
220
 
221
+ [endless] > .sonic-dial > .sonic-dial-ring {
222
+ background:
223
+ var(--_sonic-dial-ring-shade),
224
+ conic-gradient(
225
+ from
226
+ calc(var(--_sonic-dial-start) + var(--_sonic-dial-angle) - var(--_sonic-dial-segment) / 2),
227
+ var(--_sonic-dial-lit) var(--_sonic-dial-segment),
228
+ var(--_sonic-unlit) 0
229
+ );
230
+ }
231
+
158
232
  .sonic-dial-modulation {
159
233
  --_sonic-dial-modulation: var(
160
234
  --sonic-modulation,
@@ -1,3 +1,21 @@
1
+ @property --_sonic-key-size {
2
+ syntax: '<length>';
3
+ inherits: true;
4
+ initial-value: 32px;
5
+ }
6
+
7
+ @property --_sonic-key-busy-angle {
8
+ syntax: '<angle>';
9
+ inherits: false;
10
+ initial-value: 0deg;
11
+ }
12
+
13
+ @keyframes sonic-key-busy {
14
+ to {
15
+ --_sonic-key-busy-angle: 1turn;
16
+ }
17
+ }
18
+
1
19
  @layer sonic {
2
20
  :is(sonic-key, .sonic-key) {
3
21
  --_sonic-key-size: var(--sonic-key-size, 2rem);
@@ -22,6 +40,8 @@
22
40
  --_sonic-key-bevel-ratio: var(--sonic-key-bevel-ratio, 0.075);
23
41
  --_sonic-key-face-radius-ratio: var(--sonic-key-face-radius-ratio, 0.15);
24
42
  --_sonic-key-icon-ratio: var(--sonic-key-icon-ratio, 0.5);
43
+ --_sonic-key-led-ratio: var(--sonic-key-led-ratio, 0.2);
44
+ --_sonic-key-led-icon-ratio: var(--sonic-key-led-icon-ratio, 0.35);
25
45
  --_sonic-key-depth-ratio: var(--sonic-key-depth-ratio, 0.03);
26
46
  --_sonic-key-press-scale: var(--sonic-key-press-scale, 0.985);
27
47
  --_sonic-unit: calc(var(--_sonic-key-size) / 32);
@@ -39,16 +59,14 @@
39
59
  inline-size: var(--_sonic-key-size);
40
60
  block-size: var(--_sonic-key-size);
41
61
  padding: 0;
62
+ font: inherit;
42
63
  color: var(--_sonic-ink);
43
64
  cursor: pointer;
44
- background: linear-gradient(
45
- var(--_sonic-light-fall),
46
- color-mix(in oklab, var(--_sonic-unlit) 55%, #000),
47
- color-mix(in oklab, var(--_sonic-unlit) 85%, #000)
48
- );
65
+ background: var(--_sonic-well-floor);
49
66
  border: 0;
50
67
  border-radius: var(--_sonic-key-corner);
51
68
  box-shadow: var(--_sonic-well);
69
+ transition: color 120ms ease-out;
52
70
  }
53
71
 
54
72
  /* A drifting finger would turn a held pad into a pan */
@@ -56,12 +74,11 @@
56
74
  touch-action: none;
57
75
  }
58
76
 
59
- .sonic-key:active:where(:not(:disabled)),
77
+ .sonic-key:active:where(:not(:disabled, [aria-disabled='true'])),
60
78
  .sonic-key:is([aria-pressed='true'], :where([momentary]:state(pressed)) > *) {
61
79
  --_sonic-key-shift: calc(var(--_sonic-key-depth) / 2);
62
80
  --_sonic-key-scale: var(--_sonic-key-press-scale);
63
- --_sonic-key-drop: calc(0.5 * var(--_sonic-cast-x)) calc(0.5 * var(--_sonic-cast-y))
64
- var(--_sonic-unit) var(--_sonic-drop-shade);
81
+ --_sonic-key-drop: var(--_sonic-press-drop);
65
82
  }
66
83
 
67
84
  .sonic-key:is([aria-pressed='true'], :where([momentary]:state(pressed)) > *) {
@@ -77,17 +94,69 @@
77
94
  );
78
95
  }
79
96
 
80
- .sonic-key:disabled {
97
+ .sonic-key:is(:disabled, [aria-disabled='true']) {
81
98
  --_sonic-lit: var(--_sonic-lit-disabled);
82
99
 
83
100
  color: var(--_sonic-etch);
84
101
  cursor: default;
85
102
  }
86
103
 
104
+ .sonic-key:not(
105
+ [aria-pressed='true'],
106
+ :disabled,
107
+ [aria-disabled='true'],
108
+ :where([momentary]:state(pressed)) > *
109
+ ):is(:focus-visible, :active) {
110
+ color: var(--_sonic-ink-lift);
111
+ }
112
+
113
+ @media (hover: hover) {
114
+ /* fallow-ignore-next-line code-duplication -- `:hover` sits under `(hover: hover)` and its `:focus-visible` partner outside it */
115
+ .sonic-key:not(
116
+ [aria-pressed='true'],
117
+ :disabled,
118
+ [aria-disabled='true'],
119
+ :where([momentary]:state(pressed)) > *
120
+ ):hover {
121
+ color: var(--_sonic-ink-lift);
122
+ }
123
+ }
124
+
125
+ /* Turns with the arc, so evenly shaded */
126
+ [busy] > .sonic-key::after {
127
+ position: absolute;
128
+ inset: 0;
129
+ box-sizing: border-box;
130
+ padding: calc(var(--_sonic-key-size) * var(--_sonic-key-bevel-ratio));
131
+ pointer-events: none;
132
+ content: '';
133
+ background: conic-gradient(
134
+ from var(--_sonic-key-busy-angle),
135
+ transparent 0 55%,
136
+ var(--_sonic-lit)
137
+ );
138
+ border-radius: var(--_sonic-key-corner);
139
+ mask:
140
+ linear-gradient(#000 0 0) content-box exclude,
141
+ linear-gradient(#000 0 0);
142
+ animation: sonic-key-busy 1s linear infinite;
143
+
144
+ @media (prefers-reduced-motion: reduce) {
145
+ background: color-mix(in oklab, var(--_sonic-lit) 45%, transparent);
146
+ animation: none;
147
+ }
148
+
149
+ @media (forced-colors: active) {
150
+ forced-color-adjust: none;
151
+ }
152
+ }
153
+
87
154
  .sonic-key-cap {
88
155
  position: absolute;
89
156
  inset: var(--_sonic-key-gap);
90
157
  display: grid;
158
+ gap: calc(var(--_sonic-key-size) * var(--_sonic-key-led-ratio) / 2);
159
+ place-content: center;
91
160
  place-items: center;
92
161
  background: var(--_sonic-skirt);
93
162
  border-radius: max(0px, var(--_sonic-key-corner) - var(--_sonic-key-gap));
@@ -107,8 +176,18 @@
107
176
  box-shadow: var(--_sonic-face-shadow);
108
177
  }
109
178
 
110
- & > svg {
179
+ & > * {
111
180
  position: relative;
181
+ }
182
+
183
+ & > .sonic-led {
184
+ --_sonic-led-size: var(
185
+ --sonic-led-size,
186
+ calc(var(--_sonic-key-size) * var(--_sonic-key-led-ratio))
187
+ );
188
+ }
189
+
190
+ & > svg {
112
191
  inline-size: calc(var(--_sonic-key-size) * var(--_sonic-key-icon-ratio));
113
192
  block-size: calc(var(--_sonic-key-size) * var(--_sonic-key-icon-ratio));
114
193
  filter: var(--_sonic-engraving);
@@ -117,6 +196,11 @@
117
196
  }
118
197
  }
119
198
 
199
+ .sonic-key-cap:has(> .sonic-led) > svg {
200
+ inline-size: calc(var(--_sonic-key-size) * var(--_sonic-key-led-icon-ratio));
201
+ block-size: calc(var(--_sonic-key-size) * var(--_sonic-key-led-icon-ratio));
202
+ }
203
+
120
204
  /* Engraving under the glow keeps a lit icon's edge */
121
205
  .sonic-key:is([aria-pressed='true'], :where([momentary]:state(pressed)) > *)
122
206
  .sonic-key-cap
@@ -125,6 +209,7 @@
125
209
  drop-shadow(0 0 var(--_sonic-unit) color-mix(in oklab, var(--_sonic-lit) 35%, transparent));
126
210
  }
127
211
 
212
+ /* fallow-ignore-next-line code-duplication -- one pressed state, restated for each part of the cap it reaches */
128
213
  .sonic-key:is([aria-pressed='true'], :where([momentary]:state(pressed)) > *)
129
214
  .sonic-key-cap
130
215
  > [data-sonic-when='off'],
@@ -145,7 +230,7 @@
145
230
  --_sonic-face: var(--_sonic-lit);
146
231
  --_sonic-ink: HighlightText;
147
232
 
148
- &:disabled {
233
+ &:is(:disabled, [aria-disabled='true']) {
149
234
  --_sonic-ink: ButtonFace;
150
235
  }
151
236