@rogieking/figui3 8.9.18 → 8.9.20

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
@@ -724,6 +724,121 @@ function figLabSolidFillValue(raw) {
724
724
  return JSON.stringify({ type: "solid", color, alpha });
725
725
  }
726
726
 
727
+ function figLabCssUrl(url) {
728
+ return `url("${String(url).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}")`;
729
+ }
730
+
731
+ function figLabParseFillValue(raw) {
732
+ if (raw && typeof raw === "object") return raw;
733
+ const text = String(raw ?? "").trim();
734
+ if (!text) return { type: "solid", color: "#D9D9D9", alpha: 1 };
735
+ if (text.startsWith("{")) {
736
+ try {
737
+ const parsed = JSON.parse(text);
738
+ if (parsed && typeof parsed === "object") return parsed;
739
+ } catch {}
740
+ }
741
+ if (text.startsWith("#")) {
742
+ const { color, alpha } = figLabParseSolidColor(text);
743
+ return { type: "solid", color, alpha };
744
+ }
745
+ return { type: "solid", color: "#D9D9D9", alpha: 1 };
746
+ }
747
+
748
+ function figLabSerializeFillValue(fill) {
749
+ if (fill == null) return "";
750
+ return typeof fill === "string" ? fill : JSON.stringify(fill);
751
+ }
752
+
753
+ function figLabGradientCss(gradient) {
754
+ if (!gradient) return "";
755
+ const stops = [...(gradient.stops || [])]
756
+ .sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
757
+ .map((stop) => `${stop.color} ${stop.position ?? 0}%`)
758
+ .join(", ");
759
+ if (!stops) return "";
760
+ switch (gradient.type) {
761
+ case "radial":
762
+ return `radial-gradient(circle, ${stops})`;
763
+ case "angular":
764
+ return `conic-gradient(from ${gradient.angle || 0}deg, ${stops})`;
765
+ default:
766
+ return `linear-gradient(${gradient.angle ?? 180}deg, ${stops})`;
767
+ }
768
+ }
769
+
770
+ function figLabFillSwatchBackground(fill, slot) {
771
+ switch (fill?.type) {
772
+ case "solid":
773
+ return fill.color || "";
774
+ case "gradient":
775
+ return fill.css || figLabGradientCss(fill.gradient) || "";
776
+ case "image":
777
+ return fill.image?.url ? figLabCssUrl(fill.image.url) : "";
778
+ case "video":
779
+ return fill.video?.poster ? figLabCssUrl(fill.video.poster) : "";
780
+ case "webcam":
781
+ return fill.webcam?.snapshot ? figLabCssUrl(fill.webcam.snapshot) : "";
782
+ default:
783
+ return (
784
+ fill?.swatchBackground ||
785
+ fill?.background ||
786
+ fill?.css ||
787
+ slot?.getAttribute("swatch-background") ||
788
+ ""
789
+ );
790
+ }
791
+ }
792
+
793
+ function figLabFillSwatchMedia(fill) {
794
+ if (!fill || typeof fill !== "object") return null;
795
+ if (fill.type === "image") return fill.image;
796
+ if (fill.type === "video") return fill.video;
797
+ if (fill.type === "webcam") return fill.webcam;
798
+ return null;
799
+ }
800
+
801
+ function figLabFillSwatchSizing(fill) {
802
+ const media = figLabFillSwatchMedia(fill);
803
+ const scale = Number(media?.scale);
804
+ switch (media?.scaleMode) {
805
+ case "fit":
806
+ return { size: "contain", position: "center" };
807
+ case "tile":
808
+ return {
809
+ size: `${Number.isFinite(scale) ? scale : 50}%`,
810
+ position: "top left",
811
+ };
812
+ case "fill":
813
+ case "crop":
814
+ return { size: "cover", position: "center" };
815
+ default:
816
+ return fill?.type === "webcam" || fill?.type === "image" || fill?.type === "video"
817
+ ? { size: "cover", position: "center" }
818
+ : null;
819
+ }
820
+ }
821
+
822
+ function figLabFillSwatchAlpha(fill) {
823
+ switch (fill?.type) {
824
+ case "solid": {
825
+ if (Number.isFinite(fill.alpha)) return fill.alpha;
826
+ if (Number.isFinite(fill.opacity)) return fill.opacity / 100;
827
+ return 1;
828
+ }
829
+ case "gradient":
830
+ return fill.gradient?.opacity ?? fill.opacity ?? 1;
831
+ case "image":
832
+ return fill.image?.opacity ?? 1;
833
+ case "video":
834
+ return fill.video?.opacity ?? 1;
835
+ case "webcam":
836
+ return fill.webcam?.opacity ?? 1;
837
+ default:
838
+ return fill?.opacity ?? fill?.alpha ?? 1;
839
+ }
840
+ }
841
+
727
842
  /* Field + Color wrapper */
728
843
  class PropskitColor extends HTMLElement {
729
844
  #field = null;
@@ -996,13 +1111,17 @@ class PropskitColor extends HTMLElement {
996
1111
 
997
1112
  #handleClick(event) {
998
1113
  if (figLabBooleanAttribute(this, "disabled")) return;
1114
+ if (event.target instanceof Element && event.target.closest("fig-menu")) {
1115
+ return;
1116
+ }
999
1117
  if (
1000
1118
  event.target instanceof Element &&
1001
- event.target.closest("fig-fill-picker, fig-swatch, fig-menu")
1119
+ event.target.closest("fig-fill-picker, fig-swatch")
1002
1120
  ) {
1003
1121
  return;
1004
1122
  }
1005
1123
  this.focus();
1124
+ this.#input?.open?.();
1006
1125
  }
1007
1126
 
1008
1127
  get value() {
@@ -1076,6 +1195,374 @@ class PropskitColor extends HTMLElement {
1076
1195
  }
1077
1196
  figLabDefineElement("propskit-color", PropskitColor);
1078
1197
 
1198
+ /* Field + Fill wrapper — color-style swatch that opens the full fill picker */
1199
+ class PropskitFill extends HTMLElement {
1200
+ #field = null;
1201
+ #label = null;
1202
+ #input = null;
1203
+ #swatch = null;
1204
+ #hasCustomLabel = false;
1205
+ #observer = null;
1206
+ #managedInputAttrs = new Set();
1207
+ #boundHandleInput = null;
1208
+ #boundHandleChange = null;
1209
+ #boundHandleClick = this.#handleClick.bind(this);
1210
+ #initialValue = null;
1211
+
1212
+ static get observedAttributes() {
1213
+ return ["label", "direction", "aria-label"];
1214
+ }
1215
+
1216
+ connectedCallback() {
1217
+ if (!this.#field) this.#initialize();
1218
+ this.#syncField();
1219
+ this.#syncInputAttributes();
1220
+ this.#bindInputEvents();
1221
+ if (this.#initialValue === null) {
1222
+ this.#initialValue =
1223
+ this.getAttribute("default") ?? this.getAttribute("value") ?? "";
1224
+ }
1225
+ this.removeEventListener("click", this.#boundHandleClick);
1226
+ this.addEventListener("click", this.#boundHandleClick);
1227
+ figLabConnectPropskitResetMenu(this);
1228
+
1229
+ if (!this.#observer) {
1230
+ this.#observer = new MutationObserver((mutations) => {
1231
+ let syncField = false;
1232
+ let syncInput = false;
1233
+
1234
+ for (const mutation of mutations) {
1235
+ if (mutation.type !== "attributes") continue;
1236
+ if (
1237
+ mutation.attributeName === "label" ||
1238
+ mutation.attributeName === "direction" ||
1239
+ mutation.attributeName === "aria-label"
1240
+ ) {
1241
+ syncField = true;
1242
+ } else {
1243
+ syncInput = true;
1244
+ }
1245
+ }
1246
+
1247
+ if (syncField) this.#syncField();
1248
+ if (syncInput) this.#syncInputAttributes();
1249
+ });
1250
+ }
1251
+
1252
+ this.#observer.observe(this, { attributes: true });
1253
+ }
1254
+
1255
+ disconnectedCallback() {
1256
+ this.#observer?.disconnect();
1257
+ this.#unbindInputEvents();
1258
+ this.removeEventListener("click", this.#boundHandleClick);
1259
+ figLabDisconnectPropskitResetMenu(this);
1260
+ }
1261
+
1262
+ attributeChangedCallback(name, oldValue, newValue) {
1263
+ if (oldValue === newValue || !this.#field) return;
1264
+ if (name === "label" || name === "direction" || name === "aria-label") {
1265
+ this.#syncField();
1266
+ }
1267
+ }
1268
+
1269
+ #initialize() {
1270
+ const initialChildren = Array.from(this.childNodes).filter(
1271
+ (node) =>
1272
+ node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
1273
+ );
1274
+ const customLabel = initialChildren.find(
1275
+ (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
1276
+ );
1277
+ const field = document.createElement("fig-field");
1278
+ const label = customLabel || document.createElement("label");
1279
+ const picker = document.createElement("fig-fill-picker");
1280
+ const swatch = document.createElement("fig-swatch");
1281
+ picker.append(swatch);
1282
+ swatch.setAttribute("tabindex", "0");
1283
+ for (const node of initialChildren) {
1284
+ if (node !== customLabel) picker.appendChild(node);
1285
+ }
1286
+ field.append(label, picker);
1287
+ this.#field = field;
1288
+ this.#label = label;
1289
+ this.#input = picker;
1290
+ this.#swatch = swatch;
1291
+ this.#hasCustomLabel = Boolean(customLabel);
1292
+ this.replaceChildren(field);
1293
+ }
1294
+
1295
+ #syncField() {
1296
+ if (!this.#field || !this.#label || !this.#input) return;
1297
+ const hasLabelAttr = this.hasAttribute("label");
1298
+ const rawLabel = this.getAttribute("label");
1299
+ const isBlankLabel = hasLabelAttr && (rawLabel ?? "").trim() === "";
1300
+
1301
+ if (isBlankLabel) {
1302
+ this.#label.remove();
1303
+ } else {
1304
+ if (!this.#hasCustomLabel) {
1305
+ this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
1306
+ }
1307
+ if (this.#label.parentElement !== this.#field) {
1308
+ this.#field.prepend(this.#label);
1309
+ }
1310
+ }
1311
+
1312
+ this.#field.setAttribute(
1313
+ "direction",
1314
+ this.getAttribute("direction") || "horizontal",
1315
+ );
1316
+ this.#input.setAttribute(
1317
+ "aria-label",
1318
+ this.getAttribute("aria-label") ||
1319
+ this.#label.textContent?.trim() ||
1320
+ "Fill",
1321
+ );
1322
+ }
1323
+
1324
+ #parsedHostFill() {
1325
+ return figLabParseFillValue(this.getAttribute("value"));
1326
+ }
1327
+
1328
+ #pickerValueFromHost() {
1329
+ return figLabSerializeFillValue(this.#parsedHostFill());
1330
+ }
1331
+
1332
+ #customModeSlot() {
1333
+ const type = this.#parsedHostFill()?.type;
1334
+ if (!type) return null;
1335
+ return this.querySelector(`[slot="mode-${type}"]`);
1336
+ }
1337
+
1338
+ #livePickerFill() {
1339
+ const live = this.#input?.value;
1340
+ if (live && typeof live === "object") return live;
1341
+ if (typeof live === "string" && live) return figLabParseFillValue(live);
1342
+ return null;
1343
+ }
1344
+
1345
+ #captureFill(fill) {
1346
+ const live = this.#livePickerFill();
1347
+ if (!fill || typeof fill !== "object") return fill;
1348
+ if (fill.type !== "webcam") return fill;
1349
+ const snapshot = fill.webcam?.snapshot || live?.webcam?.snapshot;
1350
+ if (!snapshot || fill.webcam?.snapshot) return fill;
1351
+ return {
1352
+ ...fill,
1353
+ webcam: {
1354
+ ...(live?.webcam && typeof live.webcam === "object" ? live.webcam : {}),
1355
+ ...fill.webcam,
1356
+ snapshot,
1357
+ },
1358
+ };
1359
+ }
1360
+
1361
+ #syncSwatch(fill = this.#parsedHostFill()) {
1362
+ if (!this.#swatch) return;
1363
+ const next = this.#captureFill(fill);
1364
+ let background = figLabFillSwatchBackground(next, this.#customModeSlot());
1365
+ if (!background) {
1366
+ background = figLabFillSwatchBackground(
1367
+ this.#livePickerFill(),
1368
+ this.#customModeSlot(),
1369
+ );
1370
+ }
1371
+ if (!background) {
1372
+ const painted = this.#swatch.getAttribute("background") ?? "";
1373
+ if (painted.startsWith("url(")) background = painted;
1374
+ }
1375
+ this.#swatch.setAttribute("background", background);
1376
+ this.#swatch.setAttribute("alpha", String(figLabFillSwatchAlpha(next)));
1377
+ const sizing = figLabFillSwatchSizing(next || fill);
1378
+ if (sizing) {
1379
+ this.#swatch.style.setProperty("--swatch-bg-size", sizing.size);
1380
+ this.#swatch.style.setProperty("--swatch-bg-position", sizing.position);
1381
+ }
1382
+ }
1383
+
1384
+ #getForwardedInputAttrNames() {
1385
+ const reserved = new Set([
1386
+ "label",
1387
+ "direction",
1388
+ "oninput",
1389
+ "onchange",
1390
+ "class",
1391
+ "style",
1392
+ "id",
1393
+ "size",
1394
+ "aria-label",
1395
+ "text",
1396
+ "default",
1397
+ "value",
1398
+ ]);
1399
+ return this.getAttributeNames().filter(
1400
+ (name) => !reserved.has(name) && !name.startsWith("data-"),
1401
+ );
1402
+ }
1403
+
1404
+ #syncInputAttributes() {
1405
+ if (!this.#input) return;
1406
+ const inputAttrs = this.#getForwardedInputAttrNames();
1407
+ const nextManaged = new Set(inputAttrs);
1408
+
1409
+ for (const attrName of this.#managedInputAttrs) {
1410
+ if (!nextManaged.has(attrName)) this.#input.removeAttribute(attrName);
1411
+ }
1412
+ for (const attrName of inputAttrs) {
1413
+ this.#input.setAttribute(attrName, this.getAttribute(attrName) ?? "");
1414
+ }
1415
+
1416
+ const nextJson = this.#pickerValueFromHost();
1417
+ if (this.#input.getAttribute("value") !== nextJson) {
1418
+ this.#input.setAttribute("value", nextJson);
1419
+ }
1420
+ this.#syncSwatch();
1421
+ this.#managedInputAttrs = nextManaged;
1422
+ }
1423
+
1424
+ #bindInputEvents() {
1425
+ if (!this.#input) return;
1426
+ this.#boundHandleInput ??= this.#forwardInputEvent.bind(this, "input");
1427
+ this.#boundHandleChange ??= this.#forwardInputEvent.bind(this, "change");
1428
+ this.#input.addEventListener("input", this.#boundHandleInput);
1429
+ this.#input.addEventListener("change", this.#boundHandleChange);
1430
+ }
1431
+
1432
+ #unbindInputEvents() {
1433
+ if (!this.#input) return;
1434
+ if (this.#boundHandleInput) {
1435
+ this.#input.removeEventListener("input", this.#boundHandleInput);
1436
+ }
1437
+ if (this.#boundHandleChange) {
1438
+ this.#input.removeEventListener("change", this.#boundHandleChange);
1439
+ }
1440
+ }
1441
+
1442
+ #fillFromEvent(event) {
1443
+ const detail =
1444
+ event instanceof CustomEvent && event.detail !== undefined
1445
+ ? event.detail
1446
+ : undefined;
1447
+ if (detail && typeof detail === "object") return detail;
1448
+ if (typeof detail === "string" && detail) {
1449
+ return figLabParseFillValue(detail);
1450
+ }
1451
+ const live = this.#input?.value;
1452
+ if (live && typeof live === "object") return live;
1453
+ if (typeof live === "string" && live) return figLabParseFillValue(live);
1454
+ return this.#parsedHostFill();
1455
+ }
1456
+
1457
+ #forwardInputEvent(type, event) {
1458
+ event.stopImmediatePropagation();
1459
+ if (figLabBooleanAttribute(this, "disabled")) return;
1460
+ const fill = this.#captureFill(this.#fillFromEvent(event));
1461
+ const serialized = figLabSerializeFillValue(fill);
1462
+ this.setAttribute("value", serialized);
1463
+ if (this.#input && this.#input.getAttribute("value") !== serialized) {
1464
+ this.#input.setAttribute("value", serialized);
1465
+ }
1466
+ this.#syncSwatch(fill);
1467
+ this.dispatchEvent(
1468
+ new CustomEvent(type, {
1469
+ detail: fill,
1470
+ bubbles: true,
1471
+ cancelable: true,
1472
+ composed: true,
1473
+ }),
1474
+ );
1475
+ }
1476
+
1477
+ #handleClick(event) {
1478
+ if (figLabBooleanAttribute(this, "disabled")) return;
1479
+ if (event.target instanceof Element && event.target.closest("fig-menu")) {
1480
+ return;
1481
+ }
1482
+ if (
1483
+ event.target instanceof Element &&
1484
+ event.target.closest("fig-fill-picker, fig-swatch")
1485
+ ) {
1486
+ return;
1487
+ }
1488
+ this.focus();
1489
+ this.#input?.open?.();
1490
+ }
1491
+
1492
+ get value() {
1493
+ return (
1494
+ this.getAttribute("value") ??
1495
+ figLabSerializeFillValue(this.#input?.value) ??
1496
+ ""
1497
+ );
1498
+ }
1499
+
1500
+ set value(nextValue) {
1501
+ if (nextValue === null || nextValue === undefined || nextValue === "") {
1502
+ this.removeAttribute("value");
1503
+ this.#input?.removeAttribute("value");
1504
+ this.#syncSwatch({ type: "solid", color: "", alpha: 1 });
1505
+ return;
1506
+ }
1507
+ const serialized = figLabSerializeFillValue(
1508
+ typeof nextValue === "string" ? figLabParseFillValue(nextValue) : nextValue,
1509
+ );
1510
+ this.setAttribute("value", serialized);
1511
+ this.#forceInputValue(serialized);
1512
+ }
1513
+
1514
+ #forceInputValue(next) {
1515
+ if (!this.#input) return;
1516
+ const json = figLabSerializeFillValue(figLabParseFillValue(next));
1517
+ if (this.#input.getAttribute("value") === json) {
1518
+ this.#input.removeAttribute("value");
1519
+ }
1520
+ this.#input.setAttribute("value", json);
1521
+ this.#syncSwatch(figLabParseFillValue(json));
1522
+ }
1523
+
1524
+ #defaultValue() {
1525
+ return this.getAttribute("default") ?? this.#initialValue ?? "";
1526
+ }
1527
+
1528
+ get defaultValue() {
1529
+ return String(this.#defaultValue());
1530
+ }
1531
+
1532
+ get isDefault() {
1533
+ return figLabPropskitJsonValuesEqual(this.value, this.defaultValue);
1534
+ }
1535
+
1536
+ resetToDefault() {
1537
+ const next = this.defaultValue;
1538
+ this.setAttribute("value", next);
1539
+ this.#forceInputValue(next);
1540
+ const fill = figLabParseFillValue(next);
1541
+ this.dispatchEvent(
1542
+ new CustomEvent("input", {
1543
+ detail: fill,
1544
+ bubbles: true,
1545
+ cancelable: true,
1546
+ composed: true,
1547
+ }),
1548
+ );
1549
+ this.dispatchEvent(
1550
+ new CustomEvent("change", {
1551
+ detail: fill,
1552
+ bubbles: true,
1553
+ cancelable: true,
1554
+ composed: true,
1555
+ }),
1556
+ );
1557
+ }
1558
+
1559
+ focus(options) {
1560
+ const swatch = this.querySelector("fig-swatch");
1561
+ if (swatch instanceof HTMLElement) swatch.focus(options);
1562
+ }
1563
+ }
1564
+ figLabDefineElement("propskit-fill", PropskitFill);
1565
+
1079
1566
  /* Field + Gradient wrapper */
1080
1567
  class PropskitGradient extends HTMLElement {
1081
1568
  #field = null;
@@ -1286,13 +1773,17 @@ class PropskitGradient extends HTMLElement {
1286
1773
 
1287
1774
  #handleClick(event) {
1288
1775
  if (figLabBooleanAttribute(this, "disabled")) return;
1776
+ if (event.target instanceof Element && event.target.closest("fig-menu")) {
1777
+ return;
1778
+ }
1289
1779
  if (
1290
1780
  event.target instanceof Element &&
1291
- event.target.closest("fig-input-gradient, fig-menu")
1781
+ event.target.closest("fig-input-gradient, fig-fill-picker, fig-swatch")
1292
1782
  ) {
1293
1783
  return;
1294
1784
  }
1295
1785
  this.focus();
1786
+ this.#input?.querySelector?.("fig-fill-picker")?.open?.();
1296
1787
  }
1297
1788
 
1298
1789
  get value() {
@@ -3718,6 +4209,7 @@ class PropskitGroup extends HTMLElement {
3718
4209
 
3719
4210
  static #CONTROL_SELECTOR = [
3720
4211
  "propskit-color",
4212
+ "propskit-fill",
3721
4213
  "propskit-gradient",
3722
4214
  "propskit-number",
3723
4215
  "propskit-position",