@rogieking/figui3 6.6.4 → 6.6.6

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
@@ -164,6 +164,19 @@ function figUniqueId() {
164
164
  return Date.now().toString(36) + Math.random().toString(36).substring(2);
165
165
  }
166
166
 
167
+ /** Zero-size portal for fixed overlays so they never affect body layout metrics. */
168
+ function figGetOverlayRoot() {
169
+ if (!document.body) return null;
170
+ const attr = "data-figui-overlay-root";
171
+ let root = document.body.querySelector(`:scope > [${attr}]`);
172
+ if (!root) {
173
+ root = document.createElement("div");
174
+ root.setAttribute(attr, "");
175
+ document.body.append(root);
176
+ }
177
+ return root;
178
+ }
179
+
167
180
  let _figZCounter = 10000;
168
181
  function figGetHighestZIndex() {
169
182
  return _figZCounter++;
@@ -734,7 +747,9 @@ customElements.define("fig-dropdown", FigDropdown);
734
747
  */
735
748
  class FigTooltip extends HTMLElement {
736
749
  static #lastShownAt = 0;
750
+ static #lastShownInstance = null;
737
751
  static #warmupWindow = 500;
752
+ static #hoverOpen = null;
738
753
 
739
754
  #boundHideOnChromeOpen;
740
755
  #boundHidePopupOutsideClick;
@@ -747,6 +762,8 @@ class FigTooltip extends HTMLElement {
747
762
  #boundHandleDialogClose;
748
763
  #boundHandleEscape;
749
764
  #parentDialog = null;
765
+ #triggerEl = null;
766
+ #childObserver = null;
750
767
  #touchTimeout;
751
768
  #isTouching = false;
752
769
  constructor() {
@@ -772,6 +789,7 @@ class FigTooltip extends HTMLElement {
772
789
  }
773
790
  connectedCallback() {
774
791
  this.setup();
792
+ this.#bindTriggerListeners();
775
793
  this.setupEventListeners();
776
794
  this.#parentDialog = this.closest("dialog");
777
795
  if (this.#parentDialog) {
@@ -782,6 +800,8 @@ class FigTooltip extends HTMLElement {
782
800
  disconnectedCallback() {
783
801
  clearTimeout(this.timeout);
784
802
  this.destroy();
803
+ this.#unbindTriggerListeners();
804
+ this.#teardownChildObserver();
785
805
  document.removeEventListener(
786
806
  "mousedown",
787
807
  this.#boundHideOnChromeOpen,
@@ -801,19 +821,79 @@ class FigTooltip extends HTMLElement {
801
821
  }
802
822
 
803
823
  clearTimeout(this.#touchTimeout);
824
+ if (FigTooltip.#hoverOpen === this) FigTooltip.#hoverOpen = null;
825
+ }
826
+
827
+ #getTrigger() {
828
+ return this.firstElementChild;
829
+ }
830
+
831
+ #teardownChildObserver() {
832
+ this.#childObserver?.disconnect();
833
+ this.#childObserver = null;
834
+ }
835
+
836
+ #bindTriggerListeners() {
837
+ this.#unbindTriggerListeners();
838
+ if (this.action === "manual") return;
839
+
840
+ const trigger = this.#getTrigger();
841
+ if (!trigger) {
842
+ if (!this.#childObserver && typeof MutationObserver !== "undefined") {
843
+ this.#childObserver = new MutationObserver(() => {
844
+ if (this.#getTrigger()) {
845
+ this.#teardownChildObserver();
846
+ this.#bindTriggerListeners();
847
+ }
848
+ });
849
+ this.#childObserver.observe(this, { childList: true });
850
+ }
851
+ return;
852
+ }
853
+
854
+ this.#triggerEl = trigger;
804
855
  if (this.action === "hover") {
805
- this.removeEventListener("pointerenter", this.#boundShowDelayedPopup);
806
- this.removeEventListener("pointerleave", this.#boundHandlePointerLeave);
807
- this.removeEventListener("touchstart", this.#boundHandleTouchStart);
808
- this.removeEventListener("touchmove", this.#boundHandleTouchMove);
809
- this.removeEventListener("touchend", this.#boundHandleTouchEnd);
810
- this.removeEventListener("touchcancel", this.#boundHandleTouchCancel);
856
+ if (!this.isTouchDevice()) {
857
+ trigger.addEventListener("pointerenter", this.#boundShowDelayedPopup);
858
+ trigger.addEventListener("pointerleave", this.#boundHandlePointerLeave);
859
+ }
860
+ trigger.addEventListener("touchstart", this.#boundHandleTouchStart, {
861
+ passive: true,
862
+ });
863
+ trigger.addEventListener("touchmove", this.#boundHandleTouchMove, {
864
+ passive: true,
865
+ });
866
+ trigger.addEventListener("touchend", this.#boundHandleTouchEnd, {
867
+ passive: true,
868
+ });
869
+ trigger.addEventListener("touchcancel", this.#boundHandleTouchCancel, {
870
+ passive: true,
871
+ });
811
872
  } else if (this.action === "click") {
812
- this.removeEventListener("click", this.#boundShowDelayedPopup);
813
- this.removeEventListener("touchstart", this.#boundShowDelayedPopup);
873
+ trigger.addEventListener("click", this.#boundShowDelayedPopup);
874
+ trigger.addEventListener("touchstart", this.#boundShowDelayedPopup, {
875
+ passive: true,
876
+ });
814
877
  }
815
878
  }
816
879
 
880
+ #unbindTriggerListeners() {
881
+ const trigger = this.#triggerEl;
882
+ if (!trigger) return;
883
+ if (this.action === "hover") {
884
+ trigger.removeEventListener("pointerenter", this.#boundShowDelayedPopup);
885
+ trigger.removeEventListener("pointerleave", this.#boundHandlePointerLeave);
886
+ trigger.removeEventListener("touchstart", this.#boundHandleTouchStart);
887
+ trigger.removeEventListener("touchmove", this.#boundHandleTouchMove);
888
+ trigger.removeEventListener("touchend", this.#boundHandleTouchEnd);
889
+ trigger.removeEventListener("touchcancel", this.#boundHandleTouchCancel);
890
+ } else if (this.action === "click") {
891
+ trigger.removeEventListener("click", this.#boundShowDelayedPopup);
892
+ trigger.removeEventListener("touchstart", this.#boundShowDelayedPopup);
893
+ }
894
+ this.#triggerEl = null;
895
+ }
896
+
817
897
  setup() {
818
898
  this.style.display = "contents";
819
899
  }
@@ -859,13 +939,13 @@ class FigTooltip extends HTMLElement {
859
939
  // - Without popover support, fall back to today's behavior: nearest open
860
940
  // <dialog> ancestor if present, else document.body.
861
941
  if (supportsPopover) {
862
- document.body.append(this.popup);
942
+ (figGetOverlayRoot() ?? document.body).append(this.popup);
863
943
  } else {
864
944
  const parentDialog = this.closest("dialog");
865
945
  if (parentDialog && parentDialog.open) {
866
946
  parentDialog.append(this.popup);
867
947
  } else {
868
- document.body.append(this.popup);
948
+ (figGetOverlayRoot() ?? document.body).append(this.popup);
869
949
  }
870
950
  }
871
951
 
@@ -876,6 +956,7 @@ class FigTooltip extends HTMLElement {
876
956
 
877
957
  destroy() {
878
958
  if (this.popup) {
959
+ this.popup.hidePopup?.();
879
960
  this.popup.remove();
880
961
  this.popup = null;
881
962
  }
@@ -896,30 +977,8 @@ class FigTooltip extends HTMLElement {
896
977
  }
897
978
 
898
979
  setupEventListeners() {
899
- if (this.action === "manual") return;
900
- if (this.action === "hover") {
901
- if (!this.isTouchDevice()) {
902
- this.addEventListener("pointerenter", this.#boundShowDelayedPopup);
903
- this.addEventListener("pointerleave", this.#boundHandlePointerLeave);
904
- }
905
- this.addEventListener("touchstart", this.#boundHandleTouchStart, {
906
- passive: true,
907
- });
908
- this.addEventListener("touchmove", this.#boundHandleTouchMove, {
909
- passive: true,
910
- });
911
- this.addEventListener("touchend", this.#boundHandleTouchEnd, {
912
- passive: true,
913
- });
914
- this.addEventListener("touchcancel", this.#boundHandleTouchCancel, {
915
- passive: true,
916
- });
917
- } else if (this.action === "click") {
918
- this.addEventListener("click", this.#boundShowDelayedPopup);
980
+ if (this.action === "click") {
919
981
  document.body.addEventListener("click", this.#boundHidePopupOutsideClick);
920
- this.addEventListener("touchstart", this.#boundShowDelayedPopup, {
921
- passive: true,
922
- });
923
982
  }
924
983
 
925
984
  document.addEventListener("mousedown", this.#boundHideOnChromeOpen, true);
@@ -932,17 +991,27 @@ class FigTooltip extends HTMLElement {
932
991
 
933
992
  showDelayedPopup() {
934
993
  if (this.#showPersisted) return;
935
- this.render();
936
994
  clearTimeout(this.timeout);
937
995
  const warm =
996
+ FigTooltip.#lastShownInstance === this &&
938
997
  Date.now() - FigTooltip.#lastShownAt < FigTooltip.#warmupWindow;
939
998
  const effectiveDelay = warm ? 0 : this.delay;
940
- this.timeout = setTimeout(this.showPopup.bind(this), effectiveDelay);
999
+ this.timeout = setTimeout(() => {
1000
+ this.render();
1001
+ this.showPopup();
1002
+ }, effectiveDelay);
941
1003
  }
942
1004
 
943
1005
  showPopup() {
944
1006
  if (this.#parentDialog && !this.#parentDialog.open) return;
945
1007
  if (!this.firstElementChild) return;
1008
+ if (
1009
+ this.action === "hover" &&
1010
+ FigTooltip.#hoverOpen &&
1011
+ FigTooltip.#hoverOpen !== this
1012
+ ) {
1013
+ FigTooltip.#hoverOpen.hidePopup();
1014
+ }
946
1015
  if (!this.popup) this.render();
947
1016
  // Keep anchor in sync in case the trigger child was swapped between
948
1017
  // creation and show.
@@ -950,7 +1019,9 @@ class FigTooltip extends HTMLElement {
950
1019
  this.popup.open = true;
951
1020
 
952
1021
  this.isOpen = true;
1022
+ if (this.action === "hover") FigTooltip.#hoverOpen = this;
953
1023
  FigTooltip.#lastShownAt = Date.now();
1024
+ FigTooltip.#lastShownInstance = this;
954
1025
  }
955
1026
 
956
1027
  hidePopup() {
@@ -962,7 +1033,7 @@ class FigTooltip extends HTMLElement {
962
1033
  }
963
1034
 
964
1035
  this.isOpen = false;
965
- FigTooltip.#lastShownAt = Date.now();
1036
+ if (FigTooltip.#hoverOpen === this) FigTooltip.#hoverOpen = null;
966
1037
  }
967
1038
 
968
1039
  hidePopupOutsideClick(event) {
@@ -1142,13 +1213,13 @@ class FigTooltip extends HTMLElement {
1142
1213
  popup.append(content);
1143
1214
 
1144
1215
  if (supportsPopover) {
1145
- document.body.append(popup);
1216
+ (figGetOverlayRoot() ?? document.body).append(popup);
1146
1217
  } else {
1147
1218
  const parentDialog = anchor.closest?.("dialog");
1148
1219
  if (parentDialog && parentDialog.open) {
1149
1220
  parentDialog.append(popup);
1150
1221
  } else {
1151
- document.body.append(popup);
1222
+ (figGetOverlayRoot() ?? document.body).append(popup);
1152
1223
  }
1153
1224
  }
1154
1225
 
@@ -3026,23 +3097,6 @@ class FigPopup extends HTMLDialogElement {
3026
3097
  );
3027
3098
  }
3028
3099
 
3029
- updatePointerVisibility(anchorRect, popupRect, left, top, placementSide) {
3030
- if (!this.tracksAnchorBeak()) return;
3031
- if (this.getAttribute("pointer") === "false") return;
3032
-
3033
- if (
3034
- anchorRect &&
3035
- !this.canPointAtAnchor(anchorRect, popupRect, left, top, placementSide)
3036
- ) {
3037
- this.setAttribute("pointer", "false");
3038
- return;
3039
- }
3040
-
3041
- if (this.hasAttribute("pointer")) {
3042
- this.removeAttribute("pointer");
3043
- }
3044
- }
3045
-
3046
3100
  updatePopoverBeak(anchorRect, popupRect, left, top, placementSide) {
3047
3101
  if (!this.tracksAnchorBeak() || !anchorRect) {
3048
3102
  this.style.removeProperty("--fig-popup-beak-offset");
@@ -3110,6 +3164,23 @@ class FigPopup extends HTMLDialogElement {
3110
3164
  return this.clampToViewport(coords, popupRect, m);
3111
3165
  }
3112
3166
 
3167
+ primaryAxisOverflowPenalty(coords, popupRect, m, placementSide) {
3168
+ const bounds = this.getViewportBounds(m);
3169
+ let overflow = 0;
3170
+
3171
+ if (placementSide === "top") {
3172
+ overflow = Math.max(0, bounds.minTop - coords.top);
3173
+ } else if (placementSide === "bottom") {
3174
+ overflow = Math.max(0, coords.top + popupRect.height - bounds.maxBottom);
3175
+ } else if (placementSide === "left") {
3176
+ overflow = Math.max(0, bounds.minLeft - coords.left);
3177
+ } else if (placementSide === "right") {
3178
+ overflow = Math.max(0, coords.left + popupRect.width - bounds.maxRight);
3179
+ }
3180
+
3181
+ return overflow > 0 ? 1000 + overflow : 0;
3182
+ }
3183
+
3113
3184
  placementScore(anchorRect, popupRect, coords, placementSide, m) {
3114
3185
  const resolved = this.resolveCoordsAtViewport(
3115
3186
  anchorRect,
@@ -3119,6 +3190,12 @@ class FigPopup extends HTMLDialogElement {
3119
3190
  m,
3120
3191
  );
3121
3192
  let score = this.overflowScore(resolved, popupRect, m);
3193
+ score += this.primaryAxisOverflowPenalty(
3194
+ coords,
3195
+ popupRect,
3196
+ m,
3197
+ placementSide,
3198
+ );
3122
3199
  if (
3123
3200
  anchorRect &&
3124
3201
  !this.canPointAtAnchor(
@@ -3150,13 +3227,6 @@ class FigPopup extends HTMLDialogElement {
3150
3227
  );
3151
3228
  this.style.left = `${resolved.left}px`;
3152
3229
  this.style.top = `${resolved.top}px`;
3153
- this.updatePointerVisibility(
3154
- anchorRect,
3155
- popupRect,
3156
- resolved.left,
3157
- resolved.top,
3158
- placementSide,
3159
- );
3160
3230
  this.updatePopoverBeak(
3161
3231
  anchorRect,
3162
3232
  popupRect,
@@ -3167,7 +3237,7 @@ class FigPopup extends HTMLDialogElement {
3167
3237
  }
3168
3238
 
3169
3239
  positionPopup() {
3170
- if (!this.open || !this.matches?.(":open")) return;
3240
+ if (!this.open) return;
3171
3241
 
3172
3242
  const popupRect = this.getBoundingClientRect();
3173
3243
  const offset = this.parseOffset();
@@ -3259,7 +3329,9 @@ class FigPopup extends HTMLDialogElement {
3259
3329
  }
3260
3330
 
3261
3331
  queueReposition() {
3262
- if (!this.open || !this.shouldAutoReposition()) return;
3332
+ if (!this.open || !this.isPopupDisplayed() || !this.shouldAutoReposition()) {
3333
+ return;
3334
+ }
3263
3335
  if (this._rafId !== null) return;
3264
3336
 
3265
3337
  this._rafId = requestAnimationFrame(() => {
@@ -3272,6 +3344,14 @@ class FigPopup extends HTMLDialogElement {
3272
3344
  if (!(this.drag && this._wasDragged)) return true;
3273
3345
  return !this.resolveAnchor();
3274
3346
  }
3347
+
3348
+ isPopupDisplayed() {
3349
+ return Boolean(
3350
+ this._isPopupActive ||
3351
+ this.matches?.(":open") ||
3352
+ this.matches?.(":popover-open"),
3353
+ );
3354
+ }
3275
3355
  }
3276
3356
  figDefineCustomizedBuiltIn("fig-popup", FigPopup, { extends: "dialog" });
3277
3357
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "6.6.4",
3
+ "version": "6.6.6",
4
4
  "description": "A lightweight web components library for building Figma plugin and widget UIs with native look and feel",
5
5
  "author": "Rogie King",
6
6
  "license": "MIT",