@rogieking/figui3 6.25.0 → 6.27.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
@@ -18,6 +18,139 @@ function figLabBooleanAttribute(element, name) {
18
18
  return element.hasAttribute(name) && element.getAttribute(name) !== "false";
19
19
  }
20
20
 
21
+ function figLabColorEventAliases(color, alpha, opacity) {
22
+ const numericAlpha = Number(alpha);
23
+ const numericOpacity = Number(opacity);
24
+ const normalizedAlpha = Number.isFinite(numericAlpha)
25
+ ? Math.max(0, Math.min(1, numericAlpha))
26
+ : Number.isFinite(numericOpacity)
27
+ ? Math.max(0, Math.min(100, numericOpacity)) / 100
28
+ : 1;
29
+ const normalizedOpacity = Number.isFinite(numericOpacity)
30
+ ? Math.max(0, Math.min(100, numericOpacity))
31
+ : Math.round(normalizedAlpha * 100);
32
+ return { color, alpha: normalizedAlpha, opacity: normalizedOpacity };
33
+ }
34
+
35
+ const figLabPropskitResetMenus = new WeakMap();
36
+
37
+ function figLabConnectPropskitResetMenu(host) {
38
+ let state = figLabPropskitResetMenus.get(host);
39
+ if (!state) {
40
+ const menu = document.createElement("fig-menu");
41
+ menu.setAttribute("position", "bottom left");
42
+ menu.setAttribute("offset", "0 0");
43
+ const resetItem = document.createElement("fig-menu-item");
44
+ resetItem.setAttribute("value", "reset-default");
45
+ resetItem.textContent = "Reset";
46
+ menu.appendChild(resetItem);
47
+
48
+ let fallbackTimer = 0;
49
+ const clearPendingOpen = () => {
50
+ if (fallbackTimer) {
51
+ clearTimeout(fallbackTimer);
52
+ fallbackTimer = 0;
53
+ }
54
+ window.removeEventListener("pointerup", openMenu, true);
55
+ window.removeEventListener("pointercancel", openMenu, true);
56
+ };
57
+ const openMenu = () => {
58
+ clearPendingOpen();
59
+ const point = state?.point;
60
+ if (point) menu.showAt?.(point.x, point.y);
61
+ };
62
+ const handleContextMenu = (event) => {
63
+ if (
64
+ figLabBooleanAttribute(host, "disabled") ||
65
+ event.target?.closest?.("fig-menu")
66
+ ) {
67
+ return;
68
+ }
69
+ event.preventDefault();
70
+ event.stopPropagation();
71
+ event.stopImmediatePropagation?.();
72
+ state.point = { x: event.clientX, y: event.clientY };
73
+ clearPendingOpen();
74
+ window.addEventListener("pointerup", openMenu, {
75
+ once: true,
76
+ capture: true,
77
+ });
78
+ window.addEventListener("pointercancel", openMenu, {
79
+ once: true,
80
+ capture: true,
81
+ });
82
+ fallbackTimer = window.setTimeout(openMenu, 180);
83
+ };
84
+ const handleChange = (event) => {
85
+ event.stopPropagation();
86
+ if (event.detail?.value === "reset-default") host.resetToDefault?.();
87
+ };
88
+ state = {
89
+ menu,
90
+ point: null,
91
+ clearPendingOpen,
92
+ handleContextMenu,
93
+ handleChange,
94
+ };
95
+ figLabPropskitResetMenus.set(host, state);
96
+ }
97
+
98
+ if (state.menu.parentElement !== host) host.appendChild(state.menu);
99
+ host.removeEventListener("contextmenu", state.handleContextMenu);
100
+ host.addEventListener("contextmenu", state.handleContextMenu);
101
+ state.menu.removeEventListener("change", state.handleChange);
102
+ state.menu.addEventListener("change", state.handleChange);
103
+ }
104
+
105
+ function figLabDisconnectPropskitResetMenu(host) {
106
+ const state = figLabPropskitResetMenus.get(host);
107
+ if (!state) return;
108
+ state.clearPendingOpen();
109
+ host.removeEventListener("contextmenu", state.handleContextMenu);
110
+ state.menu.removeEventListener("change", state.handleChange);
111
+ }
112
+
113
+ function figLabEmitPropskitReset(host, detail) {
114
+ for (const type of ["input", "change"]) {
115
+ host.dispatchEvent(
116
+ new CustomEvent(type, {
117
+ detail,
118
+ bubbles: true,
119
+ cancelable: true,
120
+ composed: true,
121
+ }),
122
+ );
123
+ }
124
+ }
125
+
126
+ function figLabPropskitValuesEqual(value, defaultValue) {
127
+ if (typeof value === "boolean" || typeof defaultValue === "boolean") {
128
+ return Boolean(value) === Boolean(defaultValue);
129
+ }
130
+ return String(value ?? "") === String(defaultValue ?? "");
131
+ }
132
+
133
+ function figLabPerceivedColorTheme(context, color) {
134
+ if (!context || !color) return null;
135
+ const probe = document.createElement("span");
136
+ probe.style.cssText =
137
+ "position:absolute;visibility:hidden;pointer-events:none;color:" + color;
138
+ context.appendChild(probe);
139
+ const resolved = getComputedStyle(probe).color;
140
+ probe.remove();
141
+ if (!resolved.startsWith("rgb")) return null;
142
+ const channels = resolved.match(/[\d.]+/g)?.slice(0, 3).map(Number);
143
+ if (!channels || channels.length < 3 || channels.some(Number.isNaN)) return null;
144
+ const [r, g, b] = channels.map((channel) => {
145
+ const value = channel / 255;
146
+ return value <= 0.04045
147
+ ? value / 12.92
148
+ : ((value + 0.055) / 1.055) ** 2.4;
149
+ });
150
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
151
+ return luminance <= 0.179 ? "light" : "dark";
152
+ }
153
+
21
154
  /* Unique IDs for lab components such as propskit-group. */
22
155
  let figLabUniqueIdCounter = 0;
23
156
  function figLabUniqueId(prefix = "fig-lab") {
@@ -44,6 +177,7 @@ class PropskitSwitch extends HTMLElement {
44
177
  #boundHandleInput = null;
45
178
  #boundHandleChange = null;
46
179
  #boundHandleClick = this.#handleClick.bind(this);
180
+ #initialChecked = false;
47
181
 
48
182
  static get observedAttributes() {
49
183
  return ["label", "direction"];
@@ -56,6 +190,7 @@ class PropskitSwitch extends HTMLElement {
56
190
  this.#bindSwitchEvents();
57
191
  this.removeEventListener("click", this.#boundHandleClick);
58
192
  this.addEventListener("click", this.#boundHandleClick);
193
+ figLabConnectPropskitResetMenu(this);
59
194
 
60
195
  if (!this.#observer) {
61
196
  this.#observer = new MutationObserver((mutations) => {
@@ -86,6 +221,7 @@ class PropskitSwitch extends HTMLElement {
86
221
  this.#observer?.disconnect();
87
222
  this.#unbindSwitchEvents();
88
223
  this.removeEventListener("click", this.#boundHandleClick);
224
+ figLabDisconnectPropskitResetMenu(this);
89
225
  }
90
226
 
91
227
  attributeChangedCallback(name, oldValue, newValue) {
@@ -94,6 +230,7 @@ class PropskitSwitch extends HTMLElement {
94
230
  }
95
231
 
96
232
  #initialize() {
233
+ this.#initialChecked = figLabBooleanAttribute(this, "checked");
97
234
  const initialChildren = Array.from(this.childNodes).filter(
98
235
  (node) =>
99
236
  node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
@@ -157,6 +294,7 @@ class PropskitSwitch extends HTMLElement {
157
294
  "id",
158
295
  "checked",
159
296
  "value",
297
+ "default",
160
298
  ]);
161
299
  return this.getAttributeNames().filter(
162
300
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -219,7 +357,10 @@ class PropskitSwitch extends HTMLElement {
219
357
  }
220
358
 
221
359
  #handleClick(event) {
222
- if (event.target instanceof Element && event.target.closest("fig-segmented-control")) {
360
+ if (
361
+ event.target instanceof Element &&
362
+ event.target.closest("fig-segmented-control, fig-menu")
363
+ ) {
223
364
  return;
224
365
  }
225
366
  const value = this.#switch?.value === "on" ? "off" : "on";
@@ -246,6 +387,25 @@ class PropskitSwitch extends HTMLElement {
246
387
  this.setAttribute("value", nextValue ?? "");
247
388
  }
248
389
 
390
+ get defaultValue() {
391
+ return this.hasAttribute("default")
392
+ ? figLabBooleanAttribute(this, "default")
393
+ : this.#initialChecked;
394
+ }
395
+
396
+ get isDefault() {
397
+ return this.checked === this.defaultValue;
398
+ }
399
+
400
+ resetToDefault() {
401
+ const checked = this.defaultValue;
402
+ this.checked = checked;
403
+ figLabEmitPropskitReset(this, {
404
+ checked,
405
+ value: this.getAttribute("value") ?? "",
406
+ });
407
+ }
408
+
249
409
  focus(options) {
250
410
  const selected =
251
411
  this.#switch?.querySelector("fig-segment[selected]") ||
@@ -283,6 +443,7 @@ class PropskitColor extends HTMLElement {
283
443
  }
284
444
  this.removeEventListener("click", this.#boundHandleClick);
285
445
  this.addEventListener("click", this.#boundHandleClick);
446
+ figLabConnectPropskitResetMenu(this);
286
447
 
287
448
  if (!this.#observer) {
288
449
  this.#observer = new MutationObserver((mutations) => {
@@ -314,6 +475,7 @@ class PropskitColor extends HTMLElement {
314
475
  this.#observer?.disconnect();
315
476
  this.#unbindInputEvents();
316
477
  this.removeEventListener("click", this.#boundHandleClick);
478
+ figLabDisconnectPropskitResetMenu(this);
317
479
  }
318
480
 
319
481
  attributeChangedCallback(name, oldValue, newValue) {
@@ -477,7 +639,10 @@ class PropskitColor extends HTMLElement {
477
639
  }
478
640
 
479
641
  #handleClick(event) {
480
- if (event.target instanceof Element && event.target.closest("fig-input-color")) {
642
+ if (
643
+ event.target instanceof Element &&
644
+ event.target.closest("fig-input-color, fig-menu")
645
+ ) {
481
646
  return;
482
647
  }
483
648
  this.focus();
@@ -513,8 +678,16 @@ class PropskitColor extends HTMLElement {
513
678
  return this.getAttribute("default") ?? this.#initialValue ?? "";
514
679
  }
515
680
 
681
+ get defaultValue() {
682
+ return String(this.#defaultValue());
683
+ }
684
+
685
+ get isDefault() {
686
+ return figLabPropskitValuesEqual(this.value, this.defaultValue);
687
+ }
688
+
516
689
  resetToDefault() {
517
- const next = String(this.#defaultValue());
690
+ const next = this.defaultValue;
518
691
  this.setAttribute("value", next);
519
692
  this.#forceInputValue(next);
520
693
  this.dispatchEvent(
@@ -555,6 +728,7 @@ class PropskitSelect extends HTMLElement {
555
728
  #boundHandlePointerDown = this.#handlePointerDown.bind(this);
556
729
  /** True when pointerdown saw the menu open — skip click-to-open after light-dismiss. */
557
730
  #closeGesture = false;
731
+ #initialValue = null;
558
732
 
559
733
  static get observedAttributes() {
560
734
  return ["label", "direction", "aria-label", "options", "value"];
@@ -569,6 +743,7 @@ class PropskitSelect extends HTMLElement {
569
743
  this.addEventListener("click", this.#boundHandleClick);
570
744
  this.removeEventListener("pointerdown", this.#boundHandlePointerDown, true);
571
745
  this.addEventListener("pointerdown", this.#boundHandlePointerDown, true);
746
+ figLabConnectPropskitResetMenu(this);
572
747
 
573
748
  if (!this.#observer) {
574
749
  this.#observer = new MutationObserver((mutations) => {
@@ -602,6 +777,7 @@ class PropskitSelect extends HTMLElement {
602
777
  this.#unbindSelectEvents();
603
778
  this.removeEventListener("click", this.#boundHandleClick);
604
779
  this.removeEventListener("pointerdown", this.#boundHandlePointerDown, true);
780
+ figLabDisconnectPropskitResetMenu(this);
605
781
  }
606
782
 
607
783
  attributeChangedCallback(name, oldValue, newValue) {
@@ -616,6 +792,7 @@ class PropskitSelect extends HTMLElement {
616
792
  }
617
793
 
618
794
  #initialize() {
795
+ this.#initialValue = this.getAttribute("value") ?? "";
619
796
  const customLabel = this.querySelector(":scope > label");
620
797
  const field = document.createElement("fig-field");
621
798
  const label = customLabel || document.createElement("label");
@@ -673,6 +850,7 @@ class PropskitSelect extends HTMLElement {
673
850
  "size",
674
851
  "aria-label",
675
852
  "full",
853
+ "default",
676
854
  ]);
677
855
  return this.getAttributeNames().filter(
678
856
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -749,6 +927,7 @@ class PropskitSelect extends HTMLElement {
749
927
 
750
928
  #handlePointerDown(event) {
751
929
  if (!(event.target instanceof Element)) return;
930
+ if (event.target.closest("fig-menu")) return;
752
931
  // fig-select owns its trigger/option clicks.
753
932
  if (event.target.closest("fig-select")) {
754
933
  this.#closeGesture = false;
@@ -759,6 +938,9 @@ class PropskitSelect extends HTMLElement {
759
938
  }
760
939
 
761
940
  #handleClick(event) {
941
+ if (event.target instanceof Element && event.target.closest("fig-menu")) {
942
+ return;
943
+ }
762
944
  if (event.target instanceof Element && event.target.closest("fig-select")) {
763
945
  this.#closeGesture = false;
764
946
  return;
@@ -790,6 +972,21 @@ class PropskitSelect extends HTMLElement {
790
972
  }
791
973
  }
792
974
 
975
+ get defaultValue() {
976
+ return this.getAttribute("default") ?? this.#initialValue ?? "";
977
+ }
978
+
979
+ get isDefault() {
980
+ return figLabPropskitValuesEqual(this.value, this.defaultValue);
981
+ }
982
+
983
+ resetToDefault() {
984
+ const value = this.defaultValue;
985
+ this.value = value;
986
+ if (this.#select) this.#select.value = value;
987
+ figLabEmitPropskitReset(this, value);
988
+ }
989
+
793
990
  focus(options) {
794
991
  this.#select?.focus(options);
795
992
  }
@@ -807,6 +1004,7 @@ class PropskitText extends HTMLElement {
807
1004
  #boundHandleInput = null;
808
1005
  #boundHandleChange = null;
809
1006
  #boundHandleClick = this.#handleClick.bind(this);
1007
+ #initialValue = null;
810
1008
 
811
1009
  static get observedAttributes() {
812
1010
  return ["label", "direction", "aria-label"];
@@ -819,6 +1017,7 @@ class PropskitText extends HTMLElement {
819
1017
  this.#bindInputEvents();
820
1018
  this.removeEventListener("click", this.#boundHandleClick);
821
1019
  this.addEventListener("click", this.#boundHandleClick);
1020
+ figLabConnectPropskitResetMenu(this);
822
1021
 
823
1022
  if (!this.#observer) {
824
1023
  this.#observer = new MutationObserver((mutations) => {
@@ -850,6 +1049,7 @@ class PropskitText extends HTMLElement {
850
1049
  this.#observer?.disconnect();
851
1050
  this.#unbindInputEvents();
852
1051
  this.removeEventListener("click", this.#boundHandleClick);
1052
+ figLabDisconnectPropskitResetMenu(this);
853
1053
  }
854
1054
 
855
1055
  attributeChangedCallback(name, oldValue, newValue) {
@@ -860,6 +1060,7 @@ class PropskitText extends HTMLElement {
860
1060
  }
861
1061
 
862
1062
  #initialize() {
1063
+ this.#initialValue = this.getAttribute("value") ?? "";
863
1064
  const initialChildren = Array.from(this.childNodes).filter(
864
1065
  (node) =>
865
1066
  node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
@@ -924,6 +1125,7 @@ class PropskitText extends HTMLElement {
924
1125
  "aria-label",
925
1126
  "multiline",
926
1127
  "resizable",
1128
+ "default",
927
1129
  ]);
928
1130
  return this.getAttributeNames().filter(
929
1131
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -982,7 +1184,10 @@ class PropskitText extends HTMLElement {
982
1184
  }
983
1185
 
984
1186
  #handleClick(event) {
985
- if (event.target instanceof Element && event.target.closest("fig-input-text")) {
1187
+ if (
1188
+ event.target instanceof Element &&
1189
+ event.target.closest("fig-input-text, fig-menu")
1190
+ ) {
986
1191
  return;
987
1192
  }
988
1193
  this.focus();
@@ -1003,6 +1208,20 @@ class PropskitText extends HTMLElement {
1003
1208
  }
1004
1209
  }
1005
1210
 
1211
+ get defaultValue() {
1212
+ return this.getAttribute("default") ?? this.#initialValue ?? "";
1213
+ }
1214
+
1215
+ get isDefault() {
1216
+ return figLabPropskitValuesEqual(this.value, this.defaultValue);
1217
+ }
1218
+
1219
+ resetToDefault() {
1220
+ const value = this.defaultValue;
1221
+ this.value = value;
1222
+ figLabEmitPropskitReset(this, value);
1223
+ }
1224
+
1006
1225
  focus(options) {
1007
1226
  this.#input?.focus(options);
1008
1227
  }
@@ -1020,6 +1239,7 @@ class PropskitNumber extends HTMLElement {
1020
1239
  #boundHandleInput = null;
1021
1240
  #boundHandleChange = null;
1022
1241
  #boundHandleClick = this.#handleClick.bind(this);
1242
+ #initialValue = null;
1023
1243
 
1024
1244
  static get observedAttributes() {
1025
1245
  return ["label", "direction"];
@@ -1032,6 +1252,7 @@ class PropskitNumber extends HTMLElement {
1032
1252
  this.#bindInputEvents();
1033
1253
  this.removeEventListener("click", this.#boundHandleClick);
1034
1254
  this.addEventListener("click", this.#boundHandleClick);
1255
+ figLabConnectPropskitResetMenu(this);
1035
1256
 
1036
1257
  if (!this.#observer) {
1037
1258
  this.#observer = new MutationObserver((mutations) => {
@@ -1062,6 +1283,7 @@ class PropskitNumber extends HTMLElement {
1062
1283
  this.#observer?.disconnect();
1063
1284
  this.#unbindInputEvents();
1064
1285
  this.removeEventListener("click", this.#boundHandleClick);
1286
+ figLabDisconnectPropskitResetMenu(this);
1065
1287
  }
1066
1288
 
1067
1289
  attributeChangedCallback(name, oldValue, newValue) {
@@ -1070,6 +1292,8 @@ class PropskitNumber extends HTMLElement {
1070
1292
  }
1071
1293
 
1072
1294
  #initialize() {
1295
+ this.#initialValue =
1296
+ this.getAttribute("value") ?? this.getAttribute("min") ?? "0";
1073
1297
  const initialChildren = Array.from(this.childNodes).filter(
1074
1298
  (node) =>
1075
1299
  node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
@@ -1126,6 +1350,7 @@ class PropskitNumber extends HTMLElement {
1126
1350
  "class",
1127
1351
  "style",
1128
1352
  "id",
1353
+ "default",
1129
1354
  ]);
1130
1355
  return this.getAttributeNames().filter(
1131
1356
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -1185,7 +1410,10 @@ class PropskitNumber extends HTMLElement {
1185
1410
  }
1186
1411
 
1187
1412
  #handleClick(event) {
1188
- if (event.target instanceof Element && event.target.closest("fig-input-number")) {
1413
+ if (
1414
+ event.target instanceof Element &&
1415
+ event.target.closest("fig-input-number, fig-menu")
1416
+ ) {
1189
1417
  return;
1190
1418
  }
1191
1419
  this.focus();
@@ -1206,6 +1434,20 @@ class PropskitNumber extends HTMLElement {
1206
1434
  }
1207
1435
  }
1208
1436
 
1437
+ get defaultValue() {
1438
+ return this.getAttribute("default") ?? this.#initialValue ?? "0";
1439
+ }
1440
+
1441
+ get isDefault() {
1442
+ return figLabPropskitValuesEqual(this.value, this.defaultValue);
1443
+ }
1444
+
1445
+ resetToDefault() {
1446
+ const value = this.defaultValue;
1447
+ this.value = value;
1448
+ figLabEmitPropskitReset(this, this.#input?.value ?? value);
1449
+ }
1450
+
1209
1451
  focus(options) {
1210
1452
  this.#input?.focus(options);
1211
1453
  }
@@ -1223,12 +1465,12 @@ class PropskitGroup extends HTMLElement {
1223
1465
  "propskit-slider",
1224
1466
  "propskit-switch",
1225
1467
  "propskit-text",
1468
+ "propskit-oscillator",
1226
1469
  ].join(",");
1227
1470
 
1228
1471
  #header = null;
1229
1472
  #chevron = null;
1230
1473
  #resetTooltip = null;
1231
- #defaults = new WeakMap();
1232
1474
  #childObserver = null;
1233
1475
  #dirtyFrame = 0;
1234
1476
  #boundOnControlEvent = () => this.#queueDirtySync();
@@ -1237,7 +1479,6 @@ class PropskitGroup extends HTMLElement {
1237
1479
  this.#render();
1238
1480
  this.#bindDirtyListeners();
1239
1481
  requestAnimationFrame(() => {
1240
- this.#ensureDefaults();
1241
1482
  this.#syncDirtyState();
1242
1483
  });
1243
1484
  }
@@ -1320,12 +1561,16 @@ class PropskitGroup extends HTMLElement {
1320
1561
 
1321
1562
  if (!this.#childObserver) {
1322
1563
  this.#childObserver = new MutationObserver(() => {
1323
- this.#ensureDefaults();
1324
1564
  this.#queueDirtySync();
1325
1565
  });
1326
1566
  }
1327
1567
  this.#childObserver.disconnect();
1328
- this.#childObserver.observe(this, { childList: true, subtree: true });
1568
+ this.#childObserver.observe(this, {
1569
+ childList: true,
1570
+ subtree: true,
1571
+ attributes: true,
1572
+ attributeFilter: ["value", "checked", "default"],
1573
+ });
1329
1574
  }
1330
1575
 
1331
1576
  #unbindDirtyListeners() {
@@ -1338,7 +1583,6 @@ class PropskitGroup extends HTMLElement {
1338
1583
  if (this.#dirtyFrame) return;
1339
1584
  this.#dirtyFrame = requestAnimationFrame(() => {
1340
1585
  this.#dirtyFrame = 0;
1341
- this.#ensureDefaults();
1342
1586
  this.#syncDirtyState();
1343
1587
  });
1344
1588
  }
@@ -1377,48 +1621,15 @@ class PropskitGroup extends HTMLElement {
1377
1621
  #controls() {
1378
1622
  return [
1379
1623
  ...this.querySelectorAll(PropskitGroup.#CONTROL_SELECTOR),
1380
- ].filter((el) => el.closest("propskit-group") === this);
1381
- }
1382
-
1383
- #snapshotControl(el) {
1384
- if (el.localName === "propskit-switch") {
1385
- return { kind: "checked", value: Boolean(el.checked) };
1386
- }
1387
- if ("value" in el) {
1388
- return { kind: "value", value: el.value };
1389
- }
1390
- return { kind: "value", value: el.getAttribute("value") ?? "" };
1391
- }
1392
-
1393
- #ensureDefaults() {
1394
- for (const el of this.#controls()) {
1395
- if (this.#defaults.has(el)) continue;
1396
- if (el.hasAttribute("default")) {
1397
- if (el.localName === "propskit-switch") {
1398
- this.#defaults.set(el, {
1399
- kind: "checked",
1400
- value: figLabBooleanAttribute(el, "default"),
1401
- });
1402
- continue;
1403
- }
1404
- this.#defaults.set(el, {
1405
- kind: "value",
1406
- value: el.getAttribute("default") ?? "",
1407
- });
1408
- continue;
1409
- }
1410
- this.#defaults.set(el, this.#snapshotControl(el));
1411
- }
1624
+ ].filter(
1625
+ (el) =>
1626
+ el.closest("propskit-group") === this &&
1627
+ !el.parentElement?.closest(PropskitGroup.#CONTROL_SELECTOR),
1628
+ );
1412
1629
  }
1413
1630
 
1414
1631
  #controlIsDirty(el) {
1415
- const snap = this.#defaults.get(el);
1416
- if (!snap) return false;
1417
- const cur = this.#snapshotControl(el);
1418
- if (snap.kind === "checked") {
1419
- return Boolean(cur.value) !== Boolean(snap.value);
1420
- }
1421
- return String(cur.value ?? "") !== String(snap.value ?? "");
1632
+ return el.isDefault === false;
1422
1633
  }
1423
1634
 
1424
1635
  #computeDirty() {
@@ -1436,67 +1647,12 @@ class PropskitGroup extends HTMLElement {
1436
1647
  }
1437
1648
 
1438
1649
  #restoreControl(el) {
1439
- const snap = this.#defaults.get(el);
1440
- if (!snap) return;
1441
-
1442
- if (snap.kind === "checked") {
1443
- el.checked = snap.value;
1444
- const detail = {
1445
- checked: Boolean(snap.value),
1446
- value: el.getAttribute("value") ?? "",
1447
- };
1448
- el.dispatchEvent(
1449
- new CustomEvent("input", {
1450
- detail,
1451
- bubbles: true,
1452
- cancelable: true,
1453
- composed: true,
1454
- }),
1455
- );
1456
- el.dispatchEvent(
1457
- new CustomEvent("change", {
1458
- detail,
1459
- bubbles: true,
1460
- cancelable: true,
1461
- composed: true,
1462
- }),
1463
- );
1464
- return;
1465
- }
1466
-
1467
1650
  if (typeof el.resetToDefault === "function") {
1468
- const prevDefault = el.getAttribute("default");
1469
- el.setAttribute("default", String(snap.value ?? ""));
1470
1651
  el.resetToDefault();
1471
- if (prevDefault === null) el.removeAttribute("default");
1472
- else el.setAttribute("default", prevDefault);
1473
- return;
1474
1652
  }
1475
-
1476
- el.value = snap.value;
1477
- const detail =
1478
- el.getAttribute?.("value") ??
1479
- ("value" in el ? el.value : snap.value);
1480
- el.dispatchEvent(
1481
- new CustomEvent("input", {
1482
- detail,
1483
- bubbles: true,
1484
- cancelable: true,
1485
- composed: true,
1486
- }),
1487
- );
1488
- el.dispatchEvent(
1489
- new CustomEvent("change", {
1490
- detail,
1491
- bubbles: true,
1492
- cancelable: true,
1493
- composed: true,
1494
- }),
1495
- );
1496
1653
  }
1497
1654
 
1498
1655
  #resetControls() {
1499
- this.#ensureDefaults();
1500
1656
  for (const el of this.#controls()) this.#restoreControl(el);
1501
1657
  this.#syncDirtyState();
1502
1658
  this.dispatchEvent(
@@ -1638,6 +1794,7 @@ class PropskitSlider extends HTMLElement {
1638
1794
  #contextMenu = null;
1639
1795
  #pendingClickTimer = 0;
1640
1796
  #pendingClickValue = null;
1797
+ #initialValue = null;
1641
1798
  #isElasticTracking = false;
1642
1799
  #elasticMaxPx = 0;
1643
1800
  #elasticRangeRect = null;
@@ -1711,6 +1868,10 @@ class PropskitSlider extends HTMLElement {
1711
1868
  this.#pushExternalValueToSlider();
1712
1869
  continue;
1713
1870
  }
1871
+ if (mutation.attributeName === "color") {
1872
+ syncSlider = true;
1873
+ continue;
1874
+ }
1714
1875
  if (
1715
1876
  mutation.attributeName &&
1716
1877
  this.#ignoredSliderAttrs.has(mutation.attributeName)
@@ -1770,6 +1931,8 @@ class PropskitSlider extends HTMLElement {
1770
1931
  }
1771
1932
 
1772
1933
  #initialize() {
1934
+ this.#initialValue =
1935
+ this.getAttribute("value") ?? this.getAttribute("min") ?? "0";
1773
1936
  const initialChildren = Array.from(this.childNodes).filter((node) => {
1774
1937
  return (
1775
1938
  node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim())
@@ -1811,7 +1974,7 @@ class PropskitSlider extends HTMLElement {
1811
1974
 
1812
1975
  const resetItem = document.createElement("fig-menu-item");
1813
1976
  resetItem.setAttribute("value", "reset-default");
1814
- resetItem.textContent = "Reset to default";
1977
+ resetItem.textContent = "Reset";
1815
1978
  menu.appendChild(resetItem);
1816
1979
  menu.addEventListener("change", this.#boundHandleContextMenuChange);
1817
1980
 
@@ -1889,17 +2052,33 @@ class PropskitSlider extends HTMLElement {
1889
2052
  if (sliderType === "opacity") {
1890
2053
  this.#slider.style.setProperty(
1891
2054
  "--color",
1892
- "light-dark(#444444, #e6e6e6)",
2055
+ this.getAttribute("color") || "var(--figma-color-bg-secondary)",
1893
2056
  );
1894
2057
  } else {
1895
2058
  this.#slider.style.removeProperty("--color");
1896
2059
  }
2060
+ this.#syncNumberTheme(sliderType);
1897
2061
 
1898
2062
  this.#managedSliderAttrs = nextManaged;
1899
2063
  this.#pushExternalValueToSlider();
1900
2064
  this.#queueSteppersSync();
1901
2065
  }
1902
2066
 
2067
+ #syncNumberTheme(sliderType) {
2068
+ const numberInput = this.#slider?.querySelector("fig-input-number");
2069
+ if (!numberInput) return;
2070
+ if (sliderType !== "opacity" || !this.hasAttribute("color")) {
2071
+ numberInput.removeAttribute("theme");
2072
+ return;
2073
+ }
2074
+ const theme = figLabPerceivedColorTheme(
2075
+ this.#slider,
2076
+ this.getAttribute("color"),
2077
+ );
2078
+ if (theme) numberInput.setAttribute("theme", theme);
2079
+ else numberInput.removeAttribute("theme");
2080
+ }
2081
+
1903
2082
  #pushExternalValueToSlider() {
1904
2083
  if (!this.#slider) return;
1905
2084
  const next = this.getAttribute("value") ?? "";
@@ -1963,8 +2142,8 @@ class PropskitSlider extends HTMLElement {
1963
2142
  }
1964
2143
  }
1965
2144
  if (numberInput) {
1966
- numberInput.setAttribute("tabindex", "-1");
1967
- numberInput.setAttribute("aria-hidden", "true");
2145
+ numberInput.removeAttribute("tabindex");
2146
+ numberInput.removeAttribute("aria-hidden");
1968
2147
  }
1969
2148
  }
1970
2149
 
@@ -2214,8 +2393,7 @@ class PropskitSlider extends HTMLElement {
2214
2393
  return (
2215
2394
  this.getAttribute("default") ??
2216
2395
  this.#slider?.getAttribute("default") ??
2217
- this.getAttribute("value") ??
2218
- this.#slider?.getAttribute("value") ??
2396
+ this.#initialValue ??
2219
2397
  this.#rangeInput?.min ??
2220
2398
  "0"
2221
2399
  );
@@ -2333,6 +2511,14 @@ class PropskitSlider extends HTMLElement {
2333
2511
  if (this.#slider) this.#slider.value = next;
2334
2512
  }
2335
2513
 
2514
+ get defaultValue() {
2515
+ return this.#defaultValue();
2516
+ }
2517
+
2518
+ get isDefault() {
2519
+ return figLabPropskitValuesEqual(this.value, this.defaultValue);
2520
+ }
2521
+
2336
2522
  resetToDefault() {
2337
2523
  this.#resetToDefault();
2338
2524
  }
@@ -3243,18 +3429,29 @@ class FigCanvasControl extends HTMLElement {
3243
3429
  this.#syncPointPointCursors();
3244
3430
  }
3245
3431
 
3246
- #emitInput() {
3432
+ #emitInput(detail = this.value) {
3247
3433
  this.dispatchEvent(
3248
- new CustomEvent("input", { bubbles: true, detail: this.value }),
3434
+ new CustomEvent("input", { bubbles: true, detail }),
3249
3435
  );
3250
3436
  }
3251
3437
 
3252
- #emitChange() {
3438
+ #emitChange(detail = this.value) {
3253
3439
  this.dispatchEvent(
3254
- new CustomEvent("change", { bubbles: true, detail: this.value }),
3440
+ new CustomEvent("change", { bubbles: true, detail }),
3255
3441
  );
3256
3442
  }
3257
3443
 
3444
+ #colorEventDetail(detail) {
3445
+ return {
3446
+ ...this.value,
3447
+ ...figLabColorEventAliases(
3448
+ detail.color,
3449
+ detail.alpha,
3450
+ detail.opacity,
3451
+ ),
3452
+ };
3453
+ }
3454
+
3258
3455
  #syncValueAttribute() {
3259
3456
  this.setAttribute("value", JSON.stringify(this.value));
3260
3457
  }
@@ -3265,8 +3462,11 @@ class FigCanvasControl extends HTMLElement {
3265
3462
  this.#pointHandle.addEventListener("input", (e) => {
3266
3463
  e.stopPropagation();
3267
3464
  if (e.detail?.color) {
3268
- this.setAttribute("color", e.detail.color);
3269
- this.#emitInput();
3465
+ this.setAttribute(
3466
+ "color",
3467
+ this.#pointHandle.getAttribute("color") || e.detail.color,
3468
+ );
3469
+ this.#emitInput(this.#colorEventDetail(e.detail));
3270
3470
  return;
3271
3471
  }
3272
3472
  this.#isDragging = true;
@@ -3285,8 +3485,11 @@ class FigCanvasControl extends HTMLElement {
3285
3485
  this.#pointHandle.addEventListener("change", (e) => {
3286
3486
  e.stopPropagation();
3287
3487
  if (e.detail?.color) {
3288
- this.setAttribute("color", e.detail.color);
3289
- this.#emitChange();
3488
+ this.setAttribute(
3489
+ "color",
3490
+ this.#pointHandle.getAttribute("color") || e.detail.color,
3491
+ );
3492
+ this.#emitChange(this.#colorEventDetail(e.detail));
3290
3493
  return;
3291
3494
  }
3292
3495
  const px = e.detail?.px ?? this.#x / 100;
@@ -3718,6 +3921,7 @@ class PropskitOscillator extends HTMLElement {
3718
3921
  #activeFieldInput = null;
3719
3922
  #dragController = null;
3720
3923
  #dragCleanup = null;
3924
+ #initialValue = null;
3721
3925
 
3722
3926
  static TYPES = [
3723
3927
  { name: "Wave", value: "sine" },
@@ -3741,11 +3945,18 @@ class PropskitOscillator extends HTMLElement {
3741
3945
  }
3742
3946
 
3743
3947
  connectedCallback() {
3948
+ if (this.#initialValue === null) {
3949
+ this.#initialValue =
3950
+ this.getAttribute("default") ??
3951
+ this.getAttribute("value") ??
3952
+ JSON.stringify({ waves: [PropskitOscillator.#defaultWave()] });
3953
+ }
3744
3954
  this.#precision = this.#readInteger("precision", 2);
3745
3955
  this.#parseValue(this.getAttribute("value"));
3746
3956
  this.#syncAspectRatio();
3747
3957
  this.#render();
3748
3958
  this.#setupResizeObserver();
3959
+ figLabConnectPropskitResetMenu(this);
3749
3960
  }
3750
3961
 
3751
3962
  disconnectedCallback() {
@@ -3755,6 +3966,7 @@ class PropskitOscillator extends HTMLElement {
3755
3966
  this.#resizeObserver.disconnect();
3756
3967
  this.#resizeObserver = null;
3757
3968
  }
3969
+ figLabDisconnectPropskitResetMenu(this);
3758
3970
  }
3759
3971
 
3760
3972
  attributeChangedCallback(name, oldValue, newValue) {
@@ -3794,6 +4006,27 @@ class PropskitOscillator extends HTMLElement {
3794
4006
  );
3795
4007
  }
3796
4008
 
4009
+ get defaultValue() {
4010
+ return this.getAttribute("default") ?? this.#initialValue ?? "";
4011
+ }
4012
+
4013
+ get isDefault() {
4014
+ const defaultWaves = this.#normalizedWaves(this.defaultValue);
4015
+ if (!defaultWaves) return false;
4016
+ return (
4017
+ JSON.stringify(this.data.waves) ===
4018
+ JSON.stringify(defaultWaves.map((wave) => this.#roundWave(wave)))
4019
+ );
4020
+ }
4021
+
4022
+ resetToDefault() {
4023
+ const value = this.defaultValue;
4024
+ if (!value) return;
4025
+ this.value = value;
4026
+ this.#emit("input");
4027
+ this.#emit("change");
4028
+ }
4029
+
3797
4030
  get data() {
3798
4031
  return {
3799
4032
  waves: this.#waves.map((wave) => this.#roundWave(wave)),
@@ -3835,33 +4068,35 @@ class PropskitOscillator extends HTMLElement {
3835
4068
  }
3836
4069
 
3837
4070
  #parseValue(value) {
3838
- if (!value) return false;
4071
+ const nextWaves = this.#normalizedWaves(value);
4072
+ if (!nextWaves) return false;
4073
+ this.#waves = nextWaves;
4074
+ if (!this.#waves.length) {
4075
+ this.#waves = [PropskitOscillator.#defaultWave()];
4076
+ }
4077
+ this.#activeWaveIndex = Math.min(this.#activeWaveIndex, this.#waves.length - 1);
4078
+ return true;
4079
+ }
3839
4080
 
4081
+ #normalizedWaves(value) {
4082
+ if (!value) return null;
3840
4083
  let parsed = null;
3841
4084
  if (typeof value === "string") {
3842
4085
  try {
3843
4086
  parsed = JSON.parse(value);
3844
4087
  } catch {
3845
- parsed = null;
4088
+ return null;
3846
4089
  }
3847
4090
  } else if (typeof value === "object") {
3848
4091
  parsed = value;
3849
4092
  }
3850
-
3851
- if (!parsed) return false;
3852
-
3853
- const nextWaves = Array.isArray(parsed)
4093
+ if (!parsed) return null;
4094
+ const waves = Array.isArray(parsed)
3854
4095
  ? parsed
3855
4096
  : Array.isArray(parsed.waves)
3856
4097
  ? parsed.waves
3857
4098
  : [parsed];
3858
-
3859
- this.#waves = nextWaves.map((wave) => this.#normalizeWave(wave));
3860
- if (!this.#waves.length) {
3861
- this.#waves = [PropskitOscillator.#defaultWave()];
3862
- }
3863
- this.#activeWaveIndex = Math.min(this.#activeWaveIndex, this.#waves.length - 1);
3864
- return true;
4099
+ return waves.map((wave) => this.#normalizeWave(wave));
3865
4100
  }
3866
4101
 
3867
4102
  #normalizeWave(state = {}) {
@@ -3962,6 +4197,7 @@ class PropskitOscillator extends HTMLElement {
3962
4197
  this.#updateWaveform();
3963
4198
  this.#setupEvents();
3964
4199
  this.#startPlayhead();
4200
+ figLabConnectPropskitResetMenu(this);
3965
4201
  }
3966
4202
 
3967
4203
  #getInnerHTML() {
@@ -3998,9 +4234,7 @@ class PropskitOscillator extends HTMLElement {
3998
4234
  <fig-tooltip text="Remove form">
3999
4235
  <fig-button class="propskit-oscillator-remove-button" variant="ghost" icon data-wave-index="${index}" aria-label="Remove form"${removeDisabled}><fig-icon name="minus"></fig-icon></fig-button>
4000
4236
  </fig-tooltip>
4001
- <fig-tooltip text="Add form">
4002
- ${this.#getWaveTypeSelectHTML("propskit-oscillator-add-type propskit-oscillator-add-type-button", "sine", disabled, index)}
4003
- </fig-tooltip>
4237
+ ${this.#getWaveTypeMenuHTML(disabled, index)}
4004
4238
  </fig-header>
4005
4239
  <div class="propskit-oscillator-fields" data-wave-index="${index}"${active}>
4006
4240
  ${this.#getNumberFieldHTML(index, "frequency", "Frequency", 0.1, 16, 0.1, "")}
@@ -4011,26 +4245,33 @@ class PropskitOscillator extends HTMLElement {
4011
4245
  </fig-group>`;
4012
4246
  }
4013
4247
 
4014
- #getWaveTypeSelectHTML(className, value, disabled, index = null) {
4015
- const options = PropskitOscillator.TYPES.map((type) => {
4016
- const selected = type.value === value ? " selected" : "";
4017
- return `<fig-select-option value="${type.value}" label="${type.name}"${selected}>
4018
- <span slot="prepend">${PropskitOscillator.waveIcon(type.value, 24)}</span>
4248
+ #getWaveTypeMenuHTML(disabled, index) {
4249
+ const items = PropskitOscillator.TYPES.map((type) => {
4250
+ return `<fig-menu-item value="${type.value}">
4251
+ ${PropskitOscillator.waveIcon(type.value, 24)}
4019
4252
  <span>${type.name}</span>
4020
- </fig-select-option>`;
4253
+ </fig-menu-item>`;
4021
4254
  }).join("");
4022
- const indexAttr =
4023
- index === null ? "" : ` data-wave-index="${PropskitOscillator.#escapeAttribute(String(index))}"`;
4024
- return `<fig-select class="${className}" value="${value}" label="Add form"${indexAttr}${disabled}><fig-select-options>${options}</fig-select-options></fig-select>`;
4255
+ const indexAttr = ` data-wave-index="${PropskitOscillator.#escapeAttribute(String(index))}"`;
4256
+ return `<fig-menu class="propskit-oscillator-add-type" position="bottom right"${indexAttr}${disabled}>
4257
+ <fig-tooltip text="Add form">
4258
+ <fig-button class="propskit-oscillator-add-type-button" variant="ghost" icon fig-menu-trigger aria-label="Add form"><fig-icon name="plus"></fig-icon></fig-button>
4259
+ </fig-tooltip>
4260
+ ${items}
4261
+ </fig-menu>`;
4025
4262
  }
4026
4263
 
4027
4264
  #getNumberFieldHTML(index, name, label, min, max, step, units) {
4028
4265
  const disabled = this.#isDisabled() ? " disabled" : "";
4266
+ const typeAttrs =
4267
+ name === "amplitude" || name === "offset"
4268
+ ? ' type="delta" default="0"'
4269
+ : "";
4029
4270
  const unitsAttr = units
4030
4271
  ? ` units="${PropskitOscillator.#escapeAttribute(units)}"`
4031
4272
  : "";
4032
4273
  const wave = this.#waves[index] || PropskitOscillator.#defaultWave();
4033
- return `<propskit-slider class="propskit-oscillator-field" label="${label}" direction="horizontal" name="${name}" data-wave-index="${index}" value="${this.#round(wave[name])}" min="${min}" max="${max}" step="${step}" precision="${this.#precision}" elastic="false"${unitsAttr}${disabled}></propskit-slider>`;
4274
+ return `<propskit-slider class="propskit-oscillator-field" label="${label}" direction="horizontal" name="${name}" data-wave-index="${index}" value="${this.#round(wave[name])}" min="${min}" max="${max}" step="${step}" precision="${this.#precision}" elastic="false"${typeAttrs}${unitsAttr}${disabled}></propskit-slider>`;
4034
4275
  }
4035
4276
 
4036
4277
  #cacheRefs() {
@@ -4297,7 +4538,7 @@ class PropskitOscillator extends HTMLElement {
4297
4538
  if (this.#isDisabled()) return;
4298
4539
  const insertAfter = this.#indexFromElement(control);
4299
4540
  const insertIndex = insertAfter + 1;
4300
- const type = this.#normalizeType(event.detail ?? control.value);
4541
+ const type = this.#normalizeType(event.detail?.value ?? control.value);
4301
4542
  this.#waves.splice(insertIndex, 0, PropskitOscillator.#defaultWave(type));
4302
4543
  this.#reindexExpandedWavesAfterInsert(insertIndex);
4303
4544
  this.#activeWaveIndex = insertIndex;