@rogieking/figui3 6.6.3 → 6.6.5

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++;
@@ -859,13 +872,13 @@ class FigTooltip extends HTMLElement {
859
872
  // - Without popover support, fall back to today's behavior: nearest open
860
873
  // <dialog> ancestor if present, else document.body.
861
874
  if (supportsPopover) {
862
- document.body.append(this.popup);
875
+ (figGetOverlayRoot() ?? document.body).append(this.popup);
863
876
  } else {
864
877
  const parentDialog = this.closest("dialog");
865
878
  if (parentDialog && parentDialog.open) {
866
879
  parentDialog.append(this.popup);
867
880
  } else {
868
- document.body.append(this.popup);
881
+ (figGetOverlayRoot() ?? document.body).append(this.popup);
869
882
  }
870
883
  }
871
884
 
@@ -1142,13 +1155,13 @@ class FigTooltip extends HTMLElement {
1142
1155
  popup.append(content);
1143
1156
 
1144
1157
  if (supportsPopover) {
1145
- document.body.append(popup);
1158
+ (figGetOverlayRoot() ?? document.body).append(popup);
1146
1159
  } else {
1147
1160
  const parentDialog = anchor.closest?.("dialog");
1148
1161
  if (parentDialog && parentDialog.open) {
1149
1162
  parentDialog.append(popup);
1150
1163
  } else {
1151
- document.body.append(popup);
1164
+ (figGetOverlayRoot() ?? document.body).append(popup);
1152
1165
  }
1153
1166
  }
1154
1167
 
@@ -2884,10 +2897,150 @@ class FigPopup extends HTMLDialogElement {
2884
2897
  return "top";
2885
2898
  }
2886
2899
 
2887
- updatePopoverBeak(anchorRect, popupRect, left, top, placementSide) {
2900
+ lengthToPx(value, fallback = 0) {
2901
+ const styles = getComputedStyle(this);
2902
+ const raw = String(value || "").trim();
2903
+ const n = parseFloat(raw);
2904
+ if (!Number.isFinite(n)) return fallback;
2905
+ if (raw.endsWith("rem")) {
2906
+ return n * parseFloat(getComputedStyle(document.documentElement).fontSize);
2907
+ }
2908
+ if (raw.endsWith("em")) {
2909
+ return n * parseFloat(styles.fontSize);
2910
+ }
2911
+ return n;
2912
+ }
2913
+
2914
+ radiusForSide(side) {
2915
+ const styles = getComputedStyle(this);
2916
+ const toPx = (value) => this.lengthToPx(value, 0);
2917
+ if (side === "top") {
2918
+ return Math.max(
2919
+ toPx(styles.borderTopLeftRadius),
2920
+ toPx(styles.borderTopRightRadius),
2921
+ );
2922
+ }
2923
+ if (side === "bottom") {
2924
+ return Math.max(
2925
+ toPx(styles.borderBottomLeftRadius),
2926
+ toPx(styles.borderBottomRightRadius),
2927
+ );
2928
+ }
2929
+ if (side === "left") {
2930
+ return Math.max(
2931
+ toPx(styles.borderTopLeftRadius),
2932
+ toPx(styles.borderBottomLeftRadius),
2933
+ );
2934
+ }
2935
+ if (side === "right") {
2936
+ return Math.max(
2937
+ toPx(styles.borderTopRightRadius),
2938
+ toPx(styles.borderBottomRightRadius),
2939
+ );
2940
+ }
2941
+ return 0;
2942
+ }
2943
+
2944
+ getBeakEdgeInset(beakSide) {
2945
+ const beakWidth = this.lengthToPx(
2946
+ getComputedStyle(this).getPropertyValue("--fig-popup-beak-width"),
2947
+ 16,
2948
+ );
2949
+ return Math.max(10, this.radiusForSide(beakSide) + beakWidth / 2);
2950
+ }
2951
+
2952
+ tracksAnchorBeak() {
2888
2953
  const variant = this.getAttribute("variant");
2889
- const beakVariants = variant === "popover" || variant === "tooltip";
2890
- if (!beakVariants || !anchorRect) {
2954
+ return variant === "popover" || variant === "tooltip";
2955
+ }
2956
+
2957
+ getViewportBounds(m) {
2958
+ const vv = window.visualViewport;
2959
+ const width = vv?.width ?? window.innerWidth;
2960
+ const height = vv?.height ?? window.innerHeight;
2961
+ const offsetLeft = vv?.offsetLeft ?? 0;
2962
+ const offsetTop = vv?.offsetTop ?? 0;
2963
+
2964
+ return {
2965
+ minLeft: offsetLeft + m.left,
2966
+ minTop: offsetTop + m.top,
2967
+ maxRight: offsetLeft + width - m.right,
2968
+ maxBottom: offsetTop + height - m.bottom,
2969
+ };
2970
+ }
2971
+
2972
+ clampToViewport(coords, popupRect, m) {
2973
+ const bounds = this.getViewportBounds(m);
2974
+ const maxLeft = bounds.maxRight - popupRect.width;
2975
+ const maxTop = bounds.maxBottom - popupRect.height;
2976
+
2977
+ return {
2978
+ left: Math.min(maxLeft, Math.max(bounds.minLeft, coords.left)),
2979
+ top: Math.min(maxTop, Math.max(bounds.minTop, coords.top)),
2980
+ };
2981
+ }
2982
+
2983
+ resolveCoordsAtViewport(anchorRect, popupRect, coords, placementSide, m) {
2984
+ let { left, top } = this.clampToViewport(coords, popupRect, m);
2985
+ if (!anchorRect || !this.tracksAnchorBeak()) {
2986
+ return { left, top };
2987
+ }
2988
+
2989
+ const beakSide = this.oppositeSide(placementSide);
2990
+ const bounds = this.getViewportBounds(m);
2991
+ const maxLeft = bounds.maxRight - popupRect.width;
2992
+ const minLeft = bounds.minLeft;
2993
+ const maxTop = bounds.maxBottom - popupRect.height;
2994
+ const minTop = bounds.minTop;
2995
+
2996
+ if (beakSide === "top" || beakSide === "bottom") {
2997
+ const inset = this.getBeakEdgeInset(beakSide);
2998
+ const anchorCenterX = anchorRect.left + anchorRect.width / 2;
2999
+ const beakOffset = anchorCenterX - left;
3000
+ if (beakOffset < inset) {
3001
+ left = anchorCenterX - inset;
3002
+ } else if (beakOffset > popupRect.width - inset) {
3003
+ left = anchorCenterX - (popupRect.width - inset);
3004
+ }
3005
+ left = Math.min(maxLeft, Math.max(minLeft, left));
3006
+ } else if (beakSide === "left" || beakSide === "right") {
3007
+ const inset = this.getBeakEdgeInset(beakSide);
3008
+ const anchorCenterY = anchorRect.top + anchorRect.height / 2;
3009
+ const beakOffset = anchorCenterY - top;
3010
+ if (beakOffset < inset) {
3011
+ top = anchorCenterY - inset;
3012
+ } else if (beakOffset > popupRect.height - inset) {
3013
+ top = anchorCenterY - (popupRect.height - inset);
3014
+ }
3015
+ top = Math.min(maxTop, Math.max(minTop, top));
3016
+ }
3017
+
3018
+ return { left, top };
3019
+ }
3020
+
3021
+ canPointAtAnchor(anchorRect, popupRect, left, top, placementSide) {
3022
+ if (!anchorRect || !this.tracksAnchorBeak()) return true;
3023
+
3024
+ const beakSide = this.oppositeSide(placementSide);
3025
+ const inset = this.getBeakEdgeInset(beakSide);
3026
+
3027
+ if (beakSide === "top" || beakSide === "bottom") {
3028
+ const beakOffset = anchorRect.left + anchorRect.width / 2 - left;
3029
+ return (
3030
+ beakOffset >= inset - 0.5 &&
3031
+ beakOffset <= popupRect.width - inset + 0.5
3032
+ );
3033
+ }
3034
+
3035
+ const beakOffset = anchorRect.top + anchorRect.height / 2 - top;
3036
+ return (
3037
+ beakOffset >= inset - 0.5 &&
3038
+ beakOffset <= popupRect.height - inset + 0.5
3039
+ );
3040
+ }
3041
+
3042
+ updatePopoverBeak(anchorRect, popupRect, left, top, placementSide) {
3043
+ if (!this.tracksAnchorBeak() || !anchorRect) {
2891
3044
  this.style.removeProperty("--fig-popup-beak-offset");
2892
3045
  this.removeAttribute("data-beak-side");
2893
3046
  return;
@@ -2903,54 +3056,9 @@ class FigPopup extends HTMLDialogElement {
2903
3056
  measuredRect.width > 0 && measuredRect.height > 0
2904
3057
  ? measuredRect
2905
3058
  : popupRect;
2906
- // Always use the rendered popup rect so beak alignment matches real final placement.
2907
3059
  const resolvedLeft = rect.left;
2908
3060
  const resolvedTop = rect.top;
2909
- const styles = getComputedStyle(this);
2910
- const toPx = (value, fallback = 0) => {
2911
- const raw = String(value || "").trim();
2912
- const n = parseFloat(raw);
2913
- if (!Number.isFinite(n)) return fallback;
2914
- if (raw.endsWith("rem")) {
2915
- return n * parseFloat(getComputedStyle(document.documentElement).fontSize);
2916
- }
2917
- if (raw.endsWith("em")) {
2918
- return n * parseFloat(styles.fontSize);
2919
- }
2920
- return n;
2921
- };
2922
- const radiusForSide = (side) => {
2923
- if (side === "top") {
2924
- return Math.max(
2925
- toPx(styles.borderTopLeftRadius),
2926
- toPx(styles.borderTopRightRadius),
2927
- );
2928
- }
2929
- if (side === "bottom") {
2930
- return Math.max(
2931
- toPx(styles.borderBottomLeftRadius),
2932
- toPx(styles.borderBottomRightRadius),
2933
- );
2934
- }
2935
- if (side === "left") {
2936
- return Math.max(
2937
- toPx(styles.borderTopLeftRadius),
2938
- toPx(styles.borderBottomLeftRadius),
2939
- );
2940
- }
2941
- if (side === "right") {
2942
- return Math.max(
2943
- toPx(styles.borderTopRightRadius),
2944
- toPx(styles.borderBottomRightRadius),
2945
- );
2946
- }
2947
- return 0;
2948
- };
2949
- const beakWidth = toPx(
2950
- styles.getPropertyValue("--fig-popup-beak-width"),
2951
- 16,
2952
- );
2953
- const edgeInset = Math.max(10, radiusForSide(beakSide) + beakWidth / 2);
3061
+ const edgeInset = this.getBeakEdgeInset(beakSide);
2954
3062
 
2955
3063
  let beakOffset;
2956
3064
  if (beakSide === "top" || beakSide === "bottom") {
@@ -2969,15 +3077,14 @@ class FigPopup extends HTMLDialogElement {
2969
3077
  }
2970
3078
 
2971
3079
  overflowScore(coords, popupRect, m) {
2972
- const vw = window.innerWidth;
2973
- const vh = window.innerHeight;
3080
+ const bounds = this.getViewportBounds(m);
2974
3081
  const right = coords.left + popupRect.width;
2975
3082
  const bottom = coords.top + popupRect.height;
2976
3083
 
2977
- const overflowLeft = Math.max(0, m.left - coords.left);
2978
- const overflowTop = Math.max(0, m.top - coords.top);
2979
- const overflowRight = Math.max(0, right - (vw - m.right));
2980
- const overflowBottom = Math.max(0, bottom - (vh - m.bottom));
3084
+ const overflowLeft = Math.max(0, bounds.minLeft - coords.left);
3085
+ const overflowTop = Math.max(0, bounds.minTop - coords.top);
3086
+ const overflowRight = Math.max(0, right - bounds.maxRight);
3087
+ const overflowBottom = Math.max(0, bottom - bounds.maxBottom);
2981
3088
 
2982
3089
  return overflowLeft + overflowTop + overflowRight + overflowBottom;
2983
3090
  }
@@ -2986,26 +3093,93 @@ class FigPopup extends HTMLDialogElement {
2986
3093
  return this.overflowScore(coords, popupRect, m) === 0;
2987
3094
  }
2988
3095
 
2989
- clamp(coords, popupRect, m) {
2990
- const minLeft = m.left;
2991
- const minTop = m.top;
2992
- const maxLeft = Math.max(
2993
- m.left,
2994
- window.innerWidth - popupRect.width - m.right,
3096
+ clamp(coords, popupRect, m, anchorRect = null, placementSide = "top") {
3097
+ if (anchorRect) {
3098
+ return this.resolveCoordsAtViewport(
3099
+ anchorRect,
3100
+ popupRect,
3101
+ coords,
3102
+ placementSide,
3103
+ m,
3104
+ );
3105
+ }
3106
+ return this.clampToViewport(coords, popupRect, m);
3107
+ }
3108
+
3109
+ primaryAxisOverflowPenalty(coords, popupRect, m, placementSide) {
3110
+ const bounds = this.getViewportBounds(m);
3111
+ let overflow = 0;
3112
+
3113
+ if (placementSide === "top") {
3114
+ overflow = Math.max(0, bounds.minTop - coords.top);
3115
+ } else if (placementSide === "bottom") {
3116
+ overflow = Math.max(0, coords.top + popupRect.height - bounds.maxBottom);
3117
+ } else if (placementSide === "left") {
3118
+ overflow = Math.max(0, bounds.minLeft - coords.left);
3119
+ } else if (placementSide === "right") {
3120
+ overflow = Math.max(0, coords.left + popupRect.width - bounds.maxRight);
3121
+ }
3122
+
3123
+ return overflow > 0 ? 1000 + overflow : 0;
3124
+ }
3125
+
3126
+ placementScore(anchorRect, popupRect, coords, placementSide, m) {
3127
+ const resolved = this.resolveCoordsAtViewport(
3128
+ anchorRect,
3129
+ popupRect,
3130
+ coords,
3131
+ placementSide,
3132
+ m,
2995
3133
  );
2996
- const maxTop = Math.max(
2997
- m.top,
2998
- window.innerHeight - popupRect.height - m.bottom,
3134
+ let score = this.overflowScore(resolved, popupRect, m);
3135
+ score += this.primaryAxisOverflowPenalty(
3136
+ coords,
3137
+ popupRect,
3138
+ m,
3139
+ placementSide,
2999
3140
  );
3141
+ if (
3142
+ anchorRect &&
3143
+ !this.canPointAtAnchor(
3144
+ anchorRect,
3145
+ popupRect,
3146
+ resolved.left,
3147
+ resolved.top,
3148
+ placementSide,
3149
+ )
3150
+ ) {
3151
+ score += 10000;
3152
+ }
3153
+ return { score, resolved };
3154
+ }
3000
3155
 
3001
- return {
3002
- left: Math.min(maxLeft, Math.max(minLeft, coords.left)),
3003
- top: Math.min(maxTop, Math.max(minTop, coords.top)),
3004
- };
3156
+ applyPopupPosition(
3157
+ anchorRect,
3158
+ popupRect,
3159
+ coords,
3160
+ placementSide,
3161
+ m,
3162
+ ) {
3163
+ const resolved = this.resolveCoordsAtViewport(
3164
+ anchorRect,
3165
+ popupRect,
3166
+ coords,
3167
+ placementSide,
3168
+ m,
3169
+ );
3170
+ this.style.left = `${resolved.left}px`;
3171
+ this.style.top = `${resolved.top}px`;
3172
+ this.updatePopoverBeak(
3173
+ anchorRect,
3174
+ popupRect,
3175
+ resolved.left,
3176
+ resolved.top,
3177
+ placementSide,
3178
+ );
3005
3179
  }
3006
3180
 
3007
3181
  positionPopup() {
3008
- if (!this.open || !this.matches?.(":open")) return;
3182
+ if (!this.open) return;
3009
3183
 
3010
3184
  const popupRect = this.getBoundingClientRect();
3011
3185
  const offset = this.parseOffset();
@@ -3015,14 +3189,16 @@ class FigPopup extends HTMLDialogElement {
3015
3189
 
3016
3190
  if (!anchor) {
3017
3191
  this.updatePopoverBeak(null, popupRect, 0, 0, "top");
3192
+ const bounds = this.getViewportBounds(m);
3018
3193
  const centered = {
3019
3194
  left:
3020
- m.left + (window.innerWidth - m.right - m.left - popupRect.width) / 2,
3195
+ bounds.minLeft +
3196
+ (bounds.maxRight - bounds.minLeft - popupRect.width) / 2,
3021
3197
  top:
3022
- m.top +
3023
- (window.innerHeight - m.bottom - m.top - popupRect.height) / 2,
3198
+ bounds.minTop +
3199
+ (bounds.maxBottom - bounds.minTop - popupRect.height) / 2,
3024
3200
  };
3025
- const clamped = this.clamp(centered, popupRect, m);
3201
+ const clamped = this.clampToViewport(centered, popupRect, m);
3026
3202
  this.style.left = `${clamped.left}px`;
3027
3203
  this.style.top = `${clamped.top}px`;
3028
3204
  return;
@@ -3041,69 +3217,63 @@ class FigPopup extends HTMLDialogElement {
3041
3217
  for (const { v, h, s } of candidates) {
3042
3218
  const coords = this.computeCoords(anchorRect, popupRect, v, h, offset, s);
3043
3219
  const placementSide = this.getPlacementSide(v, h, s);
3220
+ const { score, resolved } = this.placementScore(
3221
+ anchorRect,
3222
+ popupRect,
3223
+ coords,
3224
+ placementSide,
3225
+ m,
3226
+ );
3044
3227
 
3045
3228
  if (s) {
3046
- const clamped = this.clamp(coords, popupRect, m);
3229
+ const bounds = this.getViewportBounds(m);
3047
3230
  const primaryFits =
3048
3231
  s === "left" || s === "right"
3049
- ? coords.left >= m.left &&
3050
- coords.left + popupRect.width <= window.innerWidth - m.right
3051
- : coords.top >= m.top &&
3052
- coords.top + popupRect.height <= window.innerHeight - m.bottom;
3053
- if (primaryFits) {
3054
- this.style.left = `${clamped.left}px`;
3055
- this.style.top = `${clamped.top}px`;
3056
- this.updatePopoverBeak(
3232
+ ? coords.left >= bounds.minLeft &&
3233
+ coords.left + popupRect.width <= bounds.maxRight
3234
+ : coords.top >= bounds.minTop &&
3235
+ coords.top + popupRect.height <= bounds.maxBottom;
3236
+ if (primaryFits && score < 10000) {
3237
+ this.applyPopupPosition(
3057
3238
  anchorRect,
3058
3239
  popupRect,
3059
- clamped.left,
3060
- clamped.top,
3240
+ coords,
3061
3241
  placementSide,
3242
+ m,
3062
3243
  );
3063
3244
  return;
3064
3245
  }
3065
- const score = this.overflowScore(coords, popupRect, m);
3066
- if (score < bestScore) {
3067
- bestScore = score;
3068
- best = clamped;
3069
- bestSide = placementSide;
3070
- }
3071
- } else {
3072
- if (this.fits(coords, popupRect, m)) {
3073
- this.style.left = `${coords.left}px`;
3074
- this.style.top = `${coords.top}px`;
3075
- this.updatePopoverBeak(
3076
- anchorRect,
3077
- popupRect,
3078
- coords.left,
3079
- coords.top,
3080
- placementSide,
3081
- );
3082
- return;
3083
- }
3084
- const score = this.overflowScore(coords, popupRect, m);
3085
- if (score < bestScore) {
3086
- bestScore = score;
3087
- best = coords;
3088
- bestSide = placementSide;
3089
- }
3246
+ } else if (score === 0) {
3247
+ this.applyPopupPosition(
3248
+ anchorRect,
3249
+ popupRect,
3250
+ coords,
3251
+ placementSide,
3252
+ m,
3253
+ );
3254
+ return;
3255
+ }
3256
+
3257
+ if (score < bestScore) {
3258
+ bestScore = score;
3259
+ best = resolved;
3260
+ bestSide = placementSide;
3090
3261
  }
3091
3262
  }
3092
3263
 
3093
- const clamped = this.clamp(best || { left: 0, top: 0 }, popupRect, m);
3094
- this.style.left = `${clamped.left}px`;
3095
- this.style.top = `${clamped.top}px`;
3096
- this.updatePopoverBeak(
3264
+ this.applyPopupPosition(
3097
3265
  anchorRect,
3098
3266
  popupRect,
3099
- clamped.left,
3100
- clamped.top,
3267
+ best || { left: 0, top: 0 },
3101
3268
  bestSide,
3269
+ m,
3102
3270
  );
3103
3271
  }
3104
3272
 
3105
3273
  queueReposition() {
3106
- if (!this.open || !this.shouldAutoReposition()) return;
3274
+ if (!this.open || !this.isPopupDisplayed() || !this.shouldAutoReposition()) {
3275
+ return;
3276
+ }
3107
3277
  if (this._rafId !== null) return;
3108
3278
 
3109
3279
  this._rafId = requestAnimationFrame(() => {
@@ -3116,6 +3286,14 @@ class FigPopup extends HTMLDialogElement {
3116
3286
  if (!(this.drag && this._wasDragged)) return true;
3117
3287
  return !this.resolveAnchor();
3118
3288
  }
3289
+
3290
+ isPopupDisplayed() {
3291
+ return Boolean(
3292
+ this._isPopupActive ||
3293
+ this.matches?.(":open") ||
3294
+ this.matches?.(":popover-open"),
3295
+ );
3296
+ }
3119
3297
  }
3120
3298
  figDefineCustomizedBuiltIn("fig-popup", FigPopup, { extends: "dialog" });
3121
3299
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "6.6.3",
3
+ "version": "6.6.5",
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",