@rogieking/figui3 9.0.14 → 9.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig.js CHANGED
@@ -7015,6 +7015,7 @@ figDefineElement("fig-input-text", FigInputText);
7015
7015
  * @attr {string} value - The current numeric value
7016
7016
  * @attr {string} placeholder - Placeholder text
7017
7017
  * @attr {string} size - Input size: default or "large"
7018
+ * @attr {string} variant - Visual style. Use `ghost` for a borderless control.
7018
7019
  * @attr {boolean} disabled - Whether the input is disabled
7019
7020
  * @attr {number} min - Minimum value
7020
7021
  * @attr {number} max - Maximum value
@@ -14526,11 +14527,15 @@ class FigEasingCurve extends HTMLElement {
14526
14527
  const y = pad + (1 - (pts[i].value - minVal) / range) * s;
14527
14528
  d += (i === 0 ? "M" : "L") + x.toFixed(1) + "," + y.toFixed(1);
14528
14529
  }
14529
- return FigEasingCurve.#createSvgIcon(d, size);
14530
+ return FigEasingCurve.#createSvgIcon(
14531
+ d,
14532
+ size,
14533
+ FigEasingCurve.#createIconGrid(size),
14534
+ );
14530
14535
  }
14531
14536
 
14532
14537
  static #curveIconPath(cp1x, cp1y, cp2x, cp2y, size = 24) {
14533
- const draw = 12;
14538
+ const draw = 11;
14534
14539
  const pad = (size - draw) / 2;
14535
14540
  const samples = 48;
14536
14541
  const points = [];
@@ -14578,7 +14583,28 @@ class FigEasingCurve extends HTMLElement {
14578
14583
  return d;
14579
14584
  }
14580
14585
 
14581
- static #createSvgIcon(d, size = 24) {
14586
+ static #iconGridPoints(size = 24) {
14587
+ const draw = 11;
14588
+ const start = (size - draw) / 2;
14589
+ const gap = draw / 3;
14590
+ return Array.from({ length: 16 }, (_, index) => ({
14591
+ x: start + (index % 4) * gap,
14592
+ y: start + Math.floor(index / 4) * gap,
14593
+ }));
14594
+ }
14595
+
14596
+ static #createIconGrid(size = 24) {
14597
+ return FigEasingCurve.#iconGridPoints(size).map(({ x, y }) =>
14598
+ figCreateSvgElement("circle", {
14599
+ cx: x,
14600
+ cy: y,
14601
+ r: 0.5,
14602
+ fill: "var(--figma-color-icon-tertiary)",
14603
+ }),
14604
+ );
14605
+ }
14606
+
14607
+ static #createSvgIcon(d, size = 24, decorations = []) {
14582
14608
  return figCreateSvgElement(
14583
14609
  "svg",
14584
14610
  {
@@ -14587,13 +14613,16 @@ class FigEasingCurve extends HTMLElement {
14587
14613
  viewBox: `0 0 ${size} ${size}`,
14588
14614
  fill: "none",
14589
14615
  },
14590
- figCreateSvgElement("path", {
14591
- d,
14592
- stroke: "currentColor",
14593
- "stroke-width": "1",
14594
- "stroke-linecap": "round",
14595
- fill: "none",
14596
- }),
14616
+ [
14617
+ ...decorations,
14618
+ figCreateSvgElement("path", {
14619
+ d,
14620
+ stroke: "currentColor",
14621
+ "stroke-width": "1",
14622
+ "stroke-linecap": "round",
14623
+ fill: "none",
14624
+ }),
14625
+ ],
14597
14626
  );
14598
14627
  }
14599
14628
 
@@ -14601,6 +14630,7 @@ class FigEasingCurve extends HTMLElement {
14601
14630
  return FigEasingCurve.#createSvgIcon(
14602
14631
  FigEasingCurve.#curveIconPath(cp1x, cp1y, cp2x, cp2y, size),
14603
14632
  size,
14633
+ FigEasingCurve.#createIconGrid(size),
14604
14634
  );
14605
14635
  }
14606
14636
 
@@ -14612,7 +14642,13 @@ class FigEasingCurve extends HTMLElement {
14612
14642
  cp2y,
14613
14643
  size,
14614
14644
  );
14615
- return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none"><path d="${d}" stroke="currentColor" stroke-width="1" stroke-linecap="round"/></svg>`;
14645
+ const grid = FigEasingCurve.#iconGridPoints(size)
14646
+ .map(
14647
+ ({ x, y }) =>
14648
+ `<circle cx="${x}" cy="${y}" r="0.5" fill="var(--figma-color-icon-tertiary)"></circle>`,
14649
+ )
14650
+ .join("");
14651
+ return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" fill="none">${grid}<path d="${d}" stroke="currentColor" stroke-width="1" stroke-linecap="round"/></svg>`;
14616
14652
  }
14617
14653
 
14618
14654
  // --- Rendering ---
@@ -14738,6 +14774,11 @@ class FigEasingCurve extends HTMLElement {
14738
14774
  }
14739
14775
 
14740
14776
  #createHandle(className, dataHandle, label) {
14777
+ const handle = figCreateElement("fig-handle", {
14778
+ size: "small",
14779
+ type: "minimal",
14780
+ "aria-label": label,
14781
+ });
14741
14782
  return figCreateSvgElement(
14742
14783
  "foreignObject",
14743
14784
  {
@@ -14746,14 +14787,49 @@ class FigEasingCurve extends HTMLElement {
14746
14787
  width: "20",
14747
14788
  height: "20",
14748
14789
  },
14749
- figCreateElement("fig-handle", {
14750
- size: "small",
14751
- type: "minimal",
14752
- "aria-label": label,
14753
- }),
14790
+ figCreateElement(
14791
+ "fig-tooltip",
14792
+ { text: this.#handleTooltipText(dataHandle) },
14793
+ handle,
14794
+ ),
14754
14795
  );
14755
14796
  }
14756
14797
 
14798
+ #formatHandleTooltipNumber(value) {
14799
+ if (!Number.isFinite(value)) return "0";
14800
+ return String(Number(value.toFixed(this.#precision)));
14801
+ }
14802
+
14803
+ #handleTooltipText(handle) {
14804
+ if (this.#mode === "spring") {
14805
+ if (handle === "bounce") {
14806
+ return `Bounce ${this.#formatHandleTooltipNumber(this.#springBounce())}`;
14807
+ }
14808
+ return `Duration ${Math.round(this.#springDuration * 100)}%`;
14809
+ }
14810
+
14811
+ const point = handle === "1" ? this.#cp1 : this.#cp2;
14812
+ const label = handle === "1" ? "Start" : "End";
14813
+ return `${label} ${this.#formatHandleTooltipNumber(point.x)}, ${this.#formatHandleTooltipNumber(point.y)}`;
14814
+ }
14815
+
14816
+ #syncHandleTooltips() {
14817
+ for (const [container, handle] of [
14818
+ [this.#handle1, this.#mode === "spring" ? "bounce" : "1"],
14819
+ [this.#handle2, this.#mode === "spring" ? "duration" : "2"],
14820
+ ]) {
14821
+ const tooltip = container?.querySelector("fig-tooltip");
14822
+ if (tooltip) tooltip.text = this.#handleTooltipText(handle);
14823
+ }
14824
+ }
14825
+
14826
+ #toggleHandleTooltip(container, show) {
14827
+ const tooltip = container?.querySelector("fig-tooltip");
14828
+ if (!tooltip) return;
14829
+ if (show) tooltip.setAttribute("show", "true");
14830
+ else tooltip.removeAttribute("show");
14831
+ }
14832
+
14757
14833
  #createContent() {
14758
14834
  const size = 200;
14759
14835
  const select = this.#isPresetsEnabled() ? this.#createSelect() : null;
@@ -15002,6 +15078,7 @@ class FigEasingCurve extends HTMLElement {
15002
15078
  } else {
15003
15079
  this.#updateBezierPaths();
15004
15080
  }
15081
+ this.#syncHandleTooltips();
15005
15082
  }
15006
15083
 
15007
15084
  #syncActiveBezierArm() {
@@ -15098,7 +15175,7 @@ class FigEasingCurve extends HTMLElement {
15098
15175
  totalTime,
15099
15176
  };
15100
15177
 
15101
- const durationNorm = Math.max(0.05, Math.min(0.95, this.#springDuration));
15178
+ const durationNorm = Math.max(0.05, Math.min(1, this.#springDuration));
15102
15179
  let d = "";
15103
15180
  for (let i = 0; i < points.length; i++) {
15104
15181
  const nt = (points[i].t / totalTime) * durationNorm;
@@ -15137,10 +15214,21 @@ class FigEasingCurve extends HTMLElement {
15137
15214
  #findPeakOvershoot(points) {
15138
15215
  let peak = { t: 0, value: 1 };
15139
15216
  let passedTarget = false;
15140
- for (const p of points) {
15141
- if (p.value >= 0.99) passedTarget = true;
15142
- if (passedTarget && p.value > peak.value) {
15143
- peak = { t: p.t, value: p.value };
15217
+ for (let i = 1; i < points.length - 1; i++) {
15218
+ const previous = points[i - 1];
15219
+ const current = points[i];
15220
+ const next = points[i + 1];
15221
+ if (current.value >= 0.99) passedTarget = true;
15222
+ if (passedTarget && current.value > peak.value) {
15223
+ peak = { t: current.t, value: current.value };
15224
+ }
15225
+ if (
15226
+ passedTarget &&
15227
+ current.value > 1 &&
15228
+ current.value >= previous.value &&
15229
+ current.value >= next.value
15230
+ ) {
15231
+ return { t: current.t, value: current.value };
15144
15232
  }
15145
15233
  }
15146
15234
  return peak;
@@ -15342,14 +15430,14 @@ class FigEasingCurve extends HTMLElement {
15342
15430
  this.#spring.stiffness = Math.max(10, Math.round(this.#spring.stiffness + dx * 1.5));
15343
15431
  break;
15344
15432
  case "ArrowRight":
15345
- this.#springDuration = Math.min(0.95, this.#springDuration + dx / 200);
15433
+ this.#springDuration = Math.min(1, this.#springDuration + dx / 200);
15346
15434
  this.#spring.stiffness = Math.max(10, Math.round(this.#spring.stiffness - dx * 1.5));
15347
15435
  break;
15348
15436
  case "Home":
15349
15437
  this.#springDuration = 0.05;
15350
15438
  break;
15351
15439
  case "End":
15352
- this.#springDuration = 0.95;
15440
+ this.#springDuration = 1;
15353
15441
  break;
15354
15442
  default:
15355
15443
  return false;
@@ -15551,6 +15639,8 @@ class FigEasingCurve extends HTMLElement {
15551
15639
  e.preventDefault();
15552
15640
  this.#isDragging = handle;
15553
15641
  this.#syncActiveBezierArm();
15642
+ const activeHandle = handle === 1 ? this.#handle1 : this.#handle2;
15643
+ this.#toggleHandleTooltip(activeHandle, true);
15554
15644
  const svgRect = this.#svg.getBoundingClientRect();
15555
15645
  const startClientX = e.clientX;
15556
15646
  const startClientY = e.clientY;
@@ -15601,6 +15691,7 @@ class FigEasingCurve extends HTMLElement {
15601
15691
  const onUp = () => {
15602
15692
  this.#isDragging = null;
15603
15693
  this.#syncActiveBezierArm();
15694
+ this.#toggleHandleTooltip(activeHandle, false);
15604
15695
  document.removeEventListener("pointermove", onMove);
15605
15696
  document.removeEventListener("pointerup", onUp);
15606
15697
  this.#emit("change");
@@ -15614,6 +15705,9 @@ class FigEasingCurve extends HTMLElement {
15614
15705
  e.preventDefault();
15615
15706
  this.#isDragging = handleType;
15616
15707
  this.classList.toggle("spring-bounce-dragging", handleType === "bounce");
15708
+ const activeHandle =
15709
+ handleType === "bounce" ? this.#handle1 : this.#handle2;
15710
+ this.#toggleHandleTooltip(activeHandle, true);
15617
15711
 
15618
15712
  const startBounce = this.#springBounce();
15619
15713
  const startStiffness = this.#spring.stiffness;
@@ -15631,7 +15725,7 @@ class FigEasingCurve extends HTMLElement {
15631
15725
  const dx = e.clientX - startX;
15632
15726
  this.#springDuration = Math.max(
15633
15727
  0.05,
15634
- Math.min(0.95, startDuration + dx / 200),
15728
+ Math.min(1, startDuration + dx / 200),
15635
15729
  );
15636
15730
  this.#spring.stiffness = Math.max(
15637
15731
  10,
@@ -15649,6 +15743,7 @@ class FigEasingCurve extends HTMLElement {
15649
15743
  const onUp = () => {
15650
15744
  this.#isDragging = null;
15651
15745
  this.classList.remove("spring-bounce-dragging");
15746
+ this.#toggleHandleTooltip(activeHandle, false);
15652
15747
  document.removeEventListener("pointermove", onMove);
15653
15748
  document.removeEventListener("pointerup", onUp);
15654
15749
  this.#emit("change");
@@ -15660,6 +15755,770 @@ class FigEasingCurve extends HTMLElement {
15660
15755
  }
15661
15756
  figDefineElement("fig-easing-curve", FigEasingCurve);
15662
15757
 
15758
+ /**
15759
+ * A circular angle control with an instrument-style dial.
15760
+ * @attr {number} value - Angle in the selected unit.
15761
+ * @attr {number} default - Default/reset value in the selected unit.
15762
+ * @attr {number} precision - Decimal places for output (defaults: deg/grad 0, rad 2, turn 3).
15763
+ * @attr {boolean} dial - Show the dial surface (default true).
15764
+ * @attr {number} min - Optional minimum value in the selected unit.
15765
+ * @attr {number} max - Optional maximum value in the selected unit.
15766
+ * @attr {number} step - Keyboard/text step in the selected unit.
15767
+ * @attr {string} units - deg, °, rad, turn, or grad (default deg).
15768
+ * @attr {boolean} rotations - Show completed rotations beside the text input.
15769
+ * @attr {boolean} disabled - Disable interaction.
15770
+ */
15771
+ class FigAngle extends HTMLElement {
15772
+ #angle = 0;
15773
+ #precision = 0;
15774
+ #units = "deg";
15775
+ #surface = null;
15776
+ #dial = null;
15777
+ #indicator = null;
15778
+ #indicatorHalo = null;
15779
+ #reference = null;
15780
+ #angleInput = null;
15781
+ #rotationSpan = null;
15782
+ #resetButton = null;
15783
+ #eventAbort = null;
15784
+ #activePointerId = null;
15785
+ #previousPointerAngle = null;
15786
+ #reflectingValue = false;
15787
+ #convertingUnits = false;
15788
+ #onPointerDown = (event) => this.#startPointerGesture(event);
15789
+ #onPointerMove = (event) => this.#movePointerGesture(event);
15790
+ #onPointerEnd = (event) => this.#endPointerGesture(event);
15791
+ #onKeyDown = (event) => this.#handleKeyDown(event);
15792
+ #onTextInput = (event) => this.#handleTextInput(event);
15793
+ #onRawTextChange = (event) => this.#normalizeRawTextUnit(event);
15794
+ #onResetClick = () => this.#resetToDefault();
15795
+
15796
+ static #rotationGeometry(
15797
+ angle,
15798
+ centerX = 12,
15799
+ centerY = 12,
15800
+ radius = 7,
15801
+ arrowSize = 3,
15802
+ ) {
15803
+ const value = Number(angle);
15804
+ const safeValue = Number.isFinite(value) ? value : 0;
15805
+ const direction = safeValue < 0 ? "counterclockwise" : "clockwise";
15806
+ const absoluteSweep = Math.abs(safeValue);
15807
+ const fullSweep = absoluteSweep >= 360;
15808
+ const sweep = absoluteSweep % 360;
15809
+ if (sweep < 0.001 && !fullSweep) {
15810
+ return { direction, arc: "", arrow: "" };
15811
+ }
15812
+
15813
+ const signedSweep = direction === "clockwise" ? sweep : -sweep;
15814
+ const radians = (signedSweep * Math.PI) / 180;
15815
+ const end = {
15816
+ x: centerX + Math.cos(radians) * radius,
15817
+ y: centerY + Math.sin(radians) * radius,
15818
+ };
15819
+ const tangent =
15820
+ radians + (direction === "clockwise" ? Math.PI / 2 : -Math.PI / 2);
15821
+ const back = tangent + Math.PI;
15822
+ const spread = Math.PI / 4;
15823
+ const arm = (offset) => ({
15824
+ x: end.x + Math.cos(back + offset) * arrowSize,
15825
+ y: end.y + Math.sin(back + offset) * arrowSize,
15826
+ });
15827
+ const firstArm = arm(-spread);
15828
+ const secondArm = arm(spread);
15829
+ const point = (pointValue) =>
15830
+ `${Number(pointValue.x.toFixed(3))} ${Number(pointValue.y.toFixed(3))}`;
15831
+ const sweepFlag = direction === "clockwise" ? 1 : 0;
15832
+ const arc = fullSweep
15833
+ ? `M ${centerX + radius} ${centerY} A ${radius} ${radius} 0 0 ${sweepFlag} ${centerX - radius} ${centerY} A ${radius} ${radius} 0 0 ${sweepFlag} ${centerX + radius} ${centerY}`
15834
+ : `M ${centerX + radius} ${centerY} A ${radius} ${radius} 0 ${sweep > 180 ? 1 : 0} ${sweepFlag} ${point(end)}`;
15835
+ return {
15836
+ direction,
15837
+ arc,
15838
+ arrow: `M ${point(firstArm)} L ${point(end)} L ${point(secondArm)}`,
15839
+ };
15840
+ }
15841
+
15842
+ static #createRotationIcon(angle = 0) {
15843
+ const geometry = FigAngle.#rotationGeometry(angle, 50, 50, 47, 4);
15844
+ return figCreateSvgElement(
15845
+ "svg",
15846
+ {
15847
+ class: "fig-angle-reference",
15848
+ x: "3%",
15849
+ y: "3%",
15850
+ width: "94%",
15851
+ height: "94%",
15852
+ viewBox: "3 3 94 94",
15853
+ fill: "none",
15854
+ overflow: "visible",
15855
+ "aria-hidden": "true",
15856
+ "data-direction": geometry.direction,
15857
+ },
15858
+ figCreateSvgElement("path", {
15859
+ class: "fig-angle-reference-path",
15860
+ d: [geometry.arc, geometry.arrow].filter(Boolean).join(" "),
15861
+ hidden: !geometry.arc,
15862
+ fill: "none",
15863
+ stroke: "currentColor",
15864
+ "stroke-width": "var(--stroke-width)",
15865
+ "stroke-linecap": "round",
15866
+ "stroke-linejoin": "round",
15867
+ }),
15868
+ );
15869
+ }
15870
+
15871
+ static rotationIcon(angle = 0, size = 48) {
15872
+ const iconSize = Number.isFinite(Number(size))
15873
+ ? Math.max(1, Number(size))
15874
+ : 48;
15875
+ const geometry = FigAngle.#rotationGeometry(angle);
15876
+ const paths = geometry.arc
15877
+ ? `<path d="${geometry.arc} ${geometry.arrow}" fill="none" stroke="currentColor" stroke-width="var(--stroke-width, 1)" stroke-linecap="round" stroke-linejoin="round"></path>`
15878
+ : "";
15879
+ return `<svg width="${iconSize}" height="${iconSize}" viewBox="0 0 24 24" fill="none" data-direction="${geometry.direction}">${paths}</svg>`;
15880
+ }
15881
+
15882
+ static get observedAttributes() {
15883
+ return [
15884
+ "value",
15885
+ "default",
15886
+ "precision",
15887
+ "dial",
15888
+ "min",
15889
+ "max",
15890
+ "step",
15891
+ "units",
15892
+ "rotations",
15893
+ "disabled",
15894
+ "aria-label",
15895
+ "name",
15896
+ ];
15897
+ }
15898
+
15899
+ connectedCallback() {
15900
+ this.#readConfiguration();
15901
+ this.#render();
15902
+ }
15903
+
15904
+ disconnectedCallback() {
15905
+ this.#cancelPointerGesture();
15906
+ this.#teardownEvents();
15907
+ }
15908
+
15909
+ attributeChangedCallback(name, oldValue, newValue) {
15910
+ if (oldValue === newValue) return;
15911
+ if (this.#convertingUnits) return;
15912
+ if (name === "value") {
15913
+ if (this.#reflectingValue) return;
15914
+ const next = Number(newValue);
15915
+ if (Number.isFinite(next)) {
15916
+ this.#setValue(next, { reflect: false });
15917
+ }
15918
+ return;
15919
+ }
15920
+
15921
+ if (name === "units" && this.isConnected) {
15922
+ const degrees = this.#toDegrees(this.#angle);
15923
+ const previousUnit = this.#units;
15924
+ this.#units = this.#normalizeUnit(newValue);
15925
+ this.#convertUnitAttributes(previousUnit, this.#units);
15926
+ this.#precision = this.#configuredPrecision();
15927
+ this.#setValue(this.#fromDegrees(degrees));
15928
+ this.#render();
15929
+ return;
15930
+ }
15931
+
15932
+ this.#readConfiguration();
15933
+ if (!this.isConnected) return;
15934
+ if (
15935
+ [
15936
+ "dial",
15937
+ "precision",
15938
+ "min",
15939
+ "max",
15940
+ "step",
15941
+ "units",
15942
+ "rotations",
15943
+ "disabled",
15944
+ "aria-label",
15945
+ "name",
15946
+ ].includes(name)
15947
+ ) {
15948
+ this.#render();
15949
+ return;
15950
+ }
15951
+ this.#setValue(this.#angle);
15952
+ }
15953
+
15954
+ get value() {
15955
+ return this.#angle;
15956
+ }
15957
+
15958
+ set value(value) {
15959
+ const next = Number(value);
15960
+ if (!Number.isFinite(next)) return;
15961
+ this.#setValue(next);
15962
+ }
15963
+
15964
+ get defaultValue() {
15965
+ return this.#resolveDefaultValue();
15966
+ }
15967
+
15968
+ set defaultValue(value) {
15969
+ if (value === null || value === undefined || value === "") {
15970
+ this.removeAttribute("default");
15971
+ return;
15972
+ }
15973
+ const next = Number(value);
15974
+ if (Number.isFinite(next)) this.setAttribute("default", String(next));
15975
+ }
15976
+
15977
+ get adjacent() {
15978
+ return Math.cos((this.#toDegrees(this.#angle) * Math.PI) / 180);
15979
+ }
15980
+
15981
+ get opposite() {
15982
+ return Math.sin((this.#toDegrees(this.#angle) * Math.PI) / 180);
15983
+ }
15984
+
15985
+ focus(options) {
15986
+ this.#dial?.focus(options);
15987
+ }
15988
+
15989
+ #readConfiguration() {
15990
+ this.#units = this.#normalizeUnit(this.getAttribute("units"));
15991
+ this.#precision = this.#configuredPrecision();
15992
+ const valueAttr = this.getAttribute("value");
15993
+ const value =
15994
+ valueAttr === null || valueAttr.trim() === ""
15995
+ ? this.#resolveDefaultValue()
15996
+ : Number(valueAttr);
15997
+ if (Number.isFinite(value)) this.#angle = this.#clamp(value);
15998
+ }
15999
+
16000
+ #configuredPrecision() {
16001
+ if (!this.hasAttribute("precision")) {
16002
+ if (this.#units === "turn") return 3;
16003
+ if (this.#units === "rad") return 2;
16004
+ return 0;
16005
+ }
16006
+ const precision = Number.parseInt(this.getAttribute("precision"), 10);
16007
+ return Number.isFinite(precision) ? Math.max(0, precision) : 0;
16008
+ }
16009
+
16010
+ #normalizeUnit(unit) {
16011
+ const normalized = String(unit || "deg")
16012
+ .trim()
16013
+ .toLowerCase();
16014
+ if (normalized === "°") return "°";
16015
+ return ["deg", "rad", "turn", "grad"].includes(normalized)
16016
+ ? normalized
16017
+ : "deg";
16018
+ }
16019
+
16020
+ #convertUnitAttributes(fromUnit, toUnit) {
16021
+ if (fromUnit === toUnit) return;
16022
+ this.#convertingUnits = true;
16023
+ for (const name of ["min", "max", "default", "step"]) {
16024
+ const raw = this.getAttribute(name);
16025
+ if (raw === null || raw.trim() === "") continue;
16026
+ const value = Number(raw);
16027
+ if (!Number.isFinite(value)) continue;
16028
+ const converted = this.#fromDegreesForUnit(
16029
+ this.#toDegreesForUnit(value, fromUnit),
16030
+ toUnit,
16031
+ );
16032
+ this.setAttribute(name, String(converted));
16033
+ }
16034
+ this.#convertingUnits = false;
16035
+ }
16036
+
16037
+ #readBoolean(name, defaultValue = false) {
16038
+ if (!this.hasAttribute(name)) return defaultValue;
16039
+ return this.getAttribute(name)?.toLowerCase() !== "false";
16040
+ }
16041
+
16042
+ #isDisabled() {
16043
+ return figBooleanAttribute(this, "disabled");
16044
+ }
16045
+
16046
+ #minimum() {
16047
+ const value = Number(this.getAttribute("min"));
16048
+ return this.hasAttribute("min") && Number.isFinite(value) ? value : null;
16049
+ }
16050
+
16051
+ #maximum() {
16052
+ const value = Number(this.getAttribute("max"));
16053
+ return this.hasAttribute("max") && Number.isFinite(value) ? value : null;
16054
+ }
16055
+
16056
+ #clamp(value) {
16057
+ let next = Number(value);
16058
+ if (!Number.isFinite(next)) return this.#angle;
16059
+ const min = this.#minimum();
16060
+ const max = this.#maximum();
16061
+ if (min !== null) next = Math.max(min, next);
16062
+ if (max !== null) next = Math.min(max, next);
16063
+ return next;
16064
+ }
16065
+
16066
+ #resolveDefaultValue(rawDefault = this.getAttribute("default")) {
16067
+ const parsed =
16068
+ rawDefault === null || String(rawDefault).trim() === ""
16069
+ ? 0
16070
+ : Number(rawDefault);
16071
+ return this.#clamp(Number.isFinite(parsed) ? parsed : 0);
16072
+ }
16073
+
16074
+ #toDegreesForUnit(value, unit) {
16075
+ switch (unit) {
16076
+ case "rad":
16077
+ return (value * 180) / Math.PI;
16078
+ case "turn":
16079
+ return value * 360;
16080
+ case "grad":
16081
+ return value * 0.9;
16082
+ default:
16083
+ return value;
16084
+ }
16085
+ }
16086
+
16087
+ #fromDegreesForUnit(degrees, unit) {
16088
+ switch (unit) {
16089
+ case "rad":
16090
+ return (degrees * Math.PI) / 180;
16091
+ case "turn":
16092
+ return degrees / 360;
16093
+ case "grad":
16094
+ return degrees / 0.9;
16095
+ default:
16096
+ return degrees;
16097
+ }
16098
+ }
16099
+
16100
+ #toDegrees(value) {
16101
+ return this.#toDegreesForUnit(value, this.#units);
16102
+ }
16103
+
16104
+ #fromDegrees(degrees) {
16105
+ return this.#fromDegreesForUnit(degrees, this.#units);
16106
+ }
16107
+
16108
+ #unitSuffix() {
16109
+ return this.#units === "deg" ? "°" : this.#units;
16110
+ }
16111
+
16112
+ #defaultStep() {
16113
+ switch (this.#units) {
16114
+ case "rad":
16115
+ return 0.01;
16116
+ case "turn":
16117
+ return 0.001;
16118
+ case "grad":
16119
+ return 0.1;
16120
+ default:
16121
+ return 1;
16122
+ }
16123
+ }
16124
+
16125
+ #step() {
16126
+ const step = Number(this.getAttribute("step"));
16127
+ return this.hasAttribute("step") && Number.isFinite(step) && step > 0
16128
+ ? step
16129
+ : this.#defaultStep();
16130
+ }
16131
+
16132
+ #name() {
16133
+ return this.getAttribute("aria-label") || this.getAttribute("name") || "Angle";
16134
+ }
16135
+
16136
+ #render() {
16137
+ this.#cancelPointerGesture();
16138
+ this.#teardownEvents();
16139
+ const children = [];
16140
+ const rotations = this.#readBoolean("rotations")
16141
+ ? figCreateElement("span", {
16142
+ slot: "append",
16143
+ className: "fig-angle-rotations",
16144
+ })
16145
+ : null;
16146
+ const showDial = this.#readBoolean("dial", true);
16147
+ const angleInput = figCreateElement(
16148
+ "fig-input-number",
16149
+ {
16150
+ className: "fig-angle-input",
16151
+ variant: showDial ? "ghost" : undefined,
16152
+ tabular: true,
16153
+ name: "angle",
16154
+ value: this.#angle,
16155
+ min: this.#minimum(),
16156
+ max: this.#maximum(),
16157
+ step: this.#step(),
16158
+ precision: this.#precision,
16159
+ units: this.#unitSuffix(),
16160
+ disabled: this.#isDisabled(),
16161
+ "aria-label": this.#name(),
16162
+ },
16163
+ rotations,
16164
+ );
16165
+
16166
+ if (showDial) {
16167
+ const reset = figCreateElement(
16168
+ "fig-tooltip",
16169
+ { text: "Reset" },
16170
+ figCreateElement(
16171
+ "fig-button",
16172
+ {
16173
+ variant: "ghost",
16174
+ icon: "true",
16175
+ className: "fig-angle-reset",
16176
+ disabled: this.#isDisabled(),
16177
+ "aria-label": "Reset to default",
16178
+ },
16179
+ createFigIcon("reset", { size: "small" }),
16180
+ ),
16181
+ );
16182
+ const min = this.#minimum() ?? this.#fromDegrees(0);
16183
+ const max = this.#maximum() ?? this.#fromDegrees(360);
16184
+ const disabled = this.#isDisabled();
16185
+ const ticks = Array.from({ length: 24 }, (_, index) => {
16186
+ const angle = index * 15;
16187
+ const radians = (angle * Math.PI) / 180;
16188
+ const outer = 37;
16189
+ const inner = angle % 45 === 0 ? 31 : 35;
16190
+ return figCreateSvgElement("line", {
16191
+ class:
16192
+ angle % 45 === 0
16193
+ ? "fig-angle-tick fig-angle-tick-major"
16194
+ : "fig-angle-tick",
16195
+ x1: 50 + Math.cos(radians) * inner,
16196
+ y1: 50 + Math.sin(radians) * inner,
16197
+ x2: 50 + Math.cos(radians) * outer,
16198
+ y2: 50 + Math.sin(radians) * outer,
16199
+ });
16200
+ });
16201
+ const svg = figCreateSvgElement(
16202
+ "svg",
16203
+ {
16204
+ class: "fig-angle-svg",
16205
+ viewBox: "0 0 100 100",
16206
+ preserveAspectRatio: "xMidYMid meet",
16207
+ role: "slider",
16208
+ tabindex: disabled ? "-1" : "0",
16209
+ "aria-label": this.#name(),
16210
+ "aria-valuemin": min,
16211
+ "aria-valuemax": max,
16212
+ "aria-disabled": disabled ? "true" : "false",
16213
+ },
16214
+ [
16215
+ figCreateSvgElement("rect", {
16216
+ class: "fig-angle-hit-area",
16217
+ x: "0",
16218
+ y: "0",
16219
+ width: "100",
16220
+ height: "100",
16221
+ fill: "transparent",
16222
+ }),
16223
+ figCreateSvgElement("circle", {
16224
+ class: "fig-angle-boundary",
16225
+ cx: "50",
16226
+ cy: "50",
16227
+ r: "47",
16228
+ }),
16229
+ ...ticks,
16230
+ FigAngle.#createRotationIcon(this.#toDegrees(this.#angle)),
16231
+ figCreateSvgElement("line", {
16232
+ class: "fig-angle-indicator-halo",
16233
+ x1: "75",
16234
+ y1: "50",
16235
+ x2: "87",
16236
+ y2: "50",
16237
+ }),
16238
+ figCreateSvgElement("line", {
16239
+ class: "fig-angle-indicator",
16240
+ x1: "75",
16241
+ y1: "50",
16242
+ x2: "87",
16243
+ y2: "50",
16244
+ }),
16245
+ ],
16246
+ );
16247
+ children.push(
16248
+ figCreateElement(
16249
+ "div",
16250
+ {
16251
+ className: "fig-angle-surface",
16252
+ },
16253
+ [svg, angleInput, reset],
16254
+ ),
16255
+ );
16256
+ } else {
16257
+ children.push(angleInput);
16258
+ }
16259
+
16260
+ this.replaceChildren(...children);
16261
+ this.#surface = this.querySelector(".fig-angle-surface");
16262
+ this.#dial = this.querySelector(".fig-angle-svg");
16263
+ this.#indicator = this.querySelector(".fig-angle-indicator");
16264
+ this.#indicatorHalo = this.querySelector(".fig-angle-indicator-halo");
16265
+ this.#reference = this.querySelector(".fig-angle-reference");
16266
+ this.#angleInput = this.querySelector(".fig-angle-input");
16267
+ this.#rotationSpan = this.querySelector(".fig-angle-rotations");
16268
+ this.#resetButton = this.querySelector(".fig-angle-reset");
16269
+ this.#setupEvents();
16270
+ this.#sync();
16271
+ }
16272
+
16273
+ #setupEvents() {
16274
+ this.#eventAbort = new AbortController();
16275
+ const signal = this.#eventAbort.signal;
16276
+ this.#dial?.addEventListener("pointerdown", this.#onPointerDown, { signal });
16277
+ this.#dial?.addEventListener("pointermove", this.#onPointerMove, { signal });
16278
+ this.#dial?.addEventListener("pointerup", this.#onPointerEnd, { signal });
16279
+ this.#dial?.addEventListener("pointercancel", this.#onPointerEnd, { signal });
16280
+ this.#dial?.addEventListener("lostpointercapture", this.#onPointerEnd, {
16281
+ signal,
16282
+ });
16283
+ this.#dial?.addEventListener("keydown", this.#onKeyDown, { signal });
16284
+ this.#angleInput?.addEventListener("input", this.#onTextInput, { signal });
16285
+ this.#angleInput?.addEventListener("change", this.#onTextInput, { signal });
16286
+ this.#resetButton?.addEventListener("click", this.#onResetClick, { signal });
16287
+ this.addEventListener("change", this.#onRawTextChange, {
16288
+ capture: true,
16289
+ signal,
16290
+ });
16291
+ }
16292
+
16293
+ #teardownEvents() {
16294
+ this.#eventAbort?.abort();
16295
+ this.#eventAbort = null;
16296
+ }
16297
+
16298
+ #setValue(value, { reflect = true } = {}) {
16299
+ const next = this.#clamp(value);
16300
+ this.#angle = next;
16301
+ if (reflect) {
16302
+ const serialized = String(next);
16303
+ if (this.getAttribute("value") !== serialized) {
16304
+ this.#reflectingValue = true;
16305
+ this.setAttribute("value", serialized);
16306
+ this.#reflectingValue = false;
16307
+ }
16308
+ }
16309
+ this.#sync();
16310
+ }
16311
+
16312
+ #sync() {
16313
+ const degrees = this.#toDegrees(this.#angle);
16314
+ const normalized = ((degrees % 360) + 360) % 360;
16315
+ const transform = `rotate(${normalized} 50 50)`;
16316
+ this.#indicator?.setAttribute("transform", transform);
16317
+ this.#indicatorHalo?.setAttribute("transform", transform);
16318
+ const referenceGeometry = FigAngle.#rotationGeometry(
16319
+ degrees,
16320
+ 50,
16321
+ 50,
16322
+ 47,
16323
+ 4,
16324
+ );
16325
+ this.#reference?.setAttribute(
16326
+ "data-direction",
16327
+ referenceGeometry.direction,
16328
+ );
16329
+ const referencePath = this.#reference?.querySelector(
16330
+ ".fig-angle-reference-path",
16331
+ );
16332
+ referencePath?.setAttribute(
16333
+ "d",
16334
+ [referenceGeometry.arc, referenceGeometry.arrow]
16335
+ .filter(Boolean)
16336
+ .join(" "),
16337
+ );
16338
+ referencePath?.toggleAttribute("hidden", !referenceGeometry.arc);
16339
+ if (this.#dial) {
16340
+ const defaultMin = this.#fromDegrees(0);
16341
+ const defaultMax = this.#fromDegrees(360);
16342
+ this.#dial.setAttribute(
16343
+ "aria-valuemin",
16344
+ String(this.#minimum() ?? Math.min(defaultMin, this.#angle)),
16345
+ );
16346
+ this.#dial.setAttribute(
16347
+ "aria-valuemax",
16348
+ String(this.#maximum() ?? Math.max(defaultMax, this.#angle)),
16349
+ );
16350
+ this.#dial.setAttribute("aria-valuenow", String(this.#angle));
16351
+ this.#dial.setAttribute(
16352
+ "aria-valuetext",
16353
+ `${this.#angle.toFixed(this.#precision)}${this.#unitSuffix()}`,
16354
+ );
16355
+ }
16356
+ if (this.#angleInput) {
16357
+ const value = this.#angle.toFixed(this.#precision);
16358
+ if (this.#angleInput.getAttribute("value") !== value) {
16359
+ this.#angleInput.setAttribute("value", value);
16360
+ }
16361
+ }
16362
+ if (this.#rotationSpan) {
16363
+ const rotations = Math.floor(Math.abs(degrees) / 360);
16364
+ this.#rotationSpan.textContent = rotations > 1 ? `×${rotations}` : "";
16365
+ this.#rotationSpan.hidden = rotations <= 1;
16366
+ }
16367
+ if (this.#resetButton) {
16368
+ this.#resetButton.hidden =
16369
+ this.#angle === this.#resolveDefaultValue();
16370
+ }
16371
+ }
16372
+
16373
+ #resetToDefault() {
16374
+ if (this.#isDisabled()) return;
16375
+ this.#setValue(this.#resolveDefaultValue());
16376
+ this.#emit("input");
16377
+ this.#emit("change");
16378
+ }
16379
+
16380
+ #pointerDegrees(event) {
16381
+ const rect = this.#surface.getBoundingClientRect();
16382
+ const centerX = rect.left + rect.width / 2;
16383
+ const centerY = rect.top + rect.height / 2;
16384
+ const dx = event.clientX - centerX;
16385
+ const dy = event.clientY - centerY;
16386
+ if (Math.hypot(dx, dy) < Math.min(rect.width, rect.height) * 0.08) {
16387
+ return null;
16388
+ }
16389
+ let degrees = (Math.atan2(dy, dx) * 180) / Math.PI;
16390
+ degrees = ((degrees % 360) + 360) % 360;
16391
+ if (event.shiftKey) degrees = Math.round(degrees / 15) * 15;
16392
+ return degrees;
16393
+ }
16394
+
16395
+ #updateFromPointer(event) {
16396
+ const pointerDegrees = this.#pointerDegrees(event);
16397
+ if (pointerDegrees === null) return;
16398
+ const currentDegrees = this.#toDegrees(this.#angle);
16399
+ let delta;
16400
+ if (this.#previousPointerAngle === null) {
16401
+ const currentNormalized = ((currentDegrees % 360) + 360) % 360;
16402
+ delta = pointerDegrees - currentNormalized;
16403
+ } else {
16404
+ delta = pointerDegrees - this.#previousPointerAngle;
16405
+ }
16406
+ if (delta > 180) delta -= 360;
16407
+ if (delta < -180) delta += 360;
16408
+ this.#previousPointerAngle = pointerDegrees;
16409
+ this.#setValue(this.#fromDegrees(currentDegrees + delta));
16410
+ this.#emit("input");
16411
+ }
16412
+
16413
+ #startPointerGesture(event) {
16414
+ if (this.#isDisabled() || event.button !== 0) return;
16415
+ event.preventDefault();
16416
+ this.#dial?.focus({ preventScroll: true });
16417
+ this.#activePointerId = event.pointerId;
16418
+ this.#previousPointerAngle = null;
16419
+ this.#surface.classList.add("dragging");
16420
+ try {
16421
+ this.#dial.setPointerCapture?.(event.pointerId);
16422
+ } catch {
16423
+ // Synthetic events and older browsers may not own an active pointer.
16424
+ }
16425
+ this.#updateFromPointer(event);
16426
+ }
16427
+
16428
+ #movePointerGesture(event) {
16429
+ if (event.pointerId !== this.#activePointerId) return;
16430
+ if (event.buttons === 0) {
16431
+ this.#endPointerGesture(event);
16432
+ return;
16433
+ }
16434
+ this.#updateFromPointer(event);
16435
+ }
16436
+
16437
+ #endPointerGesture(event) {
16438
+ if (event.pointerId !== this.#activePointerId) return;
16439
+ const pointerId = this.#activePointerId;
16440
+ this.#activePointerId = null;
16441
+ this.#previousPointerAngle = null;
16442
+ this.#surface?.classList.remove("dragging");
16443
+ if (this.#dial?.hasPointerCapture?.(pointerId)) {
16444
+ this.#dial.releasePointerCapture(pointerId);
16445
+ }
16446
+ this.#emit("change");
16447
+ }
16448
+
16449
+ #cancelPointerGesture() {
16450
+ this.#activePointerId = null;
16451
+ this.#previousPointerAngle = null;
16452
+ this.#surface?.classList.remove("dragging");
16453
+ }
16454
+
16455
+ #handleKeyDown(event) {
16456
+ if (this.#isDisabled()) return;
16457
+ let next = this.#angle;
16458
+ const direction =
16459
+ event.key === "ArrowLeft" || event.key === "ArrowDown"
16460
+ ? -1
16461
+ : event.key === "ArrowRight" || event.key === "ArrowUp"
16462
+ ? 1
16463
+ : 0;
16464
+ if (direction) {
16465
+ const increment = event.shiftKey ? this.#fromDegrees(15) : this.#step();
16466
+ next += direction * increment;
16467
+ } else if (event.key === "Home") {
16468
+ next = this.#minimum() ?? this.#fromDegrees(0);
16469
+ } else if (event.key === "End") {
16470
+ next = this.#maximum() ?? this.#fromDegrees(360);
16471
+ } else {
16472
+ return;
16473
+ }
16474
+ event.preventDefault();
16475
+ event.stopPropagation();
16476
+ this.#setValue(next);
16477
+ this.#emit("input");
16478
+ this.#emit("change");
16479
+ }
16480
+
16481
+ #handleTextInput(event) {
16482
+ event.stopPropagation();
16483
+ if (this.#isDisabled()) return;
16484
+ const value = Number(event.currentTarget.value);
16485
+ if (!Number.isFinite(value)) return;
16486
+ this.#setValue(value);
16487
+ this.#emit(event.type);
16488
+ }
16489
+
16490
+ #normalizeRawTextUnit(event) {
16491
+ if (!event.target?.matches?.("input")) return;
16492
+ const match = String(event.target.value).match(
16493
+ /^(-?\d*\.?\d+)\s*(deg|°|rad|turn|grad)$/i,
16494
+ );
16495
+ if (!match) return;
16496
+ const value = Number(match[1]);
16497
+ const sourceUnit = this.#normalizeUnit(match[2]);
16498
+ let degrees = value;
16499
+ if (sourceUnit === "rad") degrees = (value * 180) / Math.PI;
16500
+ else if (sourceUnit === "turn") degrees = value * 360;
16501
+ else if (sourceUnit === "grad") degrees = value * 0.9;
16502
+ event.target.value = String(this.#fromDegrees(degrees));
16503
+ }
16504
+
16505
+ #emit(type) {
16506
+ const outputValue = Number(this.value.toFixed(this.#precision));
16507
+ this.dispatchEvent(
16508
+ new CustomEvent(type, {
16509
+ bubbles: true,
16510
+ cancelable: true,
16511
+ detail: {
16512
+ value: outputValue,
16513
+ angle: outputValue,
16514
+ units: this.#units,
16515
+ },
16516
+ }),
16517
+ );
16518
+ }
16519
+ }
16520
+ figDefineElement("fig-angle", FigAngle);
16521
+
15663
16522
  /**
15664
16523
  * A 3D rotation control with an interactive cube preview.
15665
16524
  * @attr {string} value - CSS transform string, e.g. "rotateX(20deg) rotateY(-35deg) rotateZ(0deg)".
@@ -17074,8 +17933,6 @@ class FigInputJoystick extends HTMLElement {
17074
17933
 
17075
17934
  figDefineElement("fig-joystick", FigInputJoystick);
17076
17935
 
17077
-
17078
- // FigInputAngle moved to fig-lab.js
17079
17936
  // FigShimmer
17080
17937
  class FigShimmer extends HTMLElement {
17081
17938
  get durationPropertyName() {