@rogieking/figui3 8.9.31 → 8.9.32

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,1053 @@ class PropskitSlider extends HTMLElement {
5455
5545
  }
5456
5546
  figLabDefineElement("propskit-slider", PropskitSlider);
5457
5547
 
5548
+ /**
5549
+ * Full-surface numeric wheel: SVG ticks + fig-input-number.
5550
+ *
5551
+ * @attr {string} label - Field label. Omitted defaults to "Value"; authored values (including blank) are used as-is.
5552
+ * @attr {string} units - Optional unit forwarded to fig-input-number. Time aliases are normalized.
5553
+ * @attr {number} value - Numeric value. Unbounded unless min/max are set.
5554
+ * @attr {number} min - Inclusive lower bound. Omitted = unbounded below.
5555
+ * @attr {number} max - Inclusive upper bound. Omitted = unbounded above.
5556
+ * @attr {string} default - Reset target.
5557
+ * @attr {number} step - Scrub/keyboard/wheel increment. Defaults: s=0.1, ms=100, otherwise 1.
5558
+ * @attr {number} precision - Display decimals forwarded to fig-input-number. Defaults: s=2, otherwise 0.
5559
+ * @attr {boolean|string} elastic - Enables resisted handle movement while scrubbing. Defaults to true.
5560
+ * @attr {string} size - Defaults to the large row; set to "small" for compact.
5561
+ * @attr {boolean|string} disabled - Disables wheel and number.
5562
+ * @attr {string} variant - Set to "minimal" for compact chrome.
5563
+ * @fires input - Composed event while dragging or typing.
5564
+ * @fires change - Composed event on commit.
5565
+ */
5566
+ class PropskitWheel extends HTMLElement {
5567
+ static #TICK_COUNT = 33;
5568
+ static #HALF_FOV_DEG = 60;
5569
+ static #PERSPECTIVE_K = 0.55;
5570
+ static #DRAGGING_BODY_CLASS = "fig-propskit-wheel-dragging";
5571
+ static #RESERVED_ATTRS = new Set([
5572
+ "label",
5573
+ "size",
5574
+ "disabled",
5575
+ "variant",
5576
+ "elastic",
5577
+ "default",
5578
+ "class",
5579
+ "style",
5580
+ "id",
5581
+ "oninput",
5582
+ "onchange",
5583
+ ]);
5584
+
5585
+ #surface = null;
5586
+ #wheel = null;
5587
+ #svg = null;
5588
+ #tickPath = null;
5589
+ #tickMetrics = null;
5590
+ #tickMetricProbe = null;
5591
+ #wheelWidth = 0;
5592
+ #wheelHeight = 0;
5593
+ #layoutFrame = 0;
5594
+ #keyboardAnimationFrame = 0;
5595
+ #keyboardSettleTimer = 0;
5596
+ #label = null;
5597
+ #input = null;
5598
+ #hasCustomLabel = false;
5599
+ #observer = null;
5600
+ #resizeObserver = null;
5601
+ #managedInputAttrs = new Set();
5602
+ #initialValue = null;
5603
+ #isDragging = false;
5604
+ #dragPointerId = null;
5605
+ #dragStartX = 0;
5606
+ #dragStartValue = 0;
5607
+ #visualValue = null;
5608
+ #handleDragMaxPx = 0;
5609
+ #elasticMaxPx = 0;
5610
+ #elasticRangeRect = null;
5611
+ #elasticHostWidth = 0;
5612
+ #numberPointerId = null;
5613
+ #numberPointerStartX = 0;
5614
+ #numberPointerStartY = 0;
5615
+ #numberPointerStartValue = 0;
5616
+ #isNumberScrubbing = false;
5617
+ #suppressNumberClick = false;
5618
+ #numberClickResetTimer = 0;
5619
+ #boundHandleInput = null;
5620
+ #boundHandleChange = null;
5621
+ #boundPointerDown = this.#handlePointerDown.bind(this);
5622
+ #boundPointerMove = this.#handlePointerMove.bind(this);
5623
+ #boundPointerUp = this.#handlePointerUp.bind(this);
5624
+ #boundNumberPointerDown = this.#handleNumberPointerDown.bind(this);
5625
+ #boundNumberPointerMove = this.#handleNumberPointerMove.bind(this);
5626
+ #boundNumberPointerEnd = this.#handleNumberPointerEnd.bind(this);
5627
+ #boundWheel = this.#handleWheel.bind(this);
5628
+ #boundKeyDown = this.#handleKeyDown.bind(this);
5629
+ #boundClick = this.#handleClick.bind(this);
5630
+
5631
+ static get observedAttributes() {
5632
+ return ["label", "units", "value", "disabled", "step", "precision", "min", "max"];
5633
+ }
5634
+
5635
+ connectedCallback() {
5636
+ if (!this.#surface) this.#initialize();
5637
+ this.#syncLabel();
5638
+ this.#syncDisabled();
5639
+ this.#syncInputAttributes();
5640
+ this.#syncValueFromHost();
5641
+ this.#bindEvents();
5642
+ figLabConnectPropskitResetMenu(this);
5643
+ this.#observer?.disconnect();
5644
+ this.#observer = new MutationObserver((mutations) => {
5645
+ let syncInput = false;
5646
+ for (const mutation of mutations) {
5647
+ if (mutation.type !== "attributes") continue;
5648
+ const name = mutation.attributeName;
5649
+ if (
5650
+ !PropskitWheel.#RESERVED_ATTRS.has(name) &&
5651
+ !PropskitWheel.observedAttributes.includes(name) &&
5652
+ !name?.startsWith("data-")
5653
+ ) {
5654
+ syncInput = true;
5655
+ }
5656
+ }
5657
+ if (syncInput) this.#syncInputAttributes();
5658
+ });
5659
+ this.#observer.observe(this, { attributes: true });
5660
+ this.#resizeObserver?.disconnect();
5661
+ this.#resizeObserver = new ResizeObserver((entries) => {
5662
+ const rect = entries.at(-1)?.contentRect;
5663
+ if (rect) this.#setWheelSize(rect.width, rect.height);
5664
+ });
5665
+ this.#resizeObserver.observe(this.#surface);
5666
+ this.#queueWheelLayout();
5667
+ }
5668
+
5669
+ disconnectedCallback() {
5670
+ this.#observer?.disconnect();
5671
+ this.#resizeObserver?.disconnect();
5672
+ if (this.#layoutFrame) cancelAnimationFrame(this.#layoutFrame);
5673
+ this.#layoutFrame = 0;
5674
+ this.#stopKeyboardAnimation();
5675
+ this.#unbindEvents();
5676
+ this.#stopDrag();
5677
+ figLabDisconnectPropskitResetMenu(this);
5678
+ }
5679
+
5680
+ attributeChangedCallback(name, oldValue, newValue) {
5681
+ if (oldValue === newValue || !this.#surface) return;
5682
+ if (name === "label") this.#syncLabel();
5683
+ if (name === "disabled") this.#syncDisabled();
5684
+ if (name === "value" && !this.#isDragging) {
5685
+ this.#syncValueFromHost();
5686
+ }
5687
+ if (name === "units" || name === "step" || name === "precision") {
5688
+ this.#syncInputAttributes();
5689
+ this.#queueWheelLayout();
5690
+ }
5691
+ if (name === "min" || name === "max") {
5692
+ this.#syncInputAttributes();
5693
+ this.#commitValue(this.#numericValue());
5694
+ }
5695
+ }
5696
+
5697
+ #initialize() {
5698
+ this.#initialValue = this.getAttribute("value") ?? "0";
5699
+ const initialChildren = Array.from(this.childNodes).filter(
5700
+ (node) =>
5701
+ node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
5702
+ );
5703
+ const customLabel = initialChildren.find(
5704
+ (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
5705
+ );
5706
+
5707
+ const surface = figLabCreateElement("div", {
5708
+ className: "propskit-wheel-surface",
5709
+ });
5710
+ const wheel = figLabCreateElement("div", {
5711
+ className: "propskit-wheel-wheel",
5712
+ tabindex: "0",
5713
+ role: "spinbutton",
5714
+ });
5715
+ const svg = figLabCreateSvgElement("svg", {
5716
+ className: "propskit-wheel-wheel-svg",
5717
+ "aria-hidden": "true",
5718
+ });
5719
+ const label = customLabel || document.createElement("label");
5720
+ const input = document.createElement("fig-input-number");
5721
+ const labelId = figLabUniqueId("propskit-wheel-label");
5722
+ label.id = labelId;
5723
+ wheel.setAttribute("aria-labelledby", labelId);
5724
+
5725
+ const handle = figLabCreateElement("div", {
5726
+ className: "propskit-wheel-handle",
5727
+ });
5728
+ wheel.append(svg);
5729
+ surface.append(wheel, handle, label, input);
5730
+ this.#surface = surface;
5731
+ this.#wheel = wheel;
5732
+ this.#svg = svg;
5733
+ this.#label = label;
5734
+ this.#input = input;
5735
+ this.#hasCustomLabel = Boolean(customLabel);
5736
+ this.replaceChildren(surface);
5737
+
5738
+ const metricProbe = document.createElement("div");
5739
+ metricProbe.setAttribute("aria-hidden", "true");
5740
+ metricProbe.style.cssText =
5741
+ "position:absolute;visibility:hidden;pointer-events:none;left:0;top:0";
5742
+ const maxEl = document.createElement("div");
5743
+ maxEl.style.height = "var(--propskit-wheel-tick-height)";
5744
+ const minEl = document.createElement("div");
5745
+ minEl.style.height = "var(--propskit-wheel-tick-height-min)";
5746
+ const maxWidthEl = document.createElement("div");
5747
+ maxWidthEl.style.width = "var(--propskit-wheel-tick-width)";
5748
+ const minWidthEl = document.createElement("div");
5749
+ minWidthEl.style.width = "var(--propskit-wheel-tick-width-min)";
5750
+ const insetEl = document.createElement("div");
5751
+ insetEl.style.width = "var(--propskit-wheel-tick-inset)";
5752
+ metricProbe.append(maxEl, minEl, maxWidthEl, minWidthEl, insetEl);
5753
+ surface.append(metricProbe);
5754
+ this.#tickMetricProbe = {
5755
+ maxEl,
5756
+ minEl,
5757
+ maxWidthEl,
5758
+ minWidthEl,
5759
+ insetEl,
5760
+ };
5761
+
5762
+ for (const node of initialChildren) {
5763
+ if (node === customLabel) continue;
5764
+ input.appendChild(node);
5765
+ }
5766
+
5767
+ this.#ensureTickPath();
5768
+ }
5769
+
5770
+ #ensureTickPath() {
5771
+ if (!this.#svg || this.#tickPath) return;
5772
+ this.#tickPath = figLabCreateSvgElement("path", {
5773
+ className: "propskit-wheel-tick",
5774
+ });
5775
+ this.#svg.append(this.#tickPath);
5776
+ }
5777
+
5778
+ #readTickMetrics() {
5779
+ if (this.#tickMetrics) return this.#tickMetrics;
5780
+ this.#tickMetrics = {
5781
+ maxH: this.#tickMetricProbe?.maxEl.getBoundingClientRect().height || 8,
5782
+ minH: this.#tickMetricProbe?.minEl.getBoundingClientRect().height || 4,
5783
+ maxW:
5784
+ this.#tickMetricProbe?.maxWidthEl.getBoundingClientRect().width || 2,
5785
+ minW:
5786
+ this.#tickMetricProbe?.minWidthEl.getBoundingClientRect().width || 1,
5787
+ inset: this.#tickMetricProbe?.insetEl.getBoundingClientRect().width || 0,
5788
+ };
5789
+ return this.#tickMetrics;
5790
+ }
5791
+
5792
+ #units() {
5793
+ const raw = (this.getAttribute("units") || "").trim();
5794
+ const normalized = raw.toLowerCase();
5795
+ if (
5796
+ normalized === "ms" ||
5797
+ normalized === "millisecond" ||
5798
+ normalized === "milliseconds"
5799
+ ) {
5800
+ return "ms";
5801
+ }
5802
+ if (
5803
+ normalized === "s" ||
5804
+ normalized === "second" ||
5805
+ normalized === "seconds"
5806
+ ) {
5807
+ return "s";
5808
+ }
5809
+ return raw;
5810
+ }
5811
+
5812
+ #defaultStep() {
5813
+ const units = this.#units();
5814
+ if (units === "s") return 0.1;
5815
+ if (units === "ms") return 100;
5816
+ return 1;
5817
+ }
5818
+
5819
+ #defaultPrecision() {
5820
+ return this.#units() === "s" ? 2 : 0;
5821
+ }
5822
+
5823
+ #step() {
5824
+ if (this.hasAttribute("step")) {
5825
+ const parsed = Number(this.getAttribute("step"));
5826
+ if (parsed > 0) return parsed;
5827
+ }
5828
+ return this.#defaultStep();
5829
+ }
5830
+
5831
+ #precision() {
5832
+ if (this.hasAttribute("precision")) {
5833
+ const parsed = Number(this.getAttribute("precision"));
5834
+ if (Number.isInteger(parsed) && parsed >= 0) return parsed;
5835
+ }
5836
+ return this.#defaultPrecision();
5837
+ }
5838
+
5839
+ #numericValue() {
5840
+ const parsed = Number(this.getAttribute("value") ?? this.#input?.value ?? 0);
5841
+ return Number.isFinite(parsed) ? parsed : 0;
5842
+ }
5843
+
5844
+ #parseBound(name) {
5845
+ if (!this.hasAttribute(name)) return null;
5846
+ const raw = this.getAttribute(name);
5847
+ if (raw === null || raw.trim() === "") return null;
5848
+ const parsed = Number(raw);
5849
+ return Number.isFinite(parsed) ? parsed : null;
5850
+ }
5851
+
5852
+ #boundMin() {
5853
+ return this.#parseBound("min");
5854
+ }
5855
+
5856
+ #boundMax() {
5857
+ return this.#parseBound("max");
5858
+ }
5859
+
5860
+ #writeBound(name, nextValue) {
5861
+ if (nextValue === null || nextValue === undefined || nextValue === "") {
5862
+ this.removeAttribute(name);
5863
+ return;
5864
+ }
5865
+ const parsed = Number(nextValue);
5866
+ if (!Number.isFinite(parsed)) {
5867
+ this.removeAttribute(name);
5868
+ return;
5869
+ }
5870
+ this.setAttribute(name, String(parsed));
5871
+ }
5872
+
5873
+ #clamp(value) {
5874
+ let next = value;
5875
+ const min = this.#boundMin();
5876
+ const max = this.#boundMax();
5877
+ if (min !== null) next = Math.max(min, next);
5878
+ if (max !== null) next = Math.min(max, next);
5879
+ return next;
5880
+ }
5881
+
5882
+ #snap(value) {
5883
+ const step = this.#step();
5884
+ if (step <= 0) return value;
5885
+ const base = this.#boundMin() ?? 0;
5886
+ const snapped = Math.round((value - base) / step) * step + base;
5887
+ return Number(snapped.toPrecision(15));
5888
+ }
5889
+
5890
+ #commitValue(value, { snap = false, emit = null } = {}) {
5891
+ let next = snap ? this.#snap(value) : value;
5892
+ next = this.#clamp(next);
5893
+ const asString = String(next);
5894
+ if (this.getAttribute("value") !== asString) {
5895
+ this.setAttribute("value", asString);
5896
+ }
5897
+ if (this.#input?.getAttribute("value") !== asString) {
5898
+ this.#input?.setAttribute("value", asString);
5899
+ }
5900
+ this.#syncWheelAria(next);
5901
+ this.#queueWheelLayout();
5902
+ if (emit) {
5903
+ this.dispatchEvent(
5904
+ new CustomEvent(emit, {
5905
+ detail: next,
5906
+ bubbles: true,
5907
+ cancelable: true,
5908
+ composed: true,
5909
+ }),
5910
+ );
5911
+ }
5912
+ }
5913
+
5914
+ #syncValueFromHost() {
5915
+ const value = this.#numericValue();
5916
+ const clamped = this.#clamp(value);
5917
+ if (clamped !== value) {
5918
+ this.#commitValue(clamped);
5919
+ return;
5920
+ }
5921
+ const asString = String(value);
5922
+ if (this.#input?.getAttribute("value") !== asString) {
5923
+ this.#input?.setAttribute("value", asString);
5924
+ }
5925
+ this.#syncWheelAria(value);
5926
+ this.#queueWheelLayout();
5927
+ }
5928
+
5929
+ #syncWheelAria(value = this.#numericValue()) {
5930
+ if (!this.#wheel) return;
5931
+ const units = this.#units();
5932
+ const min = this.#boundMin();
5933
+ const max = this.#boundMax();
5934
+ this.#wheel.setAttribute("aria-valuenow", String(value));
5935
+ this.#wheel.setAttribute(
5936
+ "aria-valuetext",
5937
+ units
5938
+ ? `${value} ${
5939
+ units === "s" ? "seconds" : units === "ms" ? "milliseconds" : units
5940
+ }`
5941
+ : String(value),
5942
+ );
5943
+ if (min === null) this.#wheel.removeAttribute("aria-valuemin");
5944
+ else this.#wheel.setAttribute("aria-valuemin", String(min));
5945
+ if (max === null) this.#wheel.removeAttribute("aria-valuemax");
5946
+ else this.#wheel.setAttribute("aria-valuemax", String(max));
5947
+ }
5948
+
5949
+ #syncLabel() {
5950
+ if (!this.#label) return;
5951
+ if (this.#hasCustomLabel) return;
5952
+ if (!this.hasAttribute("label")) {
5953
+ this.#label.textContent = "Value";
5954
+ return;
5955
+ }
5956
+ this.#label.textContent = this.getAttribute("label") ?? "";
5957
+ }
5958
+
5959
+ #syncDisabled() {
5960
+ const disabled = figLabBooleanAttribute(this, "disabled");
5961
+ this.#input?.toggleAttribute("disabled", disabled);
5962
+ if (!this.#wheel) return;
5963
+ if (disabled) {
5964
+ this.setAttribute("aria-disabled", "true");
5965
+ this.#wheel.setAttribute("tabindex", "-1");
5966
+ this.#wheel.setAttribute("aria-disabled", "true");
5967
+ } else {
5968
+ this.removeAttribute("aria-disabled");
5969
+ this.#wheel.setAttribute("tabindex", "0");
5970
+ this.#wheel.removeAttribute("aria-disabled");
5971
+ }
5972
+ }
5973
+
5974
+ #getForwardedInputAttrNames() {
5975
+ return this.getAttributeNames().filter(
5976
+ (name) =>
5977
+ !PropskitWheel.#RESERVED_ATTRS.has(name) && !name.startsWith("data-"),
5978
+ );
5979
+ }
5980
+
5981
+ #syncInputAttributes() {
5982
+ if (!this.#input) return;
5983
+ const inputAttrs = this.#getForwardedInputAttrNames();
5984
+ const nextManaged = new Set(inputAttrs);
5985
+ for (const attrName of this.#managedInputAttrs) {
5986
+ if (!nextManaged.has(attrName)) this.#input.removeAttribute(attrName);
5987
+ }
5988
+ for (const attrName of inputAttrs) {
5989
+ this.#input.setAttribute(attrName, this.getAttribute(attrName) ?? "");
5990
+ }
5991
+ const units = this.#units();
5992
+ if (units) this.#input.setAttribute("units", units);
5993
+ else this.#input.removeAttribute("units");
5994
+ nextManaged.add("units");
5995
+ if (!this.hasAttribute("step")) {
5996
+ this.#input.setAttribute("step", String(this.#defaultStep()));
5997
+ nextManaged.add("step");
5998
+ }
5999
+ if (!this.hasAttribute("precision")) {
6000
+ this.#input.setAttribute("precision", String(this.#defaultPrecision()));
6001
+ nextManaged.add("precision");
6002
+ }
6003
+ this.#managedInputAttrs = nextManaged;
6004
+ this.#syncWheelAria();
6005
+ }
6006
+
6007
+ #setWheelSize(width, height) {
6008
+ if (!this.#svg || width < 1 || height < 1) return;
6009
+ if (width === this.#wheelWidth && height === this.#wheelHeight) return;
6010
+ this.#wheelWidth = width;
6011
+ this.#wheelHeight = height;
6012
+ this.#tickMetrics = null;
6013
+ this.#svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
6014
+ this.#svg.setAttribute("width", String(width));
6015
+ this.#svg.setAttribute("height", String(height));
6016
+ this.#queueWheelLayout();
6017
+ }
6018
+
6019
+ #queueWheelLayout() {
6020
+ if (this.#layoutFrame) return;
6021
+ this.#layoutFrame = requestAnimationFrame(() => {
6022
+ if (!this.isConnected) {
6023
+ this.#layoutFrame = 0;
6024
+ return;
6025
+ }
6026
+ if (this.#wheelWidth < 1 || this.#wheelHeight < 1) {
6027
+ const rect = this.#wheel?.getBoundingClientRect();
6028
+ if (rect) this.#setWheelSize(rect.width, rect.height);
6029
+ }
6030
+ this.#layoutWheel();
6031
+ this.#layoutFrame = 0;
6032
+ });
6033
+ }
6034
+
6035
+ #layoutWheel() {
6036
+ if (!this.#tickPath) return;
6037
+ const width = this.#wheelWidth;
6038
+ const height = this.#wheelHeight;
6039
+ if (width < 1 || height < 1) return;
6040
+
6041
+ const value = this.#visualValue ?? this.#numericValue();
6042
+ const tickStep = 360 / PropskitWheel.#TICK_COUNT;
6043
+ const step = this.#step();
6044
+ const base = this.#boundMin() ?? 0;
6045
+ const offsetDeg = ((value - base) / step) * tickStep;
6046
+ const k = PropskitWheel.#PERSPECTIVE_K;
6047
+ const cx = width / 2;
6048
+ const cy = height / 2;
6049
+
6050
+ const { maxH, minH, maxW, minW, inset } = this.#readTickMetrics();
6051
+ const drawableWidth = Math.max(0, width - inset * 2);
6052
+ const radius = drawableWidth * 0.42;
6053
+ const fov = PropskitWheel.#HALF_FOV_DEG;
6054
+ const visibleHalf = Math.max(drawableWidth / 2, 1);
6055
+ const commands = [];
6056
+
6057
+ for (let index = 0; index < PropskitWheel.#TICK_COUNT; index += 1) {
6058
+ let theta = offsetDeg + index * tickStep;
6059
+ theta = ((((theta + 180) % 360) + 360) % 360) - 180;
6060
+ if (theta < -fov || theta >= fov) continue;
6061
+ const rad = (theta * Math.PI) / 180;
6062
+ const x = cx + (radius * Math.sin(rad)) / (1 - k * Math.cos(rad));
6063
+ const t = Math.min(1, Math.abs(x - cx) / Math.max(visibleHalf, 1));
6064
+ const taper = Math.cos(t * Math.PI * 0.5);
6065
+ const visualH = minH + (maxH - minH) * taper;
6066
+ const visualW = minW + (maxW - minW) * taper;
6067
+ const halfH = visualH / 2;
6068
+ const halfW = visualW / 2;
6069
+ const top = cy - halfH;
6070
+ const bottom = cy + halfH;
6071
+ const left = x - halfW;
6072
+ const right = x + halfW;
6073
+ commands.push(
6074
+ `M ${x} ${top}` +
6075
+ ` Q ${right} ${top} ${right} ${top + halfW}` +
6076
+ ` V ${bottom - halfW}` +
6077
+ ` Q ${right} ${bottom} ${x} ${bottom}` +
6078
+ ` Q ${left} ${bottom} ${left} ${bottom - halfW}` +
6079
+ ` V ${top + halfW}` +
6080
+ ` Q ${left} ${top} ${x} ${top} Z`,
6081
+ );
6082
+ }
6083
+ this.#tickPath.setAttribute("d", commands.join(" "));
6084
+ }
6085
+
6086
+ #bindEvents() {
6087
+ this.#boundHandleInput ??= this.#forwardInputEvent.bind(this, "input");
6088
+ this.#boundHandleChange ??= this.#forwardInputEvent.bind(this, "change");
6089
+ this.#input?.addEventListener("input", this.#boundHandleInput);
6090
+ this.#input?.addEventListener("change", this.#boundHandleChange);
6091
+ this.#surface?.addEventListener("pointerdown", this.#boundPointerDown);
6092
+ this.addEventListener("pointerdown", this.#boundNumberPointerDown, {
6093
+ capture: true,
6094
+ });
6095
+ this.#wheel?.addEventListener("wheel", this.#boundWheel, { passive: false });
6096
+ this.#wheel?.addEventListener("keydown", this.#boundKeyDown);
6097
+ this.addEventListener("click", this.#boundClick, true);
6098
+ }
6099
+
6100
+ #unbindEvents() {
6101
+ this.#input?.removeEventListener("input", this.#boundHandleInput);
6102
+ this.#input?.removeEventListener("change", this.#boundHandleChange);
6103
+ this.#surface?.removeEventListener("pointerdown", this.#boundPointerDown);
6104
+ this.removeEventListener("pointerdown", this.#boundNumberPointerDown, {
6105
+ capture: true,
6106
+ });
6107
+ this.#wheel?.removeEventListener("wheel", this.#boundWheel);
6108
+ this.#wheel?.removeEventListener("keydown", this.#boundKeyDown);
6109
+ this.removeEventListener("click", this.#boundClick, true);
6110
+ this.#stopNumberTracking();
6111
+ clearTimeout(this.#numberClickResetTimer);
6112
+ this.#numberClickResetTimer = 0;
6113
+ window.removeEventListener("pointermove", this.#boundPointerMove);
6114
+ window.removeEventListener("pointerup", this.#boundPointerUp);
6115
+ window.removeEventListener("pointercancel", this.#boundPointerUp);
6116
+ }
6117
+
6118
+ #forwardInputEvent(type, event) {
6119
+ event.stopImmediatePropagation();
6120
+ if (figLabBooleanAttribute(this, "disabled")) return;
6121
+ const raw =
6122
+ event instanceof CustomEvent && event.detail !== undefined
6123
+ ? event.detail
6124
+ : this.#input?.value;
6125
+ const parsed = Number(raw);
6126
+ const next = Number.isFinite(parsed) ? parsed : this.#numericValue();
6127
+ this.#commitValue(next, { emit: type });
6128
+ }
6129
+
6130
+ #handleClick(event) {
6131
+ if (figLabBooleanAttribute(this, "disabled")) return;
6132
+ if (
6133
+ event.target instanceof Element &&
6134
+ event.target.closest("fig-input-number, fig-menu")
6135
+ ) {
6136
+ if (
6137
+ this.#suppressNumberClick &&
6138
+ event.target.closest("fig-input-number")
6139
+ ) {
6140
+ event.preventDefault();
6141
+ event.stopImmediatePropagation();
6142
+ this.#suppressNumberClick = false;
6143
+ }
6144
+ return;
6145
+ }
6146
+ this.#wheel?.focus();
6147
+ }
6148
+
6149
+ #handlePointerDown(event) {
6150
+ if (figLabBooleanAttribute(this, "disabled")) return;
6151
+ if (event.button !== 0) return;
6152
+ if (
6153
+ event.target instanceof Element &&
6154
+ event.target.closest("fig-input-number, fig-menu")
6155
+ ) {
6156
+ return;
6157
+ }
6158
+ event.preventDefault();
6159
+ this.#stopKeyboardAnimation();
6160
+ this.#isDragging = true;
6161
+ this.#dragPointerId = event.pointerId;
6162
+ this.#dragStartX = event.clientX;
6163
+ this.#dragStartValue = this.#numericValue();
6164
+ this.#startElasticPull();
6165
+ this.setAttribute("data-active", "");
6166
+ document.body.classList.add(PropskitWheel.#DRAGGING_BODY_CLASS);
6167
+ this.#wheel?.focus();
6168
+ this.#surface?.setPointerCapture?.(event.pointerId);
6169
+ window.addEventListener("pointermove", this.#boundPointerMove);
6170
+ window.addEventListener("pointerup", this.#boundPointerUp);
6171
+ window.addEventListener("pointercancel", this.#boundPointerUp);
6172
+ }
6173
+
6174
+ #handlePointerMove(event) {
6175
+ if (!this.#isDragging) return;
6176
+ if (this.#dragPointerId !== null && event.pointerId !== this.#dragPointerId) {
6177
+ return;
6178
+ }
6179
+ this.#updateDrag(event.clientX, event.shiftKey ? 10 : 1);
6180
+ }
6181
+
6182
+ #updateDrag(clientX, speed = 1) {
6183
+ const width = this.#wheel?.clientWidth || 1;
6184
+ const visibleSteps =
6185
+ PropskitWheel.#TICK_COUNT *
6186
+ ((PropskitWheel.#HALF_FOV_DEG * 2) / 360);
6187
+ const visibleUnits = this.#step() * visibleSteps;
6188
+ const delta =
6189
+ (clientX - this.#dragStartX) * (visibleUnits / width) * speed;
6190
+ const rawValue = this.#clamp(this.#dragStartValue + delta);
6191
+ this.#visualValue = rawValue;
6192
+ this.#commitValue(rawValue, {
6193
+ snap: true,
6194
+ emit: "input",
6195
+ });
6196
+ this.#updateElasticPull(clientX);
6197
+ }
6198
+
6199
+ #handlePointerUp(event) {
6200
+ if (!this.#isDragging) return;
6201
+ if (this.#dragPointerId !== null && event.pointerId !== this.#dragPointerId) {
6202
+ return;
6203
+ }
6204
+ if (this.#numericValue() !== this.#dragStartValue) {
6205
+ this.#commitValue(this.#numericValue(), { snap: true, emit: "change" });
6206
+ }
6207
+ this.#stopDrag();
6208
+ }
6209
+
6210
+ #stopDrag() {
6211
+ this.#isDragging = false;
6212
+ this.#dragPointerId = null;
6213
+ this.#visualValue = null;
6214
+ this.removeAttribute("data-active");
6215
+ document.body.classList.remove(PropskitWheel.#DRAGGING_BODY_CLASS);
6216
+ this.#resetElasticPull();
6217
+ window.removeEventListener("pointermove", this.#boundPointerMove);
6218
+ window.removeEventListener("pointerup", this.#boundPointerUp);
6219
+ window.removeEventListener("pointercancel", this.#boundPointerUp);
6220
+ this.#queueWheelLayout();
6221
+ }
6222
+
6223
+ #handleNumberPointerDown(event) {
6224
+ if (
6225
+ event.button !== 0 ||
6226
+ event.altKey ||
6227
+ figLabBooleanAttribute(this, "disabled") ||
6228
+ !(event.target instanceof Element)
6229
+ ) {
6230
+ return;
6231
+ }
6232
+ const input = event.target.closest("fig-input-number input");
6233
+ if (!input || input === document.activeElement) return;
6234
+
6235
+ event.preventDefault();
6236
+ event.stopImmediatePropagation();
6237
+ this.#stopNumberTracking();
6238
+ this.#numberPointerId = event.pointerId;
6239
+ this.#numberPointerStartX = event.clientX;
6240
+ this.#numberPointerStartY = event.clientY;
6241
+ this.#numberPointerStartValue = this.#numericValue();
6242
+ window.addEventListener("pointermove", this.#boundNumberPointerMove);
6243
+ window.addEventListener("pointerup", this.#boundNumberPointerEnd, {
6244
+ once: true,
6245
+ });
6246
+ window.addEventListener("pointercancel", this.#boundNumberPointerEnd, {
6247
+ once: true,
6248
+ });
6249
+ window.addEventListener("blur", this.#boundNumberPointerEnd, {
6250
+ once: true,
6251
+ });
6252
+ }
6253
+
6254
+ #handleNumberPointerMove(event) {
6255
+ if (event.pointerId !== this.#numberPointerId) return;
6256
+ if (event.buttons === 0) {
6257
+ this.#handleNumberPointerEnd(event);
6258
+ return;
6259
+ }
6260
+ if (!this.#isNumberScrubbing) {
6261
+ const distance = Math.hypot(
6262
+ event.clientX - this.#numberPointerStartX,
6263
+ event.clientY - this.#numberPointerStartY,
6264
+ );
6265
+ if (distance < 4) return;
6266
+ this.#stopKeyboardAnimation();
6267
+ this.#isNumberScrubbing = true;
6268
+ this.setAttribute("data-number-scrubbing", "");
6269
+ this.#isDragging = true;
6270
+ this.#dragPointerId = event.pointerId;
6271
+ this.#dragStartX = this.#numberPointerStartX;
6272
+ this.#dragStartValue = this.#numberPointerStartValue;
6273
+ this.#startElasticPull();
6274
+ this.setAttribute("data-active", "");
6275
+ document.body.classList.add(PropskitWheel.#DRAGGING_BODY_CLASS);
6276
+ this.#wheel?.focus();
6277
+ }
6278
+ this.#updateDrag(event.clientX, event.shiftKey ? 10 : 1);
6279
+ }
6280
+
6281
+ #handleNumberPointerEnd(event) {
6282
+ if (
6283
+ event?.pointerId !== undefined &&
6284
+ this.#numberPointerId !== null &&
6285
+ event.pointerId !== this.#numberPointerId
6286
+ ) {
6287
+ return;
6288
+ }
6289
+ const wasScrubbing = this.#isNumberScrubbing;
6290
+ this.#stopNumberTracking();
6291
+ if (wasScrubbing) {
6292
+ this.#commitValue(this.#numericValue(), { snap: true, emit: "change" });
6293
+ this.#stopDrag();
6294
+ this.#suppressNumberClick = true;
6295
+ clearTimeout(this.#numberClickResetTimer);
6296
+ this.#numberClickResetTimer = window.setTimeout(() => {
6297
+ this.#suppressNumberClick = false;
6298
+ this.#numberClickResetTimer = 0;
6299
+ }, 0);
6300
+ requestAnimationFrame(() => this.#wheel?.focus());
6301
+ return;
6302
+ }
6303
+ this.#input?.querySelector("input")?.focus();
6304
+ }
6305
+
6306
+ #stopNumberTracking() {
6307
+ window.removeEventListener("pointermove", this.#boundNumberPointerMove);
6308
+ window.removeEventListener("pointerup", this.#boundNumberPointerEnd);
6309
+ window.removeEventListener("pointercancel", this.#boundNumberPointerEnd);
6310
+ window.removeEventListener("blur", this.#boundNumberPointerEnd);
6311
+ this.#numberPointerId = null;
6312
+ this.#isNumberScrubbing = false;
6313
+ this.removeAttribute("data-number-scrubbing");
6314
+ }
6315
+
6316
+ #startElasticPull() {
6317
+ this.#resetElasticPull();
6318
+ if (this.getAttribute("elastic") === "false") return;
6319
+ this.#handleDragMaxPx = this.#readCssLength(
6320
+ "--propskit-wheel-handle-drag-max",
6321
+ );
6322
+ this.#elasticMaxPx = this.#readCssLength(
6323
+ "--propskit-wheel-elastic-distance",
6324
+ );
6325
+ const rect = this.#wheel?.getBoundingClientRect();
6326
+ if (rect) {
6327
+ this.#elasticRangeRect = {
6328
+ left: rect.left,
6329
+ right: rect.right,
6330
+ };
6331
+ }
6332
+ this.#elasticHostWidth = this.getBoundingClientRect().width;
6333
+ }
6334
+
6335
+ #readCssLength(propertyName) {
6336
+ let raw = getComputedStyle(this)
6337
+ .getPropertyValue(propertyName)
6338
+ .trim();
6339
+ if (raw.includes("var(") || !raw.endsWith("px")) {
6340
+ const probe = document.createElement("div");
6341
+ Object.assign(probe.style, {
6342
+ position: "absolute",
6343
+ visibility: "hidden",
6344
+ pointerEvents: "none",
6345
+ width: `var(${propertyName})`,
6346
+ });
6347
+ this.appendChild(probe);
6348
+ raw = getComputedStyle(probe).width;
6349
+ probe.remove();
6350
+ }
6351
+ const value = Number.parseFloat(raw);
6352
+ return Number.isFinite(value) ? Math.max(0, value) : 0;
6353
+ }
6354
+
6355
+ #updateElasticPull(pointerX) {
6356
+ if (!this.#handleDragMaxPx && !this.#elasticMaxPx) {
6357
+ this.#clearElasticPull();
6358
+ return;
6359
+ }
6360
+ const dragDelta = pointerX - this.#dragStartX;
6361
+ if (!dragDelta) {
6362
+ this.#clearElasticPull();
6363
+ return;
6364
+ }
6365
+ const offset =
6366
+ this.#handleDragMaxPx *
6367
+ Math.tanh(
6368
+ dragDelta / Math.max(1, this.#handleDragMaxPx * 3),
6369
+ );
6370
+ this.dataset.elasticDragging = "true";
6371
+ this.style.setProperty(
6372
+ "--propskit-wheel-handle-drag-offset",
6373
+ `${offset}px`,
6374
+ );
6375
+
6376
+ const rect = this.#elasticRangeRect;
6377
+ const overshoot = rect
6378
+ ? pointerX < rect.left
6379
+ ? pointerX - rect.left
6380
+ : pointerX > rect.right
6381
+ ? pointerX - rect.right
6382
+ : 0
6383
+ : 0;
6384
+ if (!overshoot || !this.#elasticMaxPx) {
6385
+ this.style.removeProperty("--propskit-wheel-elastic-scale");
6386
+ this.style.removeProperty("--propskit-wheel-elastic-origin");
6387
+ return;
6388
+ }
6389
+ const stretch = Math.min(this.#elasticMaxPx, Math.abs(overshoot) * 0.5);
6390
+ const scale = this.#elasticHostWidth
6391
+ ? (this.#elasticHostWidth + stretch) / this.#elasticHostWidth
6392
+ : 1;
6393
+ this.style.setProperty("--propskit-wheel-elastic-scale", `${scale}`);
6394
+ this.style.setProperty(
6395
+ "--propskit-wheel-elastic-origin",
6396
+ overshoot < 0 ? "right center" : "left center",
6397
+ );
6398
+ }
6399
+
6400
+ #resetElasticPull() {
6401
+ this.#clearElasticPull();
6402
+ this.#handleDragMaxPx = 0;
6403
+ this.#elasticMaxPx = 0;
6404
+ this.#elasticRangeRect = null;
6405
+ this.#elasticHostWidth = 0;
6406
+ }
6407
+
6408
+ #clearElasticPull() {
6409
+ this.removeAttribute("data-elastic-dragging");
6410
+ this.style.removeProperty("--propskit-wheel-handle-drag-offset");
6411
+ this.style.removeProperty("--propskit-wheel-elastic-scale");
6412
+ this.style.removeProperty("--propskit-wheel-elastic-origin");
6413
+ }
6414
+
6415
+ #handleWheel(event) {
6416
+ if (figLabBooleanAttribute(this, "disabled")) return;
6417
+ event.preventDefault();
6418
+ const direction = event.deltaY === 0 ? Math.sign(event.deltaX) : Math.sign(event.deltaY);
6419
+ if (!direction) return;
6420
+ const multiplier = event.shiftKey ? 10 : 1;
6421
+ this.#commitValue(this.#numericValue() + this.#step() * multiplier * direction, {
6422
+ snap: true,
6423
+ emit: "input",
6424
+ });
6425
+ this.#commitValue(this.#numericValue(), { emit: "change" });
6426
+ }
6427
+
6428
+ #handleKeyDown(event) {
6429
+ if (figLabBooleanAttribute(this, "disabled")) return;
6430
+ if (
6431
+ event.target instanceof Element &&
6432
+ event.target.closest("fig-input-number")
6433
+ ) {
6434
+ return;
6435
+ }
6436
+ if (event.key === "Home") {
6437
+ const min = this.#boundMin();
6438
+ if (min === null) return;
6439
+ event.preventDefault();
6440
+ this.#commitValue(min, { snap: true, emit: "input" });
6441
+ this.#commitValue(this.#numericValue(), { emit: "change" });
6442
+ return;
6443
+ }
6444
+ if (event.key === "End") {
6445
+ const max = this.#boundMax();
6446
+ if (max === null) return;
6447
+ event.preventDefault();
6448
+ this.#commitValue(max, { snap: true, emit: "input" });
6449
+ this.#commitValue(this.#numericValue(), { emit: "change" });
6450
+ return;
6451
+ }
6452
+ const keys = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"];
6453
+ if (!keys.includes(event.key)) return;
6454
+ event.preventDefault();
6455
+ const direction =
6456
+ event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : -1;
6457
+ const multiplier = event.shiftKey ? 10 : 1;
6458
+ const previousValue = this.#numericValue();
6459
+ this.#commitValue(previousValue + this.#step() * multiplier * direction, {
6460
+ snap: true,
6461
+ emit: "input",
6462
+ });
6463
+ this.#animateKeyboardMovement(
6464
+ previousValue,
6465
+ this.#numericValue(),
6466
+ direction,
6467
+ multiplier,
6468
+ );
6469
+ this.#commitValue(this.#numericValue(), { emit: "change" });
6470
+ }
6471
+
6472
+ #animateKeyboardMovement(from, to, direction, multiplier) {
6473
+ if (
6474
+ from === to ||
6475
+ globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches
6476
+ ) {
6477
+ this.#stopKeyboardAnimation();
6478
+ return;
6479
+ }
6480
+
6481
+ if (this.#keyboardAnimationFrame) {
6482
+ cancelAnimationFrame(this.#keyboardAnimationFrame);
6483
+ this.#keyboardAnimationFrame = 0;
6484
+ }
6485
+ clearTimeout(this.#keyboardSettleTimer);
6486
+ this.#keyboardSettleTimer = 0;
6487
+ this.removeAttribute("data-keyboard-settling");
6488
+ const visualFrom = this.#visualValue ?? from;
6489
+ const handleFrom =
6490
+ Number.parseFloat(
6491
+ this.style.getPropertyValue("--propskit-wheel-handle-drag-offset"),
6492
+ ) || 0;
6493
+ const duration = multiplier > 1 ? 120 : 90;
6494
+ const startedAt = performance.now();
6495
+ const animateHandle = this.getAttribute("elastic") !== "false";
6496
+ this.setAttribute("data-keyboard-moving", "");
6497
+
6498
+ const frame = (now) => {
6499
+ const progress = Math.min(1, (now - startedAt) / duration);
6500
+ this.#visualValue = visualFrom + (to - visualFrom) * progress;
6501
+ if (animateHandle) {
6502
+ const eased = Math.sin((progress * Math.PI) / 2);
6503
+ const offset = handleFrom + (direction * 4 - handleFrom) * eased;
6504
+ this.style.setProperty(
6505
+ "--propskit-wheel-handle-drag-offset",
6506
+ `${offset}px`,
6507
+ );
6508
+ }
6509
+ this.#layoutWheel();
6510
+
6511
+ if (progress < 1) {
6512
+ this.#keyboardAnimationFrame = requestAnimationFrame(frame);
6513
+ return;
6514
+ }
6515
+
6516
+ this.#keyboardAnimationFrame = 0;
6517
+ this.#visualValue = null;
6518
+ this.removeAttribute("data-keyboard-moving");
6519
+ this.setAttribute("data-keyboard-settling", "");
6520
+ this.style.removeProperty("--propskit-wheel-handle-drag-offset");
6521
+ this.#keyboardSettleTimer = window.setTimeout(() => {
6522
+ this.removeAttribute("data-keyboard-settling");
6523
+ this.#keyboardSettleTimer = 0;
6524
+ }, 140);
6525
+ this.#queueWheelLayout();
6526
+ };
6527
+
6528
+ this.#keyboardAnimationFrame = requestAnimationFrame(frame);
6529
+ }
6530
+
6531
+ #stopKeyboardAnimation() {
6532
+ if (this.#keyboardAnimationFrame) {
6533
+ cancelAnimationFrame(this.#keyboardAnimationFrame);
6534
+ this.#keyboardAnimationFrame = 0;
6535
+ }
6536
+ clearTimeout(this.#keyboardSettleTimer);
6537
+ this.#keyboardSettleTimer = 0;
6538
+ this.#visualValue = null;
6539
+ this.removeAttribute("data-keyboard-moving");
6540
+ this.removeAttribute("data-keyboard-settling");
6541
+ this.style.removeProperty("--propskit-wheel-handle-drag-offset");
6542
+ if (this.isConnected) this.#queueWheelLayout();
6543
+ }
6544
+
6545
+ get value() {
6546
+ return this.getAttribute("value") ?? "0";
6547
+ }
6548
+
6549
+ set value(nextValue) {
6550
+ if (nextValue === null || nextValue === undefined || nextValue === "") {
6551
+ this.#commitValue(0);
6552
+ return;
6553
+ }
6554
+ const parsed = Number(nextValue);
6555
+ this.#commitValue(Number.isFinite(parsed) ? parsed : 0);
6556
+ }
6557
+
6558
+ get min() {
6559
+ return this.#boundMin();
6560
+ }
6561
+
6562
+ set min(nextValue) {
6563
+ this.#writeBound("min", nextValue);
6564
+ }
6565
+
6566
+ get max() {
6567
+ return this.#boundMax();
6568
+ }
6569
+
6570
+ set max(nextValue) {
6571
+ this.#writeBound("max", nextValue);
6572
+ }
6573
+
6574
+ get defaultValue() {
6575
+ return this.getAttribute("default") ?? this.#initialValue ?? "0";
6576
+ }
6577
+
6578
+ get isDefault() {
6579
+ return figLabPropskitValuesEqual(this.value, this.defaultValue);
6580
+ }
6581
+
6582
+ resetToDefault() {
6583
+ const parsed = Number(this.defaultValue);
6584
+ const value = Number.isFinite(parsed) ? parsed : 0;
6585
+ this.#commitValue(value);
6586
+ figLabEmitPropskitReset(this, value);
6587
+ }
6588
+
6589
+ focus(options) {
6590
+ this.#wheel?.focus(options);
6591
+ }
6592
+ }
6593
+ figLabDefineElement("propskit-wheel", PropskitWheel);
6594
+
5458
6595
  /* Canvas Control */
5459
6596
  class FigCanvasControl extends HTMLElement {
5460
6597
  static observedAttributes = [