@rogieking/figui3 9.0.13 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 ---
@@ -15660,6 +15696,770 @@ class FigEasingCurve extends HTMLElement {
15660
15696
  }
15661
15697
  figDefineElement("fig-easing-curve", FigEasingCurve);
15662
15698
 
15699
+ /**
15700
+ * A circular angle control with an instrument-style dial.
15701
+ * @attr {number} value - Angle in the selected unit.
15702
+ * @attr {number} default - Default/reset value in the selected unit.
15703
+ * @attr {number} precision - Decimal places for output (defaults: deg/grad 0, rad 2, turn 3).
15704
+ * @attr {boolean} dial - Show the dial surface (default true).
15705
+ * @attr {number} min - Optional minimum value in the selected unit.
15706
+ * @attr {number} max - Optional maximum value in the selected unit.
15707
+ * @attr {number} step - Keyboard/text step in the selected unit.
15708
+ * @attr {string} units - deg, °, rad, turn, or grad (default deg).
15709
+ * @attr {boolean} rotations - Show completed rotations beside the text input.
15710
+ * @attr {boolean} disabled - Disable interaction.
15711
+ */
15712
+ class FigAngle extends HTMLElement {
15713
+ #angle = 0;
15714
+ #precision = 0;
15715
+ #units = "deg";
15716
+ #surface = null;
15717
+ #dial = null;
15718
+ #indicator = null;
15719
+ #indicatorHalo = null;
15720
+ #reference = null;
15721
+ #angleInput = null;
15722
+ #rotationSpan = null;
15723
+ #resetButton = null;
15724
+ #eventAbort = null;
15725
+ #activePointerId = null;
15726
+ #previousPointerAngle = null;
15727
+ #reflectingValue = false;
15728
+ #convertingUnits = false;
15729
+ #onPointerDown = (event) => this.#startPointerGesture(event);
15730
+ #onPointerMove = (event) => this.#movePointerGesture(event);
15731
+ #onPointerEnd = (event) => this.#endPointerGesture(event);
15732
+ #onKeyDown = (event) => this.#handleKeyDown(event);
15733
+ #onTextInput = (event) => this.#handleTextInput(event);
15734
+ #onRawTextChange = (event) => this.#normalizeRawTextUnit(event);
15735
+ #onResetClick = () => this.#resetToDefault();
15736
+
15737
+ static #rotationGeometry(
15738
+ angle,
15739
+ centerX = 12,
15740
+ centerY = 12,
15741
+ radius = 7,
15742
+ arrowSize = 3,
15743
+ ) {
15744
+ const value = Number(angle);
15745
+ const safeValue = Number.isFinite(value) ? value : 0;
15746
+ const direction = safeValue < 0 ? "counterclockwise" : "clockwise";
15747
+ const absoluteSweep = Math.abs(safeValue);
15748
+ const fullSweep = absoluteSweep >= 360;
15749
+ const sweep = absoluteSweep % 360;
15750
+ if (sweep < 0.001 && !fullSweep) {
15751
+ return { direction, arc: "", arrow: "" };
15752
+ }
15753
+
15754
+ const signedSweep = direction === "clockwise" ? sweep : -sweep;
15755
+ const radians = (signedSweep * Math.PI) / 180;
15756
+ const end = {
15757
+ x: centerX + Math.cos(radians) * radius,
15758
+ y: centerY + Math.sin(radians) * radius,
15759
+ };
15760
+ const tangent =
15761
+ radians + (direction === "clockwise" ? Math.PI / 2 : -Math.PI / 2);
15762
+ const back = tangent + Math.PI;
15763
+ const spread = Math.PI / 4;
15764
+ const arm = (offset) => ({
15765
+ x: end.x + Math.cos(back + offset) * arrowSize,
15766
+ y: end.y + Math.sin(back + offset) * arrowSize,
15767
+ });
15768
+ const firstArm = arm(-spread);
15769
+ const secondArm = arm(spread);
15770
+ const point = (pointValue) =>
15771
+ `${Number(pointValue.x.toFixed(3))} ${Number(pointValue.y.toFixed(3))}`;
15772
+ const sweepFlag = direction === "clockwise" ? 1 : 0;
15773
+ const arc = fullSweep
15774
+ ? `M ${centerX + radius} ${centerY} A ${radius} ${radius} 0 0 ${sweepFlag} ${centerX - radius} ${centerY} A ${radius} ${radius} 0 0 ${sweepFlag} ${centerX + radius} ${centerY}`
15775
+ : `M ${centerX + radius} ${centerY} A ${radius} ${radius} 0 ${sweep > 180 ? 1 : 0} ${sweepFlag} ${point(end)}`;
15776
+ return {
15777
+ direction,
15778
+ arc,
15779
+ arrow: `M ${point(firstArm)} L ${point(end)} L ${point(secondArm)}`,
15780
+ };
15781
+ }
15782
+
15783
+ static #createRotationIcon(angle = 0) {
15784
+ const geometry = FigAngle.#rotationGeometry(angle, 50, 50, 47, 4);
15785
+ return figCreateSvgElement(
15786
+ "svg",
15787
+ {
15788
+ class: "fig-angle-reference",
15789
+ x: "3%",
15790
+ y: "3%",
15791
+ width: "94%",
15792
+ height: "94%",
15793
+ viewBox: "3 3 94 94",
15794
+ fill: "none",
15795
+ overflow: "visible",
15796
+ "aria-hidden": "true",
15797
+ "data-direction": geometry.direction,
15798
+ },
15799
+ figCreateSvgElement("path", {
15800
+ class: "fig-angle-reference-path",
15801
+ d: [geometry.arc, geometry.arrow].filter(Boolean).join(" "),
15802
+ hidden: !geometry.arc,
15803
+ fill: "none",
15804
+ stroke: "currentColor",
15805
+ "stroke-width": "var(--stroke-width)",
15806
+ "stroke-linecap": "round",
15807
+ "stroke-linejoin": "round",
15808
+ }),
15809
+ );
15810
+ }
15811
+
15812
+ static rotationIcon(angle = 0, size = 48) {
15813
+ const iconSize = Number.isFinite(Number(size))
15814
+ ? Math.max(1, Number(size))
15815
+ : 48;
15816
+ const geometry = FigAngle.#rotationGeometry(angle);
15817
+ const paths = geometry.arc
15818
+ ? `<path d="${geometry.arc} ${geometry.arrow}" fill="none" stroke="currentColor" stroke-width="var(--stroke-width, 1)" stroke-linecap="round" stroke-linejoin="round"></path>`
15819
+ : "";
15820
+ return `<svg width="${iconSize}" height="${iconSize}" viewBox="0 0 24 24" fill="none" data-direction="${geometry.direction}">${paths}</svg>`;
15821
+ }
15822
+
15823
+ static get observedAttributes() {
15824
+ return [
15825
+ "value",
15826
+ "default",
15827
+ "precision",
15828
+ "dial",
15829
+ "min",
15830
+ "max",
15831
+ "step",
15832
+ "units",
15833
+ "rotations",
15834
+ "disabled",
15835
+ "aria-label",
15836
+ "name",
15837
+ ];
15838
+ }
15839
+
15840
+ connectedCallback() {
15841
+ this.#readConfiguration();
15842
+ this.#render();
15843
+ }
15844
+
15845
+ disconnectedCallback() {
15846
+ this.#cancelPointerGesture();
15847
+ this.#teardownEvents();
15848
+ }
15849
+
15850
+ attributeChangedCallback(name, oldValue, newValue) {
15851
+ if (oldValue === newValue) return;
15852
+ if (this.#convertingUnits) return;
15853
+ if (name === "value") {
15854
+ if (this.#reflectingValue) return;
15855
+ const next = Number(newValue);
15856
+ if (Number.isFinite(next)) {
15857
+ this.#setValue(next, { reflect: false });
15858
+ }
15859
+ return;
15860
+ }
15861
+
15862
+ if (name === "units" && this.isConnected) {
15863
+ const degrees = this.#toDegrees(this.#angle);
15864
+ const previousUnit = this.#units;
15865
+ this.#units = this.#normalizeUnit(newValue);
15866
+ this.#convertUnitAttributes(previousUnit, this.#units);
15867
+ this.#precision = this.#configuredPrecision();
15868
+ this.#setValue(this.#fromDegrees(degrees));
15869
+ this.#render();
15870
+ return;
15871
+ }
15872
+
15873
+ this.#readConfiguration();
15874
+ if (!this.isConnected) return;
15875
+ if (
15876
+ [
15877
+ "dial",
15878
+ "precision",
15879
+ "min",
15880
+ "max",
15881
+ "step",
15882
+ "units",
15883
+ "rotations",
15884
+ "disabled",
15885
+ "aria-label",
15886
+ "name",
15887
+ ].includes(name)
15888
+ ) {
15889
+ this.#render();
15890
+ return;
15891
+ }
15892
+ this.#setValue(this.#angle);
15893
+ }
15894
+
15895
+ get value() {
15896
+ return this.#angle;
15897
+ }
15898
+
15899
+ set value(value) {
15900
+ const next = Number(value);
15901
+ if (!Number.isFinite(next)) return;
15902
+ this.#setValue(next);
15903
+ }
15904
+
15905
+ get defaultValue() {
15906
+ return this.#resolveDefaultValue();
15907
+ }
15908
+
15909
+ set defaultValue(value) {
15910
+ if (value === null || value === undefined || value === "") {
15911
+ this.removeAttribute("default");
15912
+ return;
15913
+ }
15914
+ const next = Number(value);
15915
+ if (Number.isFinite(next)) this.setAttribute("default", String(next));
15916
+ }
15917
+
15918
+ get adjacent() {
15919
+ return Math.cos((this.#toDegrees(this.#angle) * Math.PI) / 180);
15920
+ }
15921
+
15922
+ get opposite() {
15923
+ return Math.sin((this.#toDegrees(this.#angle) * Math.PI) / 180);
15924
+ }
15925
+
15926
+ focus(options) {
15927
+ this.#dial?.focus(options);
15928
+ }
15929
+
15930
+ #readConfiguration() {
15931
+ this.#units = this.#normalizeUnit(this.getAttribute("units"));
15932
+ this.#precision = this.#configuredPrecision();
15933
+ const valueAttr = this.getAttribute("value");
15934
+ const value =
15935
+ valueAttr === null || valueAttr.trim() === ""
15936
+ ? this.#resolveDefaultValue()
15937
+ : Number(valueAttr);
15938
+ if (Number.isFinite(value)) this.#angle = this.#clamp(value);
15939
+ }
15940
+
15941
+ #configuredPrecision() {
15942
+ if (!this.hasAttribute("precision")) {
15943
+ if (this.#units === "turn") return 3;
15944
+ if (this.#units === "rad") return 2;
15945
+ return 0;
15946
+ }
15947
+ const precision = Number.parseInt(this.getAttribute("precision"), 10);
15948
+ return Number.isFinite(precision) ? Math.max(0, precision) : 0;
15949
+ }
15950
+
15951
+ #normalizeUnit(unit) {
15952
+ const normalized = String(unit || "deg")
15953
+ .trim()
15954
+ .toLowerCase();
15955
+ if (normalized === "°") return "°";
15956
+ return ["deg", "rad", "turn", "grad"].includes(normalized)
15957
+ ? normalized
15958
+ : "deg";
15959
+ }
15960
+
15961
+ #convertUnitAttributes(fromUnit, toUnit) {
15962
+ if (fromUnit === toUnit) return;
15963
+ this.#convertingUnits = true;
15964
+ for (const name of ["min", "max", "default", "step"]) {
15965
+ const raw = this.getAttribute(name);
15966
+ if (raw === null || raw.trim() === "") continue;
15967
+ const value = Number(raw);
15968
+ if (!Number.isFinite(value)) continue;
15969
+ const converted = this.#fromDegreesForUnit(
15970
+ this.#toDegreesForUnit(value, fromUnit),
15971
+ toUnit,
15972
+ );
15973
+ this.setAttribute(name, String(converted));
15974
+ }
15975
+ this.#convertingUnits = false;
15976
+ }
15977
+
15978
+ #readBoolean(name, defaultValue = false) {
15979
+ if (!this.hasAttribute(name)) return defaultValue;
15980
+ return this.getAttribute(name)?.toLowerCase() !== "false";
15981
+ }
15982
+
15983
+ #isDisabled() {
15984
+ return figBooleanAttribute(this, "disabled");
15985
+ }
15986
+
15987
+ #minimum() {
15988
+ const value = Number(this.getAttribute("min"));
15989
+ return this.hasAttribute("min") && Number.isFinite(value) ? value : null;
15990
+ }
15991
+
15992
+ #maximum() {
15993
+ const value = Number(this.getAttribute("max"));
15994
+ return this.hasAttribute("max") && Number.isFinite(value) ? value : null;
15995
+ }
15996
+
15997
+ #clamp(value) {
15998
+ let next = Number(value);
15999
+ if (!Number.isFinite(next)) return this.#angle;
16000
+ const min = this.#minimum();
16001
+ const max = this.#maximum();
16002
+ if (min !== null) next = Math.max(min, next);
16003
+ if (max !== null) next = Math.min(max, next);
16004
+ return next;
16005
+ }
16006
+
16007
+ #resolveDefaultValue(rawDefault = this.getAttribute("default")) {
16008
+ const parsed =
16009
+ rawDefault === null || String(rawDefault).trim() === ""
16010
+ ? 0
16011
+ : Number(rawDefault);
16012
+ return this.#clamp(Number.isFinite(parsed) ? parsed : 0);
16013
+ }
16014
+
16015
+ #toDegreesForUnit(value, unit) {
16016
+ switch (unit) {
16017
+ case "rad":
16018
+ return (value * 180) / Math.PI;
16019
+ case "turn":
16020
+ return value * 360;
16021
+ case "grad":
16022
+ return value * 0.9;
16023
+ default:
16024
+ return value;
16025
+ }
16026
+ }
16027
+
16028
+ #fromDegreesForUnit(degrees, unit) {
16029
+ switch (unit) {
16030
+ case "rad":
16031
+ return (degrees * Math.PI) / 180;
16032
+ case "turn":
16033
+ return degrees / 360;
16034
+ case "grad":
16035
+ return degrees / 0.9;
16036
+ default:
16037
+ return degrees;
16038
+ }
16039
+ }
16040
+
16041
+ #toDegrees(value) {
16042
+ return this.#toDegreesForUnit(value, this.#units);
16043
+ }
16044
+
16045
+ #fromDegrees(degrees) {
16046
+ return this.#fromDegreesForUnit(degrees, this.#units);
16047
+ }
16048
+
16049
+ #unitSuffix() {
16050
+ return this.#units === "deg" ? "°" : this.#units;
16051
+ }
16052
+
16053
+ #defaultStep() {
16054
+ switch (this.#units) {
16055
+ case "rad":
16056
+ return 0.01;
16057
+ case "turn":
16058
+ return 0.001;
16059
+ case "grad":
16060
+ return 0.1;
16061
+ default:
16062
+ return 1;
16063
+ }
16064
+ }
16065
+
16066
+ #step() {
16067
+ const step = Number(this.getAttribute("step"));
16068
+ return this.hasAttribute("step") && Number.isFinite(step) && step > 0
16069
+ ? step
16070
+ : this.#defaultStep();
16071
+ }
16072
+
16073
+ #name() {
16074
+ return this.getAttribute("aria-label") || this.getAttribute("name") || "Angle";
16075
+ }
16076
+
16077
+ #render() {
16078
+ this.#cancelPointerGesture();
16079
+ this.#teardownEvents();
16080
+ const children = [];
16081
+ const rotations = this.#readBoolean("rotations")
16082
+ ? figCreateElement("span", {
16083
+ slot: "append",
16084
+ className: "fig-angle-rotations",
16085
+ })
16086
+ : null;
16087
+ const showDial = this.#readBoolean("dial", true);
16088
+ const angleInput = figCreateElement(
16089
+ "fig-input-number",
16090
+ {
16091
+ className: "fig-angle-input",
16092
+ variant: showDial ? "ghost" : undefined,
16093
+ tabular: true,
16094
+ name: "angle",
16095
+ value: this.#angle,
16096
+ min: this.#minimum(),
16097
+ max: this.#maximum(),
16098
+ step: this.#step(),
16099
+ precision: this.#precision,
16100
+ units: this.#unitSuffix(),
16101
+ disabled: this.#isDisabled(),
16102
+ "aria-label": this.#name(),
16103
+ },
16104
+ rotations,
16105
+ );
16106
+
16107
+ if (showDial) {
16108
+ const reset = figCreateElement(
16109
+ "fig-tooltip",
16110
+ { text: "Reset" },
16111
+ figCreateElement(
16112
+ "fig-button",
16113
+ {
16114
+ variant: "ghost",
16115
+ icon: "true",
16116
+ className: "fig-angle-reset",
16117
+ disabled: this.#isDisabled(),
16118
+ "aria-label": "Reset to default",
16119
+ },
16120
+ createFigIcon("reset", { size: "small" }),
16121
+ ),
16122
+ );
16123
+ const min = this.#minimum() ?? this.#fromDegrees(0);
16124
+ const max = this.#maximum() ?? this.#fromDegrees(360);
16125
+ const disabled = this.#isDisabled();
16126
+ const ticks = Array.from({ length: 24 }, (_, index) => {
16127
+ const angle = index * 15;
16128
+ const radians = (angle * Math.PI) / 180;
16129
+ const outer = 37;
16130
+ const inner = angle % 45 === 0 ? 31 : 35;
16131
+ return figCreateSvgElement("line", {
16132
+ class:
16133
+ angle % 45 === 0
16134
+ ? "fig-angle-tick fig-angle-tick-major"
16135
+ : "fig-angle-tick",
16136
+ x1: 50 + Math.cos(radians) * inner,
16137
+ y1: 50 + Math.sin(radians) * inner,
16138
+ x2: 50 + Math.cos(radians) * outer,
16139
+ y2: 50 + Math.sin(radians) * outer,
16140
+ });
16141
+ });
16142
+ const svg = figCreateSvgElement(
16143
+ "svg",
16144
+ {
16145
+ class: "fig-angle-svg",
16146
+ viewBox: "0 0 100 100",
16147
+ preserveAspectRatio: "xMidYMid meet",
16148
+ role: "slider",
16149
+ tabindex: disabled ? "-1" : "0",
16150
+ "aria-label": this.#name(),
16151
+ "aria-valuemin": min,
16152
+ "aria-valuemax": max,
16153
+ "aria-disabled": disabled ? "true" : "false",
16154
+ },
16155
+ [
16156
+ figCreateSvgElement("rect", {
16157
+ class: "fig-angle-hit-area",
16158
+ x: "0",
16159
+ y: "0",
16160
+ width: "100",
16161
+ height: "100",
16162
+ fill: "transparent",
16163
+ }),
16164
+ figCreateSvgElement("circle", {
16165
+ class: "fig-angle-boundary",
16166
+ cx: "50",
16167
+ cy: "50",
16168
+ r: "47",
16169
+ }),
16170
+ ...ticks,
16171
+ FigAngle.#createRotationIcon(this.#toDegrees(this.#angle)),
16172
+ figCreateSvgElement("line", {
16173
+ class: "fig-angle-indicator-halo",
16174
+ x1: "75",
16175
+ y1: "50",
16176
+ x2: "87",
16177
+ y2: "50",
16178
+ }),
16179
+ figCreateSvgElement("line", {
16180
+ class: "fig-angle-indicator",
16181
+ x1: "75",
16182
+ y1: "50",
16183
+ x2: "87",
16184
+ y2: "50",
16185
+ }),
16186
+ ],
16187
+ );
16188
+ children.push(
16189
+ figCreateElement(
16190
+ "div",
16191
+ {
16192
+ className: "fig-angle-surface",
16193
+ },
16194
+ [svg, angleInput, reset],
16195
+ ),
16196
+ );
16197
+ } else {
16198
+ children.push(angleInput);
16199
+ }
16200
+
16201
+ this.replaceChildren(...children);
16202
+ this.#surface = this.querySelector(".fig-angle-surface");
16203
+ this.#dial = this.querySelector(".fig-angle-svg");
16204
+ this.#indicator = this.querySelector(".fig-angle-indicator");
16205
+ this.#indicatorHalo = this.querySelector(".fig-angle-indicator-halo");
16206
+ this.#reference = this.querySelector(".fig-angle-reference");
16207
+ this.#angleInput = this.querySelector(".fig-angle-input");
16208
+ this.#rotationSpan = this.querySelector(".fig-angle-rotations");
16209
+ this.#resetButton = this.querySelector(".fig-angle-reset");
16210
+ this.#setupEvents();
16211
+ this.#sync();
16212
+ }
16213
+
16214
+ #setupEvents() {
16215
+ this.#eventAbort = new AbortController();
16216
+ const signal = this.#eventAbort.signal;
16217
+ this.#dial?.addEventListener("pointerdown", this.#onPointerDown, { signal });
16218
+ this.#dial?.addEventListener("pointermove", this.#onPointerMove, { signal });
16219
+ this.#dial?.addEventListener("pointerup", this.#onPointerEnd, { signal });
16220
+ this.#dial?.addEventListener("pointercancel", this.#onPointerEnd, { signal });
16221
+ this.#dial?.addEventListener("lostpointercapture", this.#onPointerEnd, {
16222
+ signal,
16223
+ });
16224
+ this.#dial?.addEventListener("keydown", this.#onKeyDown, { signal });
16225
+ this.#angleInput?.addEventListener("input", this.#onTextInput, { signal });
16226
+ this.#angleInput?.addEventListener("change", this.#onTextInput, { signal });
16227
+ this.#resetButton?.addEventListener("click", this.#onResetClick, { signal });
16228
+ this.addEventListener("change", this.#onRawTextChange, {
16229
+ capture: true,
16230
+ signal,
16231
+ });
16232
+ }
16233
+
16234
+ #teardownEvents() {
16235
+ this.#eventAbort?.abort();
16236
+ this.#eventAbort = null;
16237
+ }
16238
+
16239
+ #setValue(value, { reflect = true } = {}) {
16240
+ const next = this.#clamp(value);
16241
+ this.#angle = next;
16242
+ if (reflect) {
16243
+ const serialized = String(next);
16244
+ if (this.getAttribute("value") !== serialized) {
16245
+ this.#reflectingValue = true;
16246
+ this.setAttribute("value", serialized);
16247
+ this.#reflectingValue = false;
16248
+ }
16249
+ }
16250
+ this.#sync();
16251
+ }
16252
+
16253
+ #sync() {
16254
+ const degrees = this.#toDegrees(this.#angle);
16255
+ const normalized = ((degrees % 360) + 360) % 360;
16256
+ const transform = `rotate(${normalized} 50 50)`;
16257
+ this.#indicator?.setAttribute("transform", transform);
16258
+ this.#indicatorHalo?.setAttribute("transform", transform);
16259
+ const referenceGeometry = FigAngle.#rotationGeometry(
16260
+ degrees,
16261
+ 50,
16262
+ 50,
16263
+ 47,
16264
+ 4,
16265
+ );
16266
+ this.#reference?.setAttribute(
16267
+ "data-direction",
16268
+ referenceGeometry.direction,
16269
+ );
16270
+ const referencePath = this.#reference?.querySelector(
16271
+ ".fig-angle-reference-path",
16272
+ );
16273
+ referencePath?.setAttribute(
16274
+ "d",
16275
+ [referenceGeometry.arc, referenceGeometry.arrow]
16276
+ .filter(Boolean)
16277
+ .join(" "),
16278
+ );
16279
+ referencePath?.toggleAttribute("hidden", !referenceGeometry.arc);
16280
+ if (this.#dial) {
16281
+ const defaultMin = this.#fromDegrees(0);
16282
+ const defaultMax = this.#fromDegrees(360);
16283
+ this.#dial.setAttribute(
16284
+ "aria-valuemin",
16285
+ String(this.#minimum() ?? Math.min(defaultMin, this.#angle)),
16286
+ );
16287
+ this.#dial.setAttribute(
16288
+ "aria-valuemax",
16289
+ String(this.#maximum() ?? Math.max(defaultMax, this.#angle)),
16290
+ );
16291
+ this.#dial.setAttribute("aria-valuenow", String(this.#angle));
16292
+ this.#dial.setAttribute(
16293
+ "aria-valuetext",
16294
+ `${this.#angle.toFixed(this.#precision)}${this.#unitSuffix()}`,
16295
+ );
16296
+ }
16297
+ if (this.#angleInput) {
16298
+ const value = this.#angle.toFixed(this.#precision);
16299
+ if (this.#angleInput.getAttribute("value") !== value) {
16300
+ this.#angleInput.setAttribute("value", value);
16301
+ }
16302
+ }
16303
+ if (this.#rotationSpan) {
16304
+ const rotations = Math.floor(Math.abs(degrees) / 360);
16305
+ this.#rotationSpan.textContent = rotations > 1 ? `×${rotations}` : "";
16306
+ this.#rotationSpan.hidden = rotations <= 1;
16307
+ }
16308
+ if (this.#resetButton) {
16309
+ this.#resetButton.hidden =
16310
+ this.#angle === this.#resolveDefaultValue();
16311
+ }
16312
+ }
16313
+
16314
+ #resetToDefault() {
16315
+ if (this.#isDisabled()) return;
16316
+ this.#setValue(this.#resolveDefaultValue());
16317
+ this.#emit("input");
16318
+ this.#emit("change");
16319
+ }
16320
+
16321
+ #pointerDegrees(event) {
16322
+ const rect = this.#surface.getBoundingClientRect();
16323
+ const centerX = rect.left + rect.width / 2;
16324
+ const centerY = rect.top + rect.height / 2;
16325
+ const dx = event.clientX - centerX;
16326
+ const dy = event.clientY - centerY;
16327
+ if (Math.hypot(dx, dy) < Math.min(rect.width, rect.height) * 0.08) {
16328
+ return null;
16329
+ }
16330
+ let degrees = (Math.atan2(dy, dx) * 180) / Math.PI;
16331
+ degrees = ((degrees % 360) + 360) % 360;
16332
+ if (event.shiftKey) degrees = Math.round(degrees / 15) * 15;
16333
+ return degrees;
16334
+ }
16335
+
16336
+ #updateFromPointer(event) {
16337
+ const pointerDegrees = this.#pointerDegrees(event);
16338
+ if (pointerDegrees === null) return;
16339
+ const currentDegrees = this.#toDegrees(this.#angle);
16340
+ let delta;
16341
+ if (this.#previousPointerAngle === null) {
16342
+ const currentNormalized = ((currentDegrees % 360) + 360) % 360;
16343
+ delta = pointerDegrees - currentNormalized;
16344
+ } else {
16345
+ delta = pointerDegrees - this.#previousPointerAngle;
16346
+ }
16347
+ if (delta > 180) delta -= 360;
16348
+ if (delta < -180) delta += 360;
16349
+ this.#previousPointerAngle = pointerDegrees;
16350
+ this.#setValue(this.#fromDegrees(currentDegrees + delta));
16351
+ this.#emit("input");
16352
+ }
16353
+
16354
+ #startPointerGesture(event) {
16355
+ if (this.#isDisabled() || event.button !== 0) return;
16356
+ event.preventDefault();
16357
+ this.#dial?.focus({ preventScroll: true });
16358
+ this.#activePointerId = event.pointerId;
16359
+ this.#previousPointerAngle = null;
16360
+ this.#surface.classList.add("dragging");
16361
+ try {
16362
+ this.#dial.setPointerCapture?.(event.pointerId);
16363
+ } catch {
16364
+ // Synthetic events and older browsers may not own an active pointer.
16365
+ }
16366
+ this.#updateFromPointer(event);
16367
+ }
16368
+
16369
+ #movePointerGesture(event) {
16370
+ if (event.pointerId !== this.#activePointerId) return;
16371
+ if (event.buttons === 0) {
16372
+ this.#endPointerGesture(event);
16373
+ return;
16374
+ }
16375
+ this.#updateFromPointer(event);
16376
+ }
16377
+
16378
+ #endPointerGesture(event) {
16379
+ if (event.pointerId !== this.#activePointerId) return;
16380
+ const pointerId = this.#activePointerId;
16381
+ this.#activePointerId = null;
16382
+ this.#previousPointerAngle = null;
16383
+ this.#surface?.classList.remove("dragging");
16384
+ if (this.#dial?.hasPointerCapture?.(pointerId)) {
16385
+ this.#dial.releasePointerCapture(pointerId);
16386
+ }
16387
+ this.#emit("change");
16388
+ }
16389
+
16390
+ #cancelPointerGesture() {
16391
+ this.#activePointerId = null;
16392
+ this.#previousPointerAngle = null;
16393
+ this.#surface?.classList.remove("dragging");
16394
+ }
16395
+
16396
+ #handleKeyDown(event) {
16397
+ if (this.#isDisabled()) return;
16398
+ let next = this.#angle;
16399
+ const direction =
16400
+ event.key === "ArrowLeft" || event.key === "ArrowDown"
16401
+ ? -1
16402
+ : event.key === "ArrowRight" || event.key === "ArrowUp"
16403
+ ? 1
16404
+ : 0;
16405
+ if (direction) {
16406
+ const increment = event.shiftKey ? this.#fromDegrees(15) : this.#step();
16407
+ next += direction * increment;
16408
+ } else if (event.key === "Home") {
16409
+ next = this.#minimum() ?? this.#fromDegrees(0);
16410
+ } else if (event.key === "End") {
16411
+ next = this.#maximum() ?? this.#fromDegrees(360);
16412
+ } else {
16413
+ return;
16414
+ }
16415
+ event.preventDefault();
16416
+ event.stopPropagation();
16417
+ this.#setValue(next);
16418
+ this.#emit("input");
16419
+ this.#emit("change");
16420
+ }
16421
+
16422
+ #handleTextInput(event) {
16423
+ event.stopPropagation();
16424
+ if (this.#isDisabled()) return;
16425
+ const value = Number(event.currentTarget.value);
16426
+ if (!Number.isFinite(value)) return;
16427
+ this.#setValue(value);
16428
+ this.#emit(event.type);
16429
+ }
16430
+
16431
+ #normalizeRawTextUnit(event) {
16432
+ if (!event.target?.matches?.("input")) return;
16433
+ const match = String(event.target.value).match(
16434
+ /^(-?\d*\.?\d+)\s*(deg|°|rad|turn|grad)$/i,
16435
+ );
16436
+ if (!match) return;
16437
+ const value = Number(match[1]);
16438
+ const sourceUnit = this.#normalizeUnit(match[2]);
16439
+ let degrees = value;
16440
+ if (sourceUnit === "rad") degrees = (value * 180) / Math.PI;
16441
+ else if (sourceUnit === "turn") degrees = value * 360;
16442
+ else if (sourceUnit === "grad") degrees = value * 0.9;
16443
+ event.target.value = String(this.#fromDegrees(degrees));
16444
+ }
16445
+
16446
+ #emit(type) {
16447
+ const outputValue = Number(this.value.toFixed(this.#precision));
16448
+ this.dispatchEvent(
16449
+ new CustomEvent(type, {
16450
+ bubbles: true,
16451
+ cancelable: true,
16452
+ detail: {
16453
+ value: outputValue,
16454
+ angle: outputValue,
16455
+ units: this.#units,
16456
+ },
16457
+ }),
16458
+ );
16459
+ }
16460
+ }
16461
+ figDefineElement("fig-angle", FigAngle);
16462
+
15663
16463
  /**
15664
16464
  * A 3D rotation control with an interactive cube preview.
15665
16465
  * @attr {string} value - CSS transform string, e.g. "rotateX(20deg) rotateY(-35deg) rotateZ(0deg)".
@@ -17074,8 +17874,6 @@ class FigInputJoystick extends HTMLElement {
17074
17874
 
17075
17875
  figDefineElement("fig-joystick", FigInputJoystick);
17076
17876
 
17077
-
17078
- // FigInputAngle moved to fig-lab.js
17079
17877
  // FigShimmer
17080
17878
  class FigShimmer extends HTMLElement {
17081
17879
  get durationPropertyName() {