@rogieking/figui3 2.17.1 → 2.17.2

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
@@ -2044,6 +2044,68 @@ dialog[is="fig-dialog"] {
2044
2044
  z-index: var(--z-index);
2045
2045
  }
2046
2046
 
2047
+ dialog[is="fig-popup"] {
2048
+ --z-index: 999999;
2049
+ z-index: var(--z-index);
2050
+ position: fixed;
2051
+ margin: 0;
2052
+ min-width: 0;
2053
+ width: max-content;
2054
+ max-width: calc(100vw - var(--spacer-4));
2055
+ max-height: calc(100vh - var(--spacer-4));
2056
+ padding: var(--spacer-2);
2057
+ overflow: auto;
2058
+
2059
+ &[open] {
2060
+ display: block;
2061
+ }
2062
+
2063
+ &[variant="popover"] {
2064
+ overflow: visible;
2065
+ box-shadow: inset 0 0.5px 0 0 rgba(255, 255, 255, 0.1);
2066
+ filter: drop-shadow(0px 1px 1.5px rgba(0, 0, 0, 0.1))
2067
+ drop-shadow(0px 2.5px 6px rgba(0, 0, 0, 0.13))
2068
+ drop-shadow(0px 0px 0.5px rgba(0, 0, 0, 0.15));
2069
+
2070
+ &:after {
2071
+ content: "";
2072
+ background-color: inherit;
2073
+ clip-path: path(
2074
+ "M16 0H0L0 1H0.757359C1.55301 1 2.31607 1.31607 2.87868 1.87868L6.29587 5.29587C7.23704 6.23704 8.76296 6.23704 9.70413 5.29587L13.1213 1.87868C13.6839 1.31607 14.447 1 15.2426 1H16V0Z"
2075
+ );
2076
+ position: absolute;
2077
+ width: 1rem;
2078
+ height: 6px;
2079
+ z-index: 2;
2080
+ pointer-events: none;
2081
+ }
2082
+
2083
+ &[data-beak-side="bottom"]:after {
2084
+ top: calc(100% - 1px);
2085
+ left: var(--beak-offset, 50%);
2086
+ transform: translateX(-50%);
2087
+ }
2088
+
2089
+ &[data-beak-side="top"]:after {
2090
+ top: 1px;
2091
+ left: var(--beak-offset, 50%);
2092
+ transform: translate(-50%, -100%) scaleY(-1);
2093
+ }
2094
+
2095
+ &[data-beak-side="left"]:after {
2096
+ left: 1px;
2097
+ top: var(--beak-offset, 50%);
2098
+ transform: translate(-100%, -50%) rotate(90deg);
2099
+ }
2100
+
2101
+ &[data-beak-side="right"]:after {
2102
+ left: calc(100% - 1px);
2103
+ top: var(--beak-offset, 50%);
2104
+ transform: translate(0, -50%) rotate(-90deg);
2105
+ }
2106
+ }
2107
+ }
2108
+
2047
2109
  dialog[is="fig-toast"] {
2048
2110
  --z-index: 999999;
2049
2111
  z-index: var(--z-index);
package/fig.js CHANGED
@@ -1050,6 +1050,547 @@ class FigDialog extends HTMLDialogElement {
1050
1050
  }
1051
1051
  customElements.define("fig-dialog", FigDialog, { extends: "dialog" });
1052
1052
 
1053
+ /* Popup */
1054
+ /**
1055
+ * A floating popup foundation component based on <dialog>.
1056
+ * @attr {string} anchor - CSS selector used to resolve the anchor element.
1057
+ * @attr {string} position - Preferred placement as "vertical horizontal" (default: "top center").
1058
+ * @attr {string} offset - Horizontal and vertical offset as "x y" (default: "0 0").
1059
+ * @attr {string} variant - Visual variant. Use variant="popover" to show an anchor beak.
1060
+ * @attr {boolean|string} open - Open when present and not "false".
1061
+ */
1062
+ class FigPopup extends HTMLDialogElement {
1063
+ #viewportPadding = 8;
1064
+ #anchorObserver = null;
1065
+ #contentObserver = null;
1066
+ #mutationObserver = null;
1067
+ #isPopupActive = false;
1068
+ #boundReposition;
1069
+ #boundScroll;
1070
+ #boundOutsidePointerDown;
1071
+ #rafId = null;
1072
+
1073
+ constructor() {
1074
+ super();
1075
+ this.#boundReposition = this.#queueReposition.bind(this);
1076
+ this.#boundScroll = this.#queueReposition.bind(this);
1077
+ this.#boundOutsidePointerDown = this.#handleOutsidePointerDown.bind(this);
1078
+ }
1079
+
1080
+ static get observedAttributes() {
1081
+ return ["open", "anchor", "position", "offset", "variant"];
1082
+ }
1083
+
1084
+ get open() {
1085
+ return this.hasAttribute("open") && this.getAttribute("open") !== "false";
1086
+ }
1087
+
1088
+ set open(value) {
1089
+ if (value === false || value === "false" || value === null) {
1090
+ if (!this.open) return;
1091
+ this.removeAttribute("open");
1092
+ return;
1093
+ }
1094
+ if (this.open) return;
1095
+ this.setAttribute("open", "true");
1096
+ }
1097
+
1098
+ connectedCallback() {
1099
+ if (!this.hasAttribute("position")) {
1100
+ this.setAttribute("position", "top center");
1101
+ }
1102
+ if (!this.hasAttribute("role")) {
1103
+ this.setAttribute("role", "dialog");
1104
+ }
1105
+ // Default dialog outside-close behavior.
1106
+ if (!this.hasAttribute("closedby")) {
1107
+ this.setAttribute("closedby", "any");
1108
+ }
1109
+
1110
+ this.addEventListener("close", () => {
1111
+ this.#teardownObservers();
1112
+ if (this.hasAttribute("open")) {
1113
+ this.removeAttribute("open");
1114
+ }
1115
+ });
1116
+
1117
+ if (this.open) {
1118
+ this.#showPopup();
1119
+ } else {
1120
+ this.#hidePopup();
1121
+ }
1122
+ }
1123
+
1124
+ disconnectedCallback() {
1125
+ this.#teardownObservers();
1126
+ document.removeEventListener(
1127
+ "pointerdown",
1128
+ this.#boundOutsidePointerDown,
1129
+ true
1130
+ );
1131
+ if (this.#rafId !== null) {
1132
+ cancelAnimationFrame(this.#rafId);
1133
+ this.#rafId = null;
1134
+ }
1135
+ }
1136
+
1137
+ attributeChangedCallback(name, oldValue, newValue) {
1138
+ if (oldValue === newValue) return;
1139
+
1140
+ if (name === "open") {
1141
+ if (newValue === null || newValue === "false") {
1142
+ this.#hidePopup();
1143
+ return;
1144
+ }
1145
+ this.#showPopup();
1146
+ return;
1147
+ }
1148
+
1149
+ if (this.open) {
1150
+ this.#queueReposition();
1151
+ this.#setupObservers();
1152
+ }
1153
+ }
1154
+
1155
+ #showPopup() {
1156
+ if (this.#isPopupActive) {
1157
+ this.#queueReposition();
1158
+ return;
1159
+ }
1160
+
1161
+ this.style.position = "fixed";
1162
+ this.style.inset = "auto";
1163
+ this.style.margin = "0";
1164
+ this.style.zIndex = String(figGetHighestZIndex() + 1);
1165
+
1166
+ if (!super.open) {
1167
+ try {
1168
+ this.show();
1169
+ } catch (e) {
1170
+ // Ignore when dialog cannot be shown yet.
1171
+ }
1172
+ }
1173
+
1174
+ this.#setupObservers();
1175
+ document.addEventListener("pointerdown", this.#boundOutsidePointerDown, true);
1176
+ this.#queueReposition();
1177
+ this.#isPopupActive = true;
1178
+ }
1179
+
1180
+ #hidePopup() {
1181
+ this.#isPopupActive = false;
1182
+ this.#teardownObservers();
1183
+ document.removeEventListener(
1184
+ "pointerdown",
1185
+ this.#boundOutsidePointerDown,
1186
+ true
1187
+ );
1188
+
1189
+ if (super.open) {
1190
+ try {
1191
+ this.close();
1192
+ } catch (e) {
1193
+ // Ignore when dialog is not in an open state.
1194
+ }
1195
+ }
1196
+ }
1197
+
1198
+ #setupObservers() {
1199
+ this.#teardownObservers();
1200
+
1201
+ const anchor = this.#resolveAnchor();
1202
+ if (anchor && "ResizeObserver" in window) {
1203
+ this.#anchorObserver = new ResizeObserver(this.#boundReposition);
1204
+ this.#anchorObserver.observe(anchor);
1205
+ }
1206
+
1207
+ if ("ResizeObserver" in window) {
1208
+ this.#contentObserver = new ResizeObserver(this.#boundReposition);
1209
+ this.#contentObserver.observe(this);
1210
+ }
1211
+
1212
+ this.#mutationObserver = new MutationObserver(this.#boundReposition);
1213
+ this.#mutationObserver.observe(this, {
1214
+ childList: true,
1215
+ subtree: true,
1216
+ characterData: true,
1217
+ });
1218
+
1219
+ window.addEventListener("resize", this.#boundReposition);
1220
+ window.addEventListener("scroll", this.#boundScroll, true);
1221
+ }
1222
+
1223
+ #teardownObservers() {
1224
+ if (this.#anchorObserver) {
1225
+ this.#anchorObserver.disconnect();
1226
+ this.#anchorObserver = null;
1227
+ }
1228
+ if (this.#contentObserver) {
1229
+ this.#contentObserver.disconnect();
1230
+ this.#contentObserver = null;
1231
+ }
1232
+ if (this.#mutationObserver) {
1233
+ this.#mutationObserver.disconnect();
1234
+ this.#mutationObserver = null;
1235
+ }
1236
+ window.removeEventListener("resize", this.#boundReposition);
1237
+ window.removeEventListener("scroll", this.#boundScroll, true);
1238
+ }
1239
+
1240
+ #handleOutsidePointerDown(event) {
1241
+ if (!this.open || !super.open) return;
1242
+ const target = event.target;
1243
+ if (!(target instanceof Node)) return;
1244
+ if (this.contains(target)) return;
1245
+
1246
+ // Fallback for browsers that do not honor dialog closedby consistently.
1247
+ this.open = false;
1248
+ }
1249
+
1250
+ #resolveAnchor() {
1251
+ const selector = this.getAttribute("anchor");
1252
+ if (!selector) return null;
1253
+
1254
+ // Local-first: nearest parent subtree.
1255
+ const localScope = this.parentElement;
1256
+ if (localScope?.querySelector) {
1257
+ const localMatch = localScope.querySelector(selector);
1258
+ if (localMatch && !this.contains(localMatch)) return localMatch;
1259
+ }
1260
+
1261
+ // Fallback: global document query.
1262
+ return document.querySelector(selector);
1263
+ }
1264
+
1265
+ #parsePosition() {
1266
+ const raw = (this.getAttribute("position") || "top center")
1267
+ .trim()
1268
+ .toLowerCase();
1269
+ const tokens = raw.split(/\s+/).filter(Boolean);
1270
+ const verticalValues = new Set(["top", "center", "bottom"]);
1271
+ const horizontalValues = new Set(["left", "center", "right"]);
1272
+
1273
+ let vertical = "top";
1274
+ let horizontal = "center";
1275
+ let shorthand = null;
1276
+
1277
+ // Treat position as "vertical horizontal" to avoid center ambiguity.
1278
+ if (tokens.length >= 2) {
1279
+ if (verticalValues.has(tokens[0])) {
1280
+ vertical = tokens[0];
1281
+ }
1282
+ if (horizontalValues.has(tokens[1])) {
1283
+ horizontal = tokens[1];
1284
+ }
1285
+ return { vertical, horizontal, shorthand };
1286
+ }
1287
+
1288
+ // Single-token fallback: apply only if non-ambiguous.
1289
+ if (tokens.length === 1) {
1290
+ const token = tokens[0];
1291
+ if (token === "top" || token === "bottom") {
1292
+ vertical = token;
1293
+ shorthand = token;
1294
+ } else if (token === "left" || token === "right") {
1295
+ horizontal = token;
1296
+ shorthand = token;
1297
+ } else if (token === "center") {
1298
+ vertical = "center";
1299
+ horizontal = "center";
1300
+ }
1301
+ }
1302
+
1303
+ return { vertical, horizontal, shorthand };
1304
+ }
1305
+
1306
+ #normalizeOffsetToken(token, fallback = "0px") {
1307
+ if (!token) return fallback;
1308
+ const trimmed = token.trim();
1309
+ if (!trimmed) return fallback;
1310
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
1311
+ return `${trimmed}px`;
1312
+ }
1313
+ return trimmed;
1314
+ }
1315
+
1316
+ #measureLengthPx(value, axis = "x") {
1317
+ if (!value) return 0;
1318
+ const normalized = this.#normalizeOffsetToken(value, "0px");
1319
+ if (normalized.endsWith("px")) {
1320
+ const px = parseFloat(normalized);
1321
+ return Number.isFinite(px) ? px : 0;
1322
+ }
1323
+
1324
+ const probe = document.createElement("div");
1325
+ probe.style.position = "fixed";
1326
+ probe.style.visibility = "hidden";
1327
+ probe.style.pointerEvents = "none";
1328
+ probe.style.left = "0";
1329
+ probe.style.top = "0";
1330
+ probe.style.margin = "0";
1331
+ probe.style.padding = "0";
1332
+ probe.style.border = "0";
1333
+ if (axis === "x") {
1334
+ probe.style.width = normalized;
1335
+ probe.style.height = "0";
1336
+ } else {
1337
+ probe.style.height = normalized;
1338
+ probe.style.width = "0";
1339
+ }
1340
+ document.body.appendChild(probe);
1341
+ const rect = probe.getBoundingClientRect();
1342
+ probe.remove();
1343
+ return axis === "x" ? rect.width : rect.height;
1344
+ }
1345
+
1346
+ #parseOffset() {
1347
+ const raw = (this.getAttribute("offset") || "0 0").trim();
1348
+ const tokens = raw.split(/\s+/).filter(Boolean);
1349
+
1350
+ const xToken = this.#normalizeOffsetToken(tokens[0], "0px");
1351
+ const yToken = this.#normalizeOffsetToken(tokens[1], "0px");
1352
+
1353
+ return {
1354
+ xToken,
1355
+ yToken,
1356
+ xPx: this.#measureLengthPx(xToken, "x"),
1357
+ yPx: this.#measureLengthPx(yToken, "y"),
1358
+ };
1359
+ }
1360
+
1361
+ #getOrder(preferred, axis) {
1362
+ const verticalMap = {
1363
+ top: ["top", "bottom", "center"],
1364
+ center: ["center", "top", "bottom"],
1365
+ bottom: ["bottom", "top", "center"],
1366
+ };
1367
+ const horizontalMap = {
1368
+ left: ["left", "right", "center"],
1369
+ center: ["center", "left", "right"],
1370
+ right: ["right", "left", "center"],
1371
+ };
1372
+
1373
+ return axis === "vertical"
1374
+ ? verticalMap[preferred] || verticalMap.top
1375
+ : horizontalMap[preferred] || horizontalMap.center;
1376
+ }
1377
+
1378
+ #computeCoords(anchorRect, popupRect, vertical, horizontal, offset, shorthand) {
1379
+ let top;
1380
+ let left;
1381
+
1382
+ // Shorthand support:
1383
+ // position="right" => top edges aligned, popup sits to the right of anchor.
1384
+ // position="left" => top edges aligned, popup sits to the left of anchor.
1385
+ if (shorthand === "right") {
1386
+ return {
1387
+ top: anchorRect.top,
1388
+ left: anchorRect.right + offset.xPx,
1389
+ };
1390
+ }
1391
+ if (shorthand === "left") {
1392
+ return {
1393
+ top: anchorRect.top,
1394
+ left: anchorRect.left - popupRect.width - offset.xPx,
1395
+ };
1396
+ }
1397
+ if (shorthand === "top") {
1398
+ return {
1399
+ top: anchorRect.top - popupRect.height - offset.yPx,
1400
+ left: anchorRect.left,
1401
+ };
1402
+ }
1403
+ if (shorthand === "bottom") {
1404
+ return {
1405
+ top: anchorRect.bottom + offset.yPx,
1406
+ left: anchorRect.left,
1407
+ };
1408
+ }
1409
+
1410
+ if (vertical === "top") {
1411
+ top = anchorRect.top - popupRect.height - offset.yPx;
1412
+ } else if (vertical === "bottom") {
1413
+ top = anchorRect.bottom + offset.yPx;
1414
+ } else {
1415
+ top = anchorRect.top + (anchorRect.height - popupRect.height) / 2;
1416
+ }
1417
+
1418
+ if (horizontal === "left") {
1419
+ left = anchorRect.left - popupRect.width - offset.xPx;
1420
+ } else if (horizontal === "right") {
1421
+ left = anchorRect.right + offset.xPx;
1422
+ } else {
1423
+ left = anchorRect.left + (anchorRect.width - popupRect.width) / 2;
1424
+ }
1425
+
1426
+ return { top, left };
1427
+ }
1428
+
1429
+ #oppositeSide(side) {
1430
+ const map = {
1431
+ top: "bottom",
1432
+ bottom: "top",
1433
+ left: "right",
1434
+ right: "left",
1435
+ };
1436
+ return map[side] || "bottom";
1437
+ }
1438
+
1439
+ #getPlacementSide(vertical, horizontal, shorthand) {
1440
+ if (shorthand === "top") return "top";
1441
+ if (shorthand === "bottom") return "bottom";
1442
+ if (shorthand === "left") return "left";
1443
+ if (shorthand === "right") return "right";
1444
+
1445
+ if (vertical !== "center") return vertical;
1446
+ if (horizontal !== "center") return horizontal;
1447
+ return "top";
1448
+ }
1449
+
1450
+ #updatePopoverBeak(anchorRect, popupRect, left, top, placementSide) {
1451
+ if (this.getAttribute("variant") !== "popover" || !anchorRect) {
1452
+ this.style.removeProperty("--beak-offset");
1453
+ this.removeAttribute("data-beak-side");
1454
+ return;
1455
+ }
1456
+
1457
+ const beakSide = this.#oppositeSide(placementSide);
1458
+ this.setAttribute("data-beak-side", beakSide);
1459
+
1460
+ const anchorCenterX = anchorRect.left + anchorRect.width / 2;
1461
+ const anchorCenterY = anchorRect.top + anchorRect.height / 2;
1462
+
1463
+ let beakOffset;
1464
+ if (beakSide === "top" || beakSide === "bottom") {
1465
+ beakOffset = anchorCenterX - left;
1466
+ const min = 8;
1467
+ const max = Math.max(min, popupRect.width - 8);
1468
+ beakOffset = Math.min(max, Math.max(min, beakOffset));
1469
+ } else {
1470
+ beakOffset = anchorCenterY - top;
1471
+ const min = 8;
1472
+ const max = Math.max(min, popupRect.height - 8);
1473
+ beakOffset = Math.min(max, Math.max(min, beakOffset));
1474
+ }
1475
+
1476
+ this.style.setProperty("--beak-offset", `${beakOffset}px`);
1477
+ }
1478
+
1479
+ #overflowScore(coords, popupRect) {
1480
+ const vw = window.innerWidth;
1481
+ const vh = window.innerHeight;
1482
+ const right = coords.left + popupRect.width;
1483
+ const bottom = coords.top + popupRect.height;
1484
+ const pad = this.#viewportPadding;
1485
+
1486
+ const overflowLeft = Math.max(0, pad - coords.left);
1487
+ const overflowTop = Math.max(0, pad - coords.top);
1488
+ const overflowRight = Math.max(0, right - (vw - pad));
1489
+ const overflowBottom = Math.max(0, bottom - (vh - pad));
1490
+
1491
+ return overflowLeft + overflowTop + overflowRight + overflowBottom;
1492
+ }
1493
+
1494
+ #fits(coords, popupRect) {
1495
+ return this.#overflowScore(coords, popupRect) === 0;
1496
+ }
1497
+
1498
+ #clamp(coords, popupRect) {
1499
+ const pad = this.#viewportPadding;
1500
+ const minLeft = pad;
1501
+ const minTop = pad;
1502
+ const maxLeft = Math.max(pad, window.innerWidth - popupRect.width - pad);
1503
+ const maxTop = Math.max(pad, window.innerHeight - popupRect.height - pad);
1504
+
1505
+ return {
1506
+ left: Math.min(maxLeft, Math.max(minLeft, coords.left)),
1507
+ top: Math.min(maxTop, Math.max(minTop, coords.top)),
1508
+ };
1509
+ }
1510
+
1511
+ #positionPopup() {
1512
+ if (!this.open || !super.open) return;
1513
+
1514
+ const popupRect = this.getBoundingClientRect();
1515
+ const offset = this.#parseOffset();
1516
+ const { vertical, horizontal, shorthand } = this.#parsePosition();
1517
+ const verticalOrder = this.#getOrder(vertical, "vertical");
1518
+ const horizontalOrder = this.#getOrder(horizontal, "horizontal");
1519
+ const anchor = this.#resolveAnchor();
1520
+
1521
+ if (!anchor) {
1522
+ this.#updatePopoverBeak(null, popupRect, 0, 0, "top");
1523
+ const centered = {
1524
+ left: (window.innerWidth - popupRect.width) / 2,
1525
+ top: (window.innerHeight - popupRect.height) / 2,
1526
+ };
1527
+ const clamped = this.#clamp(centered, popupRect);
1528
+ this.style.left = `${clamped.left}px`;
1529
+ this.style.top = `${clamped.top}px`;
1530
+ return;
1531
+ }
1532
+
1533
+ const anchorRect = anchor.getBoundingClientRect();
1534
+ let best = null;
1535
+ let bestSide = "top";
1536
+ let bestScore = Number.POSITIVE_INFINITY;
1537
+
1538
+ for (const v of verticalOrder) {
1539
+ for (const h of horizontalOrder) {
1540
+ const coords = this.#computeCoords(
1541
+ anchorRect,
1542
+ popupRect,
1543
+ v,
1544
+ h,
1545
+ offset,
1546
+ shorthand
1547
+ );
1548
+ const placementSide = this.#getPlacementSide(v, h, shorthand);
1549
+ if (this.#fits(coords, popupRect)) {
1550
+ this.style.left = `${coords.left}px`;
1551
+ this.style.top = `${coords.top}px`;
1552
+ this.#updatePopoverBeak(
1553
+ anchorRect,
1554
+ popupRect,
1555
+ coords.left,
1556
+ coords.top,
1557
+ placementSide
1558
+ );
1559
+ return;
1560
+ }
1561
+ const score = this.#overflowScore(coords, popupRect);
1562
+ if (score < bestScore) {
1563
+ bestScore = score;
1564
+ best = coords;
1565
+ bestSide = placementSide;
1566
+ }
1567
+ }
1568
+ }
1569
+
1570
+ const clamped = this.#clamp(best || { left: 0, top: 0 }, popupRect);
1571
+ this.style.left = `${clamped.left}px`;
1572
+ this.style.top = `${clamped.top}px`;
1573
+ this.#updatePopoverBeak(
1574
+ anchorRect,
1575
+ popupRect,
1576
+ clamped.left,
1577
+ clamped.top,
1578
+ bestSide
1579
+ );
1580
+ }
1581
+
1582
+ #queueReposition() {
1583
+ if (!this.open) return;
1584
+ if (this.#rafId !== null) return;
1585
+
1586
+ this.#rafId = requestAnimationFrame(() => {
1587
+ this.#rafId = null;
1588
+ this.#positionPopup();
1589
+ });
1590
+ }
1591
+ }
1592
+ customElements.define("fig-popup", FigPopup, { extends: "dialog" });
1593
+
1053
1594
  /**
1054
1595
  * A popover element using the native Popover API.
1055
1596
  * @attr {string} trigger-action - The trigger action: "click" (default) or "hover"
package/index.html CHANGED
@@ -184,6 +184,7 @@
184
184
  <a href="#input-text">Input Text</a>
185
185
  <a href="#layer">Layer</a>
186
186
  <a href="#popover">Popover</a>
187
+ <a href="#popup">Popup</a>
187
188
  <a href="#radio">Radio</a>
188
189
  <a href="#segmented-control">Segmented Control</a>
189
190
  <a href="#shimmer">Shimmer</a>
@@ -2635,6 +2636,296 @@
2635
2636
  </section>
2636
2637
  <hr>
2637
2638
 
2639
+ <!-- Popup -->
2640
+ <section id="popup">
2641
+ <h2>Popup</h2>
2642
+ <p class="description">A floating foundation component built as <code>&lt;dialog is="fig-popup"&gt;</code>
2643
+ with anchor-based positioning and viewport-aware fallback.</p>
2644
+
2645
+ <h3>Default Position (top center)</h3>
2646
+ <hstack>
2647
+ <fig-button id="popup-open-default">Open Popup</fig-button>
2648
+ <fig-button id="popup-close-default"
2649
+ variant="secondary">Close Popup</fig-button>
2650
+ </hstack>
2651
+ <dialog id="popup-default"
2652
+ is="fig-popup"
2653
+ anchor="#popup-open-default">
2654
+ <vstack style="min-width: 12rem;">
2655
+ <strong style="padding: 0 var(--spacer-1);">Default Popup</strong>
2656
+ <fig-field direction="horizontal">
2657
+ <label>Opacity</label>
2658
+ <fig-slider value="50"
2659
+ text="true"
2660
+ units="%"></fig-slider>
2661
+ </fig-field>
2662
+ </vstack>
2663
+ </dialog>
2664
+
2665
+ <h3>Preferred Position (bottom right)</h3>
2666
+ <hstack>
2667
+ <fig-button id="popup-open-preferred">Open Bottom Right</fig-button>
2668
+ <fig-button id="popup-close-preferred"
2669
+ variant="secondary">Close</fig-button>
2670
+ </hstack>
2671
+ <dialog id="popup-preferred"
2672
+ is="fig-popup"
2673
+ anchor="#popup-open-preferred"
2674
+ position="bottom right">
2675
+ <vstack style="min-width: 10rem;">
2676
+ <strong style="padding: 0 var(--spacer-1);">Preferred Placement</strong>
2677
+ <fig-checkbox label="Apply to existing strokes"
2678
+ checked></fig-checkbox>
2679
+ <fig-checkbox label="Apply to shapes with fills"></fig-checkbox>
2680
+ </vstack>
2681
+ </dialog>
2682
+
2683
+ <h3>Popover Variant (with Arrow)</h3>
2684
+ <p class="description">Use <code>variant="popover"</code> to show the beak and automatically point it at the
2685
+ anchor.</p>
2686
+ <hstack>
2687
+ <fig-button id="popup-open-popover">Open Popover Variant</fig-button>
2688
+ <fig-button id="popup-close-popover"
2689
+ variant="secondary">Close</fig-button>
2690
+ </hstack>
2691
+ <dialog id="popup-popover"
2692
+ is="fig-popup"
2693
+ anchor="#popup-open-popover"
2694
+ position="top center"
2695
+ variant="popover">
2696
+ <vstack style="min-width: 12rem;">
2697
+ <strong style="padding: 0 var(--spacer-1);">Popover Beak</strong>
2698
+ <fig-field direction="horizontal">
2699
+ <label>Apply</label>
2700
+ <fig-switch checked></fig-switch>
2701
+ </fig-field>
2702
+ </vstack>
2703
+ </dialog>
2704
+ <pre><code>&lt;fig-button id="popup-open-popover"&gt;Open Popover Variant&lt;/fig-button&gt;
2705
+ &lt;dialog is="fig-popup"
2706
+ anchor="#popup-open-popover"
2707
+ position="top center"
2708
+ variant="popover"&gt;
2709
+ ...
2710
+ &lt;/dialog&gt;</code></pre>
2711
+
2712
+ <h3>Property API</h3>
2713
+ <p class="description">Uses the popup <code>open</code> property setter/getter (attribute wrapper).</p>
2714
+ <hstack>
2715
+ <fig-button id="popup-open-prop">popup.open = true</fig-button>
2716
+ <fig-button id="popup-close-prop"
2717
+ variant="secondary">popup.open = false</fig-button>
2718
+ </hstack>
2719
+ <dialog id="popup-prop"
2720
+ is="fig-popup"
2721
+ anchor="#popup-open-prop"
2722
+ position="top left">
2723
+ <vstack style="min-width: 11rem;">
2724
+ <strong style="padding: 0 var(--spacer-1);">Property Controlled</strong>
2725
+ <fig-input-text placeholder="Type here"></fig-input-text>
2726
+ </vstack>
2727
+ </dialog>
2728
+
2729
+ <h3>Position Examples</h3>
2730
+ <p class="description">Each example uses a different popup position with a dedicated open button and code
2731
+ snippet.</p>
2732
+
2733
+ <h4>Top Left</h4>
2734
+ <hstack>
2735
+ <fig-button id="popup-open-top-left">Open Top Left</fig-button>
2736
+ <fig-button id="popup-close-top-left"
2737
+ variant="secondary">Close</fig-button>
2738
+ </hstack>
2739
+ <dialog id="popup-top-left"
2740
+ is="fig-popup"
2741
+ anchor="#popup-open-top-left"
2742
+ position="top left">
2743
+ <vstack style="min-width: 10rem;">
2744
+ <strong style="padding: 0 var(--spacer-1);">Top Left</strong>
2745
+ <fig-input-number value="12"
2746
+ units="px"></fig-input-number>
2747
+ </vstack>
2748
+ </dialog>
2749
+ <pre><code>&lt;fig-button id="popup-open-top-left"&gt;Open Top Left&lt;/fig-button&gt;
2750
+ &lt;dialog is="fig-popup" anchor="#popup-open-top-left" position="top left"&gt;
2751
+ ...
2752
+ &lt;/dialog&gt;</code></pre>
2753
+
2754
+ <h4>Top Center</h4>
2755
+ <hstack>
2756
+ <fig-button id="popup-open-top-center">Open Top Center</fig-button>
2757
+ <fig-button id="popup-close-top-center"
2758
+ variant="secondary">Close</fig-button>
2759
+ </hstack>
2760
+ <dialog id="popup-top-center"
2761
+ is="fig-popup"
2762
+ anchor="#popup-open-top-center"
2763
+ position="top center">
2764
+ <vstack style="min-width: 10rem;">
2765
+ <strong style="padding: 0 var(--spacer-1);">Top Center</strong>
2766
+ <fig-switch label="Enabled"></fig-switch>
2767
+ </vstack>
2768
+ </dialog>
2769
+ <pre><code>&lt;fig-button id="popup-open-top-center"&gt;Open Top Center&lt;/fig-button&gt;
2770
+ &lt;dialog is="fig-popup" anchor="#popup-open-top-center" position="top center"&gt;
2771
+ ...
2772
+ &lt;/dialog&gt;</code></pre>
2773
+
2774
+ <h4>Top Right</h4>
2775
+ <hstack>
2776
+ <fig-button id="popup-open-top-right">Open Top Right</fig-button>
2777
+ <fig-button id="popup-close-top-right"
2778
+ variant="secondary">Close</fig-button>
2779
+ </hstack>
2780
+ <dialog id="popup-top-right"
2781
+ is="fig-popup"
2782
+ anchor="#popup-open-top-right"
2783
+ position="top right">
2784
+ <vstack style="min-width: 10rem;">
2785
+ <strong style="padding: 0 var(--spacer-1);">Top Right</strong>
2786
+ <fig-input-number value="24"
2787
+ units="px"></fig-input-number>
2788
+ </vstack>
2789
+ </dialog>
2790
+ <pre><code>&lt;fig-button id="popup-open-top-right"&gt;Open Top Right&lt;/fig-button&gt;
2791
+ &lt;dialog is="fig-popup" anchor="#popup-open-top-right" position="top right"&gt;
2792
+ ...
2793
+ &lt;/dialog&gt;</code></pre>
2794
+
2795
+ <h4>Bottom Left</h4>
2796
+ <hstack>
2797
+ <fig-button id="popup-open-bottom-left">Open Bottom Left</fig-button>
2798
+ <fig-button id="popup-close-bottom-left"
2799
+ variant="secondary">Close</fig-button>
2800
+ </hstack>
2801
+ <dialog id="popup-bottom-left"
2802
+ is="fig-popup"
2803
+ anchor="#popup-open-bottom-left"
2804
+ position="bottom left">
2805
+ <vstack style="min-width: 10rem;">
2806
+ <strong style="padding: 0 var(--spacer-1);">Bottom Left</strong>
2807
+ <fig-checkbox label="Snap to pixel grid"></fig-checkbox>
2808
+ </vstack>
2809
+ </dialog>
2810
+ <pre><code>&lt;fig-button id="popup-open-bottom-left"&gt;Open Bottom Left&lt;/fig-button&gt;
2811
+ &lt;dialog is="fig-popup" anchor="#popup-open-bottom-left" position="bottom left"&gt;
2812
+ ...
2813
+ &lt;/dialog&gt;</code></pre>
2814
+
2815
+ <h4>Bottom Right</h4>
2816
+ <hstack>
2817
+ <fig-button id="popup-open-bottom-right">Open Bottom Right</fig-button>
2818
+ <fig-button id="popup-close-bottom-right"
2819
+ variant="secondary">Close</fig-button>
2820
+ </hstack>
2821
+ <dialog id="popup-bottom-right"
2822
+ is="fig-popup"
2823
+ anchor="#popup-open-bottom-right"
2824
+ position="bottom right">
2825
+ <vstack style="min-width: 10rem;">
2826
+ <strong style="padding: 0 var(--spacer-1);">Bottom Right</strong>
2827
+ <fig-dropdown>
2828
+ <option>Normal</option>
2829
+ <option>Multiply</option>
2830
+ <option>Screen</option>
2831
+ </fig-dropdown>
2832
+ </vstack>
2833
+ </dialog>
2834
+ <pre><code>&lt;fig-button id="popup-open-bottom-right"&gt;Open Bottom Right&lt;/fig-button&gt;
2835
+ &lt;dialog is="fig-popup" anchor="#popup-open-bottom-right" position="bottom right"&gt;
2836
+ ...
2837
+ &lt;/dialog&gt;</code></pre>
2838
+
2839
+ <h4>Center Right (Vertical Centering)</h4>
2840
+ <hstack>
2841
+ <fig-button id="popup-open-center-right">Open Center Right</fig-button>
2842
+ <fig-button id="popup-close-center-right"
2843
+ variant="secondary">Close</fig-button>
2844
+ </hstack>
2845
+ <dialog id="popup-center-right"
2846
+ is="fig-popup"
2847
+ anchor="#popup-open-center-right"
2848
+ position="center right"
2849
+ offset="12 8">
2850
+ <vstack style="min-width: 11rem;">
2851
+ <strong style="padding: 0 var(--spacer-1);">Center Right</strong>
2852
+ <fig-input-text placeholder="Vertically centered"></fig-input-text>
2853
+ </vstack>
2854
+ </dialog>
2855
+ <pre><code>&lt;fig-button id="popup-open-center-right"&gt;Open Center Right&lt;/fig-button&gt;
2856
+ &lt;dialog is="fig-popup" anchor="#popup-open-center-right" position="center right" offset="12 8"&gt;
2857
+ ...
2858
+ &lt;/dialog&gt;</code></pre>
2859
+
2860
+ <h4>Center Left (Vertical Centering)</h4>
2861
+ <hstack>
2862
+ <fig-button id="popup-open-center-left">Open Center Left</fig-button>
2863
+ <fig-button id="popup-close-center-left"
2864
+ variant="secondary">Close</fig-button>
2865
+ </hstack>
2866
+ <dialog id="popup-center-left"
2867
+ is="fig-popup"
2868
+ anchor="#popup-open-center-left"
2869
+ position="center left"
2870
+ offset="12 8">
2871
+ <vstack style="min-width: 11rem;">
2872
+ <strong style="padding: 0 var(--spacer-1);">Center Left</strong>
2873
+ <fig-input-text placeholder="Vertically centered"></fig-input-text>
2874
+ </vstack>
2875
+ </dialog>
2876
+ <pre><code>&lt;fig-button id="popup-open-center-left"&gt;Open Center Left&lt;/fig-button&gt;
2877
+ &lt;dialog is="fig-popup" anchor="#popup-open-center-left" position="center left" offset="12 8"&gt;
2878
+ ...
2879
+ &lt;/dialog&gt;</code></pre>
2880
+
2881
+ <h3>Single-Value Position Shorthand</h3>
2882
+ <p class="description">Single values are supported for directional shorthand (for example:
2883
+ <code>top</code>, <code>right</code>).</p>
2884
+
2885
+ <h4>Top (left edges aligned)</h4>
2886
+ <hstack>
2887
+ <fig-button id="popup-open-top-single">Open Top (single)</fig-button>
2888
+ <fig-button id="popup-close-top-single"
2889
+ variant="secondary">Close</fig-button>
2890
+ </hstack>
2891
+ <dialog id="popup-top-single"
2892
+ is="fig-popup"
2893
+ anchor="#popup-open-top-single"
2894
+ position="top"
2895
+ offset="12 8">
2896
+ <vstack style="min-width: 11rem;">
2897
+ <strong style="padding: 0 var(--spacer-1);">Top Shorthand</strong>
2898
+ <fig-input-text placeholder="position='top'"></fig-input-text>
2899
+ </vstack>
2900
+ </dialog>
2901
+ <pre><code>&lt;fig-button id="popup-open-top-single"&gt;Open Top (single)&lt;/fig-button&gt;
2902
+ &lt;dialog is="fig-popup" anchor="#popup-open-top-single" position="top" offset="12 8"&gt;
2903
+ ...
2904
+ &lt;/dialog&gt;</code></pre>
2905
+
2906
+ <h4>Right (top edges aligned)</h4>
2907
+ <hstack>
2908
+ <fig-button id="popup-open-right-single">Open Right (single)</fig-button>
2909
+ <fig-button id="popup-close-right-single"
2910
+ variant="secondary">Close</fig-button>
2911
+ </hstack>
2912
+ <dialog id="popup-right-single"
2913
+ is="fig-popup"
2914
+ anchor="#popup-open-right-single"
2915
+ position="right"
2916
+ offset="12 8">
2917
+ <vstack style="min-width: 11rem;">
2918
+ <strong style="padding: 0 var(--spacer-1);">Right Shorthand</strong>
2919
+ <fig-input-text placeholder="position='right'"></fig-input-text>
2920
+ </vstack>
2921
+ </dialog>
2922
+ <pre><code>&lt;fig-button id="popup-open-right-single"&gt;Open Right (single)&lt;/fig-button&gt;
2923
+ &lt;dialog is="fig-popup" anchor="#popup-open-right-single" position="right" offset="12 8"&gt;
2924
+ ...
2925
+ &lt;/dialog&gt;</code></pre>
2926
+ </section>
2927
+ <hr>
2928
+
2638
2929
  <!-- Radio -->
2639
2930
  <section id="radio">
2640
2931
  <h2>Radio</h2>
@@ -3871,6 +4162,55 @@ button.addEventListener('click', () => {
3871
4162
  setTheme(true);
3872
4163
  });
3873
4164
 
4165
+ // FigPopup demos
4166
+ const popupDefault = document.getElementById('popup-default');
4167
+ const popupPreferred = document.getElementById('popup-preferred');
4168
+ const popupProp = document.getElementById('popup-prop');
4169
+
4170
+ document.getElementById('popup-open-default')?.addEventListener('click', () => {
4171
+ popupDefault?.setAttribute('open', 'true');
4172
+ });
4173
+ document.getElementById('popup-close-default')?.addEventListener('click', () => {
4174
+ popupDefault?.removeAttribute('open');
4175
+ });
4176
+
4177
+ document.getElementById('popup-open-preferred')?.addEventListener('click', () => {
4178
+ popupPreferred?.setAttribute('open', 'true');
4179
+ });
4180
+ document.getElementById('popup-close-preferred')?.addEventListener('click', () => {
4181
+ popupPreferred?.removeAttribute('open');
4182
+ });
4183
+
4184
+ document.getElementById('popup-open-prop')?.addEventListener('click', () => {
4185
+ if (popupProp) popupProp.open = true;
4186
+ });
4187
+ document.getElementById('popup-close-prop')?.addEventListener('click', () => {
4188
+ if (popupProp) popupProp.open = false;
4189
+ });
4190
+
4191
+ const popupExamples = [
4192
+ ['popup-open-popover', 'popup-close-popover', 'popup-popover'],
4193
+ ['popup-open-top-left', 'popup-close-top-left', 'popup-top-left'],
4194
+ ['popup-open-top-center', 'popup-close-top-center', 'popup-top-center'],
4195
+ ['popup-open-top-right', 'popup-close-top-right', 'popup-top-right'],
4196
+ ['popup-open-bottom-left', 'popup-close-bottom-left', 'popup-bottom-left'],
4197
+ ['popup-open-bottom-right', 'popup-close-bottom-right', 'popup-bottom-right'],
4198
+ ['popup-open-center-right', 'popup-close-center-right', 'popup-center-right'],
4199
+ ['popup-open-center-left', 'popup-close-center-left', 'popup-center-left'],
4200
+ ['popup-open-top-single', 'popup-close-top-single', 'popup-top-single'],
4201
+ ['popup-open-right-single', 'popup-close-right-single', 'popup-right-single'],
4202
+ ];
4203
+
4204
+ popupExamples.forEach(([openId, closeId, popupId]) => {
4205
+ const popup = document.getElementById(popupId);
4206
+ document.getElementById(openId)?.addEventListener('click', () => {
4207
+ popup?.setAttribute('open', 'true');
4208
+ });
4209
+ document.getElementById(closeId)?.addEventListener('click', () => {
4210
+ popup?.removeAttribute('open');
4211
+ });
4212
+ });
4213
+
3874
4214
  // Listen for system preference changes
3875
4215
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
3876
4216
  if (!localStorage.getItem('theme')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "2.17.1",
3
+ "version": "2.17.2",
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",