@rogieking/figui3 2.38.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig.js CHANGED
@@ -2,6 +2,76 @@
2
2
  * Generates a unique ID string using timestamp and random values
3
3
  * @returns {string} A unique identifier
4
4
  */
5
+ function figIsWebKitOrIOSBrowser() {
6
+ if (typeof navigator === "undefined") {
7
+ return false;
8
+ }
9
+ const userAgent = navigator.userAgent || "";
10
+ const isIOSBrowser =
11
+ /\b(iPad|iPhone|iPod)\b/.test(userAgent) ||
12
+ /\bMacintosh\b/.test(userAgent) && /\bMobile\b/.test(userAgent);
13
+ const isDesktopWebKit =
14
+ /\bAppleWebKit\b/.test(userAgent) &&
15
+ !/\b(Chrome|Chromium|Edg|OPR|SamsungBrowser)\b/.test(userAgent);
16
+ return isIOSBrowser || isDesktopWebKit;
17
+ }
18
+
19
+ function figSupportsCustomizedBuiltIns() {
20
+ if (
21
+ typeof window === "undefined" ||
22
+ !window.customElements ||
23
+ typeof HTMLButtonElement === "undefined"
24
+ ) {
25
+ return false;
26
+ }
27
+
28
+ const testName = `fig-builtin-probe-${Math.random().toString(36).slice(2)}`;
29
+ class FigCustomizedBuiltInProbe extends HTMLButtonElement {}
30
+
31
+ try {
32
+ customElements.define(testName, FigCustomizedBuiltInProbe, {
33
+ extends: "button",
34
+ });
35
+ const probe = document.createElement("button", { is: testName });
36
+ return probe instanceof FigCustomizedBuiltInProbe;
37
+ } catch (_error) {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ const figNeedsBuiltInPolyfill =
43
+ figIsWebKitOrIOSBrowser() && !figSupportsCustomizedBuiltIns();
44
+ const figBuiltInPolyfillReady = (figNeedsBuiltInPolyfill
45
+ ? import("./polyfills/custom-elements-webkit.js")
46
+ : Promise.resolve()
47
+ )
48
+ .then(() => {})
49
+ .catch((error) => {
50
+ throw error;
51
+ });
52
+
53
+ function figDefineCustomizedBuiltIn(name, constructor, options) {
54
+ const define = () => {
55
+ if (!customElements.get(name)) {
56
+ customElements.define(name, constructor, options);
57
+ }
58
+ };
59
+
60
+ if (!figNeedsBuiltInPolyfill) {
61
+ define();
62
+ return;
63
+ }
64
+
65
+ figBuiltInPolyfillReady
66
+ .then(define)
67
+ .catch((error) => {
68
+ console.error(
69
+ `[figui3] Failed to load customized built-in polyfill for "${name}".`,
70
+ error,
71
+ );
72
+ });
73
+ }
74
+
5
75
  function figUniqueId() {
6
76
  return Date.now().toString(36) + Math.random().toString(36).substring(2);
7
77
  }
@@ -1084,7 +1154,7 @@ class FigDialog extends HTMLDialogElement {
1084
1154
  }
1085
1155
  }
1086
1156
  }
1087
- customElements.define("fig-dialog", FigDialog, { extends: "dialog" });
1157
+ figDefineCustomizedBuiltIn("fig-dialog", FigDialog, { extends: "dialog" });
1088
1158
 
1089
1159
  /* Popup */
1090
1160
  /**
@@ -1097,44 +1167,84 @@ customElements.define("fig-dialog", FigDialog, { extends: "dialog" });
1097
1167
  * @attr {boolean|string} open - Open when present and not "false".
1098
1168
  */
1099
1169
  class FigPopup extends HTMLDialogElement {
1100
- #anchorObserver = null;
1101
- #contentObserver = null;
1102
- #mutationObserver = null;
1103
- #anchorTrackRAF = null;
1104
- #lastAnchorRect = null;
1105
- #isPopupActive = false;
1106
- #boundReposition;
1107
- #boundScroll;
1108
- #boundOutsidePointerDown;
1109
- #rafId = null;
1110
- #anchorRef = null;
1111
-
1112
- #isDragging = false;
1113
- #dragPending = false;
1114
- #dragStartPos = { x: 0, y: 0 };
1115
- #dragOffset = { x: 0, y: 0 };
1116
- #dragThreshold = 3;
1117
- #boundPointerDown;
1118
- #boundPointerMove;
1119
- #boundPointerUp;
1120
- #wasDragged = false;
1170
+ _anchorObserver = null;
1171
+ _contentObserver = null;
1172
+ _mutationObserver = null;
1173
+ _anchorTrackRAF = null;
1174
+ _lastAnchorRect = null;
1175
+ _isPopupActive = false;
1176
+ _boundReposition;
1177
+ _boundScroll;
1178
+ _boundOutsidePointerDown;
1179
+ _rafId = null;
1180
+ _anchorRef = null;
1181
+
1182
+ _isDragging = false;
1183
+ _dragPending = false;
1184
+ _dragStartPos = { x: 0, y: 0 };
1185
+ _dragOffset = { x: 0, y: 0 };
1186
+ _dragThreshold = 3;
1187
+ _boundPointerDown;
1188
+ _boundPointerMove;
1189
+ _boundPointerUp;
1190
+ _wasDragged = false;
1121
1191
 
1122
1192
  constructor() {
1123
1193
  super();
1124
- this.#boundReposition = this.#queueReposition.bind(this);
1125
- this.#boundScroll = (e) => {
1194
+ this._boundReposition = this.queueReposition.bind(this);
1195
+ this._boundScroll = (e) => {
1126
1196
  if (
1127
1197
  this.open &&
1128
1198
  !this.contains(e.target) &&
1129
- this.#shouldAutoReposition()
1199
+ this.shouldAutoReposition()
1130
1200
  ) {
1131
- this.#queueReposition();
1201
+ this.queueReposition();
1132
1202
  }
1133
1203
  };
1134
- this.#boundOutsidePointerDown = this.#handleOutsidePointerDown.bind(this);
1135
- this.#boundPointerDown = this.#handlePointerDown.bind(this);
1136
- this.#boundPointerMove = this.#handlePointerMove.bind(this);
1137
- this.#boundPointerUp = this.#handlePointerUp.bind(this);
1204
+ this._boundOutsidePointerDown = this.handleOutsidePointerDown.bind(this);
1205
+ this._boundPointerDown = this.handlePointerDown.bind(this);
1206
+ this._boundPointerMove = this.handlePointerMove.bind(this);
1207
+ this._boundPointerUp = this.handlePointerUp.bind(this);
1208
+ }
1209
+
1210
+ ensureInitialized() {
1211
+ if (typeof this._anchorObserver === "undefined") this._anchorObserver = null;
1212
+ if (typeof this._contentObserver === "undefined") this._contentObserver = null;
1213
+ if (typeof this._mutationObserver === "undefined") this._mutationObserver = null;
1214
+ if (typeof this._anchorTrackRAF === "undefined") this._anchorTrackRAF = null;
1215
+ if (typeof this._lastAnchorRect === "undefined") this._lastAnchorRect = null;
1216
+ if (typeof this._isPopupActive === "undefined") this._isPopupActive = false;
1217
+ if (typeof this._rafId === "undefined") this._rafId = null;
1218
+ if (typeof this._anchorRef === "undefined") this._anchorRef = null;
1219
+ if (typeof this._isDragging === "undefined") this._isDragging = false;
1220
+ if (typeof this._dragPending === "undefined") this._dragPending = false;
1221
+ if (typeof this._dragStartPos === "undefined") this._dragStartPos = { x: 0, y: 0 };
1222
+ if (typeof this._dragOffset === "undefined") this._dragOffset = { x: 0, y: 0 };
1223
+ if (typeof this._dragThreshold !== "number") this._dragThreshold = 3;
1224
+ if (typeof this._wasDragged === "undefined") this._wasDragged = false;
1225
+
1226
+ if (typeof this._boundReposition !== "function") {
1227
+ this._boundReposition = this.queueReposition.bind(this);
1228
+ }
1229
+ if (typeof this._boundScroll !== "function") {
1230
+ this._boundScroll = (e) => {
1231
+ if (this.open && !this.contains(e.target) && this.shouldAutoReposition()) {
1232
+ this.queueReposition();
1233
+ }
1234
+ };
1235
+ }
1236
+ if (typeof this._boundOutsidePointerDown !== "function") {
1237
+ this._boundOutsidePointerDown = this.handleOutsidePointerDown.bind(this);
1238
+ }
1239
+ if (typeof this._boundPointerDown !== "function") {
1240
+ this._boundPointerDown = this.handlePointerDown.bind(this);
1241
+ }
1242
+ if (typeof this._boundPointerMove !== "function") {
1243
+ this._boundPointerMove = this.handlePointerMove.bind(this);
1244
+ }
1245
+ if (typeof this._boundPointerUp !== "function") {
1246
+ this._boundPointerUp = this.handlePointerUp.bind(this);
1247
+ }
1138
1248
  }
1139
1249
 
1140
1250
  static get observedAttributes() {
@@ -1167,22 +1277,23 @@ class FigPopup extends HTMLDialogElement {
1167
1277
  }
1168
1278
 
1169
1279
  get anchor() {
1170
- return this.#anchorRef ?? this.getAttribute("anchor");
1280
+ return this._anchorRef ?? this.getAttribute("anchor");
1171
1281
  }
1172
1282
 
1173
1283
  set anchor(value) {
1174
1284
  if (value instanceof Element) {
1175
- this.#anchorRef = value;
1285
+ this._anchorRef = value;
1176
1286
  } else if (typeof value === "string") {
1177
- this.#anchorRef = null;
1287
+ this._anchorRef = null;
1178
1288
  this.setAttribute("anchor", value);
1179
1289
  } else {
1180
- this.#anchorRef = null;
1290
+ this._anchorRef = null;
1181
1291
  }
1182
- if (this.open) this.#queueReposition();
1292
+ if (this.open) this.queueReposition();
1183
1293
  }
1184
1294
 
1185
1295
  connectedCallback() {
1296
+ this.ensureInitialized();
1186
1297
  if (!this.hasAttribute("position")) {
1187
1298
  this.setAttribute("position", "top center");
1188
1299
  }
@@ -1197,68 +1308,70 @@ class FigPopup extends HTMLDialogElement {
1197
1308
  this.hasAttribute("drag") && this.getAttribute("drag") !== "false";
1198
1309
 
1199
1310
  this.addEventListener("close", () => {
1200
- this.#teardownObservers();
1311
+ this.teardownObservers();
1201
1312
  if (this.hasAttribute("open")) {
1202
1313
  this.removeAttribute("open");
1203
1314
  }
1204
1315
  });
1205
1316
 
1206
1317
  requestAnimationFrame(() => {
1207
- this.#setupDragListeners();
1318
+ this.setupDragListeners();
1208
1319
  });
1209
1320
 
1210
1321
  if (this.open) {
1211
- this.#showPopup();
1322
+ this.showPopup();
1212
1323
  } else {
1213
- this.#hidePopup();
1324
+ this.hidePopup();
1214
1325
  }
1215
1326
  }
1216
1327
 
1217
1328
  disconnectedCallback() {
1218
- this.#teardownObservers();
1219
- this.#removeDragListeners();
1329
+ this.ensureInitialized();
1330
+ this.teardownObservers();
1331
+ this.removeDragListeners();
1220
1332
  document.removeEventListener(
1221
1333
  "pointerdown",
1222
- this.#boundOutsidePointerDown,
1334
+ this._boundOutsidePointerDown,
1223
1335
  true,
1224
1336
  );
1225
- if (this.#rafId !== null) {
1226
- cancelAnimationFrame(this.#rafId);
1227
- this.#rafId = null;
1337
+ if (this._rafId !== null) {
1338
+ cancelAnimationFrame(this._rafId);
1339
+ this._rafId = null;
1228
1340
  }
1229
1341
  }
1230
1342
 
1231
1343
  attributeChangedCallback(name, oldValue, newValue) {
1344
+ this.ensureInitialized();
1232
1345
  if (oldValue === newValue) return;
1233
1346
 
1234
1347
  if (name === "open") {
1235
1348
  if (newValue === null || newValue === "false") {
1236
- this.#hidePopup();
1349
+ this.hidePopup();
1237
1350
  return;
1238
1351
  }
1239
- this.#showPopup();
1352
+ this.showPopup();
1240
1353
  return;
1241
1354
  }
1242
1355
 
1243
1356
  if (name === "drag") {
1244
1357
  this.drag = newValue !== null && newValue !== "false";
1245
1358
  if (this.drag) {
1246
- this.#setupDragListeners();
1359
+ this.setupDragListeners();
1247
1360
  } else {
1248
- this.#removeDragListeners();
1361
+ this.removeDragListeners();
1249
1362
  }
1250
1363
  return;
1251
1364
  }
1252
1365
 
1253
1366
  if (this.open) {
1254
- this.#queueReposition();
1255
- this.#setupObservers();
1367
+ this.queueReposition();
1368
+ this.setupObservers();
1256
1369
  }
1257
1370
  }
1258
1371
 
1259
- #showPopup() {
1260
- if (this.#isPopupActive) {
1261
- this.#queueReposition();
1372
+ showPopup() {
1373
+ if (this._isPopupActive) {
1374
+ this.queueReposition();
1262
1375
  return;
1263
1376
  }
1264
1377
 
@@ -1275,30 +1388,30 @@ class FigPopup extends HTMLDialogElement {
1275
1388
  }
1276
1389
  }
1277
1390
 
1278
- this.#setupObservers();
1391
+ this.setupObservers();
1279
1392
  document.addEventListener(
1280
1393
  "pointerdown",
1281
- this.#boundOutsidePointerDown,
1394
+ this._boundOutsidePointerDown,
1282
1395
  true,
1283
1396
  );
1284
- this.#wasDragged = false;
1285
- this.#queueReposition();
1286
- this.#isPopupActive = true;
1397
+ this._wasDragged = false;
1398
+ this.queueReposition();
1399
+ this._isPopupActive = true;
1287
1400
 
1288
- const anchor = this.#resolveAnchor();
1401
+ const anchor = this.resolveAnchor();
1289
1402
  if (anchor) anchor.classList.add("has-popup-open");
1290
1403
  }
1291
1404
 
1292
- #hidePopup() {
1293
- const anchor = this.#resolveAnchor();
1405
+ hidePopup() {
1406
+ const anchor = this.resolveAnchor();
1294
1407
  if (anchor) anchor.classList.remove("has-popup-open");
1295
1408
 
1296
- this.#isPopupActive = false;
1297
- this.#wasDragged = false;
1298
- this.#teardownObservers();
1409
+ this._isPopupActive = false;
1410
+ this._wasDragged = false;
1411
+ this.teardownObservers();
1299
1412
  document.removeEventListener(
1300
1413
  "pointerdown",
1301
- this.#boundOutsidePointerDown,
1414
+ this._boundOutsidePointerDown,
1302
1415
  true,
1303
1416
  );
1304
1417
 
@@ -1316,59 +1429,59 @@ class FigPopup extends HTMLDialogElement {
1316
1429
  return val === null || val !== "false";
1317
1430
  }
1318
1431
 
1319
- #setupObservers() {
1320
- this.#teardownObservers();
1432
+ setupObservers() {
1433
+ this.teardownObservers();
1321
1434
 
1322
- const anchor = this.#resolveAnchor();
1435
+ const anchor = this.resolveAnchor();
1323
1436
  if (anchor && "ResizeObserver" in window) {
1324
- this.#anchorObserver = new ResizeObserver(this.#boundReposition);
1325
- this.#anchorObserver.observe(anchor);
1437
+ this._anchorObserver = new ResizeObserver(this._boundReposition);
1438
+ this._anchorObserver.observe(anchor);
1326
1439
  }
1327
1440
 
1328
1441
  if (this.autoresize) {
1329
1442
  if ("ResizeObserver" in window) {
1330
- this.#contentObserver = new ResizeObserver(this.#boundReposition);
1331
- this.#contentObserver.observe(this);
1443
+ this._contentObserver = new ResizeObserver(this._boundReposition);
1444
+ this._contentObserver.observe(this);
1332
1445
  }
1333
1446
 
1334
- this.#mutationObserver = new MutationObserver(this.#boundReposition);
1335
- this.#mutationObserver.observe(this, {
1447
+ this._mutationObserver = new MutationObserver(this._boundReposition);
1448
+ this._mutationObserver.observe(this, {
1336
1449
  childList: true,
1337
1450
  subtree: true,
1338
1451
  characterData: true,
1339
1452
  });
1340
1453
  }
1341
1454
 
1342
- window.addEventListener("resize", this.#boundReposition);
1343
- window.addEventListener("scroll", this.#boundScroll, {
1455
+ window.addEventListener("resize", this._boundReposition);
1456
+ window.addEventListener("scroll", this._boundScroll, {
1344
1457
  capture: true,
1345
1458
  passive: true,
1346
1459
  });
1347
- this.#startAnchorTracking();
1460
+ this.startAnchorTracking();
1348
1461
  }
1349
1462
 
1350
- #teardownObservers() {
1351
- if (this.#anchorObserver) {
1352
- this.#anchorObserver.disconnect();
1353
- this.#anchorObserver = null;
1463
+ teardownObservers() {
1464
+ if (this._anchorObserver) {
1465
+ this._anchorObserver.disconnect();
1466
+ this._anchorObserver = null;
1354
1467
  }
1355
- if (this.#contentObserver) {
1356
- this.#contentObserver.disconnect();
1357
- this.#contentObserver = null;
1468
+ if (this._contentObserver) {
1469
+ this._contentObserver.disconnect();
1470
+ this._contentObserver = null;
1358
1471
  }
1359
- if (this.#mutationObserver) {
1360
- this.#mutationObserver.disconnect();
1361
- this.#mutationObserver = null;
1472
+ if (this._mutationObserver) {
1473
+ this._mutationObserver.disconnect();
1474
+ this._mutationObserver = null;
1362
1475
  }
1363
- window.removeEventListener("resize", this.#boundReposition);
1364
- window.removeEventListener("scroll", this.#boundScroll, {
1476
+ window.removeEventListener("resize", this._boundReposition);
1477
+ window.removeEventListener("scroll", this._boundScroll, {
1365
1478
  capture: true,
1366
1479
  passive: true,
1367
1480
  });
1368
- this.#stopAnchorTracking();
1481
+ this.stopAnchorTracking();
1369
1482
  }
1370
1483
 
1371
- #readRectSnapshot(element) {
1484
+ readRectSnapshot(element) {
1372
1485
  if (!element) return null;
1373
1486
  const rect = element.getBoundingClientRect();
1374
1487
  return {
@@ -1379,7 +1492,7 @@ class FigPopup extends HTMLDialogElement {
1379
1492
  };
1380
1493
  }
1381
1494
 
1382
- #hasRectChanged(prev, next, epsilon = 0.25) {
1495
+ hasRectChanged(prev, next, epsilon = 0.25) {
1383
1496
  if (!prev && !next) return false;
1384
1497
  if (!prev || !next) return true;
1385
1498
  return (
@@ -1390,42 +1503,42 @@ class FigPopup extends HTMLDialogElement {
1390
1503
  );
1391
1504
  }
1392
1505
 
1393
- #startAnchorTracking() {
1394
- this.#stopAnchorTracking();
1506
+ startAnchorTracking() {
1507
+ this.stopAnchorTracking();
1395
1508
  if (!this.open) return;
1396
1509
 
1397
1510
  const tick = () => {
1398
1511
  if (!this.open) {
1399
- this.#anchorTrackRAF = null;
1512
+ this._anchorTrackRAF = null;
1400
1513
  return;
1401
1514
  }
1402
1515
 
1403
- const anchor = this.#resolveAnchor();
1404
- const nextRect = this.#readRectSnapshot(anchor);
1405
- const canAutoReposition = this.#shouldAutoReposition();
1406
- if (canAutoReposition && this.#hasRectChanged(this.#lastAnchorRect, nextRect)) {
1407
- this.#lastAnchorRect = nextRect;
1408
- this.#queueReposition();
1516
+ const anchor = this.resolveAnchor();
1517
+ const nextRect = this.readRectSnapshot(anchor);
1518
+ const canAutoReposition = this.shouldAutoReposition();
1519
+ if (canAutoReposition && this.hasRectChanged(this._lastAnchorRect, nextRect)) {
1520
+ this._lastAnchorRect = nextRect;
1521
+ this.queueReposition();
1409
1522
  } else if (!canAutoReposition) {
1410
1523
  // Keep anchor geometry fresh without forcing reposition when user has dragged away.
1411
- this.#lastAnchorRect = nextRect;
1524
+ this._lastAnchorRect = nextRect;
1412
1525
  }
1413
- this.#anchorTrackRAF = requestAnimationFrame(tick);
1526
+ this._anchorTrackRAF = requestAnimationFrame(tick);
1414
1527
  };
1415
1528
 
1416
- this.#lastAnchorRect = this.#readRectSnapshot(this.#resolveAnchor());
1417
- this.#anchorTrackRAF = requestAnimationFrame(tick);
1529
+ this._lastAnchorRect = this.readRectSnapshot(this.resolveAnchor());
1530
+ this._anchorTrackRAF = requestAnimationFrame(tick);
1418
1531
  }
1419
1532
 
1420
- #stopAnchorTracking() {
1421
- if (this.#anchorTrackRAF !== null) {
1422
- cancelAnimationFrame(this.#anchorTrackRAF);
1423
- this.#anchorTrackRAF = null;
1533
+ stopAnchorTracking() {
1534
+ if (this._anchorTrackRAF !== null) {
1535
+ cancelAnimationFrame(this._anchorTrackRAF);
1536
+ this._anchorTrackRAF = null;
1424
1537
  }
1425
- this.#lastAnchorRect = null;
1538
+ this._lastAnchorRect = null;
1426
1539
  }
1427
1540
 
1428
- #handleOutsidePointerDown(event) {
1541
+ handleOutsidePointerDown(event) {
1429
1542
  if (!this.open || !super.open) return;
1430
1543
  const closedby = this.getAttribute("closedby");
1431
1544
  if (closedby === "none" || closedby === "closerequest") return;
@@ -1433,15 +1546,15 @@ class FigPopup extends HTMLDialogElement {
1433
1546
  if (!(target instanceof Node)) return;
1434
1547
  if (this.contains(target)) return;
1435
1548
 
1436
- const anchor = this.#resolveAnchor();
1549
+ const anchor = this.resolveAnchor();
1437
1550
  if (anchor && anchor.contains(target)) return;
1438
1551
 
1439
- if (this.#isInsideDescendantPopup(target)) return;
1552
+ if (this.isInsideDescendantPopup(target)) return;
1440
1553
 
1441
1554
  this.open = false;
1442
1555
  }
1443
1556
 
1444
- #isInsideDescendantPopup(target) {
1557
+ isInsideDescendantPopup(target) {
1445
1558
  const targetDialog = target.closest?.('dialog[is="fig-popup"]');
1446
1559
  if (!targetDialog || targetDialog === this) return false;
1447
1560
 
@@ -1459,19 +1572,19 @@ class FigPopup extends HTMLDialogElement {
1459
1572
 
1460
1573
  // ---- Drag support ----
1461
1574
 
1462
- #setupDragListeners() {
1575
+ setupDragListeners() {
1463
1576
  if (this.drag) {
1464
- this.addEventListener("pointerdown", this.#boundPointerDown);
1577
+ this.addEventListener("pointerdown", this._boundPointerDown);
1465
1578
  }
1466
1579
  }
1467
1580
 
1468
- #removeDragListeners() {
1469
- this.removeEventListener("pointerdown", this.#boundPointerDown);
1470
- document.removeEventListener("pointermove", this.#boundPointerMove);
1471
- document.removeEventListener("pointerup", this.#boundPointerUp);
1581
+ removeDragListeners() {
1582
+ this.removeEventListener("pointerdown", this._boundPointerDown);
1583
+ document.removeEventListener("pointermove", this._boundPointerMove);
1584
+ document.removeEventListener("pointerup", this._boundPointerUp);
1472
1585
  }
1473
1586
 
1474
- #isInteractiveElement(element) {
1587
+ isInteractiveElement(element) {
1475
1588
  const interactiveSelectors = [
1476
1589
  "input",
1477
1590
  "button",
@@ -1516,9 +1629,9 @@ class FigPopup extends HTMLDialogElement {
1516
1629
  return false;
1517
1630
  }
1518
1631
 
1519
- #handlePointerDown(e) {
1632
+ handlePointerDown(e) {
1520
1633
  if (!this.drag) return;
1521
- if (this.#isInteractiveElement(e.target)) return;
1634
+ if (this.isInteractiveElement(e.target)) return;
1522
1635
 
1523
1636
  const handleSelector = this.getAttribute("handle");
1524
1637
  if (handleSelector && handleSelector.trim()) {
@@ -1526,27 +1639,27 @@ class FigPopup extends HTMLDialogElement {
1526
1639
  if (!handleEl || !handleEl.contains(e.target)) return;
1527
1640
  }
1528
1641
 
1529
- this.#dragPending = true;
1530
- this.#dragStartPos.x = e.clientX;
1531
- this.#dragStartPos.y = e.clientY;
1642
+ this._dragPending = true;
1643
+ this._dragStartPos.x = e.clientX;
1644
+ this._dragStartPos.y = e.clientY;
1532
1645
 
1533
1646
  const rect = this.getBoundingClientRect();
1534
- this.#dragOffset.x = e.clientX - rect.left;
1535
- this.#dragOffset.y = e.clientY - rect.top;
1647
+ this._dragOffset.x = e.clientX - rect.left;
1648
+ this._dragOffset.y = e.clientY - rect.top;
1536
1649
 
1537
- document.addEventListener("pointermove", this.#boundPointerMove);
1538
- document.addEventListener("pointerup", this.#boundPointerUp);
1650
+ document.addEventListener("pointermove", this._boundPointerMove);
1651
+ document.addEventListener("pointerup", this._boundPointerUp);
1539
1652
  }
1540
1653
 
1541
- #handlePointerMove(e) {
1542
- if (this.#dragPending && !this.#isDragging) {
1543
- const dx = Math.abs(e.clientX - this.#dragStartPos.x);
1544
- const dy = Math.abs(e.clientY - this.#dragStartPos.y);
1654
+ handlePointerMove(e) {
1655
+ if (this._dragPending && !this._isDragging) {
1656
+ const dx = Math.abs(e.clientX - this._dragStartPos.x);
1657
+ const dy = Math.abs(e.clientY - this._dragStartPos.y);
1545
1658
 
1546
- if (dx > this.#dragThreshold || dy > this.#dragThreshold) {
1547
- this.#isDragging = true;
1548
- this.#dragPending = false;
1549
- this.#wasDragged = true;
1659
+ if (dx > this._dragThreshold || dy > this._dragThreshold) {
1660
+ this._isDragging = true;
1661
+ this._dragPending = false;
1662
+ this._wasDragged = true;
1550
1663
  this.setPointerCapture(e.pointerId);
1551
1664
  this.style.cursor = "grabbing";
1552
1665
 
@@ -1559,31 +1672,31 @@ class FigPopup extends HTMLDialogElement {
1559
1672
  }
1560
1673
  }
1561
1674
 
1562
- if (!this.#isDragging) return;
1675
+ if (!this._isDragging) return;
1563
1676
 
1564
- this.style.left = `${e.clientX - this.#dragOffset.x}px`;
1565
- this.style.top = `${e.clientY - this.#dragOffset.y}px`;
1677
+ this.style.left = `${e.clientX - this._dragOffset.x}px`;
1678
+ this.style.top = `${e.clientY - this._dragOffset.y}px`;
1566
1679
  e.preventDefault();
1567
1680
  }
1568
1681
 
1569
- #handlePointerUp(e) {
1570
- if (this.#isDragging) {
1682
+ handlePointerUp(e) {
1683
+ if (this._isDragging) {
1571
1684
  this.releasePointerCapture(e.pointerId);
1572
1685
  this.style.cursor = "";
1573
1686
  }
1574
1687
 
1575
- this.#isDragging = false;
1576
- this.#dragPending = false;
1688
+ this._isDragging = false;
1689
+ this._dragPending = false;
1577
1690
 
1578
- document.removeEventListener("pointermove", this.#boundPointerMove);
1579
- document.removeEventListener("pointerup", this.#boundPointerUp);
1691
+ document.removeEventListener("pointermove", this._boundPointerMove);
1692
+ document.removeEventListener("pointerup", this._boundPointerUp);
1580
1693
  e.preventDefault();
1581
1694
  }
1582
1695
 
1583
1696
  // ---- Anchor resolution ----
1584
1697
 
1585
- #resolveAnchor() {
1586
- if (this.#anchorRef) return this.#anchorRef;
1698
+ resolveAnchor() {
1699
+ if (this._anchorRef) return this._anchorRef;
1587
1700
 
1588
1701
  const selector = this.getAttribute("anchor");
1589
1702
  if (!selector) return null;
@@ -1599,7 +1712,7 @@ class FigPopup extends HTMLDialogElement {
1599
1712
  return document.querySelector(selector);
1600
1713
  }
1601
1714
 
1602
- #parsePosition() {
1715
+ parsePosition() {
1603
1716
  const raw = (this.getAttribute("position") || "top center")
1604
1717
  .trim()
1605
1718
  .toLowerCase();
@@ -1640,7 +1753,7 @@ class FigPopup extends HTMLDialogElement {
1640
1753
  return { vertical, horizontal, shorthand };
1641
1754
  }
1642
1755
 
1643
- #normalizeOffsetToken(token, fallback = "0px") {
1756
+ normalizeOffsetToken(token, fallback = "0px") {
1644
1757
  if (!token) return fallback;
1645
1758
  const trimmed = token.trim();
1646
1759
  if (!trimmed) return fallback;
@@ -1650,9 +1763,9 @@ class FigPopup extends HTMLDialogElement {
1650
1763
  return trimmed;
1651
1764
  }
1652
1765
 
1653
- #measureLengthPx(value, axis = "x") {
1766
+ measureLengthPx(value, axis = "x") {
1654
1767
  if (!value) return 0;
1655
- const normalized = this.#normalizeOffsetToken(value, "0px");
1768
+ const normalized = this.normalizeOffsetToken(value, "0px");
1656
1769
  if (normalized.endsWith("px")) {
1657
1770
  const px = parseFloat(normalized);
1658
1771
  return Number.isFinite(px) ? px : 0;
@@ -1680,22 +1793,22 @@ class FigPopup extends HTMLDialogElement {
1680
1793
  return axis === "x" ? rect.width : rect.height;
1681
1794
  }
1682
1795
 
1683
- #parseOffset() {
1796
+ parseOffset() {
1684
1797
  const raw = (this.getAttribute("offset") || "0 0").trim();
1685
1798
  const tokens = raw.split(/\s+/).filter(Boolean);
1686
1799
 
1687
- const xToken = this.#normalizeOffsetToken(tokens[0], "0px");
1688
- const yToken = this.#normalizeOffsetToken(tokens[1], "0px");
1800
+ const xToken = this.normalizeOffsetToken(tokens[0], "0px");
1801
+ const yToken = this.normalizeOffsetToken(tokens[1], "0px");
1689
1802
 
1690
1803
  return {
1691
1804
  xToken,
1692
1805
  yToken,
1693
- xPx: this.#measureLengthPx(xToken, "x"),
1694
- yPx: this.#measureLengthPx(yToken, "y"),
1806
+ xPx: this.measureLengthPx(xToken, "x"),
1807
+ yPx: this.measureLengthPx(yToken, "y"),
1695
1808
  };
1696
1809
  }
1697
1810
 
1698
- #parseViewportMargins() {
1811
+ parseViewportMargins() {
1699
1812
  const raw = (this.getAttribute("viewport-margin") || "8").trim();
1700
1813
  const tokens = raw
1701
1814
  .split(/\s+/)
@@ -1732,7 +1845,7 @@ class FigPopup extends HTMLDialogElement {
1732
1845
  };
1733
1846
  }
1734
1847
 
1735
- #getPlacementCandidates(vertical, horizontal, shorthand) {
1848
+ getPlacementCandidates(vertical, horizontal, shorthand) {
1736
1849
  const opp = {
1737
1850
  top: "bottom",
1738
1851
  bottom: "top",
@@ -1782,7 +1895,7 @@ class FigPopup extends HTMLDialogElement {
1782
1895
  ];
1783
1896
  }
1784
1897
 
1785
- #computeCoords(
1898
+ computeCoords(
1786
1899
  anchorRect,
1787
1900
  popupRect,
1788
1901
  vertical,
@@ -1841,7 +1954,7 @@ class FigPopup extends HTMLDialogElement {
1841
1954
  return { top, left };
1842
1955
  }
1843
1956
 
1844
- #oppositeSide(side) {
1957
+ oppositeSide(side) {
1845
1958
  const map = {
1846
1959
  top: "bottom",
1847
1960
  bottom: "top",
@@ -1851,7 +1964,7 @@ class FigPopup extends HTMLDialogElement {
1851
1964
  return map[side] || "bottom";
1852
1965
  }
1853
1966
 
1854
- #getPlacementSide(vertical, horizontal, shorthand) {
1967
+ getPlacementSide(vertical, horizontal, shorthand) {
1855
1968
  if (shorthand === "top") return "top";
1856
1969
  if (shorthand === "bottom") return "bottom";
1857
1970
  if (shorthand === "left") return "left";
@@ -1862,14 +1975,14 @@ class FigPopup extends HTMLDialogElement {
1862
1975
  return "top";
1863
1976
  }
1864
1977
 
1865
- #updatePopoverBeak(anchorRect, popupRect, left, top, placementSide) {
1978
+ updatePopoverBeak(anchorRect, popupRect, left, top, placementSide) {
1866
1979
  if (this.getAttribute("variant") !== "popover" || !anchorRect) {
1867
1980
  this.style.removeProperty("--beak-offset");
1868
1981
  this.removeAttribute("data-beak-side");
1869
1982
  return;
1870
1983
  }
1871
1984
 
1872
- const beakSide = this.#oppositeSide(placementSide);
1985
+ const beakSide = this.oppositeSide(placementSide);
1873
1986
  this.setAttribute("data-beak-side", beakSide);
1874
1987
 
1875
1988
  const anchorCenterX = anchorRect.left + anchorRect.width / 2;
@@ -1898,7 +2011,7 @@ class FigPopup extends HTMLDialogElement {
1898
2011
  this.style.setProperty("--beak-offset", `${beakOffset}px`);
1899
2012
  }
1900
2013
 
1901
- #overflowScore(coords, popupRect, m) {
2014
+ overflowScore(coords, popupRect, m) {
1902
2015
  const vw = window.innerWidth;
1903
2016
  const vh = window.innerHeight;
1904
2017
  const right = coords.left + popupRect.width;
@@ -1912,11 +2025,11 @@ class FigPopup extends HTMLDialogElement {
1912
2025
  return overflowLeft + overflowTop + overflowRight + overflowBottom;
1913
2026
  }
1914
2027
 
1915
- #fits(coords, popupRect, m) {
1916
- return this.#overflowScore(coords, popupRect, m) === 0;
2028
+ fits(coords, popupRect, m) {
2029
+ return this.overflowScore(coords, popupRect, m) === 0;
1917
2030
  }
1918
2031
 
1919
- #clamp(coords, popupRect, m) {
2032
+ clamp(coords, popupRect, m) {
1920
2033
  const minLeft = m.left;
1921
2034
  const minTop = m.top;
1922
2035
  const maxLeft = Math.max(
@@ -1934,17 +2047,17 @@ class FigPopup extends HTMLDialogElement {
1934
2047
  };
1935
2048
  }
1936
2049
 
1937
- #positionPopup() {
2050
+ positionPopup() {
1938
2051
  if (!this.open || !super.open) return;
1939
2052
 
1940
2053
  const popupRect = this.getBoundingClientRect();
1941
- const offset = this.#parseOffset();
1942
- const { vertical, horizontal, shorthand } = this.#parsePosition();
1943
- const anchor = this.#resolveAnchor();
1944
- const m = this.#parseViewportMargins();
2054
+ const offset = this.parseOffset();
2055
+ const { vertical, horizontal, shorthand } = this.parsePosition();
2056
+ const anchor = this.resolveAnchor();
2057
+ const m = this.parseViewportMargins();
1945
2058
 
1946
2059
  if (!anchor) {
1947
- this.#updatePopoverBeak(null, popupRect, 0, 0, "top");
2060
+ this.updatePopoverBeak(null, popupRect, 0, 0, "top");
1948
2061
  const centered = {
1949
2062
  left:
1950
2063
  m.left + (window.innerWidth - m.right - m.left - popupRect.width) / 2,
@@ -1952,14 +2065,14 @@ class FigPopup extends HTMLDialogElement {
1952
2065
  m.top +
1953
2066
  (window.innerHeight - m.bottom - m.top - popupRect.height) / 2,
1954
2067
  };
1955
- const clamped = this.#clamp(centered, popupRect, m);
2068
+ const clamped = this.clamp(centered, popupRect, m);
1956
2069
  this.style.left = `${clamped.left}px`;
1957
2070
  this.style.top = `${clamped.top}px`;
1958
2071
  return;
1959
2072
  }
1960
2073
 
1961
2074
  const anchorRect = anchor.getBoundingClientRect();
1962
- const candidates = this.#getPlacementCandidates(
2075
+ const candidates = this.getPlacementCandidates(
1963
2076
  vertical,
1964
2077
  horizontal,
1965
2078
  shorthand,
@@ -1969,7 +2082,7 @@ class FigPopup extends HTMLDialogElement {
1969
2082
  let bestScore = Number.POSITIVE_INFINITY;
1970
2083
 
1971
2084
  for (const { v, h, s } of candidates) {
1972
- const coords = this.#computeCoords(
2085
+ const coords = this.computeCoords(
1973
2086
  anchorRect,
1974
2087
  popupRect,
1975
2088
  v,
@@ -1977,10 +2090,10 @@ class FigPopup extends HTMLDialogElement {
1977
2090
  offset,
1978
2091
  s,
1979
2092
  );
1980
- const placementSide = this.#getPlacementSide(v, h, s);
2093
+ const placementSide = this.getPlacementSide(v, h, s);
1981
2094
 
1982
2095
  if (s) {
1983
- const clamped = this.#clamp(coords, popupRect, m);
2096
+ const clamped = this.clamp(coords, popupRect, m);
1984
2097
  const primaryFits =
1985
2098
  s === "left" || s === "right"
1986
2099
  ? coords.left >= m.left &&
@@ -1990,7 +2103,7 @@ class FigPopup extends HTMLDialogElement {
1990
2103
  if (primaryFits) {
1991
2104
  this.style.left = `${clamped.left}px`;
1992
2105
  this.style.top = `${clamped.top}px`;
1993
- this.#updatePopoverBeak(
2106
+ this.updatePopoverBeak(
1994
2107
  anchorRect,
1995
2108
  popupRect,
1996
2109
  clamped.left,
@@ -1999,17 +2112,17 @@ class FigPopup extends HTMLDialogElement {
1999
2112
  );
2000
2113
  return;
2001
2114
  }
2002
- const score = this.#overflowScore(coords, popupRect, m);
2115
+ const score = this.overflowScore(coords, popupRect, m);
2003
2116
  if (score < bestScore) {
2004
2117
  bestScore = score;
2005
2118
  best = clamped;
2006
2119
  bestSide = placementSide;
2007
2120
  }
2008
2121
  } else {
2009
- if (this.#fits(coords, popupRect, m)) {
2122
+ if (this.fits(coords, popupRect, m)) {
2010
2123
  this.style.left = `${coords.left}px`;
2011
2124
  this.style.top = `${coords.top}px`;
2012
- this.#updatePopoverBeak(
2125
+ this.updatePopoverBeak(
2013
2126
  anchorRect,
2014
2127
  popupRect,
2015
2128
  coords.left,
@@ -2018,7 +2131,7 @@ class FigPopup extends HTMLDialogElement {
2018
2131
  );
2019
2132
  return;
2020
2133
  }
2021
- const score = this.#overflowScore(coords, popupRect, m);
2134
+ const score = this.overflowScore(coords, popupRect, m);
2022
2135
  if (score < bestScore) {
2023
2136
  bestScore = score;
2024
2137
  best = coords;
@@ -2027,10 +2140,10 @@ class FigPopup extends HTMLDialogElement {
2027
2140
  }
2028
2141
  }
2029
2142
 
2030
- const clamped = this.#clamp(best || { left: 0, top: 0 }, popupRect, m);
2143
+ const clamped = this.clamp(best || { left: 0, top: 0 }, popupRect, m);
2031
2144
  this.style.left = `${clamped.left}px`;
2032
2145
  this.style.top = `${clamped.top}px`;
2033
- this.#updatePopoverBeak(
2146
+ this.updatePopoverBeak(
2034
2147
  anchorRect,
2035
2148
  popupRect,
2036
2149
  clamped.left,
@@ -2039,22 +2152,22 @@ class FigPopup extends HTMLDialogElement {
2039
2152
  );
2040
2153
  }
2041
2154
 
2042
- #queueReposition() {
2043
- if (!this.open || !this.#shouldAutoReposition()) return;
2044
- if (this.#rafId !== null) return;
2155
+ queueReposition() {
2156
+ if (!this.open || !this.shouldAutoReposition()) return;
2157
+ if (this._rafId !== null) return;
2045
2158
 
2046
- this.#rafId = requestAnimationFrame(() => {
2047
- this.#rafId = null;
2048
- this.#positionPopup();
2159
+ this._rafId = requestAnimationFrame(() => {
2160
+ this._rafId = null;
2161
+ this.positionPopup();
2049
2162
  });
2050
2163
  }
2051
2164
 
2052
- #shouldAutoReposition() {
2053
- if (!(this.drag && this.#wasDragged)) return true;
2054
- return !this.#resolveAnchor();
2165
+ shouldAutoReposition() {
2166
+ if (!(this.drag && this._wasDragged)) return true;
2167
+ return !this.resolveAnchor();
2055
2168
  }
2056
2169
  }
2057
- customElements.define("fig-popup", FigPopup, { extends: "dialog" });
2170
+ figDefineCustomizedBuiltIn("fig-popup", FigPopup, { extends: "dialog" });
2058
2171
 
2059
2172
  /* Tabs */
2060
2173
  /**
@@ -5059,18 +5172,25 @@ customElements.define("fig-switch", FigSwitch);
5059
5172
  * @attr {boolean} open - Whether the toast is visible
5060
5173
  */
5061
5174
  class FigToast extends HTMLDialogElement {
5062
- #defaultOffset = 16; // 1rem in pixels
5063
- #autoCloseTimer = null;
5175
+ _defaultOffset = 16; // 1rem in pixels
5176
+ _autoCloseTimer = null;
5064
5177
 
5065
5178
  constructor() {
5066
5179
  super();
5067
5180
  }
5068
5181
 
5069
- get #offset() {
5070
- return parseInt(this.getAttribute("offset") ?? this.#defaultOffset);
5182
+ getOffset() {
5183
+ return parseInt(this.getAttribute("offset") ?? this._defaultOffset);
5071
5184
  }
5072
5185
 
5073
5186
  connectedCallback() {
5187
+ if (typeof this._defaultOffset !== "number") {
5188
+ this._defaultOffset = 16;
5189
+ }
5190
+ if (typeof this._autoCloseTimer === "undefined") {
5191
+ this._autoCloseTimer = null;
5192
+ }
5193
+
5074
5194
  // Set default theme if not specified
5075
5195
  if (!this.hasAttribute("theme")) {
5076
5196
  this.setAttribute("theme", "dark");
@@ -5090,8 +5210,8 @@ class FigToast extends HTMLDialogElement {
5090
5210
  }
5091
5211
 
5092
5212
  requestAnimationFrame(() => {
5093
- this.#addCloseListeners();
5094
- this.#applyPosition();
5213
+ this.addCloseListeners();
5214
+ this.applyPosition();
5095
5215
 
5096
5216
  // Auto-show if open attribute is explicitly true
5097
5217
  if (shouldOpen) {
@@ -5101,46 +5221,46 @@ class FigToast extends HTMLDialogElement {
5101
5221
  }
5102
5222
 
5103
5223
  disconnectedCallback() {
5104
- this.#clearAutoClose();
5224
+ this.clearAutoClose();
5105
5225
  }
5106
5226
 
5107
- #addCloseListeners() {
5227
+ addCloseListeners() {
5108
5228
  this.querySelectorAll("[close-toast]").forEach((button) => {
5109
- button.removeEventListener("click", this.#handleClose);
5110
- button.addEventListener("click", this.#handleClose.bind(this));
5229
+ button.removeEventListener("click", this.handleClose);
5230
+ button.addEventListener("click", this.handleClose.bind(this));
5111
5231
  });
5112
5232
  }
5113
5233
 
5114
- #handleClose() {
5234
+ handleClose() {
5115
5235
  this.hideToast();
5116
5236
  }
5117
5237
 
5118
- #applyPosition() {
5238
+ applyPosition() {
5119
5239
  // Always bottom center
5120
5240
  this.style.position = "fixed";
5121
5241
  this.style.margin = "0";
5122
5242
  this.style.top = "auto";
5123
- this.style.bottom = `${this.#offset}px`;
5243
+ this.style.bottom = `${this.getOffset()}px`;
5124
5244
  this.style.left = "50%";
5125
5245
  this.style.right = "auto";
5126
5246
  this.style.transform = "translateX(-50%)";
5127
5247
  }
5128
5248
 
5129
- #startAutoClose() {
5130
- this.#clearAutoClose();
5249
+ startAutoClose() {
5250
+ this.clearAutoClose();
5131
5251
 
5132
5252
  const duration = parseInt(this.getAttribute("duration") ?? "5000");
5133
5253
  if (duration > 0) {
5134
- this.#autoCloseTimer = setTimeout(() => {
5254
+ this._autoCloseTimer = setTimeout(() => {
5135
5255
  this.hideToast();
5136
5256
  }, duration);
5137
5257
  }
5138
5258
  }
5139
5259
 
5140
- #clearAutoClose() {
5141
- if (this.#autoCloseTimer) {
5142
- clearTimeout(this.#autoCloseTimer);
5143
- this.#autoCloseTimer = null;
5260
+ clearAutoClose() {
5261
+ if (this._autoCloseTimer) {
5262
+ clearTimeout(this._autoCloseTimer);
5263
+ this._autoCloseTimer = null;
5144
5264
  }
5145
5265
  }
5146
5266
 
@@ -5149,8 +5269,8 @@ class FigToast extends HTMLDialogElement {
5149
5269
  */
5150
5270
  showToast() {
5151
5271
  this.show(); // Non-modal show
5152
- this.#applyPosition();
5153
- this.#startAutoClose();
5272
+ this.applyPosition();
5273
+ this.startAutoClose();
5154
5274
  this.dispatchEvent(new CustomEvent("toast-show", { bubbles: true }));
5155
5275
  }
5156
5276
 
@@ -5158,7 +5278,7 @@ class FigToast extends HTMLDialogElement {
5158
5278
  * Hide the toast notification
5159
5279
  */
5160
5280
  hideToast() {
5161
- this.#clearAutoClose();
5281
+ this.clearAutoClose();
5162
5282
  this.close();
5163
5283
  this.dispatchEvent(new CustomEvent("toast-hide", { bubbles: true }));
5164
5284
  }
@@ -5169,7 +5289,7 @@ class FigToast extends HTMLDialogElement {
5169
5289
 
5170
5290
  attributeChangedCallback(name, oldValue, newValue) {
5171
5291
  if (name === "offset") {
5172
- this.#applyPosition();
5292
+ this.applyPosition();
5173
5293
  }
5174
5294
 
5175
5295
  if (name === "open") {
@@ -5181,7 +5301,7 @@ class FigToast extends HTMLDialogElement {
5181
5301
  }
5182
5302
  }
5183
5303
  }
5184
- customElements.define("fig-toast", FigToast, { extends: "dialog" });
5304
+ figDefineCustomizedBuiltIn("fig-toast", FigToast, { extends: "dialog" });
5185
5305
 
5186
5306
  /* Combo Input */
5187
5307
  /**
@@ -7375,12 +7495,24 @@ customElements.define("fig-origin-grid", FigOriginGrid);
7375
7495
 
7376
7496
  /**
7377
7497
  * A custom joystick input element.
7378
- * @attr {string} value - The current position of the joystick (e.g., "0.5,0.5").
7498
+ * @attr {string} value - The current position of the joystick (e.g., "50% 50%").
7379
7499
  * @attr {number} precision - The number of decimal places for the output.
7380
7500
  * @attr {number} transform - A scaling factor for the output.
7381
- * @attr {boolean} text - Whether to display text inputs for X and Y values.
7501
+ * @attr {boolean} fields - Whether to display X and Y inputs.
7502
+ * @attr {string} aspect-ratio - Aspect ratio for the joystick plane container.
7503
+ * @attr {string} axis-labels - Space-delimited labels. 1 token: top. 2 tokens: x y. 4 tokens: left right top bottom.
7382
7504
  */
7383
7505
  class FigInputJoystick extends HTMLElement {
7506
+ #boundMouseDown = null;
7507
+ #boundTouchStart = null;
7508
+ #boundKeyDown = null;
7509
+ #boundKeyUp = null;
7510
+ #boundXInput = null;
7511
+ #boundYInput = null;
7512
+ #boundXFocusOut = null;
7513
+ #boundYFocusOut = null;
7514
+ #isSyncingValueAttr = false;
7515
+
7384
7516
  constructor() {
7385
7517
  super();
7386
7518
 
@@ -7393,6 +7525,14 @@ class FigInputJoystick extends HTMLElement {
7393
7525
  this.yInput = null;
7394
7526
  this.coordinates = "screen"; // "screen" (0,0 top-left) or "math" (0,0 bottom-left)
7395
7527
  this.#initialized = false;
7528
+ this.#boundMouseDown = (e) => this.#handleMouseDown(e);
7529
+ this.#boundTouchStart = (e) => this.#handleTouchStart(e);
7530
+ this.#boundKeyDown = (e) => this.#handleKeyDown(e);
7531
+ this.#boundKeyUp = (e) => this.#handleKeyUp(e);
7532
+ this.#boundXInput = (e) => this.#handleXInput(e);
7533
+ this.#boundYInput = (e) => this.#handleYInput(e);
7534
+ this.#boundXFocusOut = () => this.#handleFieldFocusOut();
7535
+ this.#boundYFocusOut = () => this.#handleFieldFocusOut();
7396
7536
  }
7397
7537
 
7398
7538
  #initialized = false;
@@ -7404,12 +7544,16 @@ class FigInputJoystick extends HTMLElement {
7404
7544
  this.precision = parseInt(this.precision);
7405
7545
  this.transform = this.getAttribute("transform") || 1;
7406
7546
  this.transform = Number(this.transform);
7407
- this.text = this.getAttribute("text") === "true";
7408
7547
  this.coordinates = this.getAttribute("coordinates") || "screen";
7548
+ this.#syncAspectRatioVar(this.getAttribute("aspect-ratio"));
7549
+ if (!this.hasAttribute("value")) {
7550
+ this.setAttribute("value", "50% 50%");
7551
+ }
7409
7552
 
7410
7553
  this.#render();
7411
7554
  this.#setupListeners();
7412
7555
  this.#syncHandlePosition();
7556
+ this.#syncValueAttribute();
7413
7557
  this.#initialized = true;
7414
7558
  });
7415
7559
  }
@@ -7418,41 +7562,102 @@ class FigInputJoystick extends HTMLElement {
7418
7562
  #displayY(y) {
7419
7563
  return this.coordinates === "math" ? 1 - y : y;
7420
7564
  }
7565
+
7566
+ #syncAspectRatioVar(value) {
7567
+ if (value && value.trim()) {
7568
+ this.style.setProperty("--aspect-ratio", value.trim());
7569
+ } else {
7570
+ this.style.removeProperty("--aspect-ratio");
7571
+ }
7572
+ }
7573
+
7421
7574
  disconnectedCallback() {
7422
7575
  this.#cleanupListeners();
7423
7576
  }
7424
7577
 
7578
+ get #fieldsEnabled() {
7579
+ const fields = this.getAttribute("fields");
7580
+ if (fields === null) return false;
7581
+ return fields.toLowerCase() !== "false";
7582
+ }
7583
+
7425
7584
  #render() {
7426
7585
  this.innerHTML = this.#getInnerHTML();
7427
7586
  }
7587
+
7588
+ #getAxisLabels() {
7589
+ const raw = (this.getAttribute("axis-labels") || "").trim();
7590
+ if (!raw) {
7591
+ return { left: "", right: "", top: "", bottom: "", leftNoRotate: false };
7592
+ }
7593
+ const tokens = raw.split(/\s+/).filter(Boolean);
7594
+ if (tokens.length === 1) {
7595
+ return {
7596
+ left: "",
7597
+ right: "",
7598
+ top: tokens[0],
7599
+ bottom: "",
7600
+ leftNoRotate: false,
7601
+ };
7602
+ }
7603
+ if (tokens.length === 2) {
7604
+ const [x, y] = tokens;
7605
+ return { left: x, right: "", top: "", bottom: y, leftNoRotate: true };
7606
+ }
7607
+ if (tokens.length === 4) {
7608
+ const [left, right, top, bottom] = tokens;
7609
+ return { left, right, top, bottom, leftNoRotate: false };
7610
+ }
7611
+ return { left: "", right: "", top: "", bottom: "", leftNoRotate: false };
7612
+ }
7613
+
7428
7614
  #getInnerHTML() {
7615
+ const axisLabels = this.#getAxisLabels();
7616
+ const labelsMarkup = [
7617
+ axisLabels.left
7618
+ ? `<label class="fig-joystick-axis-label left${axisLabels.leftNoRotate ? " no-rotate" : ""}" aria-hidden="true">${axisLabels.left}</label>`
7619
+ : "",
7620
+ axisLabels.right
7621
+ ? `<label class="fig-joystick-axis-label right" aria-hidden="true">${axisLabels.right}</label>`
7622
+ : "",
7623
+ axisLabels.top
7624
+ ? `<label class="fig-joystick-axis-label top" aria-hidden="true">${axisLabels.top}</label>`
7625
+ : "",
7626
+ axisLabels.bottom
7627
+ ? `<label class="fig-joystick-axis-label bottom" aria-hidden="true">${axisLabels.bottom}</label>`
7628
+ : "",
7629
+ ].join("");
7630
+
7429
7631
  return `
7430
7632
  <div class="fig-input-joystick-plane-container" tabindex="0">
7633
+ ${labelsMarkup}
7431
7634
  <div class="fig-input-joystick-plane">
7432
7635
  <div class="fig-input-joystick-guides"></div>
7433
7636
  <div class="fig-input-joystick-handle"></div>
7434
7637
  </div>
7435
7638
  </div>
7436
7639
  ${
7437
- this.text
7438
- ? `<fig-input-number
7439
- name="x"
7440
- step="1"
7441
- value="${this.position.x * 100}"
7442
- min="0"
7443
- max="100"
7444
- units="%">
7445
- <span slot="prepend">X</span>
7446
- </fig-input-number>
7447
- <fig-input-number
7448
- name="y"
7449
- step="1"
7450
- min="0"
7451
- max="100"
7452
- value="${this.position.y * 100}"
7453
- units="%">
7454
- <span slot="prepend">Y</span>
7455
- </fig-input-number>`
7640
+ this.#fieldsEnabled
7641
+ ? `<div class="joystick-values">
7642
+ <fig-input-number
7643
+ name="x"
7644
+ step="1"
7645
+ value="${(this.position.x * 100).toFixed(this.precision)}"
7646
+ min="0"
7647
+ max="100"
7648
+ units="%">
7649
+ <span slot="prepend">X</span>
7650
+ </fig-input-number>
7651
+ <fig-input-number
7652
+ name="y"
7653
+ step="1"
7654
+ min="0"
7655
+ max="100"
7656
+ value="${(this.position.y * 100).toFixed(this.precision)}"
7657
+ units="%">
7658
+ <span slot="prepend">Y</span>
7659
+ </fig-input-number>
7660
+ </div>`
7456
7661
  : ""
7457
7662
  }
7458
7663
  `;
@@ -7463,45 +7668,57 @@ class FigInputJoystick extends HTMLElement {
7463
7668
  this.cursor = this.querySelector(".fig-input-joystick-handle");
7464
7669
  this.xInput = this.querySelector("fig-input-number[name='x']");
7465
7670
  this.yInput = this.querySelector("fig-input-number[name='y']");
7466
- this.plane.addEventListener("mousedown", this.#handleMouseDown.bind(this));
7467
- this.plane.addEventListener(
7468
- "touchstart",
7469
- this.#handleTouchStart.bind(this),
7470
- );
7471
- window.addEventListener("keydown", this.#handleKeyDown.bind(this));
7472
- window.addEventListener("keyup", this.#handleKeyUp.bind(this));
7473
- if (this.text && this.xInput && this.yInput) {
7474
- this.xInput.addEventListener("input", this.#handleXInput.bind(this));
7475
- this.yInput.addEventListener("input", this.#handleYInput.bind(this));
7671
+ this.plane.addEventListener("mousedown", this.#boundMouseDown);
7672
+ this.plane.addEventListener("touchstart", this.#boundTouchStart);
7673
+ window.addEventListener("keydown", this.#boundKeyDown);
7674
+ window.addEventListener("keyup", this.#boundKeyUp);
7675
+ if (this.#fieldsEnabled && this.xInput && this.yInput) {
7676
+ this.xInput.addEventListener("input", this.#boundXInput);
7677
+ this.xInput.addEventListener("change", this.#boundXInput);
7678
+ this.xInput.addEventListener("focusout", this.#boundXFocusOut);
7679
+ this.yInput.addEventListener("input", this.#boundYInput);
7680
+ this.yInput.addEventListener("change", this.#boundYInput);
7681
+ this.yInput.addEventListener("focusout", this.#boundYFocusOut);
7476
7682
  }
7477
7683
  }
7478
7684
 
7479
7685
  #cleanupListeners() {
7480
7686
  if (this.plane) {
7481
- this.plane.removeEventListener("mousedown", this.#handleMouseDown);
7482
- this.plane.removeEventListener("touchstart", this.#handleTouchStart);
7687
+ this.plane.removeEventListener("mousedown", this.#boundMouseDown);
7688
+ this.plane.removeEventListener("touchstart", this.#boundTouchStart);
7483
7689
  }
7484
- window.removeEventListener("keydown", this.#handleKeyDown);
7485
- window.removeEventListener("keyup", this.#handleKeyUp);
7486
- if (this.text && this.xInput && this.yInput) {
7487
- this.xInput.removeEventListener("input", this.#handleXInput);
7488
- this.yInput.removeEventListener("input", this.#handleYInput);
7690
+ window.removeEventListener("keydown", this.#boundKeyDown);
7691
+ window.removeEventListener("keyup", this.#boundKeyUp);
7692
+ if (this.#fieldsEnabled && this.xInput && this.yInput) {
7693
+ this.xInput.removeEventListener("input", this.#boundXInput);
7694
+ this.xInput.removeEventListener("change", this.#boundXInput);
7695
+ this.xInput.removeEventListener("focusout", this.#boundXFocusOut);
7696
+ this.yInput.removeEventListener("input", this.#boundYInput);
7697
+ this.yInput.removeEventListener("change", this.#boundYInput);
7698
+ this.yInput.removeEventListener("focusout", this.#boundYFocusOut);
7489
7699
  }
7490
7700
  }
7491
7701
 
7492
7702
  #handleXInput(e) {
7493
- e.stopPropagation();
7494
- this.position.x = Number(e.target.value) / 100; // Convert from percentage to decimal
7703
+ const next = Number.parseFloat(e.target.value);
7704
+ if (!Number.isFinite(next)) return;
7705
+ this.position.x = Math.max(0, Math.min(1, next / 100));
7495
7706
  this.#syncHandlePosition();
7707
+ this.#syncValueAttribute();
7496
7708
  this.#emitInputEvent();
7497
- this.#emitChangeEvent();
7498
7709
  }
7499
7710
 
7500
7711
  #handleYInput(e) {
7501
- e.stopPropagation();
7502
- this.position.y = Number(e.target.value) / 100; // Convert from percentage to decimal
7712
+ const next = Number.parseFloat(e.target.value);
7713
+ if (!Number.isFinite(next)) return;
7714
+ this.position.y = Math.max(0, Math.min(1, next / 100));
7503
7715
  this.#syncHandlePosition();
7716
+ this.#syncValueAttribute();
7504
7717
  this.#emitInputEvent();
7718
+ }
7719
+
7720
+ #handleFieldFocusOut() {
7721
+ this.#syncValueAttribute();
7505
7722
  this.#emitChangeEvent();
7506
7723
  }
7507
7724
 
@@ -7541,11 +7758,12 @@ class FigInputJoystick extends HTMLElement {
7541
7758
  const displayY = this.#displayY(snapped.y);
7542
7759
  this.cursor.style.left = `${snapped.x * 100}%`;
7543
7760
  this.cursor.style.top = `${displayY * 100}%`;
7544
- if (this.text && this.xInput && this.yInput) {
7761
+ if (this.#fieldsEnabled && this.xInput && this.yInput) {
7545
7762
  this.xInput.setAttribute("value", Math.round(snapped.x * 100));
7546
7763
  this.yInput.setAttribute("value", Math.round(snapped.y * 100));
7547
7764
  }
7548
7765
 
7766
+ this.#syncValueAttribute();
7549
7767
  this.#emitInputEvent();
7550
7768
  }
7551
7769
 
@@ -7574,12 +7792,20 @@ class FigInputJoystick extends HTMLElement {
7574
7792
  this.cursor.style.top = `${displayY * 100}%`;
7575
7793
  }
7576
7794
  // Also sync text inputs if they exist (convert to percentage 0-100)
7577
- if (this.text && this.xInput && this.yInput) {
7795
+ if (this.#fieldsEnabled && this.xInput && this.yInput) {
7578
7796
  this.xInput.setAttribute("value", Math.round(this.position.x * 100));
7579
7797
  this.yInput.setAttribute("value", Math.round(this.position.y * 100));
7580
7798
  }
7581
7799
  }
7582
7800
 
7801
+ #syncValueAttribute() {
7802
+ const next = this.value;
7803
+ if (this.getAttribute("value") === next) return;
7804
+ this.#isSyncingValueAttr = true;
7805
+ this.setAttribute("value", next);
7806
+ this.#isSyncingValueAttr = false;
7807
+ }
7808
+
7583
7809
  #handleMouseDown(e) {
7584
7810
  this.isDragging = true;
7585
7811
 
@@ -7598,6 +7824,7 @@ class FigInputJoystick extends HTMLElement {
7598
7824
  this.plane.style.cursor = "";
7599
7825
  window.removeEventListener("mousemove", handleMouseMove);
7600
7826
  window.removeEventListener("mouseup", handleMouseUp);
7827
+ this.#syncValueAttribute();
7601
7828
  this.#emitChangeEvent();
7602
7829
  };
7603
7830
 
@@ -7620,6 +7847,7 @@ class FigInputJoystick extends HTMLElement {
7620
7847
  this.plane.classList.remove("dragging");
7621
7848
  window.removeEventListener("touchmove", handleTouchMove);
7622
7849
  window.removeEventListener("touchend", handleTouchEnd);
7850
+ this.#syncValueAttribute();
7623
7851
  this.#emitChangeEvent();
7624
7852
  };
7625
7853
 
@@ -7639,32 +7867,48 @@ class FigInputJoystick extends HTMLElement {
7639
7867
  container?.focus();
7640
7868
  }
7641
7869
  static get observedAttributes() {
7642
- return ["value", "precision", "transform", "text", "coordinates"];
7643
- }
7644
- get value() {
7645
- // Return as percentage values (0-100)
7646
7870
  return [
7647
- Math.round(this.position.x * 100),
7648
- Math.round(this.position.y * 100),
7871
+ "value",
7872
+ "precision",
7873
+ "transform",
7874
+ "fields",
7875
+ "coordinates",
7876
+ "aspect-ratio",
7877
+ "axis-labels",
7649
7878
  ];
7650
7879
  }
7880
+ get value() {
7881
+ return `${Math.round(this.position.x * 100)}% ${Math.round(this.position.y * 100)}%`;
7882
+ }
7651
7883
  set value(value) {
7652
- // Parse value, strip % symbols if present, convert from 0-100 to 0-1
7653
- const v = value
7654
- .toString()
7655
- .split(",")
7656
- .map((s) => {
7657
- const num = parseFloat(s.replace(/%/g, "").trim());
7658
- return isNaN(num) ? 0.5 : num / 100; // Convert from percentage to decimal, default to 0.5 if invalid
7659
- });
7660
- this.position = { x: v[0] ?? 0.5, y: v[1] ?? 0.5 };
7884
+ const normalized = value == null ? "" : String(value).trim();
7885
+ if (!normalized) {
7886
+ this.position = { x: 0.5, y: 0.5 };
7887
+ } else {
7888
+ const parts = normalized.split(/[\s,]+/).filter(Boolean);
7889
+ const parseAxis = (token) => {
7890
+ if (!token) return 0.5;
7891
+ const isPercent = token.includes("%");
7892
+ const numeric = Number.parseFloat(token.replace(/%/g, "").trim());
7893
+ if (!Number.isFinite(numeric)) return 0.5;
7894
+ const decimal = isPercent || Math.abs(numeric) > 1 ? numeric / 100 : numeric;
7895
+ return Math.max(0, Math.min(1, decimal));
7896
+ };
7897
+ const x = parseAxis(parts[0]);
7898
+ const y = parseAxis(parts[1] ?? parts[0]);
7899
+ this.position = { x, y };
7900
+ }
7661
7901
  if (this.#initialized) {
7662
7902
  this.#syncHandlePosition();
7663
7903
  }
7664
7904
  }
7665
7905
  attributeChangedCallback(name, oldValue, newValue) {
7906
+ if (name === "aspect-ratio") {
7907
+ this.#syncAspectRatioVar(newValue);
7908
+ return;
7909
+ }
7666
7910
  if (name === "value") {
7667
- if (this.isDragging) return;
7911
+ if (this.#isSyncingValueAttr || this.isDragging) return;
7668
7912
  this.value = newValue;
7669
7913
  }
7670
7914
  if (name === "precision") {
@@ -7673,9 +7917,17 @@ class FigInputJoystick extends HTMLElement {
7673
7917
  if (name === "transform") {
7674
7918
  this.transform = Number(newValue);
7675
7919
  }
7676
- if (name === "text" && newValue !== oldValue) {
7677
- this.text = newValue.toLowerCase() === "true";
7920
+ if (name === "fields" && newValue !== oldValue) {
7921
+ this.#cleanupListeners();
7922
+ this.#render();
7923
+ this.#setupListeners();
7924
+ this.#syncHandlePosition();
7925
+ }
7926
+ if (name === "axis-labels" && newValue !== oldValue) {
7927
+ this.#cleanupListeners();
7678
7928
  this.#render();
7929
+ this.#setupListeners();
7930
+ this.#syncHandlePosition();
7679
7931
  }
7680
7932
  if (name === "coordinates") {
7681
7933
  this.coordinates = newValue || "screen";
@@ -7684,7 +7936,7 @@ class FigInputJoystick extends HTMLElement {
7684
7936
  }
7685
7937
  }
7686
7938
 
7687
- customElements.define("fig-input-joystick", FigInputJoystick);
7939
+ customElements.define("fig-joystick", FigInputJoystick);
7688
7940
 
7689
7941
  /**
7690
7942
  * A custom angle chooser input element.