@rogieking/figui3 2.20.0 → 2.22.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/components.css CHANGED
@@ -1412,7 +1412,8 @@ input[type="checkbox"]:not(.switch) {
1412
1412
  /* Light theme checkbox hover (black checkmark preview) */
1413
1413
  /* @media is a no-JS fallback — ignored when setTheme() sets classes */
1414
1414
  @media (prefers-color-scheme: light) {
1415
- :root:not(.figma-dark):not(.figma-light) input[type="checkbox"]:not(.switch):not(:disabled):hover {
1415
+ :root:not(.figma-dark):not(.figma-light)
1416
+ input[type="checkbox"]:not(.switch):not(:disabled):hover {
1416
1417
  background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4.50012 7.5L7.50012 10.5L11.5001 4.5' stroke='black' opacity='0.25' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.125' /%3E%3C/svg%3E%0A");
1417
1418
  }
1418
1419
  }
@@ -2030,12 +2031,15 @@ dialog[is="fig-popup"] {
2030
2031
  position: fixed;
2031
2032
  margin: 0;
2032
2033
  min-width: 0;
2033
- width: max-content;
2034
- max-width: calc(100vw - var(--spacer-4));
2035
- max-height: calc(100vh - var(--spacer-4));
2036
- padding: var(--spacer-2);
2034
+ padding: 0;
2037
2035
  overflow: auto;
2038
2036
 
2037
+ &[autoresize]:not([autoresize="false"]) {
2038
+ width: max-content;
2039
+ max-width: calc(100vw - var(--spacer-4));
2040
+ max-height: calc(100vh - var(--spacer-4));
2041
+ }
2042
+
2039
2043
  &[open] {
2040
2044
  display: block;
2041
2045
  }
package/fig.js CHANGED
@@ -863,13 +863,12 @@ class FigDialog extends HTMLDialogElement {
863
863
  #setupDragListeners() {
864
864
  if (this.drag) {
865
865
  this.addEventListener("pointerdown", this.#boundPointerDown);
866
- // Set move cursor on handle element (or fig-header by default)
867
866
  const handleSelector = this.getAttribute("handle");
868
867
  const handleEl = handleSelector
869
868
  ? this.querySelector(handleSelector)
870
869
  : this.querySelector("fig-header, header");
871
870
  if (handleEl) {
872
- handleEl.style.cursor = "move";
871
+ handleEl.style.cursor = "grab";
873
872
  }
874
873
  }
875
874
  }
@@ -977,16 +976,12 @@ class FigDialog extends HTMLDialogElement {
977
976
  const dy = Math.abs(e.clientY - this.#dragStartPos.y);
978
977
 
979
978
  if (dx > this.#dragThreshold || dy > this.#dragThreshold) {
980
- // Start actual drag
981
979
  this.#isDragging = true;
982
980
  this.#dragPending = false;
983
981
  this.setPointerCapture(e.pointerId);
982
+ this.style.cursor = "grabbing";
984
983
 
985
- // Get current position from computed style
986
984
  const rect = this.getBoundingClientRect();
987
-
988
- // Convert to pixel-based top/left positioning for dragging
989
- // (clears margin: auto centering)
990
985
  this.style.top = `${rect.top}px`;
991
986
  this.style.left = `${rect.left}px`;
992
987
  this.style.bottom = "auto";
@@ -997,21 +992,15 @@ class FigDialog extends HTMLDialogElement {
997
992
 
998
993
  if (!this.#isDragging) return;
999
994
 
1000
- // Calculate new position based on pointer position minus offset
1001
- const newLeft = e.clientX - this.#dragOffset.x;
1002
- const newTop = e.clientY - this.#dragOffset.y;
1003
-
1004
- // Apply position directly with pixels
1005
- this.style.left = `${newLeft}px`;
1006
- this.style.top = `${newTop}px`;
1007
-
995
+ this.style.left = `${e.clientX - this.#dragOffset.x}px`;
996
+ this.style.top = `${e.clientY - this.#dragOffset.y}px`;
1008
997
  e.preventDefault();
1009
998
  }
1010
999
 
1011
1000
  #handlePointerUp(e) {
1012
- // Clean up pending or active drag
1013
1001
  if (this.#isDragging) {
1014
1002
  this.releasePointerCapture(e.pointerId);
1003
+ this.style.cursor = "";
1015
1004
  }
1016
1005
 
1017
1006
  this.#isDragging = false;
@@ -1035,7 +1024,6 @@ class FigDialog extends HTMLDialogElement {
1035
1024
  this.#setupDragListeners();
1036
1025
  } else {
1037
1026
  this.#removeDragListeners();
1038
- // Remove move cursor from header
1039
1027
  const header = this.querySelector("fig-header, header");
1040
1028
  if (header) {
1041
1029
  header.style.cursor = "";
@@ -1070,16 +1058,29 @@ class FigPopup extends HTMLDialogElement {
1070
1058
  #boundScroll;
1071
1059
  #boundOutsidePointerDown;
1072
1060
  #rafId = null;
1061
+ #anchorRef = null;
1062
+
1063
+ #isDragging = false;
1064
+ #dragPending = false;
1065
+ #dragStartPos = { x: 0, y: 0 };
1066
+ #dragOffset = { x: 0, y: 0 };
1067
+ #dragThreshold = 3;
1068
+ #boundPointerDown;
1069
+ #boundPointerMove;
1070
+ #boundPointerUp;
1073
1071
 
1074
1072
  constructor() {
1075
1073
  super();
1076
1074
  this.#boundReposition = this.#queueReposition.bind(this);
1077
1075
  this.#boundScroll = this.#queueReposition.bind(this);
1078
1076
  this.#boundOutsidePointerDown = this.#handleOutsidePointerDown.bind(this);
1077
+ this.#boundPointerDown = this.#handlePointerDown.bind(this);
1078
+ this.#boundPointerMove = this.#handlePointerMove.bind(this);
1079
+ this.#boundPointerUp = this.#handlePointerUp.bind(this);
1079
1080
  }
1080
1081
 
1081
1082
  static get observedAttributes() {
1082
- return ["open", "anchor", "position", "offset", "variant", "theme"];
1083
+ return ["open", "anchor", "position", "offset", "variant", "theme", "drag", "handle", "autoresize"];
1083
1084
  }
1084
1085
 
1085
1086
  get open() {
@@ -1096,6 +1097,22 @@ class FigPopup extends HTMLDialogElement {
1096
1097
  this.setAttribute("open", "true");
1097
1098
  }
1098
1099
 
1100
+ get anchor() {
1101
+ return this.#anchorRef ?? this.getAttribute("anchor");
1102
+ }
1103
+
1104
+ set anchor(value) {
1105
+ if (value instanceof Element) {
1106
+ this.#anchorRef = value;
1107
+ } else if (typeof value === "string") {
1108
+ this.#anchorRef = null;
1109
+ this.setAttribute("anchor", value);
1110
+ } else {
1111
+ this.#anchorRef = null;
1112
+ }
1113
+ if (this.open) this.#queueReposition();
1114
+ }
1115
+
1099
1116
  connectedCallback() {
1100
1117
  if (!this.hasAttribute("position")) {
1101
1118
  this.setAttribute("position", "top center");
@@ -1103,11 +1120,13 @@ class FigPopup extends HTMLDialogElement {
1103
1120
  if (!this.hasAttribute("role")) {
1104
1121
  this.setAttribute("role", "dialog");
1105
1122
  }
1106
- // Default dialog outside-close behavior.
1107
1123
  if (!this.hasAttribute("closedby")) {
1108
1124
  this.setAttribute("closedby", "any");
1109
1125
  }
1110
1126
 
1127
+ this.drag =
1128
+ this.hasAttribute("drag") && this.getAttribute("drag") !== "false";
1129
+
1111
1130
  this.addEventListener("close", () => {
1112
1131
  this.#teardownObservers();
1113
1132
  if (this.hasAttribute("open")) {
@@ -1115,6 +1134,10 @@ class FigPopup extends HTMLDialogElement {
1115
1134
  }
1116
1135
  });
1117
1136
 
1137
+ requestAnimationFrame(() => {
1138
+ this.#setupDragListeners();
1139
+ });
1140
+
1118
1141
  if (this.open) {
1119
1142
  this.#showPopup();
1120
1143
  } else {
@@ -1124,6 +1147,7 @@ class FigPopup extends HTMLDialogElement {
1124
1147
 
1125
1148
  disconnectedCallback() {
1126
1149
  this.#teardownObservers();
1150
+ this.#removeDragListeners();
1127
1151
  document.removeEventListener(
1128
1152
  "pointerdown",
1129
1153
  this.#boundOutsidePointerDown,
@@ -1147,6 +1171,16 @@ class FigPopup extends HTMLDialogElement {
1147
1171
  return;
1148
1172
  }
1149
1173
 
1174
+ if (name === "drag") {
1175
+ this.drag = newValue !== null && newValue !== "false";
1176
+ if (this.drag) {
1177
+ this.#setupDragListeners();
1178
+ } else {
1179
+ this.#removeDragListeners();
1180
+ }
1181
+ return;
1182
+ }
1183
+
1150
1184
  if (this.open) {
1151
1185
  this.#queueReposition();
1152
1186
  this.#setupObservers();
@@ -1176,9 +1210,15 @@ class FigPopup extends HTMLDialogElement {
1176
1210
  document.addEventListener("pointerdown", this.#boundOutsidePointerDown, true);
1177
1211
  this.#queueReposition();
1178
1212
  this.#isPopupActive = true;
1213
+
1214
+ const anchor = this.#resolveAnchor();
1215
+ if (anchor) anchor.classList.add("has-popup-open");
1179
1216
  }
1180
1217
 
1181
1218
  #hidePopup() {
1219
+ const anchor = this.#resolveAnchor();
1220
+ if (anchor) anchor.classList.remove("has-popup-open");
1221
+
1182
1222
  this.#isPopupActive = false;
1183
1223
  this.#teardownObservers();
1184
1224
  document.removeEventListener(
@@ -1196,6 +1236,11 @@ class FigPopup extends HTMLDialogElement {
1196
1236
  }
1197
1237
  }
1198
1238
 
1239
+ get autoresize() {
1240
+ const val = this.getAttribute("autoresize");
1241
+ return val === null || val !== "false";
1242
+ }
1243
+
1199
1244
  #setupObservers() {
1200
1245
  this.#teardownObservers();
1201
1246
 
@@ -1205,17 +1250,19 @@ class FigPopup extends HTMLDialogElement {
1205
1250
  this.#anchorObserver.observe(anchor);
1206
1251
  }
1207
1252
 
1208
- if ("ResizeObserver" in window) {
1209
- this.#contentObserver = new ResizeObserver(this.#boundReposition);
1210
- this.#contentObserver.observe(this);
1211
- }
1253
+ if (this.autoresize) {
1254
+ if ("ResizeObserver" in window) {
1255
+ this.#contentObserver = new ResizeObserver(this.#boundReposition);
1256
+ this.#contentObserver.observe(this);
1257
+ }
1212
1258
 
1213
- this.#mutationObserver = new MutationObserver(this.#boundReposition);
1214
- this.#mutationObserver.observe(this, {
1215
- childList: true,
1216
- subtree: true,
1217
- characterData: true,
1218
- });
1259
+ this.#mutationObserver = new MutationObserver(this.#boundReposition);
1260
+ this.#mutationObserver.observe(this, {
1261
+ childList: true,
1262
+ subtree: true,
1263
+ characterData: true,
1264
+ });
1265
+ }
1219
1266
 
1220
1267
  window.addEventListener("resize", this.#boundReposition);
1221
1268
  window.addEventListener("scroll", this.#boundScroll, true);
@@ -1246,10 +1293,141 @@ class FigPopup extends HTMLDialogElement {
1246
1293
  if (!(target instanceof Node)) return;
1247
1294
  if (this.contains(target)) return;
1248
1295
 
1296
+ const anchor = this.#resolveAnchor();
1297
+ if (anchor && anchor.contains(target)) return;
1298
+
1299
+ if (this.#isInsideDescendantPopup(target)) return;
1300
+
1249
1301
  this.open = false;
1250
1302
  }
1251
1303
 
1304
+ #isInsideDescendantPopup(target) {
1305
+ const targetDialog = target.closest?.('dialog[is="fig-popup"]');
1306
+ if (!targetDialog || targetDialog === this) return false;
1307
+
1308
+ let current = targetDialog;
1309
+ const visited = new Set();
1310
+ while (current && !visited.has(current)) {
1311
+ visited.add(current);
1312
+ const popupAnchor = current.anchor;
1313
+ if (!(popupAnchor instanceof Element)) break;
1314
+ if (this.contains(popupAnchor)) return true;
1315
+ current = popupAnchor.closest?.('dialog[is="fig-popup"]');
1316
+ }
1317
+ return false;
1318
+ }
1319
+
1320
+ // ---- Drag support ----
1321
+
1322
+ #setupDragListeners() {
1323
+ if (this.drag) {
1324
+ this.addEventListener("pointerdown", this.#boundPointerDown);
1325
+ }
1326
+ }
1327
+
1328
+ #removeDragListeners() {
1329
+ this.removeEventListener("pointerdown", this.#boundPointerDown);
1330
+ document.removeEventListener("pointermove", this.#boundPointerMove);
1331
+ document.removeEventListener("pointerup", this.#boundPointerUp);
1332
+ }
1333
+
1334
+ #isInteractiveElement(element) {
1335
+ const interactiveSelectors = [
1336
+ "input", "button", "select", "textarea", "a",
1337
+ "label", "details", "summary",
1338
+ '[contenteditable="true"]', "[tabindex]",
1339
+ ];
1340
+
1341
+ const nonInteractiveFigElements = [
1342
+ "FIG-HEADER", "FIG-DIALOG", "FIG-POPUP", "FIG-FIELD",
1343
+ "FIG-TOOLTIP", "FIG-CONTENT", "FIG-TABS", "FIG-TAB",
1344
+ "FIG-POPOVER", "FIG-SHIMMER", "FIG-LAYER", "FIG-FILL-PICKER",
1345
+ ];
1346
+
1347
+ const isInteractive = (el) =>
1348
+ interactiveSelectors.some((s) => el.matches?.(s)) ||
1349
+ (el.tagName?.startsWith("FIG-") &&
1350
+ !nonInteractiveFigElements.includes(el.tagName));
1351
+
1352
+ if (isInteractive(element)) return true;
1353
+
1354
+ let parent = element.parentElement;
1355
+ while (parent && parent !== this) {
1356
+ if (isInteractive(parent)) return true;
1357
+ parent = parent.parentElement;
1358
+ }
1359
+
1360
+ return false;
1361
+ }
1362
+
1363
+ #handlePointerDown(e) {
1364
+ if (!this.drag) return;
1365
+ if (this.#isInteractiveElement(e.target)) return;
1366
+
1367
+ const handleSelector = this.getAttribute("handle");
1368
+ if (handleSelector && handleSelector.trim()) {
1369
+ const handleEl = this.querySelector(handleSelector);
1370
+ if (!handleEl || !handleEl.contains(e.target)) return;
1371
+ }
1372
+
1373
+ this.#dragPending = true;
1374
+ this.#dragStartPos.x = e.clientX;
1375
+ this.#dragStartPos.y = e.clientY;
1376
+
1377
+ const rect = this.getBoundingClientRect();
1378
+ this.#dragOffset.x = e.clientX - rect.left;
1379
+ this.#dragOffset.y = e.clientY - rect.top;
1380
+
1381
+ document.addEventListener("pointermove", this.#boundPointerMove);
1382
+ document.addEventListener("pointerup", this.#boundPointerUp);
1383
+ }
1384
+
1385
+ #handlePointerMove(e) {
1386
+ if (this.#dragPending && !this.#isDragging) {
1387
+ const dx = Math.abs(e.clientX - this.#dragStartPos.x);
1388
+ const dy = Math.abs(e.clientY - this.#dragStartPos.y);
1389
+
1390
+ if (dx > this.#dragThreshold || dy > this.#dragThreshold) {
1391
+ this.#isDragging = true;
1392
+ this.#dragPending = false;
1393
+ this.setPointerCapture(e.pointerId);
1394
+ this.style.cursor = "grabbing";
1395
+
1396
+ const rect = this.getBoundingClientRect();
1397
+ this.style.top = `${rect.top}px`;
1398
+ this.style.left = `${rect.left}px`;
1399
+ this.style.bottom = "auto";
1400
+ this.style.right = "auto";
1401
+ this.style.margin = "0";
1402
+ }
1403
+ }
1404
+
1405
+ if (!this.#isDragging) return;
1406
+
1407
+ this.style.left = `${e.clientX - this.#dragOffset.x}px`;
1408
+ this.style.top = `${e.clientY - this.#dragOffset.y}px`;
1409
+ e.preventDefault();
1410
+ }
1411
+
1412
+ #handlePointerUp(e) {
1413
+ if (this.#isDragging) {
1414
+ this.releasePointerCapture(e.pointerId);
1415
+ this.style.cursor = "";
1416
+ }
1417
+
1418
+ this.#isDragging = false;
1419
+ this.#dragPending = false;
1420
+
1421
+ document.removeEventListener("pointermove", this.#boundPointerMove);
1422
+ document.removeEventListener("pointerup", this.#boundPointerUp);
1423
+ e.preventDefault();
1424
+ }
1425
+
1426
+ // ---- Anchor resolution ----
1427
+
1252
1428
  #resolveAnchor() {
1429
+ if (this.#anchorRef) return this.#anchorRef;
1430
+
1253
1431
  const selector = this.getAttribute("anchor");
1254
1432
  if (!selector) return null;
1255
1433
 
@@ -1360,21 +1538,49 @@ class FigPopup extends HTMLDialogElement {
1360
1538
  };
1361
1539
  }
1362
1540
 
1363
- #getOrder(preferred, axis) {
1364
- const verticalMap = {
1365
- top: ["top", "bottom", "center"],
1366
- center: ["center", "top", "bottom"],
1367
- bottom: ["bottom", "top", "center"],
1368
- };
1369
- const horizontalMap = {
1370
- left: ["left", "right", "center"],
1371
- center: ["center", "left", "right"],
1372
- right: ["right", "left", "center"],
1373
- };
1541
+ #getPlacementCandidates(vertical, horizontal, shorthand) {
1542
+ const opp = { top: "bottom", bottom: "top", left: "right", right: "left", center: "center" };
1543
+
1544
+ if (shorthand) {
1545
+ const perp = (shorthand === "left" || shorthand === "right")
1546
+ ? ["top", "bottom"]
1547
+ : ["left", "right"];
1548
+ return [
1549
+ { v: vertical, h: horizontal, s: shorthand },
1550
+ { v: vertical, h: horizontal, s: opp[shorthand] },
1551
+ { v: vertical, h: horizontal, s: perp[0] },
1552
+ { v: vertical, h: horizontal, s: perp[1] },
1553
+ ];
1554
+ }
1555
+
1556
+ if (vertical === "center") {
1557
+ return [
1558
+ { v: "center", h: horizontal, s: null },
1559
+ { v: "center", h: opp[horizontal], s: null },
1560
+ { v: "top", h: horizontal, s: null },
1561
+ { v: "bottom", h: horizontal, s: null },
1562
+ { v: "top", h: opp[horizontal], s: null },
1563
+ { v: "bottom", h: opp[horizontal], s: null },
1564
+ ];
1565
+ }
1566
+
1567
+ if (horizontal === "center") {
1568
+ return [
1569
+ { v: vertical, h: "center", s: null },
1570
+ { v: opp[vertical], h: "center", s: null },
1571
+ { v: vertical, h: "left", s: null },
1572
+ { v: vertical, h: "right", s: null },
1573
+ { v: opp[vertical], h: "left", s: null },
1574
+ { v: opp[vertical], h: "right", s: null },
1575
+ ];
1576
+ }
1374
1577
 
1375
- return axis === "vertical"
1376
- ? verticalMap[preferred] || verticalMap.top
1377
- : horizontalMap[preferred] || horizontalMap.center;
1578
+ return [
1579
+ { v: vertical, h: horizontal, s: null },
1580
+ { v: opp[vertical], h: horizontal, s: null },
1581
+ { v: vertical, h: opp[horizontal], s: null },
1582
+ { v: opp[vertical], h: opp[horizontal], s: null },
1583
+ ];
1378
1584
  }
1379
1585
 
1380
1586
  #computeCoords(anchorRect, popupRect, vertical, horizontal, offset, shorthand) {
@@ -1417,12 +1623,24 @@ class FigPopup extends HTMLDialogElement {
1417
1623
  top = anchorRect.top + (anchorRect.height - popupRect.height) / 2;
1418
1624
  }
1419
1625
 
1420
- if (horizontal === "left") {
1421
- left = anchorRect.left - popupRect.width - offset.xPx;
1422
- } else if (horizontal === "right") {
1423
- left = anchorRect.right + offset.xPx;
1626
+ if (vertical === "center") {
1627
+ // Side placement: popup beside the anchor
1628
+ if (horizontal === "left") {
1629
+ left = anchorRect.left - popupRect.width - offset.xPx;
1630
+ } else if (horizontal === "right") {
1631
+ left = anchorRect.right + offset.xPx;
1632
+ } else {
1633
+ left = anchorRect.left + (anchorRect.width - popupRect.width) / 2;
1634
+ }
1424
1635
  } else {
1425
- left = anchorRect.left + (anchorRect.width - popupRect.width) / 2;
1636
+ // Edge alignment: popup above/below, aligned to anchor edge
1637
+ if (horizontal === "left") {
1638
+ left = anchorRect.left + offset.xPx;
1639
+ } else if (horizontal === "right") {
1640
+ left = anchorRect.right - popupRect.width - offset.xPx;
1641
+ } else {
1642
+ left = anchorRect.left + (anchorRect.width - popupRect.width) / 2;
1643
+ }
1426
1644
  }
1427
1645
 
1428
1646
  return { top, left };
@@ -1516,8 +1734,6 @@ class FigPopup extends HTMLDialogElement {
1516
1734
  const popupRect = this.getBoundingClientRect();
1517
1735
  const offset = this.#parseOffset();
1518
1736
  const { vertical, horizontal, shorthand } = this.#parsePosition();
1519
- const verticalOrder = this.#getOrder(vertical, "vertical");
1520
- const horizontalOrder = this.#getOrder(horizontal, "horizontal");
1521
1737
  const anchor = this.#resolveAnchor();
1522
1738
 
1523
1739
  if (!anchor) {
@@ -1533,52 +1749,32 @@ class FigPopup extends HTMLDialogElement {
1533
1749
  }
1534
1750
 
1535
1751
  const anchorRect = anchor.getBoundingClientRect();
1752
+ const candidates = this.#getPlacementCandidates(vertical, horizontal, shorthand);
1536
1753
  let best = null;
1537
1754
  let bestSide = "top";
1538
1755
  let bestScore = Number.POSITIVE_INFINITY;
1539
1756
 
1540
- for (const v of verticalOrder) {
1541
- for (const h of horizontalOrder) {
1542
- const coords = this.#computeCoords(
1543
- anchorRect,
1544
- popupRect,
1545
- v,
1546
- h,
1547
- offset,
1548
- shorthand
1549
- );
1550
- const placementSide = this.#getPlacementSide(v, h, shorthand);
1551
- if (this.#fits(coords, popupRect)) {
1552
- this.style.left = `${coords.left}px`;
1553
- this.style.top = `${coords.top}px`;
1554
- this.#updatePopoverBeak(
1555
- anchorRect,
1556
- popupRect,
1557
- coords.left,
1558
- coords.top,
1559
- placementSide
1560
- );
1561
- return;
1562
- }
1563
- const score = this.#overflowScore(coords, popupRect);
1564
- if (score < bestScore) {
1565
- bestScore = score;
1566
- best = coords;
1567
- bestSide = placementSide;
1568
- }
1757
+ for (const { v, h, s } of candidates) {
1758
+ const coords = this.#computeCoords(anchorRect, popupRect, v, h, offset, s);
1759
+ const placementSide = this.#getPlacementSide(v, h, s);
1760
+ if (this.#fits(coords, popupRect)) {
1761
+ this.style.left = `${coords.left}px`;
1762
+ this.style.top = `${coords.top}px`;
1763
+ this.#updatePopoverBeak(anchorRect, popupRect, coords.left, coords.top, placementSide);
1764
+ return;
1765
+ }
1766
+ const score = this.#overflowScore(coords, popupRect);
1767
+ if (score < bestScore) {
1768
+ bestScore = score;
1769
+ best = coords;
1770
+ bestSide = placementSide;
1569
1771
  }
1570
1772
  }
1571
1773
 
1572
1774
  const clamped = this.#clamp(best || { left: 0, top: 0 }, popupRect);
1573
1775
  this.style.left = `${clamped.left}px`;
1574
1776
  this.style.top = `${clamped.top}px`;
1575
- this.#updatePopoverBeak(
1576
- anchorRect,
1577
- popupRect,
1578
- clamped.left,
1579
- clamped.top,
1580
- bestSide
1581
- );
1777
+ this.#updatePopoverBeak(anchorRect, popupRect, clamped.left, clamped.top, bestSide);
1582
1778
  }
1583
1779
 
1584
1780
  #queueReposition() {
@@ -3173,6 +3369,8 @@ class FigInputColor extends HTMLElement {
3173
3369
  const showAlpha = this.getAttribute("alpha") === "true";
3174
3370
  const experimental = this.getAttribute("experimental");
3175
3371
  const expAttr = experimental ? `experimental="${experimental}"` : "";
3372
+ const dialogPos = this.getAttribute("dialog-position") || "left";
3373
+ const dialogPosAttr = `dialog-position="${dialogPos}"`;
3176
3374
 
3177
3375
  let html = ``;
3178
3376
  if (this.getAttribute("text")) {
@@ -3197,7 +3395,7 @@ class FigInputColor extends HTMLElement {
3197
3395
  let swatchElement = "";
3198
3396
  if (!hidePicker) {
3199
3397
  swatchElement = useFigmaPicker
3200
- ? `<fig-fill-picker mode="solid" ${expAttr} ${
3398
+ ? `<fig-fill-picker mode="solid" ${dialogPosAttr} ${expAttr} ${
3201
3399
  showAlpha ? "" : 'alpha="false"'
3202
3400
  } value='{"type":"solid","color":"${this.hexOpaque}","opacity":${
3203
3401
  this.alpha
@@ -3215,7 +3413,7 @@ class FigInputColor extends HTMLElement {
3215
3413
  html = ``;
3216
3414
  } else {
3217
3415
  html = useFigmaPicker
3218
- ? `<fig-fill-picker mode="solid" ${expAttr} ${
3416
+ ? `<fig-fill-picker mode="solid" ${dialogPosAttr} ${expAttr} ${
3219
3417
  showAlpha ? "" : 'alpha="false"'
3220
3418
  } value='{"type":"solid","color":"${this.hexOpaque}","opacity":${
3221
3419
  this.alpha
@@ -3407,7 +3605,7 @@ class FigInputColor extends HTMLElement {
3407
3605
  }
3408
3606
 
3409
3607
  static get observedAttributes() {
3410
- return ["value", "style", "mode", "picker", "experimental"];
3608
+ return ["value", "style", "mode", "picker", "experimental", "dialog-position"];
3411
3609
  }
3412
3610
 
3413
3611
  get mode() {
@@ -3743,7 +3941,7 @@ class FigInputFill extends HTMLElement {
3743
3941
  const experimentalAttr = this.getAttribute("experimental");
3744
3942
  this.innerHTML = `
3745
3943
  <div class="input-combo">
3746
- <fig-fill-picker value='${fillPickerValue}' ${
3944
+ <fig-fill-picker dialog-position="left" value='${fillPickerValue}' ${
3747
3945
  disabled ? "disabled" : ""
3748
3946
  } ${modeAttr ? `mode="${modeAttr}"` : ""} ${experimentalAttr ? `experimental="${experimentalAttr}"` : ""}></fig-fill-picker>
3749
3947
  ${controlsHtml}
@@ -6007,7 +6205,7 @@ customElements.define("fig-layer", FigLayer);
6007
6205
  * @attr {string} value - JSON-encoded fill value
6008
6206
  * @attr {boolean} disabled - Whether the picker is disabled
6009
6207
  * @attr {boolean} alpha - Whether to show alpha/opacity controls (default: true)
6010
- * @attr {string} dialog-position - Position of the dialog (passed to fig-dialog)
6208
+ * @attr {string} dialog-position - Position of the popup (default: "left")
6011
6209
  */
6012
6210
  class FigFillPicker extends HTMLElement {
6013
6211
  #trigger = null;
@@ -6213,21 +6411,10 @@ class FigFillPicker extends HTMLElement {
6213
6411
  this.#createDialog();
6214
6412
  }
6215
6413
 
6216
- // Position off-screen first to prevent scroll jump
6217
- this.#dialog.style.position = "fixed";
6218
- this.#dialog.style.top = "-9999px";
6219
- this.#dialog.style.left = "-9999px";
6220
-
6221
- this.#dialog.show();
6222
6414
  this.#switchTab(this.#fillType);
6415
+ this.#dialog.open = true;
6223
6416
 
6224
- // Position after dialog has rendered and has dimensions
6225
- // Use nested RAF to ensure canvas is fully ready for drawing
6226
6417
  requestAnimationFrame(() => {
6227
- this.#positionDialog();
6228
- this.#dialog.setAttribute("closedby", "any");
6229
-
6230
- // Second RAF ensures the dialog is visible and canvas is ready
6231
6418
  requestAnimationFrame(() => {
6232
6419
  this.#drawColorArea();
6233
6420
  this.#updateHandlePosition();
@@ -6235,72 +6422,17 @@ class FigFillPicker extends HTMLElement {
6235
6422
  });
6236
6423
  }
6237
6424
 
6238
- #positionDialog() {
6239
- const triggerRect = this.#trigger.getBoundingClientRect();
6240
- const dialogRect = this.#dialog.getBoundingClientRect();
6241
- const padding = 8; // Gap between trigger and dialog
6242
- const viewportPadding = 16; // Min distance from viewport edges
6243
-
6244
- // Calculate available space in each direction
6245
- const spaceBelow =
6246
- window.innerHeight - triggerRect.bottom - viewportPadding;
6247
- const spaceAbove = triggerRect.top - viewportPadding;
6248
- const spaceRight = window.innerWidth - triggerRect.left - viewportPadding;
6249
- const spaceLeft = triggerRect.right - viewportPadding;
6250
-
6251
- let top, left;
6252
-
6253
- // Vertical positioning: prefer below, fallback to above
6254
- if (spaceBelow >= dialogRect.height || spaceBelow >= spaceAbove) {
6255
- // Position below trigger
6256
- top = triggerRect.bottom + padding;
6257
- } else {
6258
- // Position above trigger
6259
- top = triggerRect.top - dialogRect.height - padding;
6260
- }
6261
-
6262
- // Horizontal positioning: align left edge with trigger, adjust if needed
6263
- left = triggerRect.left;
6264
-
6265
- // Adjust if dialog would go off right edge
6266
- if (left + dialogRect.width > window.innerWidth - viewportPadding) {
6267
- left = window.innerWidth - dialogRect.width - viewportPadding;
6268
- }
6269
-
6270
- // Adjust if dialog would go off left edge
6271
- if (left < viewportPadding) {
6272
- left = viewportPadding;
6273
- }
6274
-
6275
- // Clamp vertical position to viewport
6276
- if (top < viewportPadding) {
6277
- top = viewportPadding;
6278
- }
6279
- if (top + dialogRect.height > window.innerHeight - viewportPadding) {
6280
- top = window.innerHeight - dialogRect.height - viewportPadding;
6281
- }
6282
-
6283
- // Apply position (override fig-dialog's default positioning)
6284
- this.#dialog.style.position = "fixed";
6285
- this.#dialog.style.top = `${top}px`;
6286
- this.#dialog.style.left = `${left}px`;
6287
- this.#dialog.style.bottom = "auto";
6288
- this.#dialog.style.right = "auto";
6289
- this.#dialog.style.margin = "0";
6290
- }
6291
-
6292
6425
  #createDialog() {
6293
- this.#dialog = document.createElement("dialog", { is: "fig-dialog" });
6294
- this.#dialog.setAttribute("is", "fig-dialog");
6426
+ this.#dialog = document.createElement("dialog", { is: "fig-popup" });
6427
+ this.#dialog.setAttribute("is", "fig-popup");
6295
6428
  this.#dialog.setAttribute("drag", "true");
6296
6429
  this.#dialog.setAttribute("handle", "fig-header");
6430
+ this.#dialog.setAttribute("autoresize", "false");
6297
6431
  this.#dialog.classList.add("fig-fill-picker-dialog");
6298
6432
 
6299
- // Forward dialog attributes
6300
- const dialogPosition = this.getAttribute("dialog-position");
6301
- if (dialogPosition) {
6302
- this.#dialog.setAttribute("position", dialogPosition);
6303
- }
6433
+ this.#dialog.anchor = this.#trigger;
6434
+ const dialogPosition = this.getAttribute("dialog-position") || "left";
6435
+ this.#dialog.setAttribute("position", dialogPosition);
6304
6436
 
6305
6437
  // Check for allowed modes (supports comma-separated values like "solid,gradient")
6306
6438
  const mode = this.getAttribute("mode");
@@ -6333,7 +6465,7 @@ class FigFillPicker extends HTMLElement {
6333
6465
 
6334
6466
  let headerContent;
6335
6467
  if (allowedModes.length === 1) {
6336
- headerContent = `<span class="fig-fill-picker-type-label">${modeLabels[allowedModes[0]]}</span>`;
6468
+ headerContent = `<h3 class="fig-fill-picker-type-label">${modeLabels[allowedModes[0]]}</h3>`;
6337
6469
  } else {
6338
6470
  const options = allowedModes
6339
6471
  .map((m) => `<option value="${m}">${modeLabels[m]}</option>`)
@@ -6346,7 +6478,7 @@ class FigFillPicker extends HTMLElement {
6346
6478
  this.#dialog.innerHTML = `
6347
6479
  <fig-header>
6348
6480
  ${headerContent}
6349
- <fig-button icon variant="ghost" close-dialog>
6481
+ <fig-button icon variant="ghost" class="fig-fill-picker-close">
6350
6482
  <span class="fig-mask-icon" style="--icon: var(--icon-close)"></span>
6351
6483
  </fig-button>
6352
6484
  </fig-header>
@@ -6369,11 +6501,10 @@ class FigFillPicker extends HTMLElement {
6369
6501
  });
6370
6502
  }
6371
6503
 
6372
- // Close button
6373
6504
  this.#dialog
6374
- .querySelector("fig-button[close-dialog]")
6505
+ .querySelector(".fig-fill-picker-close")
6375
6506
  .addEventListener("click", () => {
6376
- this.#dialog.close();
6507
+ this.#dialog.open = false;
6377
6508
  });
6378
6509
 
6379
6510
  // Emit change on close
@@ -6845,7 +6976,7 @@ class FigFillPicker extends HTMLElement {
6845
6976
  <fig-input-number class="fig-fill-picker-stop-position" min="0" max="100" value="${
6846
6977
  stop.position
6847
6978
  }" units="%"></fig-input-number>
6848
- <fig-input-color class="fig-fill-picker-stop-color" text="true" alpha="true" picker="figma" value="${
6979
+ <fig-input-color class="fig-fill-picker-stop-color" text="true" alpha="true" picker="figma" dialog-position="right" value="${
6849
6980
  stop.color
6850
6981
  }"></fig-input-color>
6851
6982
  <fig-button icon variant="ghost" class="fig-fill-picker-stop-remove" ${
package/index.html CHANGED
@@ -24,7 +24,7 @@
24
24
 
25
25
  nav {
26
26
  position: fixed;
27
- width: 180px;
27
+ width: 240px;
28
28
  height: 100vh;
29
29
  overflow-y: auto;
30
30
  background: var(--figma-color-bg-secondary);
@@ -105,7 +105,7 @@
105
105
  }
106
106
 
107
107
  main {
108
- margin-left: 180px;
108
+ margin-left: 240px;
109
109
  padding: 24px 32px;
110
110
  max-width: 800px;
111
111
  min-height: 100vh;
@@ -1806,6 +1806,13 @@
1806
1806
  alpha="true"
1807
1807
  picker="figma"></fig-input-color>
1808
1808
 
1809
+ <h4>Figma Picker (Experimental Modern)</h4>
1810
+ <fig-input-color value="#E84393"
1811
+ text="true"
1812
+ alpha="true"
1813
+ picker="figma"
1814
+ experimental="modern"></fig-input-color>
1815
+
1809
1816
  <h4>No Picker (text input only)</h4>
1810
1817
  <fig-input-color value="#9747FF"
1811
1818
  text="true"
@@ -1995,6 +2002,10 @@
1995
2002
  <h3>Webcam Fill</h3>
1996
2003
  <fig-input-fill value='{"type":"webcam","webcam":{"opacity":1}}'></fig-input-fill>
1997
2004
 
2005
+ <h3>Experimental Modern</h3>
2006
+ <fig-input-fill experimental="modern"
2007
+ value='{"type":"solid","color":"#E84393","alpha":1}'></fig-input-fill>
2008
+
1998
2009
  <h3>Disabled</h3>
1999
2010
  <fig-input-fill disabled
2000
2011
  value='{"type":"solid","color":"#AA96DA","alpha":1}'></fig-input-fill>
@@ -3109,6 +3120,28 @@
3109
3120
  &lt;dialog is="fig-popup" anchor="#popup-open-right-single" position="right" offset="12 8"&gt;
3110
3121
  ...
3111
3122
  &lt;/dialog&gt;</code></pre>
3123
+
3124
+ <h4>Left (top edges aligned)</h4>
3125
+ <hstack>
3126
+ <fig-button id="popup-open-left-single">Open Left (single)</fig-button>
3127
+ <fig-button id="popup-close-left-single"
3128
+ variant="secondary">Close</fig-button>
3129
+ </hstack>
3130
+ <dialog id="popup-left-single"
3131
+ is="fig-popup"
3132
+ anchor="#popup-open-left-single"
3133
+ position="left"
3134
+ offset="12 8">
3135
+ <vstack style="min-width: 11rem;">
3136
+ <strong style="padding: 0 var(--spacer-1);">Left Shorthand</strong>
3137
+ <fig-input-text placeholder="position='left'"></fig-input-text>
3138
+ </vstack>
3139
+ </dialog>
3140
+ <pre><code>&lt;fig-button id="popup-open-left-single"&gt;Open Left (single)&lt;/fig-button&gt;
3141
+ &lt;dialog is="fig-popup" anchor="#popup-open-left-single" position="left" offset="12 8"&gt;
3142
+ ...
3143
+ &lt;/dialog&gt;</code></pre>
3144
+
3112
3145
  </section>
3113
3146
  <hr>
3114
3147
 
@@ -4452,6 +4485,7 @@ button.addEventListener('click', () => {
4452
4485
  ['popup-open-center-left', 'popup-close-center-left', 'popup-center-left'],
4453
4486
  ['popup-open-top-single', 'popup-close-top-single', 'popup-top-single'],
4454
4487
  ['popup-open-right-single', 'popup-close-right-single', 'popup-right-single'],
4488
+ ['popup-open-left-single', 'popup-close-left-single', 'popup-left-single'],
4455
4489
  ];
4456
4490
 
4457
4491
  popupExamples.forEach(([openId, closeId, popupId]) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "2.20.0",
3
+ "version": "2.22.0",
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",