@rogieking/figui3 3.16.0 → 3.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig.js CHANGED
@@ -75,24 +75,31 @@ function figUniqueId() {
75
75
  return Date.now().toString(36) + Math.random().toString(36).substring(2);
76
76
  }
77
77
 
78
- /**
79
- * Gets the highest z-index currently in use on the page
80
- * @returns {number} The highest z-index found, minimum of 1000
81
- */
78
+ let _figZCounter = 10000;
82
79
  function figGetHighestZIndex() {
83
- let highest = 1000; // Baseline minimum
80
+ return _figZCounter++;
81
+ }
84
82
 
85
- // Check all elements with inline z-index or computed z-index
86
- const elements = document.querySelectorAll("*");
87
- for (const el of elements) {
88
- const zIndex = parseInt(getComputedStyle(el).zIndex, 10);
89
- if (!isNaN(zIndex) && zIndex > highest) {
90
- highest = zIndex;
91
- }
83
+ function figSyncCssVar(el, prop, value) {
84
+ if (value && value.trim()) {
85
+ el.style.setProperty(prop, value.trim());
86
+ } else {
87
+ el.style.removeProperty(prop);
92
88
  }
89
+ }
93
90
 
94
- return highest;
91
+ let _figSharedCanvas = null;
92
+ let _figSharedCtx = null;
93
+ function figGetSharedCanvas(width = 1, height = 1) {
94
+ if (!_figSharedCanvas) {
95
+ _figSharedCanvas = document.createElement("canvas");
96
+ _figSharedCtx = _figSharedCanvas.getContext("2d");
97
+ }
98
+ if (_figSharedCanvas.width !== width) _figSharedCanvas.width = width;
99
+ if (_figSharedCanvas.height !== height) _figSharedCanvas.height = height;
100
+ return { canvas: _figSharedCanvas, ctx: _figSharedCtx };
95
101
  }
102
+
96
103
  /**
97
104
  * Checks if the browser supports the native popover API
98
105
  * @returns {boolean} True if popover is supported
@@ -253,6 +260,7 @@ class FigDropdown extends HTMLElement {
253
260
  set label(value) {
254
261
  this.#label = value;
255
262
  }
263
+ #boundSlotChange;
256
264
  constructor() {
257
265
  super();
258
266
  this.select = document.createElement("select");
@@ -260,6 +268,7 @@ class FigDropdown extends HTMLElement {
260
268
  this.attachShadow({ mode: "open" });
261
269
  this.#boundHandleSelectInput = this.#handleSelectInput.bind(this);
262
270
  this.#boundHandleSelectChange = this.#handleSelectChange.bind(this);
271
+ this.#boundSlotChange = this.slotChange.bind(this);
263
272
  }
264
273
 
265
274
  #supportsSelectedContent() {
@@ -342,7 +351,7 @@ class FigDropdown extends HTMLElement {
342
351
  this.appendChild(this.select);
343
352
  this.shadowRoot.appendChild(this.optionsSlot);
344
353
 
345
- this.optionsSlot.addEventListener("slotchange", this.slotChange.bind(this));
354
+ this.optionsSlot.addEventListener("slotchange", this.#boundSlotChange);
346
355
 
347
356
  this.#addEventListeners();
348
357
  }
@@ -478,6 +487,12 @@ class FigDropdown extends HTMLElement {
478
487
  this.select.setAttribute("aria-label", this.#label);
479
488
  }
480
489
  }
490
+
491
+ disconnectedCallback() {
492
+ this.optionsSlot.removeEventListener("slotchange", this.#boundSlotChange);
493
+ this.select.removeEventListener("input", this.#boundHandleSelectInput);
494
+ this.select.removeEventListener("change", this.#boundHandleSelectChange);
495
+ }
481
496
  }
482
497
 
483
498
  customElements.define("fig-dropdown", FigDropdown);
@@ -496,6 +511,12 @@ class FigTooltip extends HTMLElement {
496
511
 
497
512
  #boundHideOnChromeOpen;
498
513
  #boundHidePopupOutsideClick;
514
+ #boundShowDelayedPopup;
515
+ #boundHandlePointerLeave;
516
+ #boundHandleTouchStart;
517
+ #boundHandleTouchMove;
518
+ #boundHandleTouchEnd;
519
+ #boundHandleTouchCancel;
499
520
  #touchTimeout;
500
521
  #isTouching = false;
501
522
  #observer = null;
@@ -506,9 +527,14 @@ class FigTooltip extends HTMLElement {
506
527
  let delay = parseInt(this.getAttribute("delay"));
507
528
  this.delay = !isNaN(delay) ? delay : 500;
508
529
 
509
- // Bind methods that will be used as event listeners
510
530
  this.#boundHideOnChromeOpen = this.#hideOnChromeOpen.bind(this);
511
531
  this.#boundHidePopupOutsideClick = this.hidePopupOutsideClick.bind(this);
532
+ this.#boundShowDelayedPopup = this.showDelayedPopup.bind(this);
533
+ this.#boundHandlePointerLeave = this.#handlePointerLeave.bind(this);
534
+ this.#boundHandleTouchStart = this.#handleTouchStart.bind(this);
535
+ this.#boundHandleTouchMove = this.#handleTouchMove.bind(this);
536
+ this.#boundHandleTouchEnd = this.#handleTouchEnd.bind(this);
537
+ this.#boundHandleTouchCancel = this.#handleTouchCancel.bind(this);
512
538
  }
513
539
  connectedCallback() {
514
540
  this.setup();
@@ -517,16 +543,13 @@ class FigTooltip extends HTMLElement {
517
543
 
518
544
  disconnectedCallback() {
519
545
  this.destroy();
520
- // Remove global listeners
521
546
  document.removeEventListener(
522
547
  "mousedown",
523
548
  this.#boundHideOnChromeOpen,
524
549
  true,
525
550
  );
526
- // Disconnect mutation observer
527
551
  this.#stopObserving();
528
552
 
529
- // Remove click outside listener for click action
530
553
  if (this.action === "click") {
531
554
  document.body.removeEventListener(
532
555
  "click",
@@ -534,15 +557,17 @@ class FigTooltip extends HTMLElement {
534
557
  );
535
558
  }
536
559
 
537
- // Clean up touch-related timers and listeners
538
560
  clearTimeout(this.#touchTimeout);
539
561
  if (this.action === "hover") {
540
- this.removeEventListener("touchstart", this.#handleTouchStart);
541
- this.removeEventListener("touchmove", this.#handleTouchMove);
542
- this.removeEventListener("touchend", this.#handleTouchEnd);
543
- this.removeEventListener("touchcancel", this.#handleTouchCancel);
562
+ this.removeEventListener("pointerenter", this.#boundShowDelayedPopup);
563
+ this.removeEventListener("pointerleave", this.#boundHandlePointerLeave);
564
+ this.removeEventListener("touchstart", this.#boundHandleTouchStart);
565
+ this.removeEventListener("touchmove", this.#boundHandleTouchMove);
566
+ this.removeEventListener("touchend", this.#boundHandleTouchEnd);
567
+ this.removeEventListener("touchcancel", this.#boundHandleTouchCancel);
544
568
  } else if (this.action === "click") {
545
- this.removeEventListener("touchstart", this.showDelayedPopup);
569
+ this.removeEventListener("click", this.#boundShowDelayedPopup);
570
+ this.removeEventListener("touchstart", this.#boundShowDelayedPopup);
546
571
  }
547
572
  }
548
573
 
@@ -613,36 +638,29 @@ class FigTooltip extends HTMLElement {
613
638
  if (this.action === "manual") return;
614
639
  if (this.action === "hover") {
615
640
  if (!this.isTouchDevice()) {
616
- this.addEventListener("pointerenter", this.showDelayedPopup.bind(this));
617
- this.addEventListener(
618
- "pointerleave",
619
- this.#handlePointerLeave.bind(this),
620
- );
641
+ this.addEventListener("pointerenter", this.#boundShowDelayedPopup);
642
+ this.addEventListener("pointerleave", this.#boundHandlePointerLeave);
621
643
  }
622
- // Touch support for mobile hover simulation
623
- this.addEventListener("touchstart", this.#handleTouchStart.bind(this), {
644
+ this.addEventListener("touchstart", this.#boundHandleTouchStart, {
624
645
  passive: true,
625
646
  });
626
- this.addEventListener("touchmove", this.#handleTouchMove.bind(this), {
647
+ this.addEventListener("touchmove", this.#boundHandleTouchMove, {
627
648
  passive: true,
628
649
  });
629
- this.addEventListener("touchend", this.#handleTouchEnd.bind(this), {
650
+ this.addEventListener("touchend", this.#boundHandleTouchEnd, {
630
651
  passive: true,
631
652
  });
632
- this.addEventListener("touchcancel", this.#handleTouchCancel.bind(this), {
653
+ this.addEventListener("touchcancel", this.#boundHandleTouchCancel, {
633
654
  passive: true,
634
655
  });
635
656
  } else if (this.action === "click") {
636
- this.addEventListener("click", this.showDelayedPopup.bind(this));
657
+ this.addEventListener("click", this.#boundShowDelayedPopup);
637
658
  document.body.addEventListener("click", this.#boundHidePopupOutsideClick);
638
-
639
- // Touch support for better mobile responsiveness
640
- this.addEventListener("touchstart", this.showDelayedPopup.bind(this), {
659
+ this.addEventListener("touchstart", this.#boundShowDelayedPopup, {
641
660
  passive: true,
642
661
  });
643
662
  }
644
663
 
645
- // Add listener for chrome interactions
646
664
  document.addEventListener("mousedown", this.#boundHideOnChromeOpen, true);
647
665
  }
648
666
 
@@ -934,6 +952,7 @@ class FigDialog extends HTMLDialogElement {
934
952
  #boundPointerDown;
935
953
  #boundPointerMove;
936
954
  #boundPointerUp;
955
+ #boundClose;
937
956
  #offset = 16; // 1rem in pixels
938
957
  #positionInitialized = false;
939
958
  #dragThreshold = 3; // pixels before drag starts
@@ -943,6 +962,7 @@ class FigDialog extends HTMLDialogElement {
943
962
  this.#boundPointerDown = this.#handlePointerDown.bind(this);
944
963
  this.#boundPointerMove = this.#handlePointerMove.bind(this);
945
964
  this.#boundPointerUp = this.#handlePointerUp.bind(this);
965
+ this.#boundClose = this.close.bind(this);
946
966
  }
947
967
 
948
968
  connectedCallback() {
@@ -962,12 +982,15 @@ class FigDialog extends HTMLDialogElement {
962
982
 
963
983
  disconnectedCallback() {
964
984
  this.#removeDragListeners();
985
+ this.querySelectorAll("fig-button[close-dialog]").forEach((button) => {
986
+ button.removeEventListener("click", this.#boundClose);
987
+ });
965
988
  }
966
989
 
967
990
  #addCloseListeners() {
968
991
  this.querySelectorAll("fig-button[close-dialog]").forEach((button) => {
969
- button.removeEventListener("click", this.close);
970
- button.addEventListener("click", this.close.bind(this));
992
+ button.removeEventListener("click", this.#boundClose);
993
+ button.addEventListener("click", this.#boundClose);
971
994
  });
972
995
  }
973
996
 
@@ -2242,16 +2265,18 @@ figDefineCustomizedBuiltIn("fig-popup", FigPopup, { extends: "dialog" });
2242
2265
  */
2243
2266
  class FigTab extends HTMLElement {
2244
2267
  #selected;
2268
+ #boundHandleClick;
2245
2269
  constructor() {
2246
2270
  super();
2247
2271
  this.content = null;
2248
2272
  this.#selected = false;
2273
+ this.#boundHandleClick = this.handleClick.bind(this);
2249
2274
  }
2250
2275
  connectedCallback() {
2251
2276
  this.setAttribute("label", this.innerText);
2252
2277
  this.setAttribute("role", "tab");
2253
2278
  this.setAttribute("tabindex", "0");
2254
- this.addEventListener("click", this.handleClick.bind(this));
2279
+ this.addEventListener("click", this.#boundHandleClick);
2255
2280
 
2256
2281
  requestAnimationFrame(() => {
2257
2282
  if (typeof this.getAttribute("content") === "string") {
@@ -2276,7 +2301,7 @@ class FigTab extends HTMLElement {
2276
2301
  this.setAttribute("selected", value ? "true" : "false");
2277
2302
  }
2278
2303
  disconnectedCallback() {
2279
- this.removeEventListener("click", this.handleClick);
2304
+ this.removeEventListener("click", this.#boundHandleClick);
2280
2305
  }
2281
2306
  handleClick() {
2282
2307
  this.selected = true;
@@ -2305,8 +2330,12 @@ customElements.define("fig-tab", FigTab);
2305
2330
  * @attr {string} name - Identifier for the tabs group
2306
2331
  */
2307
2332
  class FigTabs extends HTMLElement {
2333
+ #boundHandleClick;
2334
+ #boundHandleKeyDown;
2308
2335
  constructor() {
2309
2336
  super();
2337
+ this.#boundHandleClick = this.handleClick.bind(this);
2338
+ this.#boundHandleKeyDown = this.#handleKeyDown.bind(this);
2310
2339
  }
2311
2340
 
2312
2341
  static get observedAttributes() {
@@ -2316,8 +2345,8 @@ class FigTabs extends HTMLElement {
2316
2345
  connectedCallback() {
2317
2346
  this.name = this.getAttribute("name") || "tabs";
2318
2347
  this.setAttribute("role", "tablist");
2319
- this.addEventListener("click", this.handleClick.bind(this));
2320
- this.addEventListener("keydown", this.#handleKeyDown.bind(this));
2348
+ this.addEventListener("click", this.#boundHandleClick);
2349
+ this.addEventListener("keydown", this.#boundHandleKeyDown);
2321
2350
  requestAnimationFrame(() => {
2322
2351
  const value = this.getAttribute("value");
2323
2352
  if (value) {
@@ -2345,8 +2374,8 @@ class FigTabs extends HTMLElement {
2345
2374
  }
2346
2375
 
2347
2376
  disconnectedCallback() {
2348
- this.removeEventListener("click", this.handleClick);
2349
- this.removeEventListener("keydown", this.#handleKeyDown);
2377
+ this.removeEventListener("click", this.#boundHandleClick);
2378
+ this.removeEventListener("keydown", this.#boundHandleKeyDown);
2350
2379
  }
2351
2380
 
2352
2381
  #handleKeyDown(event) {
@@ -2446,14 +2475,16 @@ customElements.define("fig-tabs", FigTabs);
2446
2475
  class FigSegment extends HTMLElement {
2447
2476
  #value;
2448
2477
  #selected;
2478
+ #boundHandleClick;
2449
2479
  constructor() {
2450
2480
  super();
2481
+ this.#boundHandleClick = this.handleClick.bind(this);
2451
2482
  }
2452
2483
  connectedCallback() {
2453
- this.addEventListener("click", this.handleClick.bind(this));
2484
+ this.addEventListener("click", this.#boundHandleClick);
2454
2485
  }
2455
2486
  disconnectedCallback() {
2456
- this.removeEventListener("click", this.handleClick);
2487
+ this.removeEventListener("click", this.#boundHandleClick);
2457
2488
  }
2458
2489
  handleClick() {
2459
2490
  const parentControl = this.closest("fig-segmented-control");
@@ -5084,6 +5115,12 @@ class FigInputFill extends HTMLElement {
5084
5115
  this.#render();
5085
5116
  }
5086
5117
 
5118
+ disconnectedCallback() {
5119
+ this.#fillPicker = null;
5120
+ this.#opacityInput = null;
5121
+ this.#hexInput = null;
5122
+ }
5123
+
5087
5124
  #parseValue() {
5088
5125
  const valueAttr = this.getAttribute("value");
5089
5126
  if (!valueAttr) return;
@@ -6074,6 +6111,16 @@ class FigInputGradient extends HTMLElement {
6074
6111
 
6075
6112
  disconnectedCallback() {
6076
6113
  document.removeEventListener("keydown", this.#onKeyDown);
6114
+ if (this.#colorObserver) {
6115
+ this.#colorObserver.disconnect();
6116
+ this.#colorObserver = null;
6117
+ }
6118
+ clearTimeout(this.#arrowTooltipTimer);
6119
+ this.removeEventListener("pointerenter", this.#onTrackEnter);
6120
+ this.removeEventListener("pointermove", this.#onTrackMove);
6121
+ this.removeEventListener("pointerleave", this.#onTrackLeave);
6122
+ this.removeEventListener("click", this.#onTrackClick);
6123
+ this.removeEventListener("dblclick", this.#onTrackDblClick);
6077
6124
  }
6078
6125
 
6079
6126
  #onKeyDown = (e) => {
@@ -6208,10 +6255,8 @@ class FigInputGradient extends HTMLElement {
6208
6255
  }
6209
6256
 
6210
6257
  #sampleGradientColor(position) {
6211
- const canvas = document.createElement("canvas");
6212
- canvas.width = 256;
6213
- canvas.height = 1;
6214
- const ctx = canvas.getContext("2d");
6258
+ const { ctx } = figGetSharedCanvas(256, 1);
6259
+ ctx.clearRect(0, 0, 256, 1);
6215
6260
  const grad = ctx.createLinearGradient(0, 0, 256, 0);
6216
6261
  for (const stop of this.#gradient.stops) {
6217
6262
  try {
@@ -6450,6 +6495,44 @@ class FigInputGradient extends HTMLElement {
6450
6495
  #setupEventListeners() {
6451
6496
  if (!this.#track) return;
6452
6497
 
6498
+ this.#track.addEventListener("pointerdown", (e) => {
6499
+ if (this.hasAttribute("disabled")) return;
6500
+ if (e.target.closest("fig-handle:not(.fig-input-gradient-ghost)")) return;
6501
+ if (e.button !== 0) return;
6502
+ e.preventDefault();
6503
+
6504
+ const trackRect = this.#track.getBoundingClientRect();
6505
+ const pct = Math.max(0, Math.min(1, (e.clientX - trackRect.left) / trackRect.width));
6506
+ const position = Math.round(pct * 100);
6507
+ const color = this.#sampleGradientColor(pct);
6508
+ this.#gradient.stops.push({ position, color, opacity: 100 });
6509
+ this.#gradient.stops.sort((a, b) => a.position - b.position);
6510
+ const newIndex = this.#gradient.stops.findIndex(
6511
+ (s) => s.position === position && s.color === color,
6512
+ );
6513
+ this.#syncHandles();
6514
+ this.#syncChit();
6515
+ this.#emitInput();
6516
+ this.#hideGhost();
6517
+
6518
+ const handles = this.#track.querySelectorAll(
6519
+ "fig-handle:not(.fig-input-gradient-ghost)",
6520
+ );
6521
+ const newHandle = handles[newIndex];
6522
+ if (newHandle) {
6523
+ newHandle.select();
6524
+ newHandle.dispatchEvent(new PointerEvent("pointerdown", {
6525
+ bubbles: true,
6526
+ clientX: e.clientX,
6527
+ clientY: e.clientY,
6528
+ pointerId: e.pointerId,
6529
+ pointerType: e.pointerType,
6530
+ button: e.button,
6531
+ buttons: e.buttons,
6532
+ }));
6533
+ }
6534
+ });
6535
+
6453
6536
  this.#track.addEventListener("input", (e) => {
6454
6537
  const handle = e.target.closest("fig-handle");
6455
6538
  if (!handle) return;
@@ -6851,9 +6934,11 @@ customElements.define("fig-switch", FigSwitch);
6851
6934
  class FigToast extends HTMLDialogElement {
6852
6935
  _defaultOffset = 16; // 1rem in pixels
6853
6936
  _autoCloseTimer = null;
6937
+ #boundHandleClose;
6854
6938
 
6855
6939
  constructor() {
6856
6940
  super();
6941
+ this.#boundHandleClose = this.handleClose.bind(this);
6857
6942
  }
6858
6943
 
6859
6944
  getOffset() {
@@ -6903,8 +6988,8 @@ class FigToast extends HTMLDialogElement {
6903
6988
 
6904
6989
  addCloseListeners() {
6905
6990
  this.querySelectorAll("[close-toast]").forEach((button) => {
6906
- button.removeEventListener("click", this.handleClose);
6907
- button.addEventListener("click", this.handleClose.bind(this));
6991
+ button.removeEventListener("click", this.#boundHandleClose);
6992
+ button.addEventListener("click", this.#boundHandleClose);
6908
6993
  });
6909
6994
  }
6910
6995
 
@@ -7191,12 +7276,10 @@ class FigChit extends HTMLElement {
7191
7276
  }
7192
7277
 
7193
7278
  #toHex(color) {
7194
- // Convert color to hex for the native input
7195
7279
  if (!color) return "#D9D9D9";
7196
7280
  if (color.startsWith("#")) return color.slice(0, 7);
7197
- // Use canvas to convert rgba/named colors to hex
7198
7281
  try {
7199
- const ctx = document.createElement("canvas").getContext("2d");
7282
+ const { ctx } = figGetSharedCanvas(1, 1);
7200
7283
  ctx.fillStyle = color;
7201
7284
  return ctx.fillStyle;
7202
7285
  } catch {
@@ -7665,7 +7748,7 @@ class FigEasingCurve extends HTMLElement {
7665
7748
 
7666
7749
  connectedCallback() {
7667
7750
  this.#precision = parseInt(this.getAttribute("precision") || "2");
7668
- this.#syncAspectRatioVar(this.getAttribute("aspect-ratio"));
7751
+ figSyncCssVar(this, "--aspect-ratio", this.getAttribute("aspect-ratio"));
7669
7752
  const val = this.getAttribute("value");
7670
7753
  if (val) this.#parseValue(val);
7671
7754
  this.#presetName = this.#matchPreset();
@@ -7681,17 +7764,9 @@ class FigEasingCurve extends HTMLElement {
7681
7764
  }
7682
7765
  }
7683
7766
 
7684
- #syncAspectRatioVar(value) {
7685
- if (value && value.trim()) {
7686
- this.style.setProperty("--aspect-ratio", value.trim());
7687
- } else {
7688
- this.style.removeProperty("--aspect-ratio");
7689
- }
7690
- }
7691
-
7692
7767
  attributeChangedCallback(name, oldValue, newValue) {
7693
7768
  if (name === "aspect-ratio") {
7694
- this.#syncAspectRatioVar(newValue);
7769
+ figSyncCssVar(this, "--aspect-ratio", newValue);
7695
7770
  if (this.#svg) {
7696
7771
  this.#syncViewportSize();
7697
7772
  this.#updatePaths();
@@ -8486,12 +8561,9 @@ class Fig3DRotate extends HTMLElement {
8486
8561
 
8487
8562
  connectedCallback() {
8488
8563
  this.#precision = parseInt(this.getAttribute("precision") || "1");
8489
- this.#syncAspectRatioVar(this.getAttribute("aspect-ratio"));
8490
- this.#syncPerspectiveVar(this.getAttribute("perspective"));
8491
- this.#syncCSSVar(
8492
- "--perspective-origin",
8493
- this.getAttribute("perspective-origin"),
8494
- );
8564
+ figSyncCssVar(this, "--aspect-ratio", this.getAttribute("aspect-ratio"));
8565
+ figSyncCssVar(this, "--perspective", this.getAttribute("perspective"));
8566
+ figSyncCssVar(this, "--perspective-origin", this.getAttribute("perspective-origin"));
8495
8567
  this.#syncTransformOrigin(this.getAttribute("transform-origin"));
8496
8568
  this.#parseFields(this.getAttribute("fields"));
8497
8569
  const val = this.getAttribute("value");
@@ -8509,29 +8581,7 @@ class Fig3DRotate extends HTMLElement {
8509
8581
  }
8510
8582
  }
8511
8583
 
8512
- #syncAspectRatioVar(value) {
8513
- if (value && value.trim()) {
8514
- this.style.setProperty("--aspect-ratio", value.trim());
8515
- } else {
8516
- this.style.removeProperty("--aspect-ratio");
8517
- }
8518
- }
8519
-
8520
- #syncPerspectiveVar(value) {
8521
- if (value && value.trim()) {
8522
- this.style.setProperty("--perspective", value.trim());
8523
- } else {
8524
- this.style.removeProperty("--perspective");
8525
- }
8526
- }
8527
-
8528
- #syncCSSVar(prop, value) {
8529
- if (value && value.trim()) {
8530
- this.style.setProperty(prop, value.trim());
8531
- } else {
8532
- this.style.removeProperty(prop);
8533
- }
8534
- }
8584
+
8535
8585
 
8536
8586
  #syncTransformOrigin(value) {
8537
8587
  if (!value || !value.trim()) {
@@ -8580,15 +8630,15 @@ class Fig3DRotate extends HTMLElement {
8580
8630
 
8581
8631
  attributeChangedCallback(name, oldValue, newValue) {
8582
8632
  if (name === "aspect-ratio") {
8583
- this.#syncAspectRatioVar(newValue);
8633
+ figSyncCssVar(this, "--aspect-ratio", newValue);
8584
8634
  return;
8585
8635
  }
8586
8636
  if (name === "perspective") {
8587
- this.#syncPerspectiveVar(newValue);
8637
+ figSyncCssVar(this, "--perspective", newValue);
8588
8638
  return;
8589
8639
  }
8590
8640
  if (name === "perspective-origin") {
8591
- this.#syncCSSVar("--perspective-origin", newValue);
8641
+ figSyncCssVar(this, "--perspective-origin", newValue);
8592
8642
  return;
8593
8643
  }
8594
8644
  if (name === "transform-origin") {
@@ -8838,7 +8888,7 @@ class FigOriginGrid extends HTMLElement {
8838
8888
 
8839
8889
  connectedCallback() {
8840
8890
  this.#precision = parseInt(this.getAttribute("precision") || "0");
8841
- this.#syncAspectRatioVar(this.getAttribute("aspect-ratio"));
8891
+ figSyncCssVar(this, "--aspect-ratio", this.getAttribute("aspect-ratio"));
8842
8892
  this.#applyIncomingValue(this.getAttribute("value"));
8843
8893
 
8844
8894
  this.#render();
@@ -8862,7 +8912,7 @@ class FigOriginGrid extends HTMLElement {
8862
8912
 
8863
8913
  attributeChangedCallback(name, oldValue, newValue) {
8864
8914
  if (name === "aspect-ratio") {
8865
- this.#syncAspectRatioVar(newValue);
8915
+ figSyncCssVar(this, "--aspect-ratio", newValue);
8866
8916
  return;
8867
8917
  }
8868
8918
  if (name === "drag") {
@@ -8901,14 +8951,6 @@ class FigOriginGrid extends HTMLElement {
8901
8951
  return attr.toLowerCase() !== "false";
8902
8952
  }
8903
8953
 
8904
- #syncAspectRatioVar(value) {
8905
- if (value && value.trim()) {
8906
- this.style.setProperty("--aspect-ratio", value.trim());
8907
- } else {
8908
- this.style.removeProperty("--aspect-ratio");
8909
- }
8910
- }
8911
-
8912
8954
  #syncDragState() {
8913
8955
  if (!this.#grid) return;
8914
8956
  this.#grid.classList.toggle("drag-disabled", !this.#dragEnabled);
@@ -9292,6 +9334,7 @@ class FigInputJoystick extends HTMLElement {
9292
9334
  #boundYFocusOut = null;
9293
9335
  #isSyncingValueAttr = false;
9294
9336
  #defaultPosition = { x: 0.5, y: 0.5 };
9337
+ #initialized = false;
9295
9338
 
9296
9339
  constructor() {
9297
9340
  super();
@@ -9303,7 +9346,6 @@ class FigInputJoystick extends HTMLElement {
9303
9346
  this.xInput = null;
9304
9347
  this.yInput = null;
9305
9348
  this.coordinates = "screen";
9306
- this.#initialized = false;
9307
9349
  this.#boundPlanePointerDown = (e) => this.#handlePlanePointerDown(e);
9308
9350
  this.#boundHandlePointerDown = () => {
9309
9351
  this.isDragging = true;
@@ -9317,8 +9359,6 @@ class FigInputJoystick extends HTMLElement {
9317
9359
  this.#boundYFocusOut = () => this.#handleFieldFocusOut();
9318
9360
  }
9319
9361
 
9320
- #initialized = false;
9321
-
9322
9362
  connectedCallback() {
9323
9363
  // Initialize position
9324
9364
  requestAnimationFrame(() => {
@@ -9327,7 +9367,7 @@ class FigInputJoystick extends HTMLElement {
9327
9367
  this.transform = this.getAttribute("transform") || 1;
9328
9368
  this.transform = Number(this.transform);
9329
9369
  this.coordinates = this.getAttribute("coordinates") || "screen";
9330
- this.#syncAspectRatioVar(this.getAttribute("aspect-ratio"));
9370
+ figSyncCssVar(this, "--aspect-ratio", this.getAttribute("aspect-ratio"));
9331
9371
  if (!this.hasAttribute("value")) {
9332
9372
  this.setAttribute("value", "50% 50%");
9333
9373
  }
@@ -9346,14 +9386,6 @@ class FigInputJoystick extends HTMLElement {
9346
9386
  return this.coordinates === "math" ? 1 - y : y;
9347
9387
  }
9348
9388
 
9349
- #syncAspectRatioVar(value) {
9350
- if (value && value.trim()) {
9351
- this.style.setProperty("--aspect-ratio", value.trim());
9352
- } else {
9353
- this.style.removeProperty("--aspect-ratio");
9354
- }
9355
- }
9356
-
9357
9389
  disconnectedCallback() {
9358
9390
  this.#cleanupListeners();
9359
9391
  }
@@ -9655,7 +9687,7 @@ class FigInputJoystick extends HTMLElement {
9655
9687
  }
9656
9688
  attributeChangedCallback(name, oldValue, newValue) {
9657
9689
  if (name === "aspect-ratio") {
9658
- this.#syncAspectRatioVar(newValue);
9690
+ figSyncCssVar(this, "--aspect-ratio", newValue);
9659
9691
  return;
9660
9692
  }
9661
9693
  if (name === "value") {
@@ -9705,6 +9737,11 @@ class FigInputAngle extends HTMLElement {
9705
9737
  #opposite;
9706
9738
  #prevRawAngle = null;
9707
9739
  #boundHandleRawChange;
9740
+ #boundHandleMouseDown;
9741
+ #boundHandleTouchStart;
9742
+ #boundHandleKeyDown;
9743
+ #boundHandleKeyUp;
9744
+ #boundHandleAngleInput;
9708
9745
 
9709
9746
  constructor() {
9710
9747
  super();
@@ -9725,6 +9762,11 @@ class FigInputAngle extends HTMLElement {
9725
9762
  this.rotationSpan = null;
9726
9763
 
9727
9764
  this.#boundHandleRawChange = this.#handleRawChange.bind(this);
9765
+ this.#boundHandleMouseDown = this.#handleMouseDown.bind(this);
9766
+ this.#boundHandleTouchStart = this.#handleTouchStart.bind(this);
9767
+ this.#boundHandleKeyDown = this.#handleKeyDown.bind(this);
9768
+ this.#boundHandleKeyUp = this.#handleKeyUp.bind(this);
9769
+ this.#boundHandleAngleInput = this.#handleAngleInput.bind(this);
9728
9770
  }
9729
9771
 
9730
9772
  connectedCallback() {
@@ -9899,30 +9941,23 @@ class FigInputAngle extends HTMLElement {
9899
9941
  this.angleInput = this.querySelector("fig-input-number[name='angle']");
9900
9942
  this.rotationSpan = this.querySelector(".fig-input-angle-rotations");
9901
9943
  this.#updateRotationDisplay();
9902
- this.plane?.addEventListener("mousedown", this.#handleMouseDown.bind(this));
9903
- this.plane?.addEventListener(
9904
- "touchstart",
9905
- this.#handleTouchStart.bind(this),
9906
- );
9907
- window.addEventListener("keydown", this.#handleKeyDown.bind(this));
9908
- window.addEventListener("keyup", this.#handleKeyUp.bind(this));
9944
+ this.plane?.addEventListener("mousedown", this.#boundHandleMouseDown);
9945
+ this.plane?.addEventListener("touchstart", this.#boundHandleTouchStart);
9946
+ window.addEventListener("keydown", this.#boundHandleKeyDown);
9947
+ window.addEventListener("keyup", this.#boundHandleKeyUp);
9909
9948
  if (this.text && this.angleInput) {
9910
- this.angleInput.addEventListener(
9911
- "input",
9912
- this.#handleAngleInput.bind(this),
9913
- );
9949
+ this.angleInput.addEventListener("input", this.#boundHandleAngleInput);
9914
9950
  }
9915
- // Capture-phase listener for unit suffix parsing
9916
9951
  this.addEventListener("change", this.#boundHandleRawChange, true);
9917
9952
  }
9918
9953
 
9919
9954
  #cleanupListeners() {
9920
- this.plane?.removeEventListener("mousedown", this.#handleMouseDown);
9921
- this.plane?.removeEventListener("touchstart", this.#handleTouchStart);
9922
- window.removeEventListener("keydown", this.#handleKeyDown);
9923
- window.removeEventListener("keyup", this.#handleKeyUp);
9955
+ this.plane?.removeEventListener("mousedown", this.#boundHandleMouseDown);
9956
+ this.plane?.removeEventListener("touchstart", this.#boundHandleTouchStart);
9957
+ window.removeEventListener("keydown", this.#boundHandleKeyDown);
9958
+ window.removeEventListener("keyup", this.#boundHandleKeyUp);
9924
9959
  if (this.text && this.angleInput) {
9925
- this.angleInput.removeEventListener("input", this.#handleAngleInput);
9960
+ this.angleInput.removeEventListener("input", this.#boundHandleAngleInput);
9926
9961
  }
9927
9962
  this.removeEventListener("change", this.#boundHandleRawChange, true);
9928
9963
  }
@@ -10427,6 +10462,8 @@ class FigFillPicker extends HTMLElement {
10427
10462
  #opacitySlider = null;
10428
10463
  #isDraggingColor = false;
10429
10464
  #teardownColorAreaEvents = null;
10465
+ #dialogOpenObserver = null;
10466
+ #webcamTabObserver = null;
10430
10467
 
10431
10468
  constructor() {
10432
10469
  super();
@@ -10452,10 +10489,26 @@ class FigFillPicker extends HTMLElement {
10452
10489
  this.#teardownColorAreaEvents();
10453
10490
  this.#teardownColorAreaEvents = null;
10454
10491
  }
10492
+ if (this.#dialogOpenObserver) {
10493
+ this.#dialogOpenObserver.disconnect();
10494
+ this.#dialogOpenObserver = null;
10495
+ }
10496
+ if (this.#webcamTabObserver) {
10497
+ this.#webcamTabObserver.disconnect();
10498
+ this.#webcamTabObserver = null;
10499
+ }
10500
+ if (this.#webcam.stream) {
10501
+ this.#webcam.stream.getTracks().forEach((track) => track.stop());
10502
+ this.#webcam.stream = null;
10503
+ }
10504
+ if (this.#video.url && this.#video.url.startsWith("blob:")) {
10505
+ URL.revokeObjectURL(this.#video.url);
10506
+ }
10455
10507
  if (this.#chit) this.#chit.removeAttribute("selected");
10456
10508
  if (this.#dialog) {
10457
10509
  this.#dialog.close();
10458
10510
  this.#dialog.remove();
10511
+ this.#dialog = null;
10459
10512
  }
10460
10513
  }
10461
10514
 
@@ -10792,11 +10845,11 @@ class FigFillPicker extends HTMLElement {
10792
10845
  };
10793
10846
  this.#dialog.addEventListener("close", onDialogClose);
10794
10847
 
10795
- const observer = new MutationObserver(() => {
10848
+ this.#dialogOpenObserver = new MutationObserver(() => {
10796
10849
  const isOpen = this.#dialog.hasAttribute("open") && this.#dialog.getAttribute("open") !== "false";
10797
10850
  if (!isOpen) onDialogClose();
10798
10851
  });
10799
- observer.observe(this.#dialog, { attributes: true, attributeFilter: ["open"] });
10852
+ this.#dialogOpenObserver.observe(this.#dialog, { attributes: true, attributeFilter: ["open"] });
10800
10853
 
10801
10854
  // Initialize built-in tabs (skip any overridden by custom slots)
10802
10855
  const builtinInits = {
@@ -11718,10 +11771,15 @@ class FigFillPicker extends HTMLElement {
11718
11771
  }
11719
11772
  }
11720
11773
 
11774
+ static #gradientSupportCache = new Map();
11721
11775
  #testGradientSupport(css) {
11776
+ const cached = FigFillPicker.#gradientSupportCache.get(css);
11777
+ if (cached !== undefined) return cached;
11722
11778
  const el = document.createElement("div");
11723
11779
  el.style.background = css;
11724
- return !!el.style.background;
11780
+ const result = !!el.style.background;
11781
+ FigFillPicker.#gradientSupportCache.set(css, result);
11782
+ return result;
11725
11783
  }
11726
11784
 
11727
11785
  #getGradientCSS() {
@@ -12066,13 +12124,12 @@ class FigFillPicker extends HTMLElement {
12066
12124
  }
12067
12125
  };
12068
12126
 
12069
- // Start webcam when tab is shown
12070
- const observer = new MutationObserver(() => {
12127
+ this.#webcamTabObserver = new MutationObserver(() => {
12071
12128
  if (container.style.display !== "none" && !this.#webcam.stream) {
12072
12129
  startWebcam();
12073
12130
  }
12074
12131
  });
12075
- observer.observe(container, {
12132
+ this.#webcamTabObserver.observe(container, {
12076
12133
  attributes: true,
12077
12134
  attributeFilter: ["style"],
12078
12135
  });
@@ -12591,8 +12648,8 @@ class FigColorTip extends HTMLElement {
12591
12648
  }
12592
12649
 
12593
12650
  try {
12594
- const ctx = document.createElement("canvas").getContext("2d");
12595
- if (!ctx) return "#D9D9D9";
12651
+ const { ctx } = figGetSharedCanvas(1, 1);
12652
+ ctx.fillStyle = "#000000";
12596
12653
  ctx.fillStyle = value;
12597
12654
  const resolved = ctx.fillStyle;
12598
12655
  if (resolved.startsWith("#")) {
@@ -13547,12 +13604,13 @@ class FigHandle extends HTMLElement {
13547
13604
 
13548
13605
  this.#isDragging = true;
13549
13606
  const axes = this.#axes;
13550
- const containerRect = container.getBoundingClientRect();
13551
13607
  const handleW = this.offsetWidth;
13552
13608
  const handleH = this.offsetHeight;
13609
+ let lastRect = null;
13553
13610
 
13554
13611
  const clampAndApply = (clientX, clientY, shiftKey = false) => {
13555
13612
  const rect = container.getBoundingClientRect();
13613
+ lastRect = rect;
13556
13614
  const currentLeft = parseFloat(this.style.left) || 0;
13557
13615
  const currentTop = parseFloat(this.style.top) || 0;
13558
13616
  const rawX = clientX - rect.left - handleW / 2;
@@ -13609,7 +13667,7 @@ class FigHandle extends HTMLElement {
13609
13667
  new CustomEvent("input", {
13610
13668
  bubbles: true,
13611
13669
  detail: {
13612
- ...this.#positionDetail(container.getBoundingClientRect()),
13670
+ ...this.#positionDetail(lastRect),
13613
13671
  shiftKey: e.shiftKey,
13614
13672
  },
13615
13673
  }),
@@ -13628,7 +13686,7 @@ class FigHandle extends HTMLElement {
13628
13686
  this.dispatchEvent(
13629
13687
  new CustomEvent("change", {
13630
13688
  bubbles: true,
13631
- detail: this.#positionDetail(container.getBoundingClientRect()),
13689
+ detail: this.#positionDetail(lastRect),
13632
13690
  }),
13633
13691
  );
13634
13692
  const swallowClick = (evt) => {