@rogieking/figui3 6.18.4 → 6.19.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-lab.js CHANGED
@@ -243,6 +243,7 @@ class PropskitColor extends HTMLElement {
243
243
  #boundHandleInput = null;
244
244
  #boundHandleChange = null;
245
245
  #boundHandleClick = this.#handleClick.bind(this);
246
+ #initialValue = null;
246
247
 
247
248
  static get observedAttributes() {
248
249
  return ["label", "direction", "aria-label"];
@@ -253,6 +254,10 @@ class PropskitColor extends HTMLElement {
253
254
  this.#syncField();
254
255
  this.#syncInputAttributes();
255
256
  this.#bindInputEvents();
257
+ if (this.#initialValue === null) {
258
+ this.#initialValue =
259
+ this.getAttribute("default") ?? this.getAttribute("value") ?? "";
260
+ }
256
261
  this.removeEventListener("click", this.#boundHandleClick);
257
262
  this.addEventListener("click", this.#boundHandleClick);
258
263
 
@@ -359,6 +364,7 @@ class PropskitColor extends HTMLElement {
359
364
  "size",
360
365
  "aria-label",
361
366
  "text",
367
+ "default",
362
368
  ]);
363
369
  return this.getAttributeNames().filter(
364
370
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -374,7 +380,16 @@ class PropskitColor extends HTMLElement {
374
380
  if (!nextManaged.has(attrName)) this.#input.removeAttribute(attrName);
375
381
  }
376
382
  for (const attrName of inputAttrs) {
377
- this.#input.setAttribute(attrName, this.getAttribute(attrName) ?? "");
383
+ const next = this.getAttribute(attrName) ?? "";
384
+ // Soft sync only. Never force-clear value here — that fights live edits
385
+ // (fig-input-color often keeps a stale value attribute while editing).
386
+ if (
387
+ attrName === "value" &&
388
+ this.#input.getAttribute("value") === next
389
+ ) {
390
+ continue;
391
+ }
392
+ this.#input.setAttribute(attrName, next);
378
393
  }
379
394
 
380
395
  this.#input.setAttribute("text", "true");
@@ -399,10 +414,31 @@ class PropskitColor extends HTMLElement {
399
414
  }
400
415
  }
401
416
 
417
+ #valueFromColorEvent(event) {
418
+ const detail =
419
+ event instanceof CustomEvent && event.detail !== undefined
420
+ ? event.detail
421
+ : undefined;
422
+ if (typeof detail === "string" && detail) return detail;
423
+ if (detail && typeof detail === "object") {
424
+ if (typeof detail.value === "string" && detail.value) return detail.value;
425
+ if (typeof detail.hex === "string" && detail.hex) return detail.hex;
426
+ if (typeof detail.color === "string" && detail.color) return detail.color;
427
+ }
428
+ // Prefer live JS value — fig-input-color often leaves the attribute stale.
429
+ if (typeof this.#input?.value === "string" && this.#input.value) {
430
+ return this.#input.value;
431
+ }
432
+ return this.#input?.getAttribute("value") ?? "";
433
+ }
434
+
402
435
  #forwardInputEvent(type, event) {
403
436
  event.stopImmediatePropagation();
404
- const value = this.#input?.getAttribute("value") ?? "";
437
+ const value = this.#valueFromColorEvent(event);
405
438
  this.setAttribute("value", value);
439
+ if (this.#input && this.#input.getAttribute("value") !== value) {
440
+ this.#input.setAttribute("value", value);
441
+ }
406
442
  const detail =
407
443
  event instanceof CustomEvent && event.detail !== undefined
408
444
  ? event.detail
@@ -425,15 +461,55 @@ class PropskitColor extends HTMLElement {
425
461
  }
426
462
 
427
463
  get value() {
428
- return this.#input?.getAttribute("value") ?? this.getAttribute("value") ?? "";
464
+ return this.getAttribute("value") ?? this.#input?.value ?? this.#input?.getAttribute("value") ?? "";
429
465
  }
430
466
 
431
467
  set value(nextValue) {
432
468
  if (nextValue === null || nextValue === undefined || nextValue === "") {
433
469
  this.removeAttribute("value");
434
- } else {
435
- this.setAttribute("value", String(nextValue));
470
+ this.#input?.removeAttribute("value");
471
+ return;
472
+ }
473
+ const next = String(nextValue);
474
+ this.setAttribute("value", next);
475
+ if (this.#input && this.#input.getAttribute("value") !== next) {
476
+ this.#input.setAttribute("value", next);
477
+ }
478
+ }
479
+
480
+ /** Force fig-input-color UI refresh even when the hex string is unchanged. */
481
+ #forceInputValue(next) {
482
+ if (!this.#input) return;
483
+ if (this.#input.getAttribute("value") === next) {
484
+ this.#input.removeAttribute("value");
436
485
  }
486
+ this.#input.setAttribute("value", next);
487
+ }
488
+
489
+ #defaultValue() {
490
+ return this.getAttribute("default") ?? this.#initialValue ?? "";
491
+ }
492
+
493
+ resetToDefault() {
494
+ const next = String(this.#defaultValue());
495
+ this.setAttribute("value", next);
496
+ this.#forceInputValue(next);
497
+ this.dispatchEvent(
498
+ new CustomEvent("input", {
499
+ detail: next,
500
+ bubbles: true,
501
+ cancelable: true,
502
+ composed: true,
503
+ }),
504
+ );
505
+ this.dispatchEvent(
506
+ new CustomEvent("change", {
507
+ detail: next,
508
+ bubbles: true,
509
+ cancelable: true,
510
+ composed: true,
511
+ }),
512
+ );
437
513
  }
438
514
 
439
515
  focus(options) {
@@ -521,6 +597,8 @@ class PropskitSelect extends HTMLElement {
521
597
  const field = document.createElement("fig-field");
522
598
  const label = customLabel || document.createElement("label");
523
599
  const select = document.createElement("fig-select");
600
+ // Match menu width to the full-surface control.
601
+ select.setAttribute("full", "");
524
602
  field.append(label, select);
525
603
  this.#field = field;
526
604
  this.#label = label;
@@ -556,6 +634,8 @@ class PropskitSelect extends HTMLElement {
556
634
  this.#label.textContent?.trim() ||
557
635
  "Select",
558
636
  );
637
+ // Always match menu width to the control surface.
638
+ this.#select.setAttribute("full", "");
559
639
  }
560
640
 
561
641
  #getForwardedSelectAttrNames() {
@@ -569,6 +649,7 @@ class PropskitSelect extends HTMLElement {
569
649
  "id",
570
650
  "size",
571
651
  "aria-label",
652
+ "full",
572
653
  ]);
573
654
  return this.getAttributeNames().filter(
574
655
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -1101,6 +1182,397 @@ class PropskitNumber extends HTMLElement {
1101
1182
  }
1102
1183
  customElements.define("propskit-number", PropskitNumber);
1103
1184
 
1185
+ /* Collapsible property group — always collapsible (no collapsible attr). */
1186
+ class PropskitGroup extends HTMLElement {
1187
+ static observedAttributes = ["name", "open", "show-reset"];
1188
+
1189
+ static #CONTROL_SELECTOR = [
1190
+ "propskit-color",
1191
+ "propskit-number",
1192
+ "propskit-select",
1193
+ "propskit-slider",
1194
+ "propskit-switch",
1195
+ "propskit-text",
1196
+ ].join(",");
1197
+
1198
+ #header = null;
1199
+ #chevron = null;
1200
+ #resetTooltip = null;
1201
+ #defaults = new WeakMap();
1202
+ #childObserver = null;
1203
+ #dirtyFrame = 0;
1204
+ #boundOnControlEvent = () => this.#queueDirtySync();
1205
+
1206
+ connectedCallback() {
1207
+ this.#render();
1208
+ this.#bindDirtyListeners();
1209
+ requestAnimationFrame(() => {
1210
+ this.#ensureDefaults();
1211
+ this.#syncDirtyState();
1212
+ });
1213
+ }
1214
+
1215
+ disconnectedCallback() {
1216
+ this.#unbindDirtyListeners();
1217
+ if (this.#dirtyFrame) {
1218
+ cancelAnimationFrame(this.#dirtyFrame);
1219
+ this.#dirtyFrame = 0;
1220
+ }
1221
+ if (this.#header) {
1222
+ this.#header.removeEventListener("click", this.#handleToggle);
1223
+ this.#header.removeEventListener("keydown", this.#handleHeaderKeyDown);
1224
+ }
1225
+ const resetBtn = this.#resetTooltip?.querySelector("fig-button");
1226
+ resetBtn?.removeEventListener("click", this.#handleReset);
1227
+ }
1228
+
1229
+ attributeChangedCallback(name, oldValue, newValue) {
1230
+ if (oldValue === newValue) return;
1231
+ if (name === "open") {
1232
+ this.#header?.setAttribute("aria-expanded", String(this.open));
1233
+ return;
1234
+ }
1235
+ if (name === "show-reset") {
1236
+ this.#syncResetButton();
1237
+ this.#syncDirtyState();
1238
+ return;
1239
+ }
1240
+ this.#render();
1241
+ }
1242
+
1243
+ get open() {
1244
+ const attr = this.getAttribute("open");
1245
+ return attr !== null && attr !== "false";
1246
+ }
1247
+
1248
+ set open(value) {
1249
+ const was = this.open;
1250
+ if (value) {
1251
+ this.setAttribute("open", "true");
1252
+ } else {
1253
+ this.setAttribute("open", "false");
1254
+ }
1255
+ this.#header?.setAttribute("aria-expanded", String(!!value));
1256
+ if (was !== !!value) {
1257
+ this.dispatchEvent(
1258
+ new CustomEvent("openchange", {
1259
+ detail: { open: !!value },
1260
+ bubbles: true,
1261
+ }),
1262
+ );
1263
+ }
1264
+ }
1265
+
1266
+ /** When true (default), show the reset control while the group is dirty. */
1267
+ get showReset() {
1268
+ const attr = this.getAttribute("show-reset");
1269
+ if (attr === null) return true;
1270
+ return attr !== "false";
1271
+ }
1272
+
1273
+ set showReset(value) {
1274
+ if (value) this.setAttribute("show-reset", "true");
1275
+ else this.setAttribute("show-reset", "false");
1276
+ }
1277
+
1278
+ get dirty() {
1279
+ return this.hasAttribute("data-dirty") && this.getAttribute("data-dirty") !== "false";
1280
+ }
1281
+
1282
+ /** Restore all propskit controls in this group to their captured defaults. */
1283
+ resetProperties() {
1284
+ this.#resetControls();
1285
+ }
1286
+
1287
+ #bindDirtyListeners() {
1288
+ this.removeEventListener("input", this.#boundOnControlEvent, true);
1289
+ this.removeEventListener("change", this.#boundOnControlEvent, true);
1290
+ this.addEventListener("input", this.#boundOnControlEvent, true);
1291
+ this.addEventListener("change", this.#boundOnControlEvent, true);
1292
+
1293
+ if (!this.#childObserver) {
1294
+ this.#childObserver = new MutationObserver(() => {
1295
+ this.#ensureDefaults();
1296
+ this.#queueDirtySync();
1297
+ });
1298
+ }
1299
+ this.#childObserver.disconnect();
1300
+ this.#childObserver.observe(this, { childList: true, subtree: true });
1301
+ }
1302
+
1303
+ #unbindDirtyListeners() {
1304
+ this.removeEventListener("input", this.#boundOnControlEvent, true);
1305
+ this.removeEventListener("change", this.#boundOnControlEvent, true);
1306
+ this.#childObserver?.disconnect();
1307
+ }
1308
+
1309
+ #queueDirtySync() {
1310
+ if (this.#dirtyFrame) return;
1311
+ this.#dirtyFrame = requestAnimationFrame(() => {
1312
+ this.#dirtyFrame = 0;
1313
+ this.#ensureDefaults();
1314
+ this.#syncDirtyState();
1315
+ });
1316
+ }
1317
+
1318
+ #isResetTarget(target) {
1319
+ return (
1320
+ target instanceof Element &&
1321
+ Boolean(
1322
+ target.closest(
1323
+ ".propskit-group-reset, .propskit-group-reset-tooltip",
1324
+ ),
1325
+ )
1326
+ );
1327
+ }
1328
+
1329
+ #handleToggle = (e) => {
1330
+ if (this.#isResetTarget(e.target)) return;
1331
+ e.stopPropagation();
1332
+ this.open = !this.open;
1333
+ };
1334
+
1335
+ #handleHeaderKeyDown = (e) => {
1336
+ if (this.#isResetTarget(e.target)) return;
1337
+ if (e.key !== "Enter" && e.key !== " ") return;
1338
+ e.preventDefault();
1339
+ e.stopPropagation();
1340
+ this.open = !this.open;
1341
+ };
1342
+
1343
+ #handleReset = (e) => {
1344
+ e.preventDefault();
1345
+ e.stopPropagation();
1346
+ this.#resetControls();
1347
+ };
1348
+
1349
+ #controls() {
1350
+ return [
1351
+ ...this.querySelectorAll(PropskitGroup.#CONTROL_SELECTOR),
1352
+ ].filter((el) => el.closest("propskit-group") === this);
1353
+ }
1354
+
1355
+ #snapshotControl(el) {
1356
+ if (el.localName === "propskit-switch") {
1357
+ return { kind: "checked", value: Boolean(el.checked) };
1358
+ }
1359
+ if ("value" in el) {
1360
+ return { kind: "value", value: el.value };
1361
+ }
1362
+ return { kind: "value", value: el.getAttribute("value") ?? "" };
1363
+ }
1364
+
1365
+ #ensureDefaults() {
1366
+ for (const el of this.#controls()) {
1367
+ if (this.#defaults.has(el)) continue;
1368
+ if (el.hasAttribute("default")) {
1369
+ if (el.localName === "propskit-switch") {
1370
+ this.#defaults.set(el, {
1371
+ kind: "checked",
1372
+ value: figLabBooleanAttribute(el, "default"),
1373
+ });
1374
+ continue;
1375
+ }
1376
+ this.#defaults.set(el, {
1377
+ kind: "value",
1378
+ value: el.getAttribute("default") ?? "",
1379
+ });
1380
+ continue;
1381
+ }
1382
+ this.#defaults.set(el, this.#snapshotControl(el));
1383
+ }
1384
+ }
1385
+
1386
+ #controlIsDirty(el) {
1387
+ const snap = this.#defaults.get(el);
1388
+ if (!snap) return false;
1389
+ const cur = this.#snapshotControl(el);
1390
+ if (snap.kind === "checked") {
1391
+ return Boolean(cur.value) !== Boolean(snap.value);
1392
+ }
1393
+ return String(cur.value ?? "") !== String(snap.value ?? "");
1394
+ }
1395
+
1396
+ #computeDirty() {
1397
+ for (const el of this.#controls()) {
1398
+ if (this.#controlIsDirty(el)) return true;
1399
+ }
1400
+ return false;
1401
+ }
1402
+
1403
+ #syncDirtyState() {
1404
+ const dirty = this.#computeDirty();
1405
+ if (dirty) this.setAttribute("data-dirty", "");
1406
+ else this.removeAttribute("data-dirty");
1407
+ this.#syncResetButton();
1408
+ }
1409
+
1410
+ #restoreControl(el) {
1411
+ const snap = this.#defaults.get(el);
1412
+ if (!snap) return;
1413
+
1414
+ if (snap.kind === "checked") {
1415
+ el.checked = snap.value;
1416
+ const detail = {
1417
+ checked: Boolean(snap.value),
1418
+ value: el.getAttribute("value") ?? "",
1419
+ };
1420
+ el.dispatchEvent(
1421
+ new CustomEvent("input", {
1422
+ detail,
1423
+ bubbles: true,
1424
+ cancelable: true,
1425
+ composed: true,
1426
+ }),
1427
+ );
1428
+ el.dispatchEvent(
1429
+ new CustomEvent("change", {
1430
+ detail,
1431
+ bubbles: true,
1432
+ cancelable: true,
1433
+ composed: true,
1434
+ }),
1435
+ );
1436
+ return;
1437
+ }
1438
+
1439
+ if (typeof el.resetToDefault === "function") {
1440
+ const prevDefault = el.getAttribute("default");
1441
+ el.setAttribute("default", String(snap.value ?? ""));
1442
+ el.resetToDefault();
1443
+ if (prevDefault === null) el.removeAttribute("default");
1444
+ else el.setAttribute("default", prevDefault);
1445
+ return;
1446
+ }
1447
+
1448
+ el.value = snap.value;
1449
+ const detail =
1450
+ el.getAttribute?.("value") ??
1451
+ ("value" in el ? el.value : snap.value);
1452
+ el.dispatchEvent(
1453
+ new CustomEvent("input", {
1454
+ detail,
1455
+ bubbles: true,
1456
+ cancelable: true,
1457
+ composed: true,
1458
+ }),
1459
+ );
1460
+ el.dispatchEvent(
1461
+ new CustomEvent("change", {
1462
+ detail,
1463
+ bubbles: true,
1464
+ cancelable: true,
1465
+ composed: true,
1466
+ }),
1467
+ );
1468
+ }
1469
+
1470
+ #resetControls() {
1471
+ this.#ensureDefaults();
1472
+ for (const el of this.#controls()) this.#restoreControl(el);
1473
+ this.#syncDirtyState();
1474
+ this.dispatchEvent(
1475
+ new CustomEvent("reset", {
1476
+ bubbles: true,
1477
+ composed: true,
1478
+ }),
1479
+ );
1480
+ }
1481
+
1482
+ #syncResetButton() {
1483
+ if (!this.showReset) {
1484
+ this.#resetTooltip?.setAttribute("hidden", "");
1485
+ return;
1486
+ }
1487
+ this.#ensureResetButton();
1488
+ this.#resetTooltip?.removeAttribute("hidden");
1489
+ }
1490
+
1491
+ #ensureResetButton() {
1492
+ if (!this.#header || !this.showReset) return;
1493
+ let tip = this.#header.querySelector(":scope > .propskit-group-reset-tooltip");
1494
+ if (!tip) {
1495
+ tip = document.createElement("fig-tooltip");
1496
+ tip.className = "propskit-group-reset-tooltip";
1497
+ tip.setAttribute("text", "Reset properties");
1498
+ const btn = document.createElement("fig-button");
1499
+ btn.className = "propskit-group-reset";
1500
+ btn.setAttribute("variant", "ghost");
1501
+ btn.setAttribute("icon", "");
1502
+ btn.setAttribute("aria-label", "Reset properties");
1503
+ const icon = document.createElement("fig-icon");
1504
+ icon.setAttribute("name", "reset");
1505
+ btn.appendChild(icon);
1506
+ tip.appendChild(btn);
1507
+ this.#header.appendChild(tip);
1508
+ btn.addEventListener("click", this.#handleReset);
1509
+ } else {
1510
+ const btn = tip.querySelector("fig-button");
1511
+ btn?.removeEventListener("click", this.#handleReset);
1512
+ btn?.addEventListener("click", this.#handleReset);
1513
+ }
1514
+ this.#resetTooltip = tip;
1515
+ }
1516
+
1517
+ #render() {
1518
+ const nameAttr = this.getAttribute("name");
1519
+ const label = nameAttr || "Group";
1520
+ const userHeader = this.querySelector(":scope > fig-header");
1521
+
1522
+ if (userHeader) {
1523
+ this.#header = userHeader;
1524
+ } else if (!this.#header || !this.#header.dataset.generated) {
1525
+ this.#header = document.createElement("fig-header");
1526
+ this.#header.setAttribute("borderless", "");
1527
+ this.#header.dataset.generated = "true";
1528
+ this.prepend(this.#header);
1529
+ }
1530
+
1531
+ let h3 = this.#header.querySelector("h3");
1532
+ if (!h3) {
1533
+ h3 = document.createElement("h3");
1534
+ this.#header.prepend(h3);
1535
+ }
1536
+ if (!h3.id) h3.id = figLabUniqueId("propskit-group");
1537
+ if (this.#header.dataset.generated) {
1538
+ // Preserve chevron while updating the title text node.
1539
+ const chevron = h3.querySelector(".propskit-group-chevron");
1540
+ h3.textContent = label;
1541
+ if (chevron) h3.prepend(chevron);
1542
+ }
1543
+ if (!this.hasAttribute("role")) this.setAttribute("role", "group");
1544
+ if (
1545
+ !this.hasAttribute("aria-label") &&
1546
+ !this.hasAttribute("aria-labelledby")
1547
+ ) {
1548
+ this.setAttribute("aria-labelledby", h3.id);
1549
+ }
1550
+
1551
+ if (!h3.querySelector(".propskit-group-chevron")) {
1552
+ const chevron = document.createElement("fig-icon");
1553
+ chevron.setAttribute("name", "chevron");
1554
+ chevron.setAttribute("size", "small");
1555
+ chevron.className = "propskit-group-chevron";
1556
+ h3.prepend(chevron);
1557
+ }
1558
+ this.#chevron = h3.querySelector(".propskit-group-chevron");
1559
+ this.#syncResetButton();
1560
+ this.#header.removeEventListener("click", this.#handleToggle);
1561
+ this.#header.addEventListener("click", this.#handleToggle);
1562
+ this.#header.setAttribute("role", "button");
1563
+ this.#header.setAttribute("tabindex", "0");
1564
+ this.#header.setAttribute("aria-expanded", String(this.open));
1565
+ this.#header.removeEventListener("keydown", this.#handleHeaderKeyDown);
1566
+ this.#header.addEventListener("keydown", this.#handleHeaderKeyDown);
1567
+
1568
+ if (!this.hasAttribute("open")) {
1569
+ this.setAttribute("open", "false");
1570
+ this.#header.setAttribute("aria-expanded", "false");
1571
+ }
1572
+ }
1573
+ }
1574
+ customElements.define("propskit-group", PropskitGroup);
1575
+
1104
1576
  /* Field + Slider wrapper */
1105
1577
  class PropskitSlider extends HTMLElement {
1106
1578
  #field = null;
@@ -5496,7 +5968,12 @@ class FigSelect extends HTMLElement {
5496
5968
 
5497
5969
  const selectedOffsetX = optionTextRect.left - popupRect.left;
5498
5970
  const selectedOffsetY = optionTextRect.top - popupRect.top;
5499
- let left = labelRect.left - selectedOffsetX;
5971
+ const full = figLabBooleanAttribute(this, "full");
5972
+ // [full]: pin menu to host width/edges. Otherwise overlay selected
5973
+ // option text on the trigger label (blend-mode style).
5974
+ let left = full
5975
+ ? this.getBoundingClientRect().left
5976
+ : labelRect.left - selectedOffsetX;
5500
5977
  let top = labelRect.top - selectedOffsetY;
5501
5978
 
5502
5979
  // Keep the whole menu in-view when aligning over the selected option
@@ -5877,6 +6354,7 @@ class FigSelect extends HTMLElement {
5877
6354
  const full = figLabBooleanAttribute(this, "full");
5878
6355
 
5879
6356
  // Use !important — fig-select::part(listbox) width rules beat element.style.
6357
+ // [full]: lock to host. Otherwise content-sized with host as min and 20rem max.
5880
6358
  if (full) {
5881
6359
  this.#popup.style.setProperty("width", `${anchorWidth}px`, "important");
5882
6360
  this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
package/fig.js CHANGED
@@ -15141,7 +15141,7 @@ customElements.define("fig-preview", FigPreview);
15141
15141
  const FIG_ICON_TOKENS = {
15142
15142
  chevron: "--icon-16-chevron",
15143
15143
  checkmark: "--icon-16-checkmark",
15144
- reset: "--icon-16-reset",
15144
+ reset: { medium: "--icon-24-reset", small: "--icon-16-reset" },
15145
15145
  "arrow-left": {
15146
15146
  medium: "--icon-24-arrow-left",
15147
15147
  small: "--icon-16-arrow-left",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "6.18.4",
3
+ "version": "6.19.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",