@rogieking/figui3 8.9.31 → 8.9.33

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-lab.js CHANGED
@@ -210,7 +210,7 @@ function figLabPropskitJsonValuesEqual(value, defaultValue) {
210
210
  }
211
211
  }
212
212
 
213
- function figLabPerceivedColorTheme(context, color) {
213
+ function figLabPerceivedColorTheme(context, color, darkTextThreshold = 0.179) {
214
214
  if (!context || !color) return null;
215
215
  const probe = document.createElement("span");
216
216
  probe.style.cssText =
@@ -218,8 +218,12 @@ function figLabPerceivedColorTheme(context, color) {
218
218
  context.appendChild(probe);
219
219
  const resolved = getComputedStyle(probe).color;
220
220
  probe.remove();
221
- if (!resolved.startsWith("rgb")) return null;
222
- const channels = resolved.match(/[\d.]+/g)?.slice(0, 3).map(Number);
221
+ const rawChannels = resolved.match(/[\d.]+/g)?.slice(0, 3).map(Number);
222
+ const channels = resolved.startsWith("rgb")
223
+ ? rawChannels
224
+ : resolved.startsWith("color(srgb") && rawChannels
225
+ ? rawChannels.map((channel) => channel * 255)
226
+ : null;
223
227
  if (!channels || channels.length < 3 || channels.some(Number.isNaN)) return null;
224
228
  const [r, g, b] = channels.map((channel) => {
225
229
  const value = channel / 255;
@@ -228,7 +232,7 @@ function figLabPerceivedColorTheme(context, color) {
228
232
  : ((value + 0.055) / 1.055) ** 2.4;
229
233
  });
230
234
  const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
231
- return luminance <= 0.179 ? "light" : "dark";
235
+ return luminance <= darkTextThreshold ? "light" : "dark";
232
236
  }
233
237
 
234
238
  /* Unique IDs for lab components such as propskit-group. */
@@ -578,6 +582,7 @@ class PropskitSwitch extends HTMLElement {
578
582
  "checked",
579
583
  "value",
580
584
  "default",
585
+ "variant",
581
586
  ]);
582
587
  return this.getAttributeNames().filter(
583
588
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -1011,6 +1016,7 @@ class PropskitColor extends HTMLElement {
1011
1016
  "default",
1012
1017
  "value",
1013
1018
  "mode",
1019
+ "variant",
1014
1020
  ]);
1015
1021
  return this.getAttributeNames().filter(
1016
1022
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -1418,6 +1424,7 @@ class PropskitFill extends HTMLElement {
1418
1424
  "text",
1419
1425
  "default",
1420
1426
  "value",
1427
+ "variant",
1421
1428
  ]);
1422
1429
  return this.getAttributeNames().filter(
1423
1430
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -1720,6 +1727,7 @@ class PropskitGradient extends HTMLElement {
1720
1727
  "size",
1721
1728
  "aria-label",
1722
1729
  "default",
1730
+ "variant",
1723
1731
  ]);
1724
1732
  return this.getAttributeNames().filter(
1725
1733
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -2040,6 +2048,7 @@ class PropskitSelect extends HTMLElement {
2040
2048
  "aria-label",
2041
2049
  "full",
2042
2050
  "default",
2051
+ "variant",
2043
2052
  ]);
2044
2053
  // fig-dropdown consumes light-DOM <option>s, not an options attribute.
2045
2054
  if (!this.#usesFigSelect) reserved.add("options");
@@ -2369,6 +2378,7 @@ class PropskitText extends HTMLElement {
2369
2378
  "multiline",
2370
2379
  "resizable",
2371
2380
  "default",
2381
+ "variant",
2372
2382
  ]);
2373
2383
  return this.getAttributeNames().filter(
2374
2384
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -2596,6 +2606,7 @@ class PropskitNumber extends HTMLElement {
2596
2606
  "style",
2597
2607
  "id",
2598
2608
  "default",
2609
+ "variant",
2599
2610
  ]);
2600
2611
  return this.getAttributeNames().filter(
2601
2612
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -2710,7 +2721,7 @@ figLabDefineElement("propskit-number", PropskitNumber);
2710
2721
  * @attr {string} label - Field label. Empty values use "Position".
2711
2722
  * @attr {string} units - Set to "percent" to show percentage units.
2712
2723
  * @attr {boolean|string} disabled - Disables both number inputs.
2713
- * @attr {string} size - Set to "large" for the expanded row.
2724
+ * @attr {string} size - Defaults to the large row; set to "small" for compact.
2714
2725
  * @fires input - Composed event with { x, y }.
2715
2726
  * @fires change - Composed event with { x, y }.
2716
2727
  */
@@ -4229,7 +4240,7 @@ figLabDefineElement("propskit-point-point", PropskitPointPoint);
4229
4240
 
4230
4241
  /* Collapsible property group — always collapsible (no collapsible attr). */
4231
4242
  class PropskitGroup extends HTMLElement {
4232
- static observedAttributes = ["name", "open", "show-reset", "disabled"];
4243
+ static observedAttributes = ["name", "open", "show-reset", "disabled", "size"];
4233
4244
 
4234
4245
  static #CONTROL_SELECTOR = [
4235
4246
  "propskit-color",
@@ -4245,6 +4256,7 @@ class PropskitGroup extends HTMLElement {
4245
4256
  "propskit-slider",
4246
4257
  "propskit-switch",
4247
4258
  "propskit-text",
4259
+ "propskit-wheel",
4248
4260
  "propskit-oscillator",
4249
4261
  ].join(",");
4250
4262
 
@@ -4258,6 +4270,7 @@ class PropskitGroup extends HTMLElement {
4258
4270
  connectedCallback() {
4259
4271
  this.#render();
4260
4272
  this.#syncDisabled();
4273
+ this.#syncSize();
4261
4274
  this.#bindDirtyListeners();
4262
4275
  requestAnimationFrame(() => {
4263
4276
  this.#syncDirtyState();
@@ -4291,6 +4304,10 @@ class PropskitGroup extends HTMLElement {
4291
4304
  this.#syncDisabled();
4292
4305
  return;
4293
4306
  }
4307
+ if (name === "size") {
4308
+ this.#syncSize();
4309
+ return;
4310
+ }
4294
4311
  this.#render();
4295
4312
  }
4296
4313
 
@@ -4347,6 +4364,7 @@ class PropskitGroup extends HTMLElement {
4347
4364
  if (!this.#childObserver) {
4348
4365
  this.#childObserver = new MutationObserver(() => {
4349
4366
  this.#syncDisabled();
4367
+ this.#syncSize();
4350
4368
  this.#queueDirtySync();
4351
4369
  });
4352
4370
  }
@@ -4437,6 +4455,26 @@ class PropskitGroup extends HTMLElement {
4437
4455
  ?.toggleAttribute("disabled", disabled);
4438
4456
  }
4439
4457
 
4458
+ #syncSize() {
4459
+ const small = this.getAttribute("size") === "small";
4460
+ for (const control of this.#controls()) {
4461
+ const generated = control.hasAttribute("data-propskit-group-size");
4462
+ if (small && !control.hasAttribute("size")) {
4463
+ control.setAttribute("data-propskit-group-size", "");
4464
+ control.setAttribute("size", "small");
4465
+ } else if (
4466
+ small &&
4467
+ generated &&
4468
+ control.getAttribute("size") !== "small"
4469
+ ) {
4470
+ control.setAttribute("size", "small");
4471
+ } else if (!small && generated) {
4472
+ control.removeAttribute("size");
4473
+ control.removeAttribute("data-propskit-group-size");
4474
+ }
4475
+ }
4476
+ }
4477
+
4440
4478
  #controlIsDirty(el) {
4441
4479
  return el.isDefault === false;
4442
4480
  }
@@ -4903,17 +4941,67 @@ class PropskitSlider extends HTMLElement {
4903
4941
 
4904
4942
  #syncNumberTheme(sliderType) {
4905
4943
  const numberInput = this.#slider?.querySelector("fig-input-number");
4906
- if (!numberInput) return;
4907
- if (sliderType !== "opacity" || !this.hasAttribute("color")) {
4908
- numberInput.removeAttribute("theme");
4944
+ const usesColorSurface =
4945
+ sliderType === "hue" ||
4946
+ (sliderType === "opacity" && this.hasAttribute("color"));
4947
+ if (!usesColorSurface) {
4948
+ numberInput?.removeAttribute("theme");
4949
+ this.style.removeProperty("--propskit-slider-filled-text-color");
4909
4950
  return;
4910
4951
  }
4952
+ const min = Number(this.#slider?.min);
4953
+ const max = Number(this.#slider?.max);
4954
+ const value = Number(this.#slider?.value);
4955
+ const complete =
4956
+ Number.isFinite(min) &&
4957
+ Number.isFinite(max) &&
4958
+ max > min &&
4959
+ Number.isFinite(value)
4960
+ ? Math.max(0, Math.min(1, (value - min) / (max - min)))
4961
+ : 0;
4962
+ const color = this.getAttribute("color");
4963
+ const effectiveColor =
4964
+ sliderType === "hue"
4965
+ ? `hsl(${complete * 360}deg 100% 50%)`
4966
+ : `color-mix(in srgb, ${color} ${complete * 100}%, var(--figma-color-bg-secondary))`;
4967
+ const darkSurface =
4968
+ getComputedStyle(this).colorScheme.trim().toLowerCase() === "dark";
4911
4969
  const theme = figLabPerceivedColorTheme(
4912
4970
  this.#slider,
4913
- this.getAttribute("color"),
4971
+ effectiveColor,
4972
+ sliderType === "hue" ? 0.35 : darkSurface ? 0.2 : 0.35,
4914
4973
  );
4915
- if (theme) numberInput.setAttribute("theme", theme);
4916
- else numberInput.removeAttribute("theme");
4974
+ if (theme) {
4975
+ numberInput?.setAttribute("theme", theme);
4976
+ const useDefaultTextToken =
4977
+ (theme === "light" && darkSurface) ||
4978
+ (theme === "dark" && !darkSurface);
4979
+ this.style.setProperty(
4980
+ "--propskit-slider-filled-text-color",
4981
+ useDefaultTextToken
4982
+ ? "var(--figma-color-text)"
4983
+ : "var(--figma-color-text-oninverse)",
4984
+ );
4985
+ } else {
4986
+ numberInput?.removeAttribute("theme");
4987
+ this.style.removeProperty("--propskit-slider-filled-text-color");
4988
+ }
4989
+ }
4990
+
4991
+ #syncProgressStyles() {
4992
+ if (!this.#slider) return;
4993
+ const min = Number(this.#slider.min);
4994
+ const max = Number(this.#slider.max);
4995
+ const value = Number(this.#slider.value);
4996
+ const complete =
4997
+ Number.isFinite(min) &&
4998
+ Number.isFinite(max) &&
4999
+ max > min &&
5000
+ Number.isFinite(value)
5001
+ ? Math.max(0, Math.min(1, (value - min) / (max - min)))
5002
+ : 0;
5003
+ this.style.setProperty("--propskit-slider-complete", String(complete));
5004
+ this.#syncNumberTheme((this.getAttribute("type") || "range").toLowerCase());
4917
5005
  }
4918
5006
 
4919
5007
  #pushExternalValueToSlider() {
@@ -4924,6 +5012,7 @@ class PropskitSlider extends HTMLElement {
4924
5012
  if (String(this.#slider.value) !== next) {
4925
5013
  this.#slider.value = next;
4926
5014
  }
5015
+ this.#syncProgressStyles();
4927
5016
  }
4928
5017
 
4929
5018
  #getForwardedSliderAttrNames() {
@@ -5409,6 +5498,7 @@ class PropskitSlider extends HTMLElement {
5409
5498
  this.setAttribute("value", next);
5410
5499
  }
5411
5500
  }
5501
+ this.#syncProgressStyles();
5412
5502
  this.dispatchEvent(
5413
5503
  new CustomEvent(type, {
5414
5504
  detail,
@@ -5455,6 +5545,1338 @@ class PropskitSlider extends HTMLElement {
5455
5545
  }
5456
5546
  figLabDefineElement("propskit-slider", PropskitSlider);
5457
5547
 
5548
+ /**
5549
+ * Standalone interactive numeric wheel.
5550
+ *
5551
+ * @attr {number} value - Numeric value. Unbounded unless min/max are set.
5552
+ * @attr {number} min - Inclusive lower bound. Omitted = unbounded below.
5553
+ * @attr {number} max - Inclusive upper bound. Omitted = unbounded above.
5554
+ * @attr {number} step - Increment. Defaults to 1.
5555
+ * @attr {boolean|string} elastic - Enables resisted handle movement. Defaults to true.
5556
+ * @attr {boolean|string} disabled - Disables interaction and focus.
5557
+ * @fires input - Numeric composed event during interaction.
5558
+ * @fires change - Numeric composed event on commit.
5559
+ */
5560
+ class FigInputWheel extends HTMLElement {
5561
+ static #TICK_COUNT = 33;
5562
+ static #HALF_FOV_DEG = 60;
5563
+ static #PERSPECTIVE_K = 0.55;
5564
+ static #DRAGGING_BODY_CLASS = "fig-input-wheel-dragging";
5565
+
5566
+ #surface = null;
5567
+ #track = null;
5568
+ #wheel = null;
5569
+ #svg = null;
5570
+ #tickPath = null;
5571
+ #tickMetrics = null;
5572
+ #tickMetricProbe = null;
5573
+ #wheelWidth = 0;
5574
+ #wheelHeight = 0;
5575
+ #layoutFrame = 0;
5576
+ #keyboardAnimationFrame = 0;
5577
+ #keyboardSettleTimer = 0;
5578
+ #resizeObserver = null;
5579
+ #isDragging = false;
5580
+ #dragPointerId = null;
5581
+ #dragStartX = 0;
5582
+ #dragStartValue = 0;
5583
+ #visualValue = null;
5584
+ #handleDragMaxPx = 0;
5585
+ #elasticMaxPx = 0;
5586
+ #elasticRangeRect = null;
5587
+ #elasticHostWidth = 0;
5588
+ #boundPointerDown = this.#handlePointerDown.bind(this);
5589
+ #boundPointerMove = this.#handlePointerMove.bind(this);
5590
+ #boundPointerUp = this.#handlePointerUp.bind(this);
5591
+ #boundWheel = this.#handleWheel.bind(this);
5592
+ #boundKeyDown = this.#handleKeyDown.bind(this);
5593
+
5594
+ static get observedAttributes() {
5595
+ return [
5596
+ "value",
5597
+ "disabled",
5598
+ "step",
5599
+ "min",
5600
+ "max",
5601
+ "elastic",
5602
+ ];
5603
+ }
5604
+
5605
+ connectedCallback() {
5606
+ if (!this.#surface) this.#initialize();
5607
+ this.#syncDisabled();
5608
+ this.#syncValueFromHost();
5609
+ this.#bindEvents();
5610
+ this.#resizeObserver?.disconnect();
5611
+ if (globalThis.ResizeObserver) {
5612
+ this.#resizeObserver = new ResizeObserver((entries) => {
5613
+ const rect = entries.at(-1)?.contentRect;
5614
+ if (rect) this.#setWheelSize(rect.width, rect.height);
5615
+ });
5616
+ this.#resizeObserver.observe(this);
5617
+ }
5618
+ this.#queueWheelLayout();
5619
+ }
5620
+
5621
+ disconnectedCallback() {
5622
+ this.#resizeObserver?.disconnect();
5623
+ if (this.#layoutFrame) cancelAnimationFrame(this.#layoutFrame);
5624
+ this.#layoutFrame = 0;
5625
+ this.#stopKeyboardAnimation();
5626
+ this.#unbindEvents();
5627
+ this.#stopDrag();
5628
+ }
5629
+
5630
+ attributeChangedCallback(name, oldValue, newValue) {
5631
+ if (oldValue === newValue || !this.#surface) return;
5632
+ if (name === "disabled") this.#syncDisabled();
5633
+ if (name === "value" && !this.#isDragging) {
5634
+ this.#syncValueFromHost();
5635
+ }
5636
+ if (name === "step") {
5637
+ this.#syncWheelAria();
5638
+ this.#queueWheelLayout();
5639
+ }
5640
+ if (name === "min" || name === "max") {
5641
+ this.#commitValue(this.#numericValue());
5642
+ }
5643
+ }
5644
+
5645
+ #initialize() {
5646
+ this.#surface = this;
5647
+ this.#track = this;
5648
+ this.#wheel = this;
5649
+ this.setAttribute("role", "spinbutton");
5650
+ if (
5651
+ !this.hasAttribute("aria-label") &&
5652
+ !this.hasAttribute("aria-labelledby")
5653
+ ) {
5654
+ this.setAttribute("aria-label", "Value");
5655
+ }
5656
+ this.setAttribute("tabindex", "0");
5657
+ const svg = figLabCreateSvgElement("svg", {
5658
+ className: "fig-input-wheel-svg",
5659
+ "aria-hidden": "true",
5660
+ });
5661
+ const handle = figLabCreateElement("div", {
5662
+ className: "fig-input-wheel-handle",
5663
+ });
5664
+ this.#svg = svg;
5665
+ this.replaceChildren(svg, handle);
5666
+
5667
+ const metricProbe = document.createElement("div");
5668
+ metricProbe.setAttribute("aria-hidden", "true");
5669
+ metricProbe.style.cssText =
5670
+ "position:absolute;visibility:hidden;pointer-events:none;left:0;top:0";
5671
+ const maxEl = document.createElement("div");
5672
+ maxEl.style.height = "var(--fig-input-wheel-tick-height)";
5673
+ const minEl = document.createElement("div");
5674
+ minEl.style.height = "var(--fig-input-wheel-tick-height-min)";
5675
+ const maxWidthEl = document.createElement("div");
5676
+ maxWidthEl.style.width = "var(--fig-input-wheel-tick-width)";
5677
+ const minWidthEl = document.createElement("div");
5678
+ minWidthEl.style.width = "var(--fig-input-wheel-tick-width-min)";
5679
+ metricProbe.append(maxEl, minEl, maxWidthEl, minWidthEl);
5680
+ this.append(metricProbe);
5681
+ this.#tickMetricProbe = {
5682
+ maxEl,
5683
+ minEl,
5684
+ maxWidthEl,
5685
+ minWidthEl,
5686
+ };
5687
+
5688
+ this.#ensureTickPath();
5689
+ }
5690
+
5691
+ #ensureTickPath() {
5692
+ if (!this.#svg || this.#tickPath) return;
5693
+ this.#tickPath = figLabCreateSvgElement("path", {
5694
+ className: "fig-input-wheel-tick",
5695
+ });
5696
+ this.#svg.append(this.#tickPath);
5697
+ }
5698
+
5699
+ #readTickMetrics() {
5700
+ if (this.#tickMetrics) return this.#tickMetrics;
5701
+ this.#tickMetrics = {
5702
+ maxH: this.#tickMetricProbe?.maxEl.getBoundingClientRect().height || 8,
5703
+ minH: this.#tickMetricProbe?.minEl.getBoundingClientRect().height || 4,
5704
+ maxW:
5705
+ this.#tickMetricProbe?.maxWidthEl.getBoundingClientRect().width || 2,
5706
+ minW:
5707
+ this.#tickMetricProbe?.minWidthEl.getBoundingClientRect().width || 1,
5708
+ };
5709
+ return this.#tickMetrics;
5710
+ }
5711
+
5712
+ #step() {
5713
+ if (this.hasAttribute("step")) {
5714
+ const parsed = Number(this.getAttribute("step"));
5715
+ if (parsed > 0) return parsed;
5716
+ }
5717
+ return 1;
5718
+ }
5719
+
5720
+ #numericValue() {
5721
+ const parsed = Number(this.getAttribute("value") ?? 0);
5722
+ return Number.isFinite(parsed) ? parsed : 0;
5723
+ }
5724
+
5725
+ #parseBound(name) {
5726
+ if (!this.hasAttribute(name)) return null;
5727
+ const raw = this.getAttribute(name);
5728
+ if (raw === null || raw.trim() === "") return null;
5729
+ const parsed = Number(raw);
5730
+ return Number.isFinite(parsed) ? parsed : null;
5731
+ }
5732
+
5733
+ #boundMin() {
5734
+ return this.#parseBound("min");
5735
+ }
5736
+
5737
+ #boundMax() {
5738
+ return this.#parseBound("max");
5739
+ }
5740
+
5741
+ #writeBound(name, nextValue) {
5742
+ if (nextValue === null || nextValue === undefined || nextValue === "") {
5743
+ this.removeAttribute(name);
5744
+ return;
5745
+ }
5746
+ const parsed = Number(nextValue);
5747
+ if (!Number.isFinite(parsed)) {
5748
+ this.removeAttribute(name);
5749
+ return;
5750
+ }
5751
+ this.setAttribute(name, String(parsed));
5752
+ }
5753
+
5754
+ #clamp(value) {
5755
+ let next = value;
5756
+ const min = this.#boundMin();
5757
+ const max = this.#boundMax();
5758
+ if (min !== null) next = Math.max(min, next);
5759
+ if (max !== null) next = Math.min(max, next);
5760
+ return next;
5761
+ }
5762
+
5763
+ #snap(value) {
5764
+ const step = this.#step();
5765
+ if (step <= 0) return value;
5766
+ const base = this.#boundMin() ?? 0;
5767
+ const snapped = Math.round((value - base) / step) * step + base;
5768
+ return Number(snapped.toPrecision(15));
5769
+ }
5770
+
5771
+ #commitValue(value, { snap = false, emit = null } = {}) {
5772
+ let next = snap ? this.#snap(value) : value;
5773
+ next = this.#clamp(next);
5774
+ const asString = String(next);
5775
+ if (this.getAttribute("value") !== asString) {
5776
+ this.setAttribute("value", asString);
5777
+ }
5778
+ this.#syncWheelAria(next);
5779
+ this.#queueWheelLayout();
5780
+ if (emit) {
5781
+ this.dispatchEvent(
5782
+ new CustomEvent(emit, {
5783
+ detail: next,
5784
+ bubbles: true,
5785
+ cancelable: true,
5786
+ composed: true,
5787
+ }),
5788
+ );
5789
+ }
5790
+ }
5791
+
5792
+ #syncValueFromHost() {
5793
+ const value = this.#numericValue();
5794
+ const clamped = this.#clamp(value);
5795
+ if (
5796
+ clamped !== value ||
5797
+ this.getAttribute("value") !== String(clamped)
5798
+ ) {
5799
+ this.#commitValue(clamped);
5800
+ return;
5801
+ }
5802
+ this.#syncWheelAria(value);
5803
+ this.#queueWheelLayout();
5804
+ }
5805
+
5806
+ #syncWheelAria(value = this.#numericValue()) {
5807
+ if (!this.#wheel) return;
5808
+ const min = this.#boundMin();
5809
+ const max = this.#boundMax();
5810
+ this.#wheel.setAttribute("aria-valuenow", String(value));
5811
+ this.#wheel.setAttribute("aria-valuetext", String(value));
5812
+ if (min === null) this.#wheel.removeAttribute("aria-valuemin");
5813
+ else this.#wheel.setAttribute("aria-valuemin", String(min));
5814
+ if (max === null) this.#wheel.removeAttribute("aria-valuemax");
5815
+ else this.#wheel.setAttribute("aria-valuemax", String(max));
5816
+ }
5817
+
5818
+ #syncDisabled() {
5819
+ const disabled = figLabBooleanAttribute(this, "disabled");
5820
+ if (!this.#wheel) return;
5821
+ if (disabled) {
5822
+ if (this.#isDragging) this.#stopDrag();
5823
+ this.#stopKeyboardAnimation();
5824
+ this.setAttribute("aria-disabled", "true");
5825
+ this.#wheel.setAttribute("tabindex", "-1");
5826
+ this.#wheel.setAttribute("aria-disabled", "true");
5827
+ } else {
5828
+ this.removeAttribute("aria-disabled");
5829
+ this.#wheel.setAttribute("tabindex", "0");
5830
+ this.#wheel.removeAttribute("aria-disabled");
5831
+ }
5832
+ }
5833
+
5834
+ #setWheelSize(width, height) {
5835
+ if (!this.#svg || width < 1 || height < 1) return;
5836
+ if (width === this.#wheelWidth && height === this.#wheelHeight) return;
5837
+ this.#wheelWidth = width;
5838
+ this.#wheelHeight = height;
5839
+ this.#tickMetrics = null;
5840
+ this.#svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
5841
+ this.#svg.setAttribute("width", String(width));
5842
+ this.#svg.setAttribute("height", String(height));
5843
+ this.#queueWheelLayout();
5844
+ }
5845
+
5846
+ #queueWheelLayout() {
5847
+ if (this.#layoutFrame) return;
5848
+ this.#layoutFrame = requestAnimationFrame(() => {
5849
+ if (!this.isConnected) {
5850
+ this.#layoutFrame = 0;
5851
+ return;
5852
+ }
5853
+ if (this.#wheelWidth < 1 || this.#wheelHeight < 1) {
5854
+ const rect = this.#wheel?.getBoundingClientRect();
5855
+ if (rect) this.#setWheelSize(rect.width, rect.height);
5856
+ }
5857
+ this.#layoutWheel();
5858
+ this.#layoutFrame = 0;
5859
+ });
5860
+ }
5861
+
5862
+ #layoutWheel() {
5863
+ if (!this.#tickPath) return;
5864
+ const width = this.#wheelWidth;
5865
+ const height = this.#wheelHeight;
5866
+ if (width < 1 || height < 1) return;
5867
+
5868
+ const value = this.#visualValue ?? this.#numericValue();
5869
+ const tickStep = 360 / FigInputWheel.#TICK_COUNT;
5870
+ const step = this.#step();
5871
+ const base = this.#boundMin() ?? 0;
5872
+ const offsetDeg = ((value - base) / step) * tickStep;
5873
+ const k = FigInputWheel.#PERSPECTIVE_K;
5874
+ const cx = width / 2;
5875
+ const cy = height / 2;
5876
+
5877
+ const { maxH, minH, maxW, minW } = this.#readTickMetrics();
5878
+ const fov = FigInputWheel.#HALF_FOV_DEG;
5879
+ const visibleHalf = Math.max(width / 2, 1);
5880
+ const radius = visibleHalf * 0.84;
5881
+ const commands = [];
5882
+
5883
+ for (let index = 0; index < FigInputWheel.#TICK_COUNT; index += 1) {
5884
+ let theta = offsetDeg + index * tickStep;
5885
+ theta = ((((theta + 180) % 360) + 360) % 360) - 180;
5886
+ if (theta < -fov || theta >= fov) continue;
5887
+ const rad = (theta * Math.PI) / 180;
5888
+ const x = cx + (radius * Math.sin(rad)) / (1 - k * Math.cos(rad));
5889
+ const t = Math.min(1, Math.abs(x - cx) / Math.max(visibleHalf, 1));
5890
+ const taper = Math.cos(t * Math.PI * 0.5);
5891
+ const visualH = minH + (maxH - minH) * taper;
5892
+ const visualW = minW + (maxW - minW) * taper;
5893
+ const halfH = visualH / 2;
5894
+ const halfW = visualW / 2;
5895
+ const top = cy - halfH;
5896
+ const bottom = cy + halfH;
5897
+ const left = x - halfW;
5898
+ const right = x + halfW;
5899
+ commands.push(
5900
+ `M ${x} ${top}` +
5901
+ ` Q ${right} ${top} ${right} ${top + halfW}` +
5902
+ ` V ${bottom - halfW}` +
5903
+ ` Q ${right} ${bottom} ${x} ${bottom}` +
5904
+ ` Q ${left} ${bottom} ${left} ${bottom - halfW}` +
5905
+ ` V ${top + halfW}` +
5906
+ ` Q ${left} ${top} ${x} ${top} Z`,
5907
+ );
5908
+ }
5909
+ this.#tickPath.setAttribute("d", commands.join(" "));
5910
+ }
5911
+
5912
+ #bindEvents() {
5913
+ this.#surface?.addEventListener("pointerdown", this.#boundPointerDown);
5914
+ this.#wheel?.addEventListener("wheel", this.#boundWheel, { passive: false });
5915
+ this.#wheel?.addEventListener("keydown", this.#boundKeyDown);
5916
+ }
5917
+
5918
+ #unbindEvents() {
5919
+ this.#surface?.removeEventListener("pointerdown", this.#boundPointerDown);
5920
+ this.#wheel?.removeEventListener("wheel", this.#boundWheel);
5921
+ this.#wheel?.removeEventListener("keydown", this.#boundKeyDown);
5922
+ window.removeEventListener("pointermove", this.#boundPointerMove);
5923
+ window.removeEventListener("pointerup", this.#boundPointerUp);
5924
+ window.removeEventListener("pointercancel", this.#boundPointerUp);
5925
+ }
5926
+
5927
+ #handlePointerDown(event) {
5928
+ if (figLabBooleanAttribute(this, "disabled")) return;
5929
+ if (event.button !== 0) return;
5930
+ event.preventDefault();
5931
+ if (!this.beginScrub(event)) return;
5932
+ this.#surface?.setPointerCapture?.(event.pointerId);
5933
+ window.addEventListener("pointermove", this.#boundPointerMove);
5934
+ window.addEventListener("pointerup", this.#boundPointerUp);
5935
+ window.addEventListener("pointercancel", this.#boundPointerUp);
5936
+ }
5937
+
5938
+ beginScrub(start = {}) {
5939
+ if (figLabBooleanAttribute(this, "disabled")) return false;
5940
+ const clientX =
5941
+ typeof start === "number" ? start : Number(start?.clientX ?? start?.x);
5942
+ if (!Number.isFinite(clientX)) return false;
5943
+ this.#stopKeyboardAnimation();
5944
+ this.#isDragging = true;
5945
+ this.#dragPointerId =
5946
+ start && typeof start === "object" && start.pointerId !== undefined
5947
+ ? start.pointerId
5948
+ : null;
5949
+ this.#dragStartX = clientX;
5950
+ const requestedStart =
5951
+ start && typeof start === "object"
5952
+ ? Number(start.startValue)
5953
+ : Number.NaN;
5954
+ this.#dragStartValue = Number.isFinite(requestedStart)
5955
+ ? this.#clamp(requestedStart)
5956
+ : this.#numericValue();
5957
+ this.#startElasticPull();
5958
+ this.setAttribute("data-fig-input-wheel-active", "");
5959
+ document.body.classList.add(FigInputWheel.#DRAGGING_BODY_CLASS);
5960
+ this.focus();
5961
+ return true;
5962
+ }
5963
+
5964
+ updateScrub(position, speed) {
5965
+ if (!this.#isDragging) return this.#numericValue();
5966
+ const clientX =
5967
+ typeof position === "number"
5968
+ ? position
5969
+ : Number(position?.clientX ?? position?.x);
5970
+ if (!Number.isFinite(clientX)) return this.#numericValue();
5971
+ const multiplier =
5972
+ speed ??
5973
+ (typeof position === "object" && position?.shiftKey ? 10 : 1);
5974
+ this.#updateDrag(clientX, multiplier);
5975
+ return this.#numericValue();
5976
+ }
5977
+
5978
+ endScrub(commit = true) {
5979
+ if (!this.#isDragging) return this.#numericValue();
5980
+ if (commit && this.#numericValue() !== this.#dragStartValue) {
5981
+ this.#commitValue(this.#numericValue(), { snap: true, emit: "change" });
5982
+ }
5983
+ this.#stopDrag();
5984
+ return this.#numericValue();
5985
+ }
5986
+
5987
+ #handlePointerMove(event) {
5988
+ if (!this.#isDragging) return;
5989
+ if (this.#dragPointerId !== null && event.pointerId !== this.#dragPointerId) {
5990
+ return;
5991
+ }
5992
+ this.updateScrub(event);
5993
+ }
5994
+
5995
+ #updateDrag(clientX, speed = 1) {
5996
+ const width = this.#wheel?.clientWidth || 1;
5997
+ const visibleSteps =
5998
+ FigInputWheel.#TICK_COUNT *
5999
+ ((FigInputWheel.#HALF_FOV_DEG * 2) / 360);
6000
+ const visibleUnits = this.#step() * visibleSteps;
6001
+ const delta =
6002
+ (clientX - this.#dragStartX) * (visibleUnits / width) * speed;
6003
+ const rawValue = this.#clamp(this.#dragStartValue + delta);
6004
+ this.#visualValue = rawValue;
6005
+ this.#commitValue(rawValue, {
6006
+ snap: true,
6007
+ emit: "input",
6008
+ });
6009
+ this.#updateElasticPull(clientX);
6010
+ }
6011
+
6012
+ #handlePointerUp(event) {
6013
+ if (!this.#isDragging) return;
6014
+ if (this.#dragPointerId !== null && event.pointerId !== this.#dragPointerId) {
6015
+ return;
6016
+ }
6017
+ this.endScrub();
6018
+ }
6019
+
6020
+ #stopDrag() {
6021
+ this.#isDragging = false;
6022
+ this.#dragPointerId = null;
6023
+ this.#visualValue = null;
6024
+ this.removeAttribute("data-fig-input-wheel-active");
6025
+ document.body.classList.remove(FigInputWheel.#DRAGGING_BODY_CLASS);
6026
+ this.#resetElasticPull();
6027
+ window.removeEventListener("pointermove", this.#boundPointerMove);
6028
+ window.removeEventListener("pointerup", this.#boundPointerUp);
6029
+ window.removeEventListener("pointercancel", this.#boundPointerUp);
6030
+ this.#queueWheelLayout();
6031
+ }
6032
+
6033
+ #startElasticPull() {
6034
+ this.#resetElasticPull();
6035
+ if (this.getAttribute("elastic") === "false") return;
6036
+ this.#handleDragMaxPx = this.#readCssLength(
6037
+ "--fig-input-wheel-handle-drag-max",
6038
+ );
6039
+ this.#elasticMaxPx = this.#readCssLength(
6040
+ "--fig-input-wheel-elastic-distance",
6041
+ );
6042
+ const rect = this.#wheel?.getBoundingClientRect();
6043
+ if (rect) {
6044
+ this.#elasticRangeRect = {
6045
+ left: rect.left,
6046
+ right: rect.right,
6047
+ };
6048
+ }
6049
+ this.#elasticHostWidth = this.getBoundingClientRect().width;
6050
+ }
6051
+
6052
+ #readCssLength(propertyName) {
6053
+ let raw = getComputedStyle(this)
6054
+ .getPropertyValue(propertyName)
6055
+ .trim();
6056
+ if (raw.includes("var(") || !raw.endsWith("px")) {
6057
+ const probe = document.createElement("div");
6058
+ Object.assign(probe.style, {
6059
+ position: "absolute",
6060
+ visibility: "hidden",
6061
+ pointerEvents: "none",
6062
+ width: `var(${propertyName})`,
6063
+ });
6064
+ this.appendChild(probe);
6065
+ raw = getComputedStyle(probe).width;
6066
+ probe.remove();
6067
+ }
6068
+ const value = Number.parseFloat(raw);
6069
+ return Number.isFinite(value) ? Math.max(0, value) : 0;
6070
+ }
6071
+
6072
+ #updateElasticPull(pointerX) {
6073
+ if (!this.#handleDragMaxPx && !this.#elasticMaxPx) {
6074
+ this.#clearElasticPull();
6075
+ return;
6076
+ }
6077
+ const dragDelta = pointerX - this.#dragStartX;
6078
+ if (!dragDelta) {
6079
+ this.#clearElasticPull();
6080
+ return;
6081
+ }
6082
+ const offset =
6083
+ this.#handleDragMaxPx *
6084
+ Math.tanh(
6085
+ dragDelta / Math.max(1, this.#handleDragMaxPx * 3),
6086
+ );
6087
+ this.setAttribute("data-fig-input-wheel-elastic-dragging", "");
6088
+ this.style.setProperty(
6089
+ "--fig-input-wheel-handle-drag-offset",
6090
+ `${offset}px`,
6091
+ );
6092
+
6093
+ const rect = this.#elasticRangeRect;
6094
+ const overshoot = rect
6095
+ ? pointerX < rect.left
6096
+ ? pointerX - rect.left
6097
+ : pointerX > rect.right
6098
+ ? pointerX - rect.right
6099
+ : 0
6100
+ : 0;
6101
+ if (!overshoot || !this.#elasticMaxPx) {
6102
+ this.style.removeProperty("--fig-input-wheel-elastic-scale");
6103
+ this.style.removeProperty("--fig-input-wheel-elastic-origin");
6104
+ return;
6105
+ }
6106
+ const stretch = Math.min(this.#elasticMaxPx, Math.abs(overshoot) * 0.5);
6107
+ const scale = this.#elasticHostWidth
6108
+ ? (this.#elasticHostWidth + stretch) / this.#elasticHostWidth
6109
+ : 1;
6110
+ this.style.setProperty("--fig-input-wheel-elastic-scale", `${scale}`);
6111
+ this.style.setProperty(
6112
+ "--fig-input-wheel-elastic-origin",
6113
+ overshoot < 0 ? "right center" : "left center",
6114
+ );
6115
+ }
6116
+
6117
+ #resetElasticPull() {
6118
+ this.#clearElasticPull();
6119
+ this.#handleDragMaxPx = 0;
6120
+ this.#elasticMaxPx = 0;
6121
+ this.#elasticRangeRect = null;
6122
+ this.#elasticHostWidth = 0;
6123
+ }
6124
+
6125
+ #clearElasticPull() {
6126
+ this.removeAttribute("data-fig-input-wheel-elastic-dragging");
6127
+ this.style.removeProperty("--fig-input-wheel-handle-drag-offset");
6128
+ this.style.removeProperty("--fig-input-wheel-elastic-scale");
6129
+ this.style.removeProperty("--fig-input-wheel-elastic-origin");
6130
+ }
6131
+
6132
+ #handleWheel(event) {
6133
+ if (figLabBooleanAttribute(this, "disabled")) return;
6134
+ event.preventDefault();
6135
+ const direction = event.deltaY === 0 ? Math.sign(event.deltaX) : Math.sign(event.deltaY);
6136
+ if (!direction) return;
6137
+ const multiplier = event.shiftKey ? 10 : 1;
6138
+ this.#commitValue(this.#numericValue() + this.#step() * multiplier * direction, {
6139
+ snap: true,
6140
+ emit: "input",
6141
+ });
6142
+ this.#commitValue(this.#numericValue(), { emit: "change" });
6143
+ }
6144
+
6145
+ #handleKeyDown(event) {
6146
+ if (figLabBooleanAttribute(this, "disabled")) return;
6147
+ if (
6148
+ event.target instanceof Element &&
6149
+ event.target.closest("fig-input-number")
6150
+ ) {
6151
+ return;
6152
+ }
6153
+ if (event.key === "Home") {
6154
+ const min = this.#boundMin();
6155
+ if (min === null) return;
6156
+ event.preventDefault();
6157
+ this.#commitValue(min, { snap: true, emit: "input" });
6158
+ this.#commitValue(this.#numericValue(), { emit: "change" });
6159
+ return;
6160
+ }
6161
+ if (event.key === "End") {
6162
+ const max = this.#boundMax();
6163
+ if (max === null) return;
6164
+ event.preventDefault();
6165
+ this.#commitValue(max, { snap: true, emit: "input" });
6166
+ this.#commitValue(this.#numericValue(), { emit: "change" });
6167
+ return;
6168
+ }
6169
+ const keys = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"];
6170
+ if (!keys.includes(event.key)) return;
6171
+ event.preventDefault();
6172
+ const direction =
6173
+ event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : -1;
6174
+ const multiplier = event.shiftKey ? 10 : 1;
6175
+ const previousValue = this.#numericValue();
6176
+ this.#commitValue(previousValue + this.#step() * multiplier * direction, {
6177
+ snap: true,
6178
+ emit: "input",
6179
+ });
6180
+ this.#animateKeyboardMovement(
6181
+ previousValue,
6182
+ this.#numericValue(),
6183
+ direction,
6184
+ multiplier,
6185
+ );
6186
+ this.#commitValue(this.#numericValue(), { emit: "change" });
6187
+ }
6188
+
6189
+ #animateKeyboardMovement(from, to, direction, multiplier) {
6190
+ if (
6191
+ from === to ||
6192
+ globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches
6193
+ ) {
6194
+ this.#stopKeyboardAnimation();
6195
+ return;
6196
+ }
6197
+
6198
+ if (this.#keyboardAnimationFrame) {
6199
+ cancelAnimationFrame(this.#keyboardAnimationFrame);
6200
+ this.#keyboardAnimationFrame = 0;
6201
+ }
6202
+ clearTimeout(this.#keyboardSettleTimer);
6203
+ this.#keyboardSettleTimer = 0;
6204
+ this.removeAttribute("data-fig-input-wheel-keyboard-settling");
6205
+ const visualFrom = this.#visualValue ?? from;
6206
+ const handleFrom =
6207
+ Number.parseFloat(
6208
+ this.style.getPropertyValue("--fig-input-wheel-handle-drag-offset"),
6209
+ ) || 0;
6210
+ const duration = multiplier > 1 ? 120 : 90;
6211
+ const startedAt = performance.now();
6212
+ const animateHandle = this.getAttribute("elastic") !== "false";
6213
+ this.setAttribute("data-fig-input-wheel-keyboard-moving", "");
6214
+
6215
+ const frame = (now) => {
6216
+ const progress = Math.min(1, (now - startedAt) / duration);
6217
+ this.#visualValue = visualFrom + (to - visualFrom) * progress;
6218
+ if (animateHandle) {
6219
+ const eased = Math.sin((progress * Math.PI) / 2);
6220
+ const offset = handleFrom + (direction * 4 - handleFrom) * eased;
6221
+ this.style.setProperty(
6222
+ "--fig-input-wheel-handle-drag-offset",
6223
+ `${offset}px`,
6224
+ );
6225
+ }
6226
+ this.#layoutWheel();
6227
+
6228
+ if (progress < 1) {
6229
+ this.#keyboardAnimationFrame = requestAnimationFrame(frame);
6230
+ return;
6231
+ }
6232
+
6233
+ this.#keyboardAnimationFrame = 0;
6234
+ this.#visualValue = null;
6235
+ this.removeAttribute("data-fig-input-wheel-keyboard-moving");
6236
+ this.setAttribute("data-fig-input-wheel-keyboard-settling", "");
6237
+ this.style.removeProperty("--fig-input-wheel-handle-drag-offset");
6238
+ this.#keyboardSettleTimer = window.setTimeout(() => {
6239
+ this.removeAttribute("data-fig-input-wheel-keyboard-settling");
6240
+ this.#keyboardSettleTimer = 0;
6241
+ }, 140);
6242
+ this.#queueWheelLayout();
6243
+ };
6244
+
6245
+ this.#keyboardAnimationFrame = requestAnimationFrame(frame);
6246
+ }
6247
+
6248
+ #stopKeyboardAnimation() {
6249
+ if (this.#keyboardAnimationFrame) {
6250
+ cancelAnimationFrame(this.#keyboardAnimationFrame);
6251
+ this.#keyboardAnimationFrame = 0;
6252
+ }
6253
+ clearTimeout(this.#keyboardSettleTimer);
6254
+ this.#keyboardSettleTimer = 0;
6255
+ this.#visualValue = null;
6256
+ this.removeAttribute("data-fig-input-wheel-keyboard-moving");
6257
+ this.removeAttribute("data-fig-input-wheel-keyboard-settling");
6258
+ this.style.removeProperty("--fig-input-wheel-handle-drag-offset");
6259
+ if (this.isConnected) this.#queueWheelLayout();
6260
+ }
6261
+
6262
+ get value() {
6263
+ return this.getAttribute("value") ?? "0";
6264
+ }
6265
+
6266
+ set value(nextValue) {
6267
+ if (nextValue === null || nextValue === undefined || nextValue === "") {
6268
+ this.#commitValue(0);
6269
+ return;
6270
+ }
6271
+ const parsed = Number(nextValue);
6272
+ this.#commitValue(Number.isFinite(parsed) ? parsed : 0);
6273
+ }
6274
+
6275
+ get min() {
6276
+ return this.#boundMin();
6277
+ }
6278
+
6279
+ set min(nextValue) {
6280
+ this.#writeBound("min", nextValue);
6281
+ }
6282
+
6283
+ get max() {
6284
+ return this.#boundMax();
6285
+ }
6286
+
6287
+ set max(nextValue) {
6288
+ this.#writeBound("max", nextValue);
6289
+ }
6290
+
6291
+ get step() {
6292
+ return this.#step();
6293
+ }
6294
+
6295
+ focus(options) {
6296
+ HTMLElement.prototype.focus.call(this, options);
6297
+ }
6298
+ }
6299
+ figLabDefineElement("fig-input-wheel", FigInputWheel);
6300
+
6301
+ /**
6302
+ * Labeled numeric wheel with an optional editable number field.
6303
+ *
6304
+ * @attr {string} label - Field label. Defaults to "Value".
6305
+ * @attr {string} default - Reset target.
6306
+ * @attr {number} precision - Number field display decimals.
6307
+ * @attr {boolean|string} text - Shows the number field. Defaults to true.
6308
+ * @attr {string} size - Set to "small" for compact sizing.
6309
+ * @attr {string} variant - Set to "minimal" for compact chrome.
6310
+ * @fires input - Retargeted numeric composed event.
6311
+ * @fires change - Retargeted numeric composed event.
6312
+ */
6313
+ class PropskitWheel extends HTMLElement {
6314
+ static #RESERVED_ATTRS = new Set([
6315
+ "label",
6316
+ "size",
6317
+ "disabled",
6318
+ "variant",
6319
+ "elastic",
6320
+ "text",
6321
+ "default",
6322
+ "class",
6323
+ "style",
6324
+ "id",
6325
+ "oninput",
6326
+ "onchange",
6327
+ ]);
6328
+
6329
+ static get observedAttributes() {
6330
+ return [
6331
+ "label",
6332
+ "units",
6333
+ "value",
6334
+ "disabled",
6335
+ "step",
6336
+ "precision",
6337
+ "min",
6338
+ "max",
6339
+ "elastic",
6340
+ "text",
6341
+ ];
6342
+ }
6343
+
6344
+ #surface = null;
6345
+ #wheel = null;
6346
+ #label = null;
6347
+ #input = null;
6348
+ #hasCustomLabel = false;
6349
+ #observer = null;
6350
+ #elasticObserver = null;
6351
+ #managedInputAttrs = new Set();
6352
+ #initialValue = null;
6353
+ #numberPointerId = null;
6354
+ #numberPointerStartX = 0;
6355
+ #numberPointerStartY = 0;
6356
+ #numberPointerStartValue = 0;
6357
+ #isNumberScrubbing = false;
6358
+ #suppressNumberClick = false;
6359
+ #numberClickResetTimer = 0;
6360
+ #boundPrimitiveInput = this.#handlePrimitiveEvent.bind(this, "input");
6361
+ #boundPrimitiveChange = this.#handlePrimitiveEvent.bind(this, "change");
6362
+ #boundNumberInput = this.#handleNumberEvent.bind(this, "input");
6363
+ #boundNumberChange = this.#handleNumberEvent.bind(this, "change");
6364
+ #boundNumberPointerDown = this.#handleNumberPointerDown.bind(this);
6365
+ #boundNumberPointerMove = this.#handleNumberPointerMove.bind(this);
6366
+ #boundNumberPointerEnd = this.#handleNumberPointerEnd.bind(this);
6367
+ #boundClick = this.#handleClick.bind(this);
6368
+
6369
+ connectedCallback() {
6370
+ if (!this.#surface) this.#initialize();
6371
+ this.#syncLabel();
6372
+ this.#syncText();
6373
+ this.#syncPrimitive();
6374
+ this.#syncInputAttributes();
6375
+ this.#bindEvents();
6376
+ figLabConnectPropskitResetMenu(this);
6377
+ this.#observer?.disconnect();
6378
+ this.#observer = new MutationObserver((mutations) => {
6379
+ if (
6380
+ mutations.some(
6381
+ ({ type, attributeName }) =>
6382
+ type === "attributes" &&
6383
+ attributeName &&
6384
+ !PropskitWheel.#RESERVED_ATTRS.has(attributeName) &&
6385
+ !PropskitWheel.observedAttributes.includes(attributeName) &&
6386
+ !attributeName.startsWith("data-"),
6387
+ )
6388
+ ) {
6389
+ this.#syncInputAttributes();
6390
+ }
6391
+ });
6392
+ this.#observer.observe(this, { attributes: true });
6393
+ this.#elasticObserver?.disconnect();
6394
+ this.#elasticObserver = new MutationObserver(() => {
6395
+ this.#syncElasticComposition();
6396
+ });
6397
+ if (this.#wheel) {
6398
+ this.#elasticObserver.observe(this.#wheel, {
6399
+ attributes: true,
6400
+ attributeFilter: ["style", "data-fig-input-wheel-elastic-dragging"],
6401
+ });
6402
+ }
6403
+ this.#syncElasticComposition();
6404
+ }
6405
+
6406
+ disconnectedCallback() {
6407
+ this.#observer?.disconnect();
6408
+ this.#elasticObserver?.disconnect();
6409
+ this.#elasticObserver = null;
6410
+ this.#clearElasticComposition();
6411
+ this.#unbindEvents();
6412
+ this.#stopNumberTracking();
6413
+ clearTimeout(this.#numberClickResetTimer);
6414
+ this.#numberClickResetTimer = 0;
6415
+ this.#wheel?.endScrub(false);
6416
+ figLabDisconnectPropskitResetMenu(this);
6417
+ }
6418
+
6419
+ attributeChangedCallback(name, oldValue, newValue) {
6420
+ if (oldValue === newValue || !this.#surface) return;
6421
+ if (name === "label") this.#syncLabel();
6422
+ if (name === "text") this.#syncText();
6423
+ if (
6424
+ ["units", "value", "disabled", "step", "min", "max", "elastic"].includes(
6425
+ name,
6426
+ )
6427
+ ) {
6428
+ this.#syncPrimitive();
6429
+ }
6430
+ if (
6431
+ ["units", "value", "disabled", "step", "precision", "min", "max"].includes(
6432
+ name,
6433
+ )
6434
+ ) {
6435
+ this.#syncInputAttributes();
6436
+ }
6437
+ }
6438
+
6439
+ #initialize() {
6440
+ this.#initialValue = this.getAttribute("value") ?? "0";
6441
+ const initialChildren = Array.from(this.childNodes).filter(
6442
+ (node) =>
6443
+ node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
6444
+ );
6445
+ const customLabel = initialChildren.find(
6446
+ (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
6447
+ );
6448
+ const surface = figLabCreateElement("div", {
6449
+ className: "propskit-wheel-surface",
6450
+ });
6451
+ const label = customLabel || document.createElement("label");
6452
+ const wheel = document.createElement("fig-input-wheel");
6453
+ const input = document.createElement("fig-input-number");
6454
+ const labelId = figLabUniqueId("propskit-wheel-label");
6455
+ label.id = labelId;
6456
+ wheel.setAttribute("aria-labelledby", labelId);
6457
+ input.setAttribute("aria-labelledby", labelId);
6458
+ surface.append(label, wheel, input);
6459
+ this.#surface = surface;
6460
+ this.#wheel = wheel;
6461
+ this.#label = label;
6462
+ this.#input = input;
6463
+ this.#hasCustomLabel = Boolean(customLabel);
6464
+ this.replaceChildren(surface);
6465
+ for (const node of initialChildren) {
6466
+ if (node !== customLabel) input.appendChild(node);
6467
+ }
6468
+ }
6469
+
6470
+ #syncLabel() {
6471
+ if (!this.#label) return;
6472
+ if (!this.#hasCustomLabel) {
6473
+ this.#label.textContent = this.hasAttribute("label")
6474
+ ? (this.getAttribute("label") ?? "")
6475
+ : "Value";
6476
+ }
6477
+ this.toggleAttribute(
6478
+ "data-label-empty",
6479
+ !(this.#label.textContent ?? "").trim(),
6480
+ );
6481
+ }
6482
+
6483
+ #syncText() {
6484
+ if (!this.#surface || !this.#input) return;
6485
+ const enabled = this.getAttribute("text") !== "false";
6486
+ const isInserted = this.#input.parentElement === this.#surface;
6487
+ if (enabled && !isInserted) this.#surface.append(this.#input);
6488
+ else if (!enabled && isInserted) this.#input.remove();
6489
+ }
6490
+
6491
+ #mirrorAttribute(name) {
6492
+ if (!this.#wheel) return;
6493
+ if (this.hasAttribute(name)) {
6494
+ this.#wheel.setAttribute(name, this.getAttribute(name) ?? "");
6495
+ } else {
6496
+ this.#wheel.removeAttribute(name);
6497
+ }
6498
+ }
6499
+
6500
+ #syncPrimitive() {
6501
+ if (!this.#wheel) return;
6502
+ this.#wheel.removeAttribute("units");
6503
+ for (const name of ["min", "max", "elastic"]) {
6504
+ this.#mirrorAttribute(name);
6505
+ }
6506
+ if (this.hasAttribute("step")) this.#mirrorAttribute("step");
6507
+ else this.#wheel.setAttribute("step", String(this.#defaultStep()));
6508
+ this.#wheel.toggleAttribute(
6509
+ "disabled",
6510
+ figLabBooleanAttribute(this, "disabled"),
6511
+ );
6512
+ this.#wheel.value = this.getAttribute("value") ?? "0";
6513
+ const normalized = this.#wheel.value;
6514
+ if (this.getAttribute("value") !== normalized) {
6515
+ this.setAttribute("value", normalized);
6516
+ }
6517
+ if (this.#input?.getAttribute("value") !== normalized) {
6518
+ this.#input?.setAttribute("value", normalized);
6519
+ }
6520
+ this.#syncPrimitiveAria();
6521
+ }
6522
+
6523
+ #syncElasticComposition() {
6524
+ if (!this.#wheel) return;
6525
+ const active = this.#wheel.hasAttribute(
6526
+ "data-fig-input-wheel-elastic-dragging",
6527
+ );
6528
+ if (!active) {
6529
+ this.#clearElasticComposition();
6530
+ return;
6531
+ }
6532
+ this.toggleAttribute("data-propskit-wheel-elastic-dragging", true);
6533
+ this.style.setProperty(
6534
+ "--propskit-wheel-elastic-scale",
6535
+ this.#wheel.style.getPropertyValue("--fig-input-wheel-elastic-scale") ||
6536
+ "1",
6537
+ );
6538
+ this.style.setProperty(
6539
+ "--propskit-wheel-elastic-origin",
6540
+ this.#wheel.style.getPropertyValue("--fig-input-wheel-elastic-origin") ||
6541
+ "left center",
6542
+ );
6543
+ }
6544
+
6545
+ #clearElasticComposition() {
6546
+ this.removeAttribute("data-propskit-wheel-elastic-dragging");
6547
+ this.style.removeProperty("--propskit-wheel-elastic-scale");
6548
+ this.style.removeProperty("--propskit-wheel-elastic-origin");
6549
+ }
6550
+
6551
+ #getForwardedInputAttrNames() {
6552
+ return this.getAttributeNames().filter(
6553
+ (name) =>
6554
+ !PropskitWheel.#RESERVED_ATTRS.has(name) &&
6555
+ !PropskitWheel.observedAttributes.includes(name) &&
6556
+ !name.startsWith("data-"),
6557
+ );
6558
+ }
6559
+
6560
+ #units() {
6561
+ const raw = (this.getAttribute("units") || "").trim();
6562
+ const normalized = raw.toLowerCase();
6563
+ if (
6564
+ normalized === "ms" ||
6565
+ normalized === "millisecond" ||
6566
+ normalized === "milliseconds"
6567
+ ) {
6568
+ return "ms";
6569
+ }
6570
+ if (
6571
+ normalized === "s" ||
6572
+ normalized === "second" ||
6573
+ normalized === "seconds"
6574
+ ) {
6575
+ return "s";
6576
+ }
6577
+ return raw;
6578
+ }
6579
+
6580
+ #defaultStep() {
6581
+ const units = this.#units();
6582
+ if (units === "s") return 0.1;
6583
+ if (units === "ms") return 100;
6584
+ return 1;
6585
+ }
6586
+
6587
+ #defaultPrecision() {
6588
+ return this.#units() === "s" ? 2 : 0;
6589
+ }
6590
+
6591
+ #syncPrimitiveAria() {
6592
+ if (!this.#wheel) return;
6593
+ const value = Number(this.#wheel.value);
6594
+ const units = this.#units();
6595
+ this.#wheel.setAttribute(
6596
+ "aria-valuetext",
6597
+ units
6598
+ ? `${value} ${
6599
+ units === "s" ? "seconds" : units === "ms" ? "milliseconds" : units
6600
+ }`
6601
+ : String(value),
6602
+ );
6603
+ }
6604
+
6605
+ #syncInputAttributes() {
6606
+ if (!this.#input || !this.#wheel) return;
6607
+ const forwarded = this.#getForwardedInputAttrNames();
6608
+ const nextManaged = new Set(forwarded);
6609
+ for (const name of this.#managedInputAttrs) {
6610
+ if (!nextManaged.has(name)) this.#input.removeAttribute(name);
6611
+ }
6612
+ for (const name of forwarded) {
6613
+ this.#input.setAttribute(name, this.getAttribute(name) ?? "");
6614
+ }
6615
+ const units = this.#units();
6616
+ if (units) this.#input.setAttribute("units", units);
6617
+ else this.#input.removeAttribute("units");
6618
+ this.#input.setAttribute("step", String(this.#wheel.step));
6619
+ if (this.hasAttribute("precision")) {
6620
+ this.#input.setAttribute(
6621
+ "precision",
6622
+ this.getAttribute("precision") ?? "",
6623
+ );
6624
+ } else {
6625
+ this.#input.setAttribute("precision", String(this.#defaultPrecision()));
6626
+ }
6627
+ for (const name of ["min", "max"]) {
6628
+ const value = this.#wheel[name];
6629
+ if (value === null) this.#input.removeAttribute(name);
6630
+ else this.#input.setAttribute(name, String(value));
6631
+ }
6632
+ this.#input.toggleAttribute(
6633
+ "disabled",
6634
+ figLabBooleanAttribute(this, "disabled"),
6635
+ );
6636
+ this.#input.setAttribute("value", this.#wheel.value);
6637
+ this.#managedInputAttrs = nextManaged;
6638
+ }
6639
+
6640
+ #bindEvents() {
6641
+ this.#unbindEvents();
6642
+ this.#wheel?.addEventListener("input", this.#boundPrimitiveInput);
6643
+ this.#wheel?.addEventListener("change", this.#boundPrimitiveChange);
6644
+ this.#input?.addEventListener("input", this.#boundNumberInput);
6645
+ this.#input?.addEventListener("change", this.#boundNumberChange);
6646
+ this.addEventListener("pointerdown", this.#boundNumberPointerDown, {
6647
+ capture: true,
6648
+ });
6649
+ this.addEventListener("click", this.#boundClick, true);
6650
+ }
6651
+
6652
+ #unbindEvents() {
6653
+ this.#wheel?.removeEventListener("input", this.#boundPrimitiveInput);
6654
+ this.#wheel?.removeEventListener("change", this.#boundPrimitiveChange);
6655
+ this.#input?.removeEventListener("input", this.#boundNumberInput);
6656
+ this.#input?.removeEventListener("change", this.#boundNumberChange);
6657
+ this.removeEventListener("pointerdown", this.#boundNumberPointerDown, {
6658
+ capture: true,
6659
+ });
6660
+ this.removeEventListener("click", this.#boundClick, true);
6661
+ }
6662
+
6663
+ #setSynchronizedValue(value) {
6664
+ const parsed = Number(value);
6665
+ if (!this.#wheel) {
6666
+ const normalized = Number.isFinite(parsed) ? parsed : 0;
6667
+ this.setAttribute("value", String(normalized));
6668
+ return normalized;
6669
+ }
6670
+ this.#wheel.value = Number.isFinite(parsed) ? parsed : 0;
6671
+ const normalized = this.#wheel.value;
6672
+ if (this.getAttribute("value") !== normalized) {
6673
+ this.setAttribute("value", normalized);
6674
+ }
6675
+ if (this.#input?.getAttribute("value") !== normalized) {
6676
+ this.#input?.setAttribute("value", normalized);
6677
+ }
6678
+ this.#syncPrimitiveAria();
6679
+ return Number(normalized);
6680
+ }
6681
+
6682
+ #emit(type, value) {
6683
+ this.dispatchEvent(
6684
+ new CustomEvent(type, {
6685
+ detail: value,
6686
+ bubbles: true,
6687
+ cancelable: true,
6688
+ composed: true,
6689
+ }),
6690
+ );
6691
+ }
6692
+
6693
+ #handlePrimitiveEvent(type, event) {
6694
+ event.stopImmediatePropagation();
6695
+ const value = this.#setSynchronizedValue(event.detail);
6696
+ this.#emit(type, value);
6697
+ }
6698
+
6699
+ #handleNumberEvent(type, event) {
6700
+ event.stopImmediatePropagation();
6701
+ if (figLabBooleanAttribute(this, "disabled")) return;
6702
+ const raw =
6703
+ event instanceof CustomEvent && event.detail !== undefined
6704
+ ? event.detail
6705
+ : this.#input?.value;
6706
+ const value = this.#setSynchronizedValue(raw);
6707
+ this.#emit(type, value);
6708
+ }
6709
+
6710
+ #handleNumberPointerDown(event) {
6711
+ if (
6712
+ event.button !== 0 ||
6713
+ event.altKey ||
6714
+ figLabBooleanAttribute(this, "disabled") ||
6715
+ !(event.target instanceof Element)
6716
+ ) {
6717
+ return;
6718
+ }
6719
+ const input = event.target.closest("fig-input-number input");
6720
+ if (!input || input === document.activeElement) return;
6721
+ event.preventDefault();
6722
+ event.stopImmediatePropagation();
6723
+ this.#stopNumberTracking();
6724
+ this.#numberPointerId = event.pointerId;
6725
+ this.#numberPointerStartX = event.clientX;
6726
+ this.#numberPointerStartY = event.clientY;
6727
+ this.#numberPointerStartValue = Number(this.#wheel?.value ?? 0);
6728
+ window.addEventListener("pointermove", this.#boundNumberPointerMove);
6729
+ window.addEventListener("pointerup", this.#boundNumberPointerEnd, {
6730
+ once: true,
6731
+ });
6732
+ window.addEventListener("pointercancel", this.#boundNumberPointerEnd, {
6733
+ once: true,
6734
+ });
6735
+ window.addEventListener("blur", this.#boundNumberPointerEnd, {
6736
+ once: true,
6737
+ });
6738
+ }
6739
+
6740
+ #handleNumberPointerMove(event) {
6741
+ if (event.pointerId !== this.#numberPointerId) return;
6742
+ if (event.buttons === 0) {
6743
+ this.#handleNumberPointerEnd(event);
6744
+ return;
6745
+ }
6746
+ if (!this.#isNumberScrubbing) {
6747
+ const distance = Math.hypot(
6748
+ event.clientX - this.#numberPointerStartX,
6749
+ event.clientY - this.#numberPointerStartY,
6750
+ );
6751
+ if (distance < 4) return;
6752
+ this.#isNumberScrubbing = true;
6753
+ this.setAttribute("data-number-scrubbing", "");
6754
+ this.#wheel?.beginScrub({
6755
+ clientX: this.#numberPointerStartX,
6756
+ pointerId: event.pointerId,
6757
+ startValue: this.#numberPointerStartValue,
6758
+ });
6759
+ }
6760
+ this.#wheel?.updateScrub(event);
6761
+ }
6762
+
6763
+ #handleNumberPointerEnd(event) {
6764
+ if (
6765
+ event?.pointerId !== undefined &&
6766
+ this.#numberPointerId !== null &&
6767
+ event.pointerId !== this.#numberPointerId
6768
+ ) {
6769
+ return;
6770
+ }
6771
+ const wasScrubbing = this.#isNumberScrubbing;
6772
+ this.#stopNumberTracking();
6773
+ if (wasScrubbing) {
6774
+ this.#wheel?.endScrub();
6775
+ this.#suppressNumberClick = true;
6776
+ clearTimeout(this.#numberClickResetTimer);
6777
+ this.#numberClickResetTimer = window.setTimeout(() => {
6778
+ this.#suppressNumberClick = false;
6779
+ this.#numberClickResetTimer = 0;
6780
+ }, 0);
6781
+ requestAnimationFrame(() => this.#wheel?.focus());
6782
+ return;
6783
+ }
6784
+ this.#input?.querySelector("input")?.focus();
6785
+ }
6786
+
6787
+ #stopNumberTracking() {
6788
+ window.removeEventListener("pointermove", this.#boundNumberPointerMove);
6789
+ window.removeEventListener("pointerup", this.#boundNumberPointerEnd);
6790
+ window.removeEventListener("pointercancel", this.#boundNumberPointerEnd);
6791
+ window.removeEventListener("blur", this.#boundNumberPointerEnd);
6792
+ this.#numberPointerId = null;
6793
+ this.#isNumberScrubbing = false;
6794
+ this.removeAttribute("data-number-scrubbing");
6795
+ }
6796
+
6797
+ #handleClick(event) {
6798
+ if (figLabBooleanAttribute(this, "disabled")) return;
6799
+ if (
6800
+ event.target instanceof Element &&
6801
+ event.target.closest("fig-input-number, fig-menu")
6802
+ ) {
6803
+ if (
6804
+ this.#suppressNumberClick &&
6805
+ event.target.closest("fig-input-number")
6806
+ ) {
6807
+ event.preventDefault();
6808
+ event.stopImmediatePropagation();
6809
+ this.#suppressNumberClick = false;
6810
+ }
6811
+ return;
6812
+ }
6813
+ if (
6814
+ !(event.target instanceof Element) ||
6815
+ !event.target.closest("fig-input-wheel")
6816
+ ) {
6817
+ this.#wheel?.focus();
6818
+ }
6819
+ }
6820
+
6821
+ get value() {
6822
+ return this.getAttribute("value") ?? this.#wheel?.value ?? "0";
6823
+ }
6824
+
6825
+ set value(nextValue) {
6826
+ const parsed = Number(nextValue);
6827
+ this.#setSynchronizedValue(Number.isFinite(parsed) ? parsed : 0);
6828
+ }
6829
+
6830
+ get min() {
6831
+ return this.#wheel?.min ?? null;
6832
+ }
6833
+
6834
+ set min(nextValue) {
6835
+ if (nextValue === null || nextValue === undefined || nextValue === "") {
6836
+ this.removeAttribute("min");
6837
+ } else {
6838
+ const parsed = Number(nextValue);
6839
+ if (Number.isFinite(parsed)) this.setAttribute("min", String(parsed));
6840
+ else this.removeAttribute("min");
6841
+ }
6842
+ }
6843
+
6844
+ get max() {
6845
+ return this.#wheel?.max ?? null;
6846
+ }
6847
+
6848
+ set max(nextValue) {
6849
+ if (nextValue === null || nextValue === undefined || nextValue === "") {
6850
+ this.removeAttribute("max");
6851
+ } else {
6852
+ const parsed = Number(nextValue);
6853
+ if (Number.isFinite(parsed)) this.setAttribute("max", String(parsed));
6854
+ else this.removeAttribute("max");
6855
+ }
6856
+ }
6857
+
6858
+ get defaultValue() {
6859
+ return this.getAttribute("default") ?? this.#initialValue ?? "0";
6860
+ }
6861
+
6862
+ get isDefault() {
6863
+ return figLabPropskitValuesEqual(this.value, this.defaultValue);
6864
+ }
6865
+
6866
+ resetToDefault() {
6867
+ const parsed = Number(this.defaultValue);
6868
+ const value = this.#setSynchronizedValue(
6869
+ Number.isFinite(parsed) ? parsed : 0,
6870
+ );
6871
+ figLabEmitPropskitReset(this, value);
6872
+ }
6873
+
6874
+ focus(options) {
6875
+ this.#wheel?.focus(options);
6876
+ }
6877
+ }
6878
+ figLabDefineElement("propskit-wheel", PropskitWheel);
6879
+
5458
6880
  /* Canvas Control */
5459
6881
  class FigCanvasControl extends HTMLElement {
5460
6882
  static observedAttributes = [
@@ -8619,6 +10041,7 @@ class FigReorder extends HTMLElement {
8619
10041
  "fig-input-gradient",
8620
10042
  "fig-easing-curve",
8621
10043
  "fig-input-angle",
10044
+ "fig-input-wheel",
8622
10045
  "fig-input-joystick",
8623
10046
  "fig-canvas-control",
8624
10047
  "propskit-color-point",