@rogieking/figui3 6.26.0 → 6.28.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
@@ -32,6 +32,125 @@ function figLabColorEventAliases(color, alpha, opacity) {
32
32
  return { color, alpha: normalizedAlpha, opacity: normalizedOpacity };
33
33
  }
34
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
+
35
154
  /* Unique IDs for lab components such as propskit-group. */
36
155
  let figLabUniqueIdCounter = 0;
37
156
  function figLabUniqueId(prefix = "fig-lab") {
@@ -58,6 +177,7 @@ class PropskitSwitch extends HTMLElement {
58
177
  #boundHandleInput = null;
59
178
  #boundHandleChange = null;
60
179
  #boundHandleClick = this.#handleClick.bind(this);
180
+ #initialChecked = false;
61
181
 
62
182
  static get observedAttributes() {
63
183
  return ["label", "direction"];
@@ -70,6 +190,7 @@ class PropskitSwitch extends HTMLElement {
70
190
  this.#bindSwitchEvents();
71
191
  this.removeEventListener("click", this.#boundHandleClick);
72
192
  this.addEventListener("click", this.#boundHandleClick);
193
+ figLabConnectPropskitResetMenu(this);
73
194
 
74
195
  if (!this.#observer) {
75
196
  this.#observer = new MutationObserver((mutations) => {
@@ -100,6 +221,7 @@ class PropskitSwitch extends HTMLElement {
100
221
  this.#observer?.disconnect();
101
222
  this.#unbindSwitchEvents();
102
223
  this.removeEventListener("click", this.#boundHandleClick);
224
+ figLabDisconnectPropskitResetMenu(this);
103
225
  }
104
226
 
105
227
  attributeChangedCallback(name, oldValue, newValue) {
@@ -108,6 +230,7 @@ class PropskitSwitch extends HTMLElement {
108
230
  }
109
231
 
110
232
  #initialize() {
233
+ this.#initialChecked = figLabBooleanAttribute(this, "checked");
111
234
  const initialChildren = Array.from(this.childNodes).filter(
112
235
  (node) =>
113
236
  node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
@@ -171,6 +294,7 @@ class PropskitSwitch extends HTMLElement {
171
294
  "id",
172
295
  "checked",
173
296
  "value",
297
+ "default",
174
298
  ]);
175
299
  return this.getAttributeNames().filter(
176
300
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -233,7 +357,10 @@ class PropskitSwitch extends HTMLElement {
233
357
  }
234
358
 
235
359
  #handleClick(event) {
236
- 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
+ ) {
237
364
  return;
238
365
  }
239
366
  const value = this.#switch?.value === "on" ? "off" : "on";
@@ -260,6 +387,25 @@ class PropskitSwitch extends HTMLElement {
260
387
  this.setAttribute("value", nextValue ?? "");
261
388
  }
262
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
+
263
409
  focus(options) {
264
410
  const selected =
265
411
  this.#switch?.querySelector("fig-segment[selected]") ||
@@ -297,6 +443,7 @@ class PropskitColor extends HTMLElement {
297
443
  }
298
444
  this.removeEventListener("click", this.#boundHandleClick);
299
445
  this.addEventListener("click", this.#boundHandleClick);
446
+ figLabConnectPropskitResetMenu(this);
300
447
 
301
448
  if (!this.#observer) {
302
449
  this.#observer = new MutationObserver((mutations) => {
@@ -328,6 +475,7 @@ class PropskitColor extends HTMLElement {
328
475
  this.#observer?.disconnect();
329
476
  this.#unbindInputEvents();
330
477
  this.removeEventListener("click", this.#boundHandleClick);
478
+ figLabDisconnectPropskitResetMenu(this);
331
479
  }
332
480
 
333
481
  attributeChangedCallback(name, oldValue, newValue) {
@@ -491,7 +639,10 @@ class PropskitColor extends HTMLElement {
491
639
  }
492
640
 
493
641
  #handleClick(event) {
494
- 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
+ ) {
495
646
  return;
496
647
  }
497
648
  this.focus();
@@ -527,8 +678,16 @@ class PropskitColor extends HTMLElement {
527
678
  return this.getAttribute("default") ?? this.#initialValue ?? "";
528
679
  }
529
680
 
681
+ get defaultValue() {
682
+ return String(this.#defaultValue());
683
+ }
684
+
685
+ get isDefault() {
686
+ return figLabPropskitValuesEqual(this.value, this.defaultValue);
687
+ }
688
+
530
689
  resetToDefault() {
531
- const next = String(this.#defaultValue());
690
+ const next = this.defaultValue;
532
691
  this.setAttribute("value", next);
533
692
  this.#forceInputValue(next);
534
693
  this.dispatchEvent(
@@ -565,10 +724,12 @@ class PropskitSelect extends HTMLElement {
565
724
  #managedSelectAttrs = new Set();
566
725
  #boundHandleInput = null;
567
726
  #boundHandleChange = null;
727
+ #boundHandleOptionHover = null;
568
728
  #boundHandleClick = this.#handleClick.bind(this);
569
729
  #boundHandlePointerDown = this.#handlePointerDown.bind(this);
570
730
  /** True when pointerdown saw the menu open — skip click-to-open after light-dismiss. */
571
731
  #closeGesture = false;
732
+ #initialValue = null;
572
733
 
573
734
  static get observedAttributes() {
574
735
  return ["label", "direction", "aria-label", "options", "value"];
@@ -583,6 +744,7 @@ class PropskitSelect extends HTMLElement {
583
744
  this.addEventListener("click", this.#boundHandleClick);
584
745
  this.removeEventListener("pointerdown", this.#boundHandlePointerDown, true);
585
746
  this.addEventListener("pointerdown", this.#boundHandlePointerDown, true);
747
+ figLabConnectPropskitResetMenu(this);
586
748
 
587
749
  if (!this.#observer) {
588
750
  this.#observer = new MutationObserver((mutations) => {
@@ -616,6 +778,7 @@ class PropskitSelect extends HTMLElement {
616
778
  this.#unbindSelectEvents();
617
779
  this.removeEventListener("click", this.#boundHandleClick);
618
780
  this.removeEventListener("pointerdown", this.#boundHandlePointerDown, true);
781
+ figLabDisconnectPropskitResetMenu(this);
619
782
  }
620
783
 
621
784
  attributeChangedCallback(name, oldValue, newValue) {
@@ -630,6 +793,7 @@ class PropskitSelect extends HTMLElement {
630
793
  }
631
794
 
632
795
  #initialize() {
796
+ this.#initialValue = this.getAttribute("value") ?? "";
633
797
  const customLabel = this.querySelector(":scope > label");
634
798
  const field = document.createElement("fig-field");
635
799
  const label = customLabel || document.createElement("label");
@@ -687,6 +851,7 @@ class PropskitSelect extends HTMLElement {
687
851
  "size",
688
852
  "aria-label",
689
853
  "full",
854
+ "default",
690
855
  ]);
691
856
  return this.getAttributeNames().filter(
692
857
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -719,8 +884,13 @@ class PropskitSelect extends HTMLElement {
719
884
  if (!this.#select) return;
720
885
  this.#boundHandleInput ??= this.#forwardSelectEvent.bind(this, "input");
721
886
  this.#boundHandleChange ??= this.#forwardSelectEvent.bind(this, "change");
887
+ this.#boundHandleOptionHover ??= this.#forwardSelectEvent.bind(
888
+ this,
889
+ "optionhover",
890
+ );
722
891
  this.#select.addEventListener("input", this.#boundHandleInput);
723
892
  this.#select.addEventListener("change", this.#boundHandleChange);
893
+ this.#select.addEventListener("optionhover", this.#boundHandleOptionHover);
724
894
  }
725
895
 
726
896
  #unbindSelectEvents() {
@@ -731,13 +901,19 @@ class PropskitSelect extends HTMLElement {
731
901
  if (this.#boundHandleChange) {
732
902
  this.#select.removeEventListener("change", this.#boundHandleChange);
733
903
  }
904
+ if (this.#boundHandleOptionHover) {
905
+ this.#select.removeEventListener(
906
+ "optionhover",
907
+ this.#boundHandleOptionHover,
908
+ );
909
+ }
734
910
  }
735
911
 
736
912
  #forwardSelectEvent(type, event) {
737
913
  if (event.target !== this.#select) return;
738
914
  event.stopImmediatePropagation();
739
915
  const value = this.#select?.value ?? "";
740
- this.setAttribute("value", String(value));
916
+ if (type !== "optionhover") this.setAttribute("value", String(value));
741
917
  const detail =
742
918
  event instanceof CustomEvent && event.detail !== undefined
743
919
  ? event.detail
@@ -763,6 +939,7 @@ class PropskitSelect extends HTMLElement {
763
939
 
764
940
  #handlePointerDown(event) {
765
941
  if (!(event.target instanceof Element)) return;
942
+ if (event.target.closest("fig-menu")) return;
766
943
  // fig-select owns its trigger/option clicks.
767
944
  if (event.target.closest("fig-select")) {
768
945
  this.#closeGesture = false;
@@ -773,6 +950,9 @@ class PropskitSelect extends HTMLElement {
773
950
  }
774
951
 
775
952
  #handleClick(event) {
953
+ if (event.target instanceof Element && event.target.closest("fig-menu")) {
954
+ return;
955
+ }
776
956
  if (event.target instanceof Element && event.target.closest("fig-select")) {
777
957
  this.#closeGesture = false;
778
958
  return;
@@ -804,6 +984,21 @@ class PropskitSelect extends HTMLElement {
804
984
  }
805
985
  }
806
986
 
987
+ get defaultValue() {
988
+ return this.getAttribute("default") ?? this.#initialValue ?? "";
989
+ }
990
+
991
+ get isDefault() {
992
+ return figLabPropskitValuesEqual(this.value, this.defaultValue);
993
+ }
994
+
995
+ resetToDefault() {
996
+ const value = this.defaultValue;
997
+ this.value = value;
998
+ if (this.#select) this.#select.value = value;
999
+ figLabEmitPropskitReset(this, value);
1000
+ }
1001
+
807
1002
  focus(options) {
808
1003
  this.#select?.focus(options);
809
1004
  }
@@ -821,6 +1016,7 @@ class PropskitText extends HTMLElement {
821
1016
  #boundHandleInput = null;
822
1017
  #boundHandleChange = null;
823
1018
  #boundHandleClick = this.#handleClick.bind(this);
1019
+ #initialValue = null;
824
1020
 
825
1021
  static get observedAttributes() {
826
1022
  return ["label", "direction", "aria-label"];
@@ -833,6 +1029,7 @@ class PropskitText extends HTMLElement {
833
1029
  this.#bindInputEvents();
834
1030
  this.removeEventListener("click", this.#boundHandleClick);
835
1031
  this.addEventListener("click", this.#boundHandleClick);
1032
+ figLabConnectPropskitResetMenu(this);
836
1033
 
837
1034
  if (!this.#observer) {
838
1035
  this.#observer = new MutationObserver((mutations) => {
@@ -864,6 +1061,7 @@ class PropskitText extends HTMLElement {
864
1061
  this.#observer?.disconnect();
865
1062
  this.#unbindInputEvents();
866
1063
  this.removeEventListener("click", this.#boundHandleClick);
1064
+ figLabDisconnectPropskitResetMenu(this);
867
1065
  }
868
1066
 
869
1067
  attributeChangedCallback(name, oldValue, newValue) {
@@ -874,6 +1072,7 @@ class PropskitText extends HTMLElement {
874
1072
  }
875
1073
 
876
1074
  #initialize() {
1075
+ this.#initialValue = this.getAttribute("value") ?? "";
877
1076
  const initialChildren = Array.from(this.childNodes).filter(
878
1077
  (node) =>
879
1078
  node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
@@ -938,6 +1137,7 @@ class PropskitText extends HTMLElement {
938
1137
  "aria-label",
939
1138
  "multiline",
940
1139
  "resizable",
1140
+ "default",
941
1141
  ]);
942
1142
  return this.getAttributeNames().filter(
943
1143
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -996,7 +1196,10 @@ class PropskitText extends HTMLElement {
996
1196
  }
997
1197
 
998
1198
  #handleClick(event) {
999
- if (event.target instanceof Element && event.target.closest("fig-input-text")) {
1199
+ if (
1200
+ event.target instanceof Element &&
1201
+ event.target.closest("fig-input-text, fig-menu")
1202
+ ) {
1000
1203
  return;
1001
1204
  }
1002
1205
  this.focus();
@@ -1017,6 +1220,20 @@ class PropskitText extends HTMLElement {
1017
1220
  }
1018
1221
  }
1019
1222
 
1223
+ get defaultValue() {
1224
+ return this.getAttribute("default") ?? this.#initialValue ?? "";
1225
+ }
1226
+
1227
+ get isDefault() {
1228
+ return figLabPropskitValuesEqual(this.value, this.defaultValue);
1229
+ }
1230
+
1231
+ resetToDefault() {
1232
+ const value = this.defaultValue;
1233
+ this.value = value;
1234
+ figLabEmitPropskitReset(this, value);
1235
+ }
1236
+
1020
1237
  focus(options) {
1021
1238
  this.#input?.focus(options);
1022
1239
  }
@@ -1034,6 +1251,7 @@ class PropskitNumber extends HTMLElement {
1034
1251
  #boundHandleInput = null;
1035
1252
  #boundHandleChange = null;
1036
1253
  #boundHandleClick = this.#handleClick.bind(this);
1254
+ #initialValue = null;
1037
1255
 
1038
1256
  static get observedAttributes() {
1039
1257
  return ["label", "direction"];
@@ -1046,6 +1264,7 @@ class PropskitNumber extends HTMLElement {
1046
1264
  this.#bindInputEvents();
1047
1265
  this.removeEventListener("click", this.#boundHandleClick);
1048
1266
  this.addEventListener("click", this.#boundHandleClick);
1267
+ figLabConnectPropskitResetMenu(this);
1049
1268
 
1050
1269
  if (!this.#observer) {
1051
1270
  this.#observer = new MutationObserver((mutations) => {
@@ -1076,6 +1295,7 @@ class PropskitNumber extends HTMLElement {
1076
1295
  this.#observer?.disconnect();
1077
1296
  this.#unbindInputEvents();
1078
1297
  this.removeEventListener("click", this.#boundHandleClick);
1298
+ figLabDisconnectPropskitResetMenu(this);
1079
1299
  }
1080
1300
 
1081
1301
  attributeChangedCallback(name, oldValue, newValue) {
@@ -1084,6 +1304,8 @@ class PropskitNumber extends HTMLElement {
1084
1304
  }
1085
1305
 
1086
1306
  #initialize() {
1307
+ this.#initialValue =
1308
+ this.getAttribute("value") ?? this.getAttribute("min") ?? "0";
1087
1309
  const initialChildren = Array.from(this.childNodes).filter(
1088
1310
  (node) =>
1089
1311
  node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
@@ -1140,6 +1362,7 @@ class PropskitNumber extends HTMLElement {
1140
1362
  "class",
1141
1363
  "style",
1142
1364
  "id",
1365
+ "default",
1143
1366
  ]);
1144
1367
  return this.getAttributeNames().filter(
1145
1368
  (name) => !reserved.has(name) && !name.startsWith("data-"),
@@ -1199,7 +1422,10 @@ class PropskitNumber extends HTMLElement {
1199
1422
  }
1200
1423
 
1201
1424
  #handleClick(event) {
1202
- if (event.target instanceof Element && event.target.closest("fig-input-number")) {
1425
+ if (
1426
+ event.target instanceof Element &&
1427
+ event.target.closest("fig-input-number, fig-menu")
1428
+ ) {
1203
1429
  return;
1204
1430
  }
1205
1431
  this.focus();
@@ -1220,6 +1446,20 @@ class PropskitNumber extends HTMLElement {
1220
1446
  }
1221
1447
  }
1222
1448
 
1449
+ get defaultValue() {
1450
+ return this.getAttribute("default") ?? this.#initialValue ?? "0";
1451
+ }
1452
+
1453
+ get isDefault() {
1454
+ return figLabPropskitValuesEqual(this.value, this.defaultValue);
1455
+ }
1456
+
1457
+ resetToDefault() {
1458
+ const value = this.defaultValue;
1459
+ this.value = value;
1460
+ figLabEmitPropskitReset(this, this.#input?.value ?? value);
1461
+ }
1462
+
1223
1463
  focus(options) {
1224
1464
  this.#input?.focus(options);
1225
1465
  }
@@ -1237,12 +1477,12 @@ class PropskitGroup extends HTMLElement {
1237
1477
  "propskit-slider",
1238
1478
  "propskit-switch",
1239
1479
  "propskit-text",
1480
+ "propskit-oscillator",
1240
1481
  ].join(",");
1241
1482
 
1242
1483
  #header = null;
1243
1484
  #chevron = null;
1244
1485
  #resetTooltip = null;
1245
- #defaults = new WeakMap();
1246
1486
  #childObserver = null;
1247
1487
  #dirtyFrame = 0;
1248
1488
  #boundOnControlEvent = () => this.#queueDirtySync();
@@ -1251,7 +1491,6 @@ class PropskitGroup extends HTMLElement {
1251
1491
  this.#render();
1252
1492
  this.#bindDirtyListeners();
1253
1493
  requestAnimationFrame(() => {
1254
- this.#ensureDefaults();
1255
1494
  this.#syncDirtyState();
1256
1495
  });
1257
1496
  }
@@ -1334,12 +1573,16 @@ class PropskitGroup extends HTMLElement {
1334
1573
 
1335
1574
  if (!this.#childObserver) {
1336
1575
  this.#childObserver = new MutationObserver(() => {
1337
- this.#ensureDefaults();
1338
1576
  this.#queueDirtySync();
1339
1577
  });
1340
1578
  }
1341
1579
  this.#childObserver.disconnect();
1342
- this.#childObserver.observe(this, { childList: true, subtree: true });
1580
+ this.#childObserver.observe(this, {
1581
+ childList: true,
1582
+ subtree: true,
1583
+ attributes: true,
1584
+ attributeFilter: ["value", "checked", "default"],
1585
+ });
1343
1586
  }
1344
1587
 
1345
1588
  #unbindDirtyListeners() {
@@ -1352,7 +1595,6 @@ class PropskitGroup extends HTMLElement {
1352
1595
  if (this.#dirtyFrame) return;
1353
1596
  this.#dirtyFrame = requestAnimationFrame(() => {
1354
1597
  this.#dirtyFrame = 0;
1355
- this.#ensureDefaults();
1356
1598
  this.#syncDirtyState();
1357
1599
  });
1358
1600
  }
@@ -1391,48 +1633,15 @@ class PropskitGroup extends HTMLElement {
1391
1633
  #controls() {
1392
1634
  return [
1393
1635
  ...this.querySelectorAll(PropskitGroup.#CONTROL_SELECTOR),
1394
- ].filter((el) => el.closest("propskit-group") === this);
1395
- }
1396
-
1397
- #snapshotControl(el) {
1398
- if (el.localName === "propskit-switch") {
1399
- return { kind: "checked", value: Boolean(el.checked) };
1400
- }
1401
- if ("value" in el) {
1402
- return { kind: "value", value: el.value };
1403
- }
1404
- return { kind: "value", value: el.getAttribute("value") ?? "" };
1405
- }
1406
-
1407
- #ensureDefaults() {
1408
- for (const el of this.#controls()) {
1409
- if (this.#defaults.has(el)) continue;
1410
- if (el.hasAttribute("default")) {
1411
- if (el.localName === "propskit-switch") {
1412
- this.#defaults.set(el, {
1413
- kind: "checked",
1414
- value: figLabBooleanAttribute(el, "default"),
1415
- });
1416
- continue;
1417
- }
1418
- this.#defaults.set(el, {
1419
- kind: "value",
1420
- value: el.getAttribute("default") ?? "",
1421
- });
1422
- continue;
1423
- }
1424
- this.#defaults.set(el, this.#snapshotControl(el));
1425
- }
1636
+ ].filter(
1637
+ (el) =>
1638
+ el.closest("propskit-group") === this &&
1639
+ !el.parentElement?.closest(PropskitGroup.#CONTROL_SELECTOR),
1640
+ );
1426
1641
  }
1427
1642
 
1428
1643
  #controlIsDirty(el) {
1429
- const snap = this.#defaults.get(el);
1430
- if (!snap) return false;
1431
- const cur = this.#snapshotControl(el);
1432
- if (snap.kind === "checked") {
1433
- return Boolean(cur.value) !== Boolean(snap.value);
1434
- }
1435
- return String(cur.value ?? "") !== String(snap.value ?? "");
1644
+ return el.isDefault === false;
1436
1645
  }
1437
1646
 
1438
1647
  #computeDirty() {
@@ -1450,67 +1659,12 @@ class PropskitGroup extends HTMLElement {
1450
1659
  }
1451
1660
 
1452
1661
  #restoreControl(el) {
1453
- const snap = this.#defaults.get(el);
1454
- if (!snap) return;
1455
-
1456
- if (snap.kind === "checked") {
1457
- el.checked = snap.value;
1458
- const detail = {
1459
- checked: Boolean(snap.value),
1460
- value: el.getAttribute("value") ?? "",
1461
- };
1462
- el.dispatchEvent(
1463
- new CustomEvent("input", {
1464
- detail,
1465
- bubbles: true,
1466
- cancelable: true,
1467
- composed: true,
1468
- }),
1469
- );
1470
- el.dispatchEvent(
1471
- new CustomEvent("change", {
1472
- detail,
1473
- bubbles: true,
1474
- cancelable: true,
1475
- composed: true,
1476
- }),
1477
- );
1478
- return;
1479
- }
1480
-
1481
1662
  if (typeof el.resetToDefault === "function") {
1482
- const prevDefault = el.getAttribute("default");
1483
- el.setAttribute("default", String(snap.value ?? ""));
1484
1663
  el.resetToDefault();
1485
- if (prevDefault === null) el.removeAttribute("default");
1486
- else el.setAttribute("default", prevDefault);
1487
- return;
1488
1664
  }
1489
-
1490
- el.value = snap.value;
1491
- const detail =
1492
- el.getAttribute?.("value") ??
1493
- ("value" in el ? el.value : snap.value);
1494
- el.dispatchEvent(
1495
- new CustomEvent("input", {
1496
- detail,
1497
- bubbles: true,
1498
- cancelable: true,
1499
- composed: true,
1500
- }),
1501
- );
1502
- el.dispatchEvent(
1503
- new CustomEvent("change", {
1504
- detail,
1505
- bubbles: true,
1506
- cancelable: true,
1507
- composed: true,
1508
- }),
1509
- );
1510
1665
  }
1511
1666
 
1512
1667
  #resetControls() {
1513
- this.#ensureDefaults();
1514
1668
  for (const el of this.#controls()) this.#restoreControl(el);
1515
1669
  this.#syncDirtyState();
1516
1670
  this.dispatchEvent(
@@ -1652,6 +1806,7 @@ class PropskitSlider extends HTMLElement {
1652
1806
  #contextMenu = null;
1653
1807
  #pendingClickTimer = 0;
1654
1808
  #pendingClickValue = null;
1809
+ #initialValue = null;
1655
1810
  #isElasticTracking = false;
1656
1811
  #elasticMaxPx = 0;
1657
1812
  #elasticRangeRect = null;
@@ -1725,6 +1880,10 @@ class PropskitSlider extends HTMLElement {
1725
1880
  this.#pushExternalValueToSlider();
1726
1881
  continue;
1727
1882
  }
1883
+ if (mutation.attributeName === "color") {
1884
+ syncSlider = true;
1885
+ continue;
1886
+ }
1728
1887
  if (
1729
1888
  mutation.attributeName &&
1730
1889
  this.#ignoredSliderAttrs.has(mutation.attributeName)
@@ -1784,6 +1943,8 @@ class PropskitSlider extends HTMLElement {
1784
1943
  }
1785
1944
 
1786
1945
  #initialize() {
1946
+ this.#initialValue =
1947
+ this.getAttribute("value") ?? this.getAttribute("min") ?? "0";
1787
1948
  const initialChildren = Array.from(this.childNodes).filter((node) => {
1788
1949
  return (
1789
1950
  node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim())
@@ -1825,7 +1986,7 @@ class PropskitSlider extends HTMLElement {
1825
1986
 
1826
1987
  const resetItem = document.createElement("fig-menu-item");
1827
1988
  resetItem.setAttribute("value", "reset-default");
1828
- resetItem.textContent = "Reset to default";
1989
+ resetItem.textContent = "Reset";
1829
1990
  menu.appendChild(resetItem);
1830
1991
  menu.addEventListener("change", this.#boundHandleContextMenuChange);
1831
1992
 
@@ -1903,17 +2064,33 @@ class PropskitSlider extends HTMLElement {
1903
2064
  if (sliderType === "opacity") {
1904
2065
  this.#slider.style.setProperty(
1905
2066
  "--color",
1906
- "light-dark(#444444, #e6e6e6)",
2067
+ this.getAttribute("color") || "var(--figma-color-bg-secondary)",
1907
2068
  );
1908
2069
  } else {
1909
2070
  this.#slider.style.removeProperty("--color");
1910
2071
  }
2072
+ this.#syncNumberTheme(sliderType);
1911
2073
 
1912
2074
  this.#managedSliderAttrs = nextManaged;
1913
2075
  this.#pushExternalValueToSlider();
1914
2076
  this.#queueSteppersSync();
1915
2077
  }
1916
2078
 
2079
+ #syncNumberTheme(sliderType) {
2080
+ const numberInput = this.#slider?.querySelector("fig-input-number");
2081
+ if (!numberInput) return;
2082
+ if (sliderType !== "opacity" || !this.hasAttribute("color")) {
2083
+ numberInput.removeAttribute("theme");
2084
+ return;
2085
+ }
2086
+ const theme = figLabPerceivedColorTheme(
2087
+ this.#slider,
2088
+ this.getAttribute("color"),
2089
+ );
2090
+ if (theme) numberInput.setAttribute("theme", theme);
2091
+ else numberInput.removeAttribute("theme");
2092
+ }
2093
+
1917
2094
  #pushExternalValueToSlider() {
1918
2095
  if (!this.#slider) return;
1919
2096
  const next = this.getAttribute("value") ?? "";
@@ -1977,8 +2154,8 @@ class PropskitSlider extends HTMLElement {
1977
2154
  }
1978
2155
  }
1979
2156
  if (numberInput) {
1980
- numberInput.setAttribute("tabindex", "-1");
1981
- numberInput.setAttribute("aria-hidden", "true");
2157
+ numberInput.removeAttribute("tabindex");
2158
+ numberInput.removeAttribute("aria-hidden");
1982
2159
  }
1983
2160
  }
1984
2161
 
@@ -2228,8 +2405,7 @@ class PropskitSlider extends HTMLElement {
2228
2405
  return (
2229
2406
  this.getAttribute("default") ??
2230
2407
  this.#slider?.getAttribute("default") ??
2231
- this.getAttribute("value") ??
2232
- this.#slider?.getAttribute("value") ??
2408
+ this.#initialValue ??
2233
2409
  this.#rangeInput?.min ??
2234
2410
  "0"
2235
2411
  );
@@ -2347,6 +2523,14 @@ class PropskitSlider extends HTMLElement {
2347
2523
  if (this.#slider) this.#slider.value = next;
2348
2524
  }
2349
2525
 
2526
+ get defaultValue() {
2527
+ return this.#defaultValue();
2528
+ }
2529
+
2530
+ get isDefault() {
2531
+ return figLabPropskitValuesEqual(this.value, this.defaultValue);
2532
+ }
2533
+
2350
2534
  resetToDefault() {
2351
2535
  this.#resetToDefault();
2352
2536
  }
@@ -3749,6 +3933,7 @@ class PropskitOscillator extends HTMLElement {
3749
3933
  #activeFieldInput = null;
3750
3934
  #dragController = null;
3751
3935
  #dragCleanup = null;
3936
+ #initialValue = null;
3752
3937
 
3753
3938
  static TYPES = [
3754
3939
  { name: "Wave", value: "sine" },
@@ -3772,11 +3957,18 @@ class PropskitOscillator extends HTMLElement {
3772
3957
  }
3773
3958
 
3774
3959
  connectedCallback() {
3960
+ if (this.#initialValue === null) {
3961
+ this.#initialValue =
3962
+ this.getAttribute("default") ??
3963
+ this.getAttribute("value") ??
3964
+ JSON.stringify({ waves: [PropskitOscillator.#defaultWave()] });
3965
+ }
3775
3966
  this.#precision = this.#readInteger("precision", 2);
3776
3967
  this.#parseValue(this.getAttribute("value"));
3777
3968
  this.#syncAspectRatio();
3778
3969
  this.#render();
3779
3970
  this.#setupResizeObserver();
3971
+ figLabConnectPropskitResetMenu(this);
3780
3972
  }
3781
3973
 
3782
3974
  disconnectedCallback() {
@@ -3786,6 +3978,7 @@ class PropskitOscillator extends HTMLElement {
3786
3978
  this.#resizeObserver.disconnect();
3787
3979
  this.#resizeObserver = null;
3788
3980
  }
3981
+ figLabDisconnectPropskitResetMenu(this);
3789
3982
  }
3790
3983
 
3791
3984
  attributeChangedCallback(name, oldValue, newValue) {
@@ -3825,6 +4018,27 @@ class PropskitOscillator extends HTMLElement {
3825
4018
  );
3826
4019
  }
3827
4020
 
4021
+ get defaultValue() {
4022
+ return this.getAttribute("default") ?? this.#initialValue ?? "";
4023
+ }
4024
+
4025
+ get isDefault() {
4026
+ const defaultWaves = this.#normalizedWaves(this.defaultValue);
4027
+ if (!defaultWaves) return false;
4028
+ return (
4029
+ JSON.stringify(this.data.waves) ===
4030
+ JSON.stringify(defaultWaves.map((wave) => this.#roundWave(wave)))
4031
+ );
4032
+ }
4033
+
4034
+ resetToDefault() {
4035
+ const value = this.defaultValue;
4036
+ if (!value) return;
4037
+ this.value = value;
4038
+ this.#emit("input");
4039
+ this.#emit("change");
4040
+ }
4041
+
3828
4042
  get data() {
3829
4043
  return {
3830
4044
  waves: this.#waves.map((wave) => this.#roundWave(wave)),
@@ -3866,33 +4080,35 @@ class PropskitOscillator extends HTMLElement {
3866
4080
  }
3867
4081
 
3868
4082
  #parseValue(value) {
3869
- if (!value) return false;
4083
+ const nextWaves = this.#normalizedWaves(value);
4084
+ if (!nextWaves) return false;
4085
+ this.#waves = nextWaves;
4086
+ if (!this.#waves.length) {
4087
+ this.#waves = [PropskitOscillator.#defaultWave()];
4088
+ }
4089
+ this.#activeWaveIndex = Math.min(this.#activeWaveIndex, this.#waves.length - 1);
4090
+ return true;
4091
+ }
3870
4092
 
4093
+ #normalizedWaves(value) {
4094
+ if (!value) return null;
3871
4095
  let parsed = null;
3872
4096
  if (typeof value === "string") {
3873
4097
  try {
3874
4098
  parsed = JSON.parse(value);
3875
4099
  } catch {
3876
- parsed = null;
4100
+ return null;
3877
4101
  }
3878
4102
  } else if (typeof value === "object") {
3879
4103
  parsed = value;
3880
4104
  }
3881
-
3882
- if (!parsed) return false;
3883
-
3884
- const nextWaves = Array.isArray(parsed)
4105
+ if (!parsed) return null;
4106
+ const waves = Array.isArray(parsed)
3885
4107
  ? parsed
3886
4108
  : Array.isArray(parsed.waves)
3887
4109
  ? parsed.waves
3888
4110
  : [parsed];
3889
-
3890
- this.#waves = nextWaves.map((wave) => this.#normalizeWave(wave));
3891
- if (!this.#waves.length) {
3892
- this.#waves = [PropskitOscillator.#defaultWave()];
3893
- }
3894
- this.#activeWaveIndex = Math.min(this.#activeWaveIndex, this.#waves.length - 1);
3895
- return true;
4111
+ return waves.map((wave) => this.#normalizeWave(wave));
3896
4112
  }
3897
4113
 
3898
4114
  #normalizeWave(state = {}) {
@@ -3993,6 +4209,7 @@ class PropskitOscillator extends HTMLElement {
3993
4209
  this.#updateWaveform();
3994
4210
  this.#setupEvents();
3995
4211
  this.#startPlayhead();
4212
+ figLabConnectPropskitResetMenu(this);
3996
4213
  }
3997
4214
 
3998
4215
  #getInnerHTML() {
@@ -4029,9 +4246,7 @@ class PropskitOscillator extends HTMLElement {
4029
4246
  <fig-tooltip text="Remove form">
4030
4247
  <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>
4031
4248
  </fig-tooltip>
4032
- <fig-tooltip text="Add form">
4033
- ${this.#getWaveTypeSelectHTML("propskit-oscillator-add-type propskit-oscillator-add-type-button", "sine", disabled, index)}
4034
- </fig-tooltip>
4249
+ ${this.#getWaveTypeMenuHTML(disabled, index)}
4035
4250
  </fig-header>
4036
4251
  <div class="propskit-oscillator-fields" data-wave-index="${index}"${active}>
4037
4252
  ${this.#getNumberFieldHTML(index, "frequency", "Frequency", 0.1, 16, 0.1, "")}
@@ -4042,26 +4257,33 @@ class PropskitOscillator extends HTMLElement {
4042
4257
  </fig-group>`;
4043
4258
  }
4044
4259
 
4045
- #getWaveTypeSelectHTML(className, value, disabled, index = null) {
4046
- const options = PropskitOscillator.TYPES.map((type) => {
4047
- const selected = type.value === value ? " selected" : "";
4048
- return `<fig-select-option value="${type.value}" label="${type.name}"${selected}>
4049
- <span slot="prepend">${PropskitOscillator.waveIcon(type.value, 24)}</span>
4260
+ #getWaveTypeMenuHTML(disabled, index) {
4261
+ const items = PropskitOscillator.TYPES.map((type) => {
4262
+ return `<fig-menu-item value="${type.value}">
4263
+ ${PropskitOscillator.waveIcon(type.value, 24)}
4050
4264
  <span>${type.name}</span>
4051
- </fig-select-option>`;
4265
+ </fig-menu-item>`;
4052
4266
  }).join("");
4053
- const indexAttr =
4054
- index === null ? "" : ` data-wave-index="${PropskitOscillator.#escapeAttribute(String(index))}"`;
4055
- return `<fig-select class="${className}" value="${value}" label="Add form"${indexAttr}${disabled}><fig-select-options>${options}</fig-select-options></fig-select>`;
4267
+ const indexAttr = ` data-wave-index="${PropskitOscillator.#escapeAttribute(String(index))}"`;
4268
+ return `<fig-menu class="propskit-oscillator-add-type" position="bottom right"${indexAttr}${disabled}>
4269
+ <fig-tooltip text="Add form">
4270
+ <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>
4271
+ </fig-tooltip>
4272
+ ${items}
4273
+ </fig-menu>`;
4056
4274
  }
4057
4275
 
4058
4276
  #getNumberFieldHTML(index, name, label, min, max, step, units) {
4059
4277
  const disabled = this.#isDisabled() ? " disabled" : "";
4278
+ const typeAttrs =
4279
+ name === "amplitude" || name === "offset"
4280
+ ? ' type="delta" default="0"'
4281
+ : "";
4060
4282
  const unitsAttr = units
4061
4283
  ? ` units="${PropskitOscillator.#escapeAttribute(units)}"`
4062
4284
  : "";
4063
4285
  const wave = this.#waves[index] || PropskitOscillator.#defaultWave();
4064
- 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>`;
4286
+ 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>`;
4065
4287
  }
4066
4288
 
4067
4289
  #cacheRefs() {
@@ -4328,7 +4550,7 @@ class PropskitOscillator extends HTMLElement {
4328
4550
  if (this.#isDisabled()) return;
4329
4551
  const insertAfter = this.#indexFromElement(control);
4330
4552
  const insertIndex = insertAfter + 1;
4331
- const type = this.#normalizeType(event.detail ?? control.value);
4553
+ const type = this.#normalizeType(event.detail?.value ?? control.value);
4332
4554
  this.#waves.splice(insertIndex, 0, PropskitOscillator.#defaultWave(type));
4333
4555
  this.#reindexExpandedWavesAfterInsert(insertIndex);
4334
4556
  this.#activeWaveIndex = insertIndex;