@rogieking/figui3 8.9.49 → 8.10.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
@@ -163,16 +163,38 @@ function figLabDisconnectPropskitResetMenu(host) {
163
163
  state.menu.removeEventListener("change", state.handleChange);
164
164
  }
165
165
 
166
- function figLabEmitPropskitReset(host, detail) {
166
+ function figLabPropskitEventDetail(host, value = host.value) {
167
+ const detail = {
168
+ control: host.localName,
169
+ value,
170
+ };
171
+ const name = host.getAttribute("name");
172
+ if (name !== null && name !== "") detail.name = name;
173
+ return detail;
174
+ }
175
+
176
+ function figLabDispatchPropskitEvent(host, type, value = host.value) {
177
+ host.dispatchEvent(
178
+ new CustomEvent(type, {
179
+ detail: figLabPropskitEventDetail(host, value),
180
+ bubbles: true,
181
+ cancelable: true,
182
+ composed: true,
183
+ }),
184
+ );
185
+ }
186
+
187
+ function figLabFiniteNumberOrNull(value) {
188
+ if (value === null || value === undefined || String(value).trim() === "") {
189
+ return null;
190
+ }
191
+ const number = Number(value);
192
+ return Number.isFinite(number) ? number : null;
193
+ }
194
+
195
+ function figLabEmitPropskitReset(host) {
167
196
  for (const type of ["input", "change"]) {
168
- host.dispatchEvent(
169
- new CustomEvent(type, {
170
- detail,
171
- bubbles: true,
172
- cancelable: true,
173
- composed: true,
174
- }),
175
- );
197
+ figLabDispatchPropskitEvent(host, type);
176
198
  }
177
199
  }
178
200
 
@@ -242,6 +264,66 @@ function figLabUniqueId(prefix = "fig-lab") {
242
264
  return `${prefix}-${figLabUniqueIdCounter}`;
243
265
  }
244
266
 
267
+ class FigLabPropskitElement extends HTMLElement {
268
+ get name() {
269
+ return this.getAttribute("name") ?? "";
270
+ }
271
+
272
+ set name(value) {
273
+ if (value === null || value === undefined || value === "") {
274
+ this.removeAttribute("name");
275
+ } else {
276
+ this.setAttribute("name", String(value));
277
+ }
278
+ }
279
+ }
280
+
281
+ function figLabSyncPropskitLabel(
282
+ host,
283
+ surface,
284
+ label,
285
+ hasCustomLabel = false,
286
+ ) {
287
+ if (!surface || !label) return null;
288
+ const hasLabelAttr = host.hasAttribute("label");
289
+ const rawLabel = host.getAttribute("label") ?? "";
290
+ const hidden = hasLabelAttr && rawLabel.trim() === "";
291
+
292
+ if (!hasCustomLabel) {
293
+ label.textContent = hasLabelAttr ? rawLabel : "Label";
294
+ }
295
+
296
+ host.toggleAttribute("data-label-empty", hidden);
297
+ if (hidden) {
298
+ label.remove();
299
+ return null;
300
+ }
301
+
302
+ if (!label.id) label.id = figLabUniqueId(`${host.localName}-label`);
303
+ if (label.parentElement !== surface) surface.prepend(label);
304
+ return label.id;
305
+ }
306
+
307
+ function figLabSyncPropskitControlLabel(
308
+ host,
309
+ control,
310
+ labelId,
311
+ fallback = "Label",
312
+ ) {
313
+ if (!control) return;
314
+ const explicitLabel = host.getAttribute("aria-label")?.trim();
315
+ if (explicitLabel) {
316
+ control.setAttribute("aria-label", explicitLabel);
317
+ control.removeAttribute("aria-labelledby");
318
+ } else if (labelId) {
319
+ control.setAttribute("aria-labelledby", labelId);
320
+ control.removeAttribute("aria-label");
321
+ } else {
322
+ control.setAttribute("aria-label", fallback);
323
+ control.removeAttribute("aria-labelledby");
324
+ }
325
+ }
326
+
245
327
  /* Presentation-only composition surface for AI prompt controls. */
246
328
  class FigAiPrompt extends HTMLElement {}
247
329
  figLabDefineElement("fig-ai-prompt", FigAiPrompt);
@@ -453,9 +535,9 @@ class FigAttachments extends HTMLElement {
453
535
  }
454
536
  figLabDefineElement("fig-attachments", FigAttachments);
455
537
 
456
- /* Field + Switch wrapper */
457
- class PropskitSwitch extends HTMLElement {
458
- #field = null;
538
+ /* PropsKit switch surface */
539
+ class PropskitSwitch extends FigLabPropskitElement {
540
+ #surface = null;
459
541
  #label = null;
460
542
  #switch = null;
461
543
  #hasCustomLabel = false;
@@ -467,12 +549,12 @@ class PropskitSwitch extends HTMLElement {
467
549
  #initialChecked = false;
468
550
 
469
551
  static get observedAttributes() {
470
- return ["label", "direction"];
552
+ return ["label", "aria-label", "variant"];
471
553
  }
472
554
 
473
555
  connectedCallback() {
474
- if (!this.#field) this.#initialize();
475
- this.#syncField();
556
+ if (!this.#surface) this.#initialize();
557
+ this.#syncSurface();
476
558
  this.#syncSwitchAttributes();
477
559
  this.#bindSwitchEvents();
478
560
  this.removeEventListener("click", this.#boundHandleClick);
@@ -481,22 +563,22 @@ class PropskitSwitch extends HTMLElement {
481
563
 
482
564
  if (!this.#observer) {
483
565
  this.#observer = new MutationObserver((mutations) => {
484
- let syncField = false;
566
+ let syncSurface = false;
485
567
  let syncSwitch = false;
486
568
 
487
569
  for (const mutation of mutations) {
488
570
  if (mutation.type !== "attributes") continue;
489
571
  if (
490
572
  mutation.attributeName === "label" ||
491
- mutation.attributeName === "direction"
573
+ mutation.attributeName === "aria-label"
492
574
  ) {
493
- syncField = true;
575
+ syncSurface = true;
494
576
  } else {
495
577
  syncSwitch = true;
496
578
  }
497
579
  }
498
580
 
499
- if (syncField) this.#syncField();
581
+ if (syncSurface) this.#syncSurface();
500
582
  if (syncSwitch) this.#syncSwitchAttributes();
501
583
  });
502
584
  }
@@ -512,8 +594,12 @@ class PropskitSwitch extends HTMLElement {
512
594
  }
513
595
 
514
596
  attributeChangedCallback(name, oldValue, newValue) {
515
- if (oldValue === newValue || !this.#field) return;
516
- if (name === "label" || name === "direction") this.#syncField();
597
+ if (oldValue === newValue || !this.#surface) return;
598
+ if (name === "variant") {
599
+ this.#syncSwitchAttributes();
600
+ } else if (name === "label" || name === "aria-label") {
601
+ this.#syncSurface();
602
+ }
517
603
  }
518
604
 
519
605
  #initialize() {
@@ -525,48 +611,68 @@ class PropskitSwitch extends HTMLElement {
525
611
  const customLabel = initialChildren.find(
526
612
  (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
527
613
  );
528
- const field = document.createElement("fig-field");
614
+ const surface = figLabCreateElement("div", {
615
+ className: "propskit-switch-surface",
616
+ });
529
617
  const label = customLabel || document.createElement("label");
530
- const switchControl = document.createElement("fig-segmented-control");
531
- const offSegment = document.createElement("fig-segment");
532
- const onSegment = document.createElement("fig-segment");
533
- switchControl.setAttribute("sizing", "equal");
534
- offSegment.setAttribute("value", "off");
535
- offSegment.textContent = "Off";
536
- onSegment.setAttribute("value", "on");
537
- onSegment.textContent = "On";
538
- switchControl.append(offSegment, onSegment);
539
-
540
- field.append(label, switchControl);
541
- this.#field = field;
618
+ surface.append(label);
619
+ this.#surface = surface;
542
620
  this.#label = label;
543
- this.#switch = switchControl;
544
621
  this.#hasCustomLabel = Boolean(customLabel);
545
- this.replaceChildren(field);
622
+ this.replaceChildren(surface);
623
+ this.#ensureSwitchControl();
624
+ }
546
625
 
626
+ #controlVariant() {
627
+ return this.getAttribute("variant") === "segmented-control"
628
+ ? "segmented-control"
629
+ : "switch";
547
630
  }
548
631
 
549
- #syncField() {
550
- if (!this.#field || !this.#label) return;
551
- const hasLabelAttr = this.hasAttribute("label");
552
- const rawLabel = this.getAttribute("label");
553
- const isBlankLabel = hasLabelAttr && (rawLabel ?? "").trim() === "";
632
+ #ensureSwitchControl() {
633
+ const tagName =
634
+ this.#controlVariant() === "segmented-control"
635
+ ? "fig-segmented-control"
636
+ : "fig-switch";
637
+ if (this.#switch?.localName === tagName) return;
554
638
 
555
- if (isBlankLabel) {
556
- this.#label.remove();
557
- } else {
558
- if (!this.#hasCustomLabel) {
559
- this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
560
- }
561
- if (this.#label.parentElement !== this.#field) {
562
- this.#field.prepend(this.#label);
563
- }
639
+ this.#unbindSwitchEvents();
640
+ this.#switch?.remove();
641
+
642
+ const switchControl = document.createElement(tagName);
643
+ if (tagName === "fig-segmented-control") {
644
+ const offSegment = document.createElement("fig-segment");
645
+ const onSegment = document.createElement("fig-segment");
646
+ switchControl.setAttribute("sizing", "equal");
647
+ offSegment.setAttribute("value", "off");
648
+ offSegment.textContent = "Off";
649
+ onSegment.setAttribute("value", "on");
650
+ onSegment.textContent = "On";
651
+ switchControl.append(offSegment, onSegment);
564
652
  }
565
653
 
566
- this.#field.setAttribute(
567
- "direction",
568
- this.getAttribute("direction") || "horizontal",
654
+ this.#switch = switchControl;
655
+ this.#managedSwitchAttrs.clear();
656
+ this.#surface?.append(switchControl);
657
+ if (this.isConnected) this.#bindSwitchEvents();
658
+ }
659
+
660
+ #syncSurface() {
661
+ const labelId = figLabSyncPropskitLabel(
662
+ this,
663
+ this.#surface,
664
+ this.#label,
665
+ this.#hasCustomLabel,
569
666
  );
667
+ figLabSyncPropskitControlLabel(this, this.#switch, labelId, "Switch");
668
+ if (this.#switch?.localName === "fig-switch" && this.#switch.input) {
669
+ figLabSyncPropskitControlLabel(
670
+ this,
671
+ this.#switch.input,
672
+ labelId,
673
+ "Switch",
674
+ );
675
+ }
570
676
  }
571
677
 
572
678
  #getForwardedSwitchAttrNames() {
@@ -574,6 +680,7 @@ class PropskitSwitch extends HTMLElement {
574
680
  "label",
575
681
  "direction",
576
682
  "size",
683
+ "aria-label",
577
684
  "oninput",
578
685
  "onchange",
579
686
  "class",
@@ -590,6 +697,7 @@ class PropskitSwitch extends HTMLElement {
590
697
  }
591
698
 
592
699
  #syncSwitchAttributes() {
700
+ this.#ensureSwitchControl();
593
701
  if (!this.#switch) return;
594
702
  const switchAttrs = this.#getForwardedSwitchAttrNames();
595
703
  const nextManaged = new Set(switchAttrs);
@@ -601,11 +709,15 @@ class PropskitSwitch extends HTMLElement {
601
709
  this.#switch.setAttribute(attrName, this.getAttribute(attrName) ?? "");
602
710
  }
603
711
 
604
- this.#switch.setAttribute(
605
- "value",
606
- figLabBooleanAttribute(this, "checked") ? "on" : "off",
607
- );
712
+ const checked = figLabBooleanAttribute(this, "checked");
713
+ if (this.#switch.localName === "fig-switch") {
714
+ this.#switch.checked = checked;
715
+ this.#switch.value = checked ? "on" : "off";
716
+ } else {
717
+ this.#switch.setAttribute("value", checked ? "on" : "off");
718
+ }
608
719
  this.#managedSwitchAttrs = nextManaged;
720
+ this.#syncSurface();
609
721
  }
610
722
 
611
723
  #bindSwitchEvents() {
@@ -629,52 +741,59 @@ class PropskitSwitch extends HTMLElement {
629
741
  #forwardSwitchEvent(type, event) {
630
742
  event.stopImmediatePropagation();
631
743
  if (figLabBooleanAttribute(this, "disabled")) return;
632
- const checked = this.#switch?.value === "on";
744
+ const checked =
745
+ this.#switch?.localName === "fig-switch"
746
+ ? Boolean(this.#switch.checked)
747
+ : this.#switch?.value === "on";
633
748
  this.toggleAttribute("checked", checked);
634
- const detail = {
635
- checked,
636
- value: this.getAttribute("value") ?? "",
637
- };
638
- this.dispatchEvent(
639
- new CustomEvent(type, {
640
- detail,
641
- bubbles: true,
642
- cancelable: true,
643
- composed: true,
644
- }),
645
- );
749
+ figLabDispatchPropskitEvent(this, type);
646
750
  }
647
751
 
648
752
  #handleClick(event) {
649
753
  if (figLabBooleanAttribute(this, "disabled")) return;
650
754
  if (
651
755
  event.target instanceof Element &&
652
- event.target.closest("fig-segmented-control, fig-menu")
756
+ event.target.closest("fig-switch, fig-segmented-control, fig-menu")
653
757
  ) {
654
758
  return;
655
759
  }
656
- const value = this.#switch?.value === "on" ? "off" : "on";
657
- this.#switch?.querySelector(`fig-segment[value="${value}"]`)?.click();
760
+ if (this.#switch?.localName === "fig-switch") {
761
+ this.#switch.click();
762
+ } else {
763
+ const value = this.#switch?.value === "on" ? "off" : "on";
764
+ this.#switch?.querySelector(`fig-segment[value="${value}"]`)?.click();
765
+ }
658
766
  }
659
767
 
660
768
  get checked() {
661
- return this.#switch
662
- ? this.#switch.value === "on"
663
- : figLabBooleanAttribute(this, "checked");
769
+ if (!this.#switch) return figLabBooleanAttribute(this, "checked");
770
+ return this.#switch.localName === "fig-switch"
771
+ ? Boolean(this.#switch.checked)
772
+ : this.#switch.value === "on";
664
773
  }
665
774
 
666
775
  set checked(nextChecked) {
667
776
  const checked = Boolean(nextChecked);
668
777
  this.toggleAttribute("checked", checked);
669
- if (this.#switch) this.#switch.value = checked ? "on" : "off";
778
+ if (!this.#switch) return;
779
+ if (this.#switch.localName === "fig-switch") {
780
+ this.#switch.checked = checked;
781
+ this.#switch.value = checked ? "on" : "off";
782
+ } else {
783
+ this.#switch.value = checked ? "on" : "off";
784
+ }
670
785
  }
671
786
 
672
787
  get value() {
673
- return this.#switch?.value ?? this.getAttribute("value") ?? "";
788
+ return this.checked;
674
789
  }
675
790
 
676
791
  set value(nextValue) {
677
- this.setAttribute("value", nextValue ?? "");
792
+ const checked =
793
+ typeof nextValue === "string"
794
+ ? !["", "false", "off", "0"].includes(nextValue.trim().toLowerCase())
795
+ : Boolean(nextValue);
796
+ this.checked = checked;
678
797
  }
679
798
 
680
799
  get defaultValue() {
@@ -690,17 +809,18 @@ class PropskitSwitch extends HTMLElement {
690
809
  resetToDefault() {
691
810
  const checked = this.defaultValue;
692
811
  this.checked = checked;
693
- figLabEmitPropskitReset(this, {
694
- checked,
695
- value: this.getAttribute("value") ?? "",
696
- });
812
+ figLabEmitPropskitReset(this);
697
813
  }
698
814
 
699
815
  focus(options) {
700
- const selected =
701
- this.#switch?.querySelector("fig-segment[selected]") ||
702
- this.#switch?.querySelector("fig-segment");
703
- selected?.focus(options);
816
+ if (this.#switch?.localName === "fig-switch") {
817
+ this.#switch.focus(options);
818
+ } else {
819
+ const selected =
820
+ this.#switch?.querySelector("fig-segment[selected]") ||
821
+ this.#switch?.querySelector("fig-segment");
822
+ selected?.focus(options);
823
+ }
704
824
  }
705
825
  }
706
826
  figLabDefineElement("propskit-switch", PropskitSwitch);
@@ -865,9 +985,9 @@ function figLabFillSwatchAlpha(fill) {
865
985
  }
866
986
  }
867
987
 
868
- /* Field + Color wrapper */
869
- class PropskitColor extends HTMLElement {
870
- #field = null;
988
+ /* PropsKit color surface */
989
+ class PropskitColor extends FigLabPropskitElement {
990
+ #surface = null;
871
991
  #label = null;
872
992
  #input = null;
873
993
  #swatch = null;
@@ -880,12 +1000,12 @@ class PropskitColor extends HTMLElement {
880
1000
  #initialValue = null;
881
1001
 
882
1002
  static get observedAttributes() {
883
- return ["label", "direction", "aria-label"];
1003
+ return ["label", "aria-label"];
884
1004
  }
885
1005
 
886
1006
  connectedCallback() {
887
- if (!this.#field) this.#initialize();
888
- this.#syncField();
1007
+ if (!this.#surface) this.#initialize();
1008
+ this.#syncSurface();
889
1009
  this.#syncInputAttributes();
890
1010
  this.#bindInputEvents();
891
1011
  if (this.#initialValue === null) {
@@ -898,23 +1018,24 @@ class PropskitColor extends HTMLElement {
898
1018
 
899
1019
  if (!this.#observer) {
900
1020
  this.#observer = new MutationObserver((mutations) => {
901
- let syncField = false;
1021
+ let syncSurface = false;
902
1022
  let syncInput = false;
903
1023
 
904
1024
  for (const mutation of mutations) {
905
1025
  if (mutation.type !== "attributes") continue;
906
1026
  if (
907
1027
  mutation.attributeName === "label" ||
908
- mutation.attributeName === "direction" ||
909
1028
  mutation.attributeName === "aria-label"
910
1029
  ) {
911
- syncField = true;
1030
+ syncSurface = true;
1031
+ } else if (mutation.attributeName === "direction") {
1032
+ continue;
912
1033
  } else {
913
1034
  syncInput = true;
914
1035
  }
915
1036
  }
916
1037
 
917
- if (syncField) this.#syncField();
1038
+ if (syncSurface) this.#syncSurface();
918
1039
  if (syncInput) this.#syncInputAttributes();
919
1040
  });
920
1041
  }
@@ -930,9 +1051,9 @@ class PropskitColor extends HTMLElement {
930
1051
  }
931
1052
 
932
1053
  attributeChangedCallback(name, oldValue, newValue) {
933
- if (oldValue === newValue || !this.#field) return;
934
- if (name === "label" || name === "direction" || name === "aria-label") {
935
- this.#syncField();
1054
+ if (oldValue === newValue || !this.#surface) return;
1055
+ if (name === "label" || name === "aria-label") {
1056
+ this.#syncSurface();
936
1057
  }
937
1058
  }
938
1059
 
@@ -944,7 +1065,9 @@ class PropskitColor extends HTMLElement {
944
1065
  const customLabel = initialChildren.find(
945
1066
  (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
946
1067
  );
947
- const field = document.createElement("fig-field");
1068
+ const surface = figLabCreateElement("div", {
1069
+ className: "propskit-color-surface",
1070
+ });
948
1071
  const label = customLabel || document.createElement("label");
949
1072
  const picker = document.createElement("fig-fill-picker");
950
1073
  const swatch = document.createElement("fig-swatch");
@@ -954,42 +1077,24 @@ class PropskitColor extends HTMLElement {
954
1077
  for (const node of initialChildren) {
955
1078
  if (node !== customLabel) picker.appendChild(node);
956
1079
  }
957
- field.append(label, picker);
958
- this.#field = field;
1080
+ surface.append(label, picker);
1081
+ this.#surface = surface;
959
1082
  this.#label = label;
960
1083
  this.#input = picker;
961
1084
  this.#swatch = swatch;
962
1085
  this.#hasCustomLabel = Boolean(customLabel);
963
- this.replaceChildren(field);
1086
+ this.replaceChildren(surface);
964
1087
  }
965
1088
 
966
- #syncField() {
967
- if (!this.#field || !this.#label || !this.#input) return;
968
- const hasLabelAttr = this.hasAttribute("label");
969
- const rawLabel = this.getAttribute("label");
970
- const isBlankLabel = hasLabelAttr && (rawLabel ?? "").trim() === "";
971
-
972
- if (isBlankLabel) {
973
- this.#label.remove();
974
- } else {
975
- if (!this.#hasCustomLabel) {
976
- this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
977
- }
978
- if (this.#label.parentElement !== this.#field) {
979
- this.#field.prepend(this.#label);
980
- }
981
- }
982
-
983
- this.#field.setAttribute(
984
- "direction",
985
- this.getAttribute("direction") || "horizontal",
986
- );
987
- this.#input.setAttribute(
988
- "aria-label",
989
- this.getAttribute("aria-label") ||
990
- this.#label.textContent?.trim() ||
991
- "Color",
1089
+ #syncSurface() {
1090
+ if (!this.#surface || !this.#label || !this.#input) return;
1091
+ const labelId = figLabSyncPropskitLabel(
1092
+ this,
1093
+ this.#surface,
1094
+ this.#label,
1095
+ this.#hasCustomLabel,
992
1096
  );
1097
+ figLabSyncPropskitControlLabel(this, this.#input, labelId, "Color");
993
1098
  }
994
1099
 
995
1100
  #parsedHostColor() {
@@ -1127,14 +1232,7 @@ class PropskitColor extends HTMLElement {
1127
1232
  this.#swatch.setAttribute("background", detail.color);
1128
1233
  this.#swatch.setAttribute("alpha", String(detail.alpha));
1129
1234
  }
1130
- this.dispatchEvent(
1131
- new CustomEvent(type, {
1132
- detail,
1133
- bubbles: true,
1134
- cancelable: true,
1135
- composed: true,
1136
- }),
1137
- );
1235
+ figLabDispatchPropskitEvent(this, type);
1138
1236
  }
1139
1237
 
1140
1238
  #handleClick(event) {
@@ -1198,22 +1296,7 @@ class PropskitColor extends HTMLElement {
1198
1296
  const next = this.defaultValue;
1199
1297
  this.setAttribute("value", next);
1200
1298
  this.#forceInputValue(next);
1201
- this.dispatchEvent(
1202
- new CustomEvent("input", {
1203
- detail: next,
1204
- bubbles: true,
1205
- cancelable: true,
1206
- composed: true,
1207
- }),
1208
- );
1209
- this.dispatchEvent(
1210
- new CustomEvent("change", {
1211
- detail: next,
1212
- bubbles: true,
1213
- cancelable: true,
1214
- composed: true,
1215
- }),
1216
- );
1299
+ figLabEmitPropskitReset(this);
1217
1300
  }
1218
1301
 
1219
1302
  focus(options) {
@@ -1223,9 +1306,9 @@ class PropskitColor extends HTMLElement {
1223
1306
  }
1224
1307
  figLabDefineElement("propskit-color", PropskitColor);
1225
1308
 
1226
- /* Field + Fill wrapper — color-style swatch that opens the full fill picker */
1227
- class PropskitFill extends HTMLElement {
1228
- #field = null;
1309
+ /* PropsKit fill surface — color-style swatch that opens the full fill picker */
1310
+ class PropskitFill extends FigLabPropskitElement {
1311
+ #surface = null;
1229
1312
  #label = null;
1230
1313
  #input = null;
1231
1314
  #swatch = null;
@@ -1238,12 +1321,12 @@ class PropskitFill extends HTMLElement {
1238
1321
  #initialValue = null;
1239
1322
 
1240
1323
  static get observedAttributes() {
1241
- return ["label", "direction", "aria-label"];
1324
+ return ["label", "aria-label"];
1242
1325
  }
1243
1326
 
1244
1327
  connectedCallback() {
1245
- if (!this.#field) this.#initialize();
1246
- this.#syncField();
1328
+ if (!this.#surface) this.#initialize();
1329
+ this.#syncSurface();
1247
1330
  this.#syncInputAttributes();
1248
1331
  this.#bindInputEvents();
1249
1332
  if (this.#initialValue === null) {
@@ -1256,23 +1339,24 @@ class PropskitFill extends HTMLElement {
1256
1339
 
1257
1340
  if (!this.#observer) {
1258
1341
  this.#observer = new MutationObserver((mutations) => {
1259
- let syncField = false;
1342
+ let syncSurface = false;
1260
1343
  let syncInput = false;
1261
1344
 
1262
1345
  for (const mutation of mutations) {
1263
1346
  if (mutation.type !== "attributes") continue;
1264
1347
  if (
1265
1348
  mutation.attributeName === "label" ||
1266
- mutation.attributeName === "direction" ||
1267
1349
  mutation.attributeName === "aria-label"
1268
1350
  ) {
1269
- syncField = true;
1351
+ syncSurface = true;
1352
+ } else if (mutation.attributeName === "direction") {
1353
+ continue;
1270
1354
  } else {
1271
1355
  syncInput = true;
1272
1356
  }
1273
1357
  }
1274
1358
 
1275
- if (syncField) this.#syncField();
1359
+ if (syncSurface) this.#syncSurface();
1276
1360
  if (syncInput) this.#syncInputAttributes();
1277
1361
  });
1278
1362
  }
@@ -1288,9 +1372,9 @@ class PropskitFill extends HTMLElement {
1288
1372
  }
1289
1373
 
1290
1374
  attributeChangedCallback(name, oldValue, newValue) {
1291
- if (oldValue === newValue || !this.#field) return;
1292
- if (name === "label" || name === "direction" || name === "aria-label") {
1293
- this.#syncField();
1375
+ if (oldValue === newValue || !this.#surface) return;
1376
+ if (name === "label" || name === "aria-label") {
1377
+ this.#syncSurface();
1294
1378
  }
1295
1379
  }
1296
1380
 
@@ -1302,7 +1386,9 @@ class PropskitFill extends HTMLElement {
1302
1386
  const customLabel = initialChildren.find(
1303
1387
  (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
1304
1388
  );
1305
- const field = document.createElement("fig-field");
1389
+ const surface = figLabCreateElement("div", {
1390
+ className: "propskit-fill-surface",
1391
+ });
1306
1392
  const label = customLabel || document.createElement("label");
1307
1393
  const picker = document.createElement("fig-fill-picker");
1308
1394
  const swatch = document.createElement("fig-swatch");
@@ -1312,42 +1398,24 @@ class PropskitFill extends HTMLElement {
1312
1398
  for (const node of initialChildren) {
1313
1399
  if (node !== customLabel) picker.appendChild(node);
1314
1400
  }
1315
- field.append(label, picker);
1316
- this.#field = field;
1401
+ surface.append(label, picker);
1402
+ this.#surface = surface;
1317
1403
  this.#label = label;
1318
1404
  this.#input = picker;
1319
1405
  this.#swatch = swatch;
1320
1406
  this.#hasCustomLabel = Boolean(customLabel);
1321
- this.replaceChildren(field);
1407
+ this.replaceChildren(surface);
1322
1408
  }
1323
1409
 
1324
- #syncField() {
1325
- if (!this.#field || !this.#label || !this.#input) return;
1326
- const hasLabelAttr = this.hasAttribute("label");
1327
- const rawLabel = this.getAttribute("label");
1328
- const isBlankLabel = hasLabelAttr && (rawLabel ?? "").trim() === "";
1329
-
1330
- if (isBlankLabel) {
1331
- this.#label.remove();
1332
- } else {
1333
- if (!this.#hasCustomLabel) {
1334
- this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
1335
- }
1336
- if (this.#label.parentElement !== this.#field) {
1337
- this.#field.prepend(this.#label);
1338
- }
1339
- }
1340
-
1341
- this.#field.setAttribute(
1342
- "direction",
1343
- this.getAttribute("direction") || "horizontal",
1344
- );
1345
- this.#input.setAttribute(
1346
- "aria-label",
1347
- this.getAttribute("aria-label") ||
1348
- this.#label.textContent?.trim() ||
1349
- "Fill",
1410
+ #syncSurface() {
1411
+ if (!this.#surface || !this.#label || !this.#input) return;
1412
+ const labelId = figLabSyncPropskitLabel(
1413
+ this,
1414
+ this.#surface,
1415
+ this.#label,
1416
+ this.#hasCustomLabel,
1350
1417
  );
1418
+ figLabSyncPropskitControlLabel(this, this.#input, labelId, "Fill");
1351
1419
  }
1352
1420
 
1353
1421
  #parsedHostFill() {
@@ -1494,14 +1562,7 @@ class PropskitFill extends HTMLElement {
1494
1562
  this.#input.setAttribute("value", serialized);
1495
1563
  }
1496
1564
  this.#syncSwatch(fill);
1497
- this.dispatchEvent(
1498
- new CustomEvent(type, {
1499
- detail: fill,
1500
- bubbles: true,
1501
- cancelable: true,
1502
- composed: true,
1503
- }),
1504
- );
1565
+ figLabDispatchPropskitEvent(this, type);
1505
1566
  }
1506
1567
 
1507
1568
  #handleClick(event) {
@@ -1567,23 +1628,7 @@ class PropskitFill extends HTMLElement {
1567
1628
  const next = this.defaultValue;
1568
1629
  this.setAttribute("value", next);
1569
1630
  this.#forceInputValue(next);
1570
- const fill = figLabParseFillValue(next);
1571
- this.dispatchEvent(
1572
- new CustomEvent("input", {
1573
- detail: fill,
1574
- bubbles: true,
1575
- cancelable: true,
1576
- composed: true,
1577
- }),
1578
- );
1579
- this.dispatchEvent(
1580
- new CustomEvent("change", {
1581
- detail: fill,
1582
- bubbles: true,
1583
- cancelable: true,
1584
- composed: true,
1585
- }),
1586
- );
1631
+ figLabEmitPropskitReset(this);
1587
1632
  }
1588
1633
 
1589
1634
  focus(options) {
@@ -1593,9 +1638,9 @@ class PropskitFill extends HTMLElement {
1593
1638
  }
1594
1639
  figLabDefineElement("propskit-fill", PropskitFill);
1595
1640
 
1596
- /* Field + Gradient wrapper */
1597
- class PropskitGradient extends HTMLElement {
1598
- #field = null;
1641
+ /* PropsKit gradient surface */
1642
+ class PropskitGradient extends FigLabPropskitElement {
1643
+ #surface = null;
1599
1644
  #label = null;
1600
1645
  #input = null;
1601
1646
  #hasCustomLabel = false;
@@ -1607,12 +1652,12 @@ class PropskitGradient extends HTMLElement {
1607
1652
  #initialValue = null;
1608
1653
 
1609
1654
  static get observedAttributes() {
1610
- return ["label", "direction", "aria-label"];
1655
+ return ["label", "aria-label"];
1611
1656
  }
1612
1657
 
1613
1658
  connectedCallback() {
1614
- if (!this.#field) this.#initialize();
1615
- this.#syncField();
1659
+ if (!this.#surface) this.#initialize();
1660
+ this.#syncSurface();
1616
1661
  this.#syncInputAttributes();
1617
1662
  this.#bindInputEvents();
1618
1663
  if (this.#initialValue === null) {
@@ -1627,23 +1672,24 @@ class PropskitGradient extends HTMLElement {
1627
1672
 
1628
1673
  if (!this.#observer) {
1629
1674
  this.#observer = new MutationObserver((mutations) => {
1630
- let syncField = false;
1675
+ let syncSurface = false;
1631
1676
  let syncInput = false;
1632
1677
 
1633
1678
  for (const mutation of mutations) {
1634
1679
  if (mutation.type !== "attributes") continue;
1635
1680
  if (
1636
1681
  mutation.attributeName === "label" ||
1637
- mutation.attributeName === "direction" ||
1638
1682
  mutation.attributeName === "aria-label"
1639
1683
  ) {
1640
- syncField = true;
1684
+ syncSurface = true;
1685
+ } else if (mutation.attributeName === "direction") {
1686
+ continue;
1641
1687
  } else {
1642
1688
  syncInput = true;
1643
1689
  }
1644
1690
  }
1645
1691
 
1646
- if (syncField) this.#syncField();
1692
+ if (syncSurface) this.#syncSurface();
1647
1693
  if (syncInput) this.#syncInputAttributes();
1648
1694
  });
1649
1695
  }
@@ -1659,9 +1705,9 @@ class PropskitGradient extends HTMLElement {
1659
1705
  }
1660
1706
 
1661
1707
  attributeChangedCallback(name, oldValue, newValue) {
1662
- if (oldValue === newValue || !this.#field) return;
1663
- if (name === "label" || name === "direction" || name === "aria-label") {
1664
- this.#syncField();
1708
+ if (oldValue === newValue || !this.#surface) return;
1709
+ if (name === "label" || name === "aria-label") {
1710
+ this.#syncSurface();
1665
1711
  }
1666
1712
  }
1667
1713
 
@@ -1673,46 +1719,30 @@ class PropskitGradient extends HTMLElement {
1673
1719
  const customLabel = initialChildren.find(
1674
1720
  (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
1675
1721
  );
1676
- const field = document.createElement("fig-field");
1722
+ const surface = figLabCreateElement("div", {
1723
+ className: "propskit-gradient-surface",
1724
+ });
1677
1725
  const label = customLabel || document.createElement("label");
1678
1726
  const input = document.createElement("fig-input-gradient");
1679
1727
  input.anchorElement = this;
1680
1728
 
1681
- field.append(label, input);
1682
- this.#field = field;
1729
+ surface.append(label, input);
1730
+ this.#surface = surface;
1683
1731
  this.#label = label;
1684
1732
  this.#input = input;
1685
1733
  this.#hasCustomLabel = Boolean(customLabel);
1686
- this.replaceChildren(field);
1734
+ this.replaceChildren(surface);
1687
1735
  }
1688
1736
 
1689
- #syncField() {
1690
- if (!this.#field || !this.#label || !this.#input) return;
1691
- const hasLabelAttr = this.hasAttribute("label");
1692
- const rawLabel = this.getAttribute("label");
1693
- const isBlankLabel = hasLabelAttr && (rawLabel ?? "").trim() === "";
1694
-
1695
- if (isBlankLabel) {
1696
- this.#label.remove();
1697
- } else {
1698
- if (!this.#hasCustomLabel) {
1699
- this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
1700
- }
1701
- if (this.#label.parentElement !== this.#field) {
1702
- this.#field.prepend(this.#label);
1703
- }
1704
- }
1705
-
1706
- this.#field.setAttribute(
1707
- "direction",
1708
- this.getAttribute("direction") || "horizontal",
1709
- );
1710
- this.#input.setAttribute(
1711
- "aria-label",
1712
- this.getAttribute("aria-label") ||
1713
- this.#label.textContent?.trim() ||
1714
- "Gradient",
1737
+ #syncSurface() {
1738
+ if (!this.#surface || !this.#label || !this.#input) return;
1739
+ const labelId = figLabSyncPropskitLabel(
1740
+ this,
1741
+ this.#surface,
1742
+ this.#label,
1743
+ this.#hasCustomLabel,
1715
1744
  );
1745
+ figLabSyncPropskitControlLabel(this, this.#input, labelId, "Gradient");
1716
1746
  }
1717
1747
 
1718
1748
  #getForwardedInputAttrNames() {
@@ -1789,18 +1819,7 @@ class PropskitGradient extends HTMLElement {
1789
1819
  if (figLabBooleanAttribute(this, "disabled")) return;
1790
1820
  const value = this.#valueFromGradientEvent(event);
1791
1821
  this.setAttribute("value", value);
1792
- const detail =
1793
- event instanceof CustomEvent && event.detail !== undefined
1794
- ? event.detail
1795
- : this.#input?.value;
1796
- this.dispatchEvent(
1797
- new CustomEvent(type, {
1798
- detail,
1799
- bubbles: true,
1800
- cancelable: true,
1801
- composed: true,
1802
- }),
1803
- );
1822
+ figLabDispatchPropskitEvent(this, type);
1804
1823
  }
1805
1824
 
1806
1825
  #handleClick(event) {
@@ -1851,8 +1870,7 @@ class PropskitGradient extends HTMLElement {
1851
1870
  const next = this.defaultValue;
1852
1871
  if (!next) return;
1853
1872
  this.value = next;
1854
- const detail = this.#input?.value;
1855
- figLabEmitPropskitReset(this, detail);
1873
+ figLabEmitPropskitReset(this);
1856
1874
  }
1857
1875
 
1858
1876
  focus(options) {
@@ -1861,74 +1879,36 @@ class PropskitGradient extends HTMLElement {
1861
1879
  }
1862
1880
  figLabDefineElement("propskit-gradient", PropskitGradient);
1863
1881
 
1864
- /* Field + Select wrapper */
1865
- class PropskitSelect extends HTMLElement {
1866
- #field = null;
1882
+ /* PropsKit palette select surface */
1883
+ class PropskitPalette extends FigLabPropskitElement {
1884
+ #surface = null;
1867
1885
  #label = null;
1868
1886
  #select = null;
1869
- #usesFigSelect = false;
1887
+ #palettes = [];
1870
1888
  #hasCustomLabel = false;
1871
1889
  #observer = null;
1872
- #managedSelectAttrs = new Set();
1890
+ #initialValue = [];
1891
+ #eventValue = undefined;
1892
+ #syncing = false;
1893
+ #syncVersion = 0;
1894
+ #buttonPreview = null;
1873
1895
  #boundHandleInput = null;
1874
1896
  #boundHandleChange = null;
1875
1897
  #boundHandleOptionHover = null;
1876
1898
  #boundHandleClick = this.#handleClick.bind(this);
1877
1899
  #boundHandlePointerDown = this.#handlePointerDown.bind(this);
1878
- /** True when pointerdown saw the menu open — skip click-to-open after light-dismiss. */
1879
1900
  #closeGesture = false;
1880
- #initialValue = null;
1881
1901
 
1882
1902
  static get observedAttributes() {
1883
- return ["label", "direction", "aria-label", "options", "value"];
1884
- }
1885
-
1886
- static #canUseFigSelect() {
1887
- return ["fig-select", "fig-select-options", "fig-select-option"].every(
1888
- (tag) => {
1889
- const Constructor = customElements.get(tag);
1890
- return (
1891
- typeof Constructor === "function" &&
1892
- Constructor.prototype instanceof HTMLElement
1893
- );
1894
- },
1895
- );
1896
- }
1897
-
1898
- static #parseOptionsAttribute(raw) {
1899
- const text = raw || "";
1900
- if (text.startsWith("[")) {
1901
- try {
1902
- const parsed = JSON.parse(text);
1903
- return Array.isArray(parsed) ? parsed : [];
1904
- } catch {
1905
- /* fall through */
1906
- }
1907
- }
1908
- const delimiter = text.includes("\n") ? "\n" : ",";
1909
- return text
1910
- .split(delimiter)
1911
- .map((s) => s.trim())
1912
- .filter(Boolean);
1913
- }
1914
-
1915
- static #optionEntryValue(opt) {
1916
- if (opt && typeof opt === "object") {
1917
- return String(opt.value ?? opt.label ?? "");
1918
- }
1919
- return String(opt ?? "");
1920
- }
1921
-
1922
- static #optionEntryLabel(opt) {
1923
- if (opt && typeof opt === "object") {
1924
- return String(opt.label ?? opt.value ?? "");
1925
- }
1926
- return String(opt ?? "");
1903
+ return ["label", "aria-label", "options", "value", "disabled"];
1927
1904
  }
1928
1905
 
1929
1906
  connectedCallback() {
1930
- if (!this.#field) this.#initialize();
1931
- this.#syncField();
1907
+ if (!this.#surface) {
1908
+ this.#initialize();
1909
+ this.#initialValue = this.#clonePalette(this.value);
1910
+ }
1911
+ this.#syncSurface();
1932
1912
  this.#syncSelectAttributes();
1933
1913
  this.#bindSelectEvents();
1934
1914
  this.removeEventListener("click", this.#boundHandleClick);
@@ -1939,28 +1919,31 @@ class PropskitSelect extends HTMLElement {
1939
1919
 
1940
1920
  if (!this.#observer) {
1941
1921
  this.#observer = new MutationObserver((mutations) => {
1942
- let syncField = false;
1943
- let syncSelect = false;
1944
-
1945
- for (const mutation of mutations) {
1946
- if (mutation.type !== "attributes") continue;
1947
- const name = mutation.attributeName;
1948
- if (
1949
- name === "label" ||
1950
- name === "direction" ||
1951
- name === "aria-label"
1952
- ) {
1953
- syncField = true;
1954
- } else {
1955
- syncSelect = true;
1956
- }
1922
+ if (
1923
+ mutations.some(
1924
+ ({ type, attributeName }) =>
1925
+ type === "attributes" &&
1926
+ attributeName &&
1927
+ !PropskitPalette.observedAttributes.includes(attributeName) &&
1928
+ ![
1929
+ "label",
1930
+ "name",
1931
+ "options",
1932
+ "value",
1933
+ "default",
1934
+ "size",
1935
+ "variant",
1936
+ "class",
1937
+ "style",
1938
+ "id",
1939
+ ].includes(attributeName) &&
1940
+ !attributeName.startsWith("data-"),
1941
+ )
1942
+ ) {
1943
+ this.#syncSelectAttributes();
1957
1944
  }
1958
-
1959
- if (syncField) this.#syncField();
1960
- if (syncSelect) this.#syncSelectAttributes();
1961
1945
  });
1962
1946
  }
1963
-
1964
1947
  this.#observer.observe(this, { attributes: true });
1965
1948
  }
1966
1949
 
@@ -1973,157 +1956,204 @@ class PropskitSelect extends HTMLElement {
1973
1956
  }
1974
1957
 
1975
1958
  attributeChangedCallback(name, oldValue, newValue) {
1976
- if (oldValue === newValue || !this.#field) return;
1977
- if (name === "label" || name === "direction" || name === "aria-label") {
1978
- this.#syncField();
1979
- return;
1980
- }
1981
- if (name === "options" || name === "value") {
1959
+ if (oldValue === newValue || !this.#surface) return;
1960
+ if (this.#syncing && name === "value") return;
1961
+ if (name === "label" || name === "aria-label") {
1962
+ this.#syncSurface();
1963
+ } else if (name === "options") {
1964
+ this.#syncOptions();
1965
+ } else if (name === "value") {
1966
+ this.#syncSelection();
1967
+ } else if (name === "disabled") {
1982
1968
  this.#syncSelectAttributes();
1983
1969
  }
1984
1970
  }
1985
1971
 
1986
1972
  #initialize() {
1987
- this.#initialValue = this.getAttribute("value") ?? "";
1988
1973
  const customLabel = this.querySelector(":scope > label");
1989
- const customOptions = this.querySelector(":scope > fig-select-options");
1990
- const field = document.createElement("fig-field");
1974
+ const surface = figLabCreateElement("div", {
1975
+ className: "propskit-palette-surface",
1976
+ });
1991
1977
  const label = customLabel || document.createElement("label");
1992
- this.#usesFigSelect = PropskitSelect.#canUseFigSelect();
1993
- const select = document.createElement(
1994
- this.#usesFigSelect ? "fig-select" : "fig-dropdown",
1995
- );
1996
- // Match menu/control width to the full-surface field.
1978
+ const select = document.createElement("fig-select");
1979
+ const buttonPreview = this.#createPalettePreview([]);
1980
+ buttonPreview.setAttribute("slot", "trigger");
1981
+ buttonPreview.setAttribute("data-propskit-palette-trigger", "");
1982
+ buttonPreview.hidden = true;
1997
1983
  select.setAttribute("full", "");
1998
- if (this.#usesFigSelect && customOptions) select.append(customOptions);
1999
- field.append(label, select);
2000
- this.#field = field;
1984
+ select.setAttribute("subtle", "");
1985
+ select.append(buttonPreview);
1986
+ surface.append(label, select);
1987
+ this.#surface = surface;
2001
1988
  this.#label = label;
2002
1989
  this.#select = select;
1990
+ this.#buttonPreview = buttonPreview;
2003
1991
  this.#hasCustomLabel = Boolean(customLabel);
2004
- this.replaceChildren(field);
1992
+ this.replaceChildren(surface);
1993
+ this.#syncOptions();
2005
1994
  }
2006
1995
 
2007
- #syncField() {
2008
- if (!this.#field || !this.#label || !this.#select) return;
2009
- const hasLabelAttr = this.hasAttribute("label");
2010
- const rawLabel = this.getAttribute("label");
2011
- const isBlankLabel = hasLabelAttr && (rawLabel ?? "").trim() === "";
1996
+ #clonePalette(palette) {
1997
+ if (!Array.isArray(palette)) return [];
1998
+ return palette.map((entry) => ({ ...entry }));
1999
+ }
2012
2000
 
2013
- if (isBlankLabel) {
2014
- this.#label.remove();
2001
+ #normalizePalette(value) {
2002
+ const palette = document.createElement("fig-input-palette");
2003
+ if (typeof value === "string") {
2004
+ palette.setAttribute("value", value);
2015
2005
  } else {
2016
- if (!this.#hasCustomLabel) {
2017
- this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
2018
- }
2019
- if (this.#label.parentElement !== this.#field) {
2020
- this.#field.prepend(this.#label);
2021
- }
2006
+ palette.setAttribute("value", JSON.stringify(value ?? []));
2022
2007
  }
2008
+ return this.#clonePalette(palette.value);
2009
+ }
2023
2010
 
2024
- this.#field.setAttribute(
2025
- "direction",
2026
- this.getAttribute("direction") || "horizontal",
2027
- );
2028
- this.#select.setAttribute(
2029
- "label",
2030
- this.getAttribute("aria-label") ||
2031
- this.#label.textContent?.trim() ||
2032
- "Select",
2033
- );
2034
- // Always match menu width to the control surface.
2035
- this.#select.setAttribute("full", "");
2011
+ #serializePalette(palette) {
2012
+ return JSON.stringify(this.#normalizePalette(palette));
2036
2013
  }
2037
2014
 
2038
- #getForwardedSelectAttrNames() {
2039
- const reserved = new Set([
2040
- "label",
2041
- "direction",
2042
- "oninput",
2043
- "onchange",
2044
- "class",
2045
- "style",
2046
- "id",
2047
- "size",
2048
- "aria-label",
2049
- "full",
2050
- "default",
2051
- "variant",
2052
- ]);
2053
- // fig-dropdown consumes light-DOM <option>s, not an options attribute.
2054
- if (!this.#usesFigSelect) reserved.add("options");
2055
- return this.getAttributeNames().filter(
2056
- (name) => !reserved.has(name) && !name.startsWith("data-"),
2057
- );
2015
+ #parseOptions() {
2016
+ try {
2017
+ const options = JSON.parse(this.getAttribute("options") || "[]");
2018
+ if (!Array.isArray(options)) return [];
2019
+ return options
2020
+ .filter((palette) => Array.isArray(palette))
2021
+ .map((palette) => this.#normalizePalette(palette));
2022
+ } catch {
2023
+ return [];
2024
+ }
2058
2025
  }
2059
2026
 
2060
- #syncDropdownOptions() {
2061
- if (!this.#select || this.#usesFigSelect) return;
2062
- const parsed = PropskitSelect.#parseOptionsAttribute(
2063
- this.getAttribute("options"),
2064
- );
2065
- // Keep fig-dropdown's internal <select>; only replace slotted options.
2066
- for (const child of [...this.#select.children]) {
2067
- if (child.localName === "option" || child.localName === "optgroup") {
2068
- child.remove();
2069
- }
2070
- }
2071
- for (const entry of parsed) {
2072
- const option = document.createElement("option");
2073
- option.value = PropskitSelect.#optionEntryValue(entry);
2074
- option.textContent = PropskitSelect.#optionEntryLabel(entry);
2075
- this.#select.appendChild(option);
2076
- }
2077
- // Re-apply value after options land; fig-dropdown clones via async slotchange.
2078
- const value = this.getAttribute("value");
2079
- if (value != null) {
2080
- const applyValue = () => {
2081
- this.#select?.setAttribute("value", value);
2082
- if (this.#select && "value" in this.#select) {
2083
- this.#select.value = value;
2084
- }
2085
- };
2086
- applyValue();
2087
- queueMicrotask(applyValue);
2088
- }
2027
+ #createPalettePreview(palette) {
2028
+ const preview = document.createElement("fig-input-palette");
2029
+ preview.className = "propskit-palette-preview";
2030
+ preview.setAttribute("value", JSON.stringify(palette));
2031
+ preview.setAttribute("fixed", "");
2032
+ preview.setAttribute("disabled", "");
2033
+ preview.setAttribute("full", "");
2034
+ preview.setAttribute("aria-hidden", "true");
2035
+ return preview;
2089
2036
  }
2090
2037
 
2091
- #syncSelectAttributes() {
2038
+ #syncButtonPreview(palette) {
2039
+ if (!this.#buttonPreview) return;
2040
+ this.#buttonPreview.setAttribute("value", JSON.stringify(palette));
2041
+ this.#buttonPreview.hidden = !palette.length;
2042
+ }
2043
+
2044
+ #syncOptions() {
2092
2045
  if (!this.#select) return;
2093
- if (!this.#usesFigSelect) this.#syncDropdownOptions();
2094
- const selectAttrs = this.#getForwardedSelectAttrNames().sort((a, b) => {
2095
- // Build options before applying value so fig-select can resolve selection.
2096
- if (a === "options") return -1;
2097
- if (b === "options") return 1;
2098
- if (a === "value") return 1;
2099
- if (b === "value") return -1;
2100
- return 0;
2046
+ const syncVersion = ++this.#syncVersion;
2047
+ this.#palettes = this.#parseOptions();
2048
+ const panel = document.createElement("fig-select-options");
2049
+ panel.setAttribute("slot", "panel");
2050
+ panel.className = "propskit-palette-options";
2051
+
2052
+ this.#palettes.forEach((palette, index) => {
2053
+ const option = document.createElement("fig-select-option");
2054
+ const previewSlot = document.createElement("span");
2055
+ option.setAttribute("value", String(index));
2056
+ option.setAttribute("label", `Palette ${index + 1}`);
2057
+ option.setAttribute("aria-label", `Palette ${index + 1}`);
2058
+ previewSlot.setAttribute("slot", "prepend");
2059
+ previewSlot.className = "propskit-palette-option-preview";
2060
+ previewSlot.append(this.#createPalettePreview(palette));
2061
+ option.append(previewSlot);
2062
+ panel.append(option);
2101
2063
  });
2102
- const nextManaged = new Set(selectAttrs);
2103
2064
 
2104
- for (const attrName of this.#managedSelectAttrs) {
2105
- if (!nextManaged.has(attrName)) this.#select.removeAttribute(attrName);
2106
- }
2107
- for (const attrName of selectAttrs) {
2108
- this.#select.setAttribute(attrName, this.getAttribute(attrName) ?? "");
2065
+ this.#syncing = true;
2066
+ this.#select.replaceChildren(this.#buttonPreview, panel);
2067
+ this.#syncSelection();
2068
+ queueMicrotask(() => {
2069
+ if (syncVersion !== this.#syncVersion) return;
2070
+ this.#syncSelection();
2071
+ this.#syncing = false;
2072
+ });
2073
+ }
2074
+
2075
+ #paletteIndex(value) {
2076
+ const palette = this.#normalizePalette(value);
2077
+ return this.#palettes.findIndex((option) =>
2078
+ figLabPropskitJsonValuesEqual(option, palette),
2079
+ );
2080
+ }
2081
+
2082
+ #reflectPaletteValue(palette) {
2083
+ const serialized = this.#serializePalette(palette);
2084
+ if (this.getAttribute("value") === serialized) return;
2085
+ const wasSyncing = this.#syncing;
2086
+ this.#syncing = true;
2087
+ this.setAttribute("value", serialized);
2088
+ this.#syncing = wasSyncing;
2089
+ }
2090
+
2091
+ #syncSelection() {
2092
+ if (!this.#select) return;
2093
+ if (!this.#palettes.length) {
2094
+ this.#select.removeAttribute("value");
2095
+ this.#syncButtonPreview([]);
2096
+ const wasSyncing = this.#syncing;
2097
+ this.#syncing = true;
2098
+ this.removeAttribute("value");
2099
+ this.#syncing = wasSyncing;
2100
+ return;
2109
2101
  }
2110
2102
 
2111
- this.#managedSelectAttrs = nextManaged;
2103
+ const requested = this.hasAttribute("value")
2104
+ ? this.getAttribute("value")
2105
+ : this.#palettes[0];
2106
+ const requestedIndex = this.#paletteIndex(requested);
2107
+ const index = requestedIndex >= 0 ? requestedIndex : 0;
2108
+ this.#select.setAttribute("value", String(index));
2109
+ this.#reflectPaletteValue(this.#palettes[index]);
2110
+ this.#syncButtonPreview(this.#palettes[index]);
2111
+ }
2112
+
2113
+ #syncSurface() {
2114
+ if (!this.#surface || !this.#label || !this.#select) return;
2115
+ const labelId = figLabSyncPropskitLabel(
2116
+ this,
2117
+ this.#surface,
2118
+ this.#label,
2119
+ this.#hasCustomLabel,
2120
+ );
2121
+ figLabSyncPropskitControlLabel(this, this.#select, labelId, "Palette");
2122
+ this.#select.setAttribute(
2123
+ "label",
2124
+ this.getAttribute("aria-label") ||
2125
+ this.#label.textContent?.trim() ||
2126
+ "Palette",
2127
+ );
2128
+ this.#select.setAttribute("full", "");
2129
+ }
2130
+
2131
+ #syncSelectAttributes() {
2132
+ if (!this.#select) return;
2133
+ this.#select.toggleAttribute(
2134
+ "disabled",
2135
+ figLabBooleanAttribute(this, "disabled"),
2136
+ );
2137
+ for (const name of ["position", "offset", "closedby"]) {
2138
+ if (this.hasAttribute(name)) {
2139
+ this.#select.setAttribute(name, this.getAttribute(name) ?? "");
2140
+ } else {
2141
+ this.#select.removeAttribute(name);
2142
+ }
2143
+ }
2112
2144
  }
2113
2145
 
2114
2146
  #bindSelectEvents() {
2115
2147
  if (!this.#select) return;
2116
2148
  this.#boundHandleInput ??= this.#forwardSelectEvent.bind(this, "input");
2117
2149
  this.#boundHandleChange ??= this.#forwardSelectEvent.bind(this, "change");
2150
+ this.#boundHandleOptionHover ??= this.#forwardSelectEvent.bind(
2151
+ this,
2152
+ "optionhover",
2153
+ );
2118
2154
  this.#select.addEventListener("input", this.#boundHandleInput);
2119
2155
  this.#select.addEventListener("change", this.#boundHandleChange);
2120
- if (this.#usesFigSelect) {
2121
- this.#boundHandleOptionHover ??= this.#forwardSelectEvent.bind(
2122
- this,
2123
- "optionhover",
2124
- );
2125
- this.#select.addEventListener("optionhover", this.#boundHandleOptionHover);
2126
- }
2156
+ this.#select.addEventListener("optionhover", this.#boundHandleOptionHover);
2127
2157
  }
2128
2158
 
2129
2159
  #unbindSelectEvents() {
@@ -2142,28 +2172,37 @@ class PropskitSelect extends HTMLElement {
2142
2172
  }
2143
2173
  }
2144
2174
 
2175
+ #paletteAt(index) {
2176
+ return this.#clonePalette(this.#palettes[index] ?? []);
2177
+ }
2178
+
2179
+ #dispatchPropskitEvent(type, palette) {
2180
+ this.#eventValue = palette;
2181
+ try {
2182
+ figLabDispatchPropskitEvent(this, type, palette);
2183
+ } finally {
2184
+ this.#eventValue = undefined;
2185
+ }
2186
+ }
2187
+
2145
2188
  #forwardSelectEvent(type, event) {
2146
2189
  if (event.target !== this.#select) return;
2147
2190
  event.stopImmediatePropagation();
2148
- if (figLabBooleanAttribute(this, "disabled")) return;
2149
- const value = this.#select?.value ?? "";
2150
- if (type !== "optionhover") this.setAttribute("value", String(value));
2151
- const detail =
2152
- event instanceof CustomEvent && event.detail !== undefined
2191
+ if (this.#syncing || figLabBooleanAttribute(this, "disabled")) return;
2192
+ const rawIndex =
2193
+ type === "optionhover" && event instanceof CustomEvent
2153
2194
  ? event.detail
2154
- : value;
2155
- this.dispatchEvent(
2156
- new CustomEvent(type, {
2157
- detail,
2158
- bubbles: true,
2159
- cancelable: true,
2160
- composed: true,
2161
- }),
2162
- );
2195
+ : this.#select.getAttribute("value");
2196
+ const palette = this.#paletteAt(Number(rawIndex));
2197
+ if (type !== "optionhover") {
2198
+ this.#reflectPaletteValue(palette);
2199
+ this.#syncButtonPreview(palette);
2200
+ }
2201
+ this.#dispatchPropskitEvent(type, palette);
2163
2202
  }
2164
2203
 
2165
2204
  #isSelectMenuOpen() {
2166
- if (!this.#select || !this.#usesFigSelect) return false;
2205
+ if (!this.#select) return false;
2167
2206
  if (this.#select.open) return true;
2168
2207
  const popup = this.#select.shadowRoot?.querySelector(
2169
2208
  'dialog[is="fig-popup"]',
@@ -2174,24 +2213,18 @@ class PropskitSelect extends HTMLElement {
2174
2213
  #handlePointerDown(event) {
2175
2214
  if (!(event.target instanceof Element)) return;
2176
2215
  if (event.target.closest("fig-menu")) return;
2177
- // Native dropdown / fig-select own their own trigger clicks.
2178
- if (event.target.closest("fig-select, fig-dropdown")) {
2216
+ if (event.target.closest("fig-select")) {
2179
2217
  this.#closeGesture = false;
2180
2218
  return;
2181
2219
  }
2182
- if (!this.#usesFigSelect) return;
2183
- // Light-dismiss closes on pointerdown; remember so click doesn't reopen.
2184
2220
  this.#closeGesture = this.#isSelectMenuOpen();
2185
2221
  }
2186
2222
 
2187
2223
  #handleClick(event) {
2188
2224
  if (figLabBooleanAttribute(this, "disabled")) return;
2189
- if (event.target instanceof Element && event.target.closest("fig-menu")) {
2190
- return;
2191
- }
2192
2225
  if (
2193
2226
  event.target instanceof Element &&
2194
- event.target.closest("fig-select, fig-dropdown")
2227
+ event.target.closest("fig-menu, fig-select")
2195
2228
  ) {
2196
2229
  this.#closeGesture = false;
2197
2230
  return;
@@ -2201,96 +2234,110 @@ class PropskitSelect extends HTMLElement {
2201
2234
  return;
2202
2235
  }
2203
2236
  this.#select.focus();
2204
- if (!this.#usesFigSelect) return;
2205
-
2206
2237
  if (this.#closeGesture || this.#isSelectMenuOpen()) {
2207
2238
  this.#closeGesture = false;
2208
2239
  this.#select.open = false;
2209
2240
  return;
2210
2241
  }
2211
-
2212
2242
  this.#select.open = true;
2213
2243
  }
2214
2244
 
2215
2245
  get value() {
2216
- return this.#select?.value ?? this.getAttribute("value") ?? "";
2246
+ if (this.#eventValue !== undefined) {
2247
+ return this.#eventValue;
2248
+ }
2249
+ const index = Number(this.#select?.getAttribute("value"));
2250
+ if (Number.isInteger(index) && index >= 0) return this.#paletteAt(index);
2251
+ if (this.hasAttribute("value")) {
2252
+ return this.#normalizePalette(this.getAttribute("value"));
2253
+ }
2254
+ return [];
2217
2255
  }
2218
2256
 
2219
2257
  set value(nextValue) {
2220
2258
  if (nextValue === null || nextValue === undefined) {
2221
2259
  this.removeAttribute("value");
2222
- } else {
2223
- this.setAttribute("value", String(nextValue));
2260
+ return;
2224
2261
  }
2262
+ this.setAttribute("value", this.#serializePalette(nextValue));
2225
2263
  }
2226
2264
 
2227
2265
  get defaultValue() {
2228
- return this.getAttribute("default") ?? this.#initialValue ?? "";
2266
+ if (this.hasAttribute("default")) {
2267
+ return this.#normalizePalette(this.getAttribute("default"));
2268
+ }
2269
+ return this.#clonePalette(this.#initialValue);
2229
2270
  }
2230
2271
 
2231
2272
  get isDefault() {
2232
- return figLabPropskitValuesEqual(this.value, this.defaultValue);
2273
+ return figLabPropskitJsonValuesEqual(this.value, this.defaultValue);
2233
2274
  }
2234
2275
 
2235
2276
  resetToDefault() {
2236
- const value = this.defaultValue;
2237
- this.value = value;
2238
- if (this.#select) this.#select.value = value;
2239
- figLabEmitPropskitReset(this, value);
2277
+ this.value = this.defaultValue;
2278
+ for (const type of ["input", "change"]) {
2279
+ this.#dispatchPropskitEvent(type, this.value);
2280
+ }
2240
2281
  }
2241
2282
 
2242
2283
  focus(options) {
2243
2284
  this.#select?.focus(options);
2244
2285
  }
2245
2286
  }
2246
- figLabDefineElement("propskit-select", PropskitSelect);
2287
+ figLabDefineElement("propskit-palette", PropskitPalette);
2247
2288
 
2248
- /* Field + Text wrapper */
2249
- class PropskitText extends HTMLElement {
2250
- #field = null;
2289
+ /* PropsKit select surface */
2290
+ class PropskitSelect extends FigLabPropskitElement {
2291
+ #surface = null;
2251
2292
  #label = null;
2252
- #input = null;
2293
+ #select = null;
2253
2294
  #hasCustomLabel = false;
2254
2295
  #observer = null;
2255
- #managedInputAttrs = new Set();
2296
+ #managedSelectAttrs = new Set();
2256
2297
  #boundHandleInput = null;
2257
2298
  #boundHandleChange = null;
2299
+ #boundHandleOptionHover = null;
2258
2300
  #boundHandleClick = this.#handleClick.bind(this);
2301
+ #boundHandlePointerDown = this.#handlePointerDown.bind(this);
2302
+ /** True when pointerdown saw the menu open — skip click-to-open after light-dismiss. */
2303
+ #closeGesture = false;
2304
+ #eventValue = undefined;
2259
2305
  #initialValue = null;
2260
2306
 
2261
2307
  static get observedAttributes() {
2262
- return ["label", "direction", "aria-label"];
2308
+ return ["label", "aria-label", "options", "value"];
2263
2309
  }
2264
2310
 
2265
2311
  connectedCallback() {
2266
- if (!this.#field) this.#initialize();
2267
- this.#syncField();
2268
- this.#syncInputAttributes();
2269
- this.#bindInputEvents();
2312
+ if (!this.#surface) this.#initialize();
2313
+ this.#syncSurface();
2314
+ this.#syncSelectAttributes();
2315
+ this.#bindSelectEvents();
2270
2316
  this.removeEventListener("click", this.#boundHandleClick);
2271
2317
  this.addEventListener("click", this.#boundHandleClick);
2318
+ this.removeEventListener("pointerdown", this.#boundHandlePointerDown, true);
2319
+ this.addEventListener("pointerdown", this.#boundHandlePointerDown, true);
2272
2320
  figLabConnectPropskitResetMenu(this);
2273
2321
 
2274
2322
  if (!this.#observer) {
2275
2323
  this.#observer = new MutationObserver((mutations) => {
2276
- let syncField = false;
2277
- let syncInput = false;
2324
+ let syncSurface = false;
2325
+ let syncSelect = false;
2278
2326
 
2279
2327
  for (const mutation of mutations) {
2280
2328
  if (mutation.type !== "attributes") continue;
2281
- if (
2282
- mutation.attributeName === "label" ||
2283
- mutation.attributeName === "direction" ||
2284
- mutation.attributeName === "aria-label"
2285
- ) {
2286
- syncField = true;
2329
+ const name = mutation.attributeName;
2330
+ if (name === "label" || name === "aria-label") {
2331
+ syncSurface = true;
2332
+ } else if (name === "direction") {
2333
+ continue;
2287
2334
  } else {
2288
- syncInput = true;
2335
+ syncSelect = true;
2289
2336
  }
2290
2337
  }
2291
2338
 
2292
- if (syncField) this.#syncField();
2293
- if (syncInput) this.#syncInputAttributes();
2339
+ if (syncSurface) this.#syncSurface();
2340
+ if (syncSelect) this.#syncSelectAttributes();
2294
2341
  });
2295
2342
  }
2296
2343
 
@@ -2299,72 +2346,63 @@ class PropskitText extends HTMLElement {
2299
2346
 
2300
2347
  disconnectedCallback() {
2301
2348
  this.#observer?.disconnect();
2302
- this.#unbindInputEvents();
2349
+ this.#unbindSelectEvents();
2303
2350
  this.removeEventListener("click", this.#boundHandleClick);
2351
+ this.removeEventListener("pointerdown", this.#boundHandlePointerDown, true);
2304
2352
  figLabDisconnectPropskitResetMenu(this);
2305
2353
  }
2306
2354
 
2307
2355
  attributeChangedCallback(name, oldValue, newValue) {
2308
- if (oldValue === newValue || !this.#field) return;
2309
- if (name === "label" || name === "direction" || name === "aria-label") {
2310
- this.#syncField();
2356
+ if (oldValue === newValue || !this.#surface) return;
2357
+ if (name === "label" || name === "aria-label") {
2358
+ this.#syncSurface();
2359
+ return;
2360
+ }
2361
+ if (name === "options" || name === "value") {
2362
+ this.#syncSelectAttributes();
2311
2363
  }
2312
2364
  }
2313
2365
 
2314
2366
  #initialize() {
2315
2367
  this.#initialValue = this.getAttribute("value") ?? "";
2316
- const initialChildren = Array.from(this.childNodes).filter(
2317
- (node) =>
2318
- node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
2319
- );
2320
- const customLabel = initialChildren.find(
2321
- (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
2322
- );
2323
- const field = document.createElement("fig-field");
2368
+ const customLabel = this.querySelector(":scope > label");
2369
+ const customOptions = this.querySelector(":scope > fig-select-options");
2370
+ const surface = figLabCreateElement("div", {
2371
+ className: "propskit-select-surface",
2372
+ });
2324
2373
  const label = customLabel || document.createElement("label");
2325
- const input = document.createElement("fig-input-text");
2326
-
2327
- for (const node of initialChildren) {
2328
- if (node !== customLabel) input.appendChild(node);
2329
- }
2330
- field.append(label, input);
2331
- this.#field = field;
2374
+ const select = document.createElement("fig-select");
2375
+ // Match menu/control width to the full surface.
2376
+ select.setAttribute("full", "");
2377
+ if (customOptions) select.append(customOptions);
2378
+ surface.append(label, select);
2379
+ this.#surface = surface;
2332
2380
  this.#label = label;
2333
- this.#input = input;
2381
+ this.#select = select;
2334
2382
  this.#hasCustomLabel = Boolean(customLabel);
2335
- this.replaceChildren(field);
2383
+ this.replaceChildren(surface);
2336
2384
  }
2337
2385
 
2338
- #syncField() {
2339
- if (!this.#field || !this.#label || !this.#input) return;
2340
- const hasLabelAttr = this.hasAttribute("label");
2341
- const rawLabel = this.getAttribute("label");
2342
- const isBlankLabel = hasLabelAttr && (rawLabel ?? "").trim() === "";
2343
-
2344
- if (isBlankLabel) {
2345
- this.#label.remove();
2346
- } else {
2347
- if (!this.#hasCustomLabel) {
2348
- this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
2349
- }
2350
- if (this.#label.parentElement !== this.#field) {
2351
- this.#field.prepend(this.#label);
2352
- }
2353
- }
2354
-
2355
- this.#field.setAttribute(
2356
- "direction",
2357
- this.getAttribute("direction") || "horizontal",
2386
+ #syncSurface() {
2387
+ if (!this.#surface || !this.#label || !this.#select) return;
2388
+ const labelId = figLabSyncPropskitLabel(
2389
+ this,
2390
+ this.#surface,
2391
+ this.#label,
2392
+ this.#hasCustomLabel,
2358
2393
  );
2359
- this.#input.setAttribute(
2360
- "aria-label",
2394
+ figLabSyncPropskitControlLabel(this, this.#select, labelId, "Select");
2395
+ this.#select.setAttribute(
2396
+ "label",
2361
2397
  this.getAttribute("aria-label") ||
2362
2398
  this.#label.textContent?.trim() ||
2363
- "Text",
2399
+ "Select",
2364
2400
  );
2401
+ // Always match menu width to the control surface.
2402
+ this.#select.setAttribute("full", "");
2365
2403
  }
2366
2404
 
2367
- #getForwardedInputAttrNames() {
2405
+ #getForwardedSelectAttrNames() {
2368
2406
  const reserved = new Set([
2369
2407
  "label",
2370
2408
  "direction",
@@ -2375,8 +2413,7 @@ class PropskitText extends HTMLElement {
2375
2413
  "id",
2376
2414
  "size",
2377
2415
  "aria-label",
2378
- "multiline",
2379
- "resizable",
2416
+ "full",
2380
2417
  "default",
2381
2418
  "variant",
2382
2419
  ]);
@@ -2385,81 +2422,139 @@ class PropskitText extends HTMLElement {
2385
2422
  );
2386
2423
  }
2387
2424
 
2388
- #syncInputAttributes() {
2389
- if (!this.#input) return;
2390
- const inputAttrs = this.#getForwardedInputAttrNames();
2391
- const nextManaged = new Set(inputAttrs);
2425
+ #syncSelectAttributes() {
2426
+ if (!this.#select) return;
2427
+ const selectAttrs = this.#getForwardedSelectAttrNames().sort((a, b) => {
2428
+ // Build options before applying value so fig-select can resolve selection.
2429
+ if (a === "options") return -1;
2430
+ if (b === "options") return 1;
2431
+ if (a === "value") return 1;
2432
+ if (b === "value") return -1;
2433
+ return 0;
2434
+ });
2435
+ const nextManaged = new Set(selectAttrs);
2392
2436
 
2393
- for (const attrName of this.#managedInputAttrs) {
2394
- if (!nextManaged.has(attrName)) this.#input.removeAttribute(attrName);
2437
+ for (const attrName of this.#managedSelectAttrs) {
2438
+ if (!nextManaged.has(attrName)) this.#select.removeAttribute(attrName);
2395
2439
  }
2396
- for (const attrName of inputAttrs) {
2397
- this.#input.setAttribute(attrName, this.getAttribute(attrName) ?? "");
2440
+ for (const attrName of selectAttrs) {
2441
+ this.#select.setAttribute(attrName, this.getAttribute(attrName) ?? "");
2398
2442
  }
2399
2443
 
2400
- this.#managedInputAttrs = nextManaged;
2444
+ this.#managedSelectAttrs = nextManaged;
2401
2445
  }
2402
2446
 
2403
- #bindInputEvents() {
2404
- if (!this.#input) return;
2405
- this.#boundHandleInput ??= this.#forwardInputEvent.bind(this, "input");
2406
- this.#boundHandleChange ??= this.#forwardInputEvent.bind(this, "change");
2407
- this.#input.addEventListener("input", this.#boundHandleInput);
2408
- this.#input.addEventListener("change", this.#boundHandleChange);
2447
+ #bindSelectEvents() {
2448
+ if (!this.#select) return;
2449
+ this.#boundHandleInput ??= this.#forwardSelectEvent.bind(this, "input");
2450
+ this.#boundHandleChange ??= this.#forwardSelectEvent.bind(this, "change");
2451
+ this.#select.addEventListener("input", this.#boundHandleInput);
2452
+ this.#select.addEventListener("change", this.#boundHandleChange);
2453
+ this.#boundHandleOptionHover ??= this.#forwardSelectEvent.bind(
2454
+ this,
2455
+ "optionhover",
2456
+ );
2457
+ this.#select.addEventListener("optionhover", this.#boundHandleOptionHover);
2409
2458
  }
2410
2459
 
2411
- #unbindInputEvents() {
2412
- if (!this.#input) return;
2460
+ #unbindSelectEvents() {
2461
+ if (!this.#select) return;
2413
2462
  if (this.#boundHandleInput) {
2414
- this.#input.removeEventListener("input", this.#boundHandleInput);
2463
+ this.#select.removeEventListener("input", this.#boundHandleInput);
2415
2464
  }
2416
2465
  if (this.#boundHandleChange) {
2417
- this.#input.removeEventListener("change", this.#boundHandleChange);
2466
+ this.#select.removeEventListener("change", this.#boundHandleChange);
2467
+ }
2468
+ if (this.#boundHandleOptionHover) {
2469
+ this.#select.removeEventListener(
2470
+ "optionhover",
2471
+ this.#boundHandleOptionHover,
2472
+ );
2418
2473
  }
2419
2474
  }
2420
2475
 
2421
- #forwardInputEvent(type, event) {
2476
+ #forwardSelectEvent(type, event) {
2477
+ if (event.target !== this.#select) return;
2422
2478
  event.stopImmediatePropagation();
2423
2479
  if (figLabBooleanAttribute(this, "disabled")) return;
2424
- const value = this.#input?.value ?? "";
2425
- this.setAttribute("value", String(value));
2426
- const detail =
2427
- event instanceof CustomEvent && event.detail !== undefined
2480
+ const value = this.#select?.value ?? "";
2481
+ if (type !== "optionhover") this.setAttribute("value", String(value));
2482
+ const eventValue =
2483
+ type === "optionhover" &&
2484
+ event instanceof CustomEvent &&
2485
+ event.detail !== undefined
2428
2486
  ? event.detail
2429
- : value;
2430
- this.dispatchEvent(
2431
- new CustomEvent(type, {
2432
- detail,
2433
- bubbles: true,
2434
- cancelable: true,
2435
- composed: true,
2436
- }),
2487
+ : this.value;
2488
+ if (type === "optionhover") this.#eventValue = eventValue;
2489
+ try {
2490
+ figLabDispatchPropskitEvent(this, type, eventValue);
2491
+ } finally {
2492
+ this.#eventValue = undefined;
2493
+ }
2494
+ }
2495
+
2496
+ #isSelectMenuOpen() {
2497
+ if (!this.#select) return false;
2498
+ if (this.#select.open) return true;
2499
+ const popup = this.#select.shadowRoot?.querySelector(
2500
+ 'dialog[is="fig-popup"]',
2437
2501
  );
2502
+ return Boolean(popup?.open || popup?.matches?.(":open"));
2503
+ }
2504
+
2505
+ #handlePointerDown(event) {
2506
+ if (!(event.target instanceof Element)) return;
2507
+ if (event.target.closest("fig-menu")) return;
2508
+ // fig-select owns its trigger clicks.
2509
+ if (event.target.closest("fig-select")) {
2510
+ this.#closeGesture = false;
2511
+ return;
2512
+ }
2513
+ // Light-dismiss closes on pointerdown; remember so click doesn't reopen.
2514
+ this.#closeGesture = this.#isSelectMenuOpen();
2438
2515
  }
2439
2516
 
2440
2517
  #handleClick(event) {
2441
2518
  if (figLabBooleanAttribute(this, "disabled")) return;
2519
+ if (event.target instanceof Element && event.target.closest("fig-menu")) {
2520
+ return;
2521
+ }
2442
2522
  if (
2443
2523
  event.target instanceof Element &&
2444
- event.target.closest("fig-input-text, fig-menu")
2524
+ event.target.closest("fig-select")
2445
2525
  ) {
2526
+ this.#closeGesture = false;
2446
2527
  return;
2447
2528
  }
2448
- this.focus();
2529
+ if (!this.#select || figLabBooleanAttribute(this.#select, "disabled")) {
2530
+ this.#closeGesture = false;
2531
+ return;
2532
+ }
2533
+ this.#select.focus();
2534
+
2535
+ if (this.#closeGesture || this.#isSelectMenuOpen()) {
2536
+ this.#closeGesture = false;
2537
+ this.#select.open = false;
2538
+ return;
2539
+ }
2540
+
2541
+ this.#select.open = true;
2449
2542
  }
2450
2543
 
2451
2544
  get value() {
2452
- return this.#input?.value ?? this.getAttribute("value") ?? "";
2545
+ return (
2546
+ this.#eventValue ??
2547
+ this.#select?.value ??
2548
+ this.getAttribute("value") ??
2549
+ ""
2550
+ );
2453
2551
  }
2454
2552
 
2455
2553
  set value(nextValue) {
2456
2554
  if (nextValue === null || nextValue === undefined) {
2457
2555
  this.removeAttribute("value");
2458
- if (this.#input) this.#input.value = "";
2459
2556
  } else {
2460
- const next = String(nextValue);
2461
- this.setAttribute("value", next);
2462
- if (this.#input) this.#input.value = next;
2557
+ this.setAttribute("value", String(nextValue));
2463
2558
  }
2464
2559
  }
2465
2560
 
@@ -2474,18 +2569,19 @@ class PropskitText extends HTMLElement {
2474
2569
  resetToDefault() {
2475
2570
  const value = this.defaultValue;
2476
2571
  this.value = value;
2477
- figLabEmitPropskitReset(this, value);
2572
+ if (this.#select) this.#select.value = value;
2573
+ figLabEmitPropskitReset(this);
2478
2574
  }
2479
2575
 
2480
2576
  focus(options) {
2481
- this.#input?.focus(options);
2577
+ this.#select?.focus(options);
2482
2578
  }
2483
2579
  }
2484
- figLabDefineElement("propskit-text", PropskitText);
2580
+ figLabDefineElement("propskit-select", PropskitSelect);
2485
2581
 
2486
- /* Field + Number wrapper */
2487
- class PropskitNumber extends HTMLElement {
2488
- #field = null;
2582
+ /* PropsKit text surface */
2583
+ class PropskitText extends FigLabPropskitElement {
2584
+ #surface = null;
2489
2585
  #label = null;
2490
2586
  #input = null;
2491
2587
  #hasCustomLabel = false;
@@ -2497,12 +2593,12 @@ class PropskitNumber extends HTMLElement {
2497
2593
  #initialValue = null;
2498
2594
 
2499
2595
  static get observedAttributes() {
2500
- return ["label", "direction"];
2596
+ return ["label", "aria-label"];
2501
2597
  }
2502
2598
 
2503
2599
  connectedCallback() {
2504
- if (!this.#field) this.#initialize();
2505
- this.#syncField();
2600
+ if (!this.#surface) this.#initialize();
2601
+ this.#syncSurface();
2506
2602
  this.#syncInputAttributes();
2507
2603
  this.#bindInputEvents();
2508
2604
  this.removeEventListener("click", this.#boundHandleClick);
@@ -2511,22 +2607,24 @@ class PropskitNumber extends HTMLElement {
2511
2607
 
2512
2608
  if (!this.#observer) {
2513
2609
  this.#observer = new MutationObserver((mutations) => {
2514
- let syncField = false;
2610
+ let syncSurface = false;
2515
2611
  let syncInput = false;
2516
2612
 
2517
2613
  for (const mutation of mutations) {
2518
2614
  if (mutation.type !== "attributes") continue;
2519
2615
  if (
2520
2616
  mutation.attributeName === "label" ||
2521
- mutation.attributeName === "direction"
2617
+ mutation.attributeName === "aria-label"
2522
2618
  ) {
2523
- syncField = true;
2619
+ syncSurface = true;
2620
+ } else if (mutation.attributeName === "direction") {
2621
+ continue;
2524
2622
  } else {
2525
2623
  syncInput = true;
2526
2624
  }
2527
2625
  }
2528
2626
 
2529
- if (syncField) this.#syncField();
2627
+ if (syncSurface) this.#syncSurface();
2530
2628
  if (syncInput) this.#syncInputAttributes();
2531
2629
  });
2532
2630
  }
@@ -2542,13 +2640,14 @@ class PropskitNumber extends HTMLElement {
2542
2640
  }
2543
2641
 
2544
2642
  attributeChangedCallback(name, oldValue, newValue) {
2545
- if (oldValue === newValue || !this.#field) return;
2546
- if (name === "label" || name === "direction") this.#syncField();
2643
+ if (oldValue === newValue || !this.#surface) return;
2644
+ if (name === "label" || name === "aria-label") {
2645
+ this.#syncSurface();
2646
+ }
2547
2647
  }
2548
2648
 
2549
2649
  #initialize() {
2550
- this.#initialValue =
2551
- this.getAttribute("value") ?? this.getAttribute("min") ?? "0";
2650
+ this.#initialValue = this.getAttribute("value") ?? "";
2552
2651
  const initialChildren = Array.from(this.childNodes).filter(
2553
2652
  (node) =>
2554
2653
  node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
@@ -2556,55 +2655,49 @@ class PropskitNumber extends HTMLElement {
2556
2655
  const customLabel = initialChildren.find(
2557
2656
  (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
2558
2657
  );
2559
- const field = document.createElement("fig-field");
2658
+ const surface = figLabCreateElement("div", {
2659
+ className: "propskit-text-surface",
2660
+ });
2560
2661
  const label = customLabel || document.createElement("label");
2561
- const input = document.createElement("fig-input-number");
2562
-
2563
- field.append(label, input);
2564
- this.#field = field;
2565
- this.#label = label;
2566
- this.#input = input;
2567
- this.#hasCustomLabel = Boolean(customLabel);
2568
- this.replaceChildren(field);
2662
+ const input = document.createElement("fig-input-text");
2569
2663
 
2570
2664
  for (const node of initialChildren) {
2571
2665
  if (node !== customLabel) input.appendChild(node);
2572
2666
  }
2667
+ surface.append(label, input);
2668
+ this.#surface = surface;
2669
+ this.#label = label;
2670
+ this.#input = input;
2671
+ this.#hasCustomLabel = Boolean(customLabel);
2672
+ this.replaceChildren(surface);
2573
2673
  }
2574
2674
 
2575
- #syncField() {
2576
- if (!this.#field || !this.#label) return;
2577
- const hasLabelAttr = this.hasAttribute("label");
2578
- const rawLabel = this.getAttribute("label");
2579
- const isBlankLabel = hasLabelAttr && (rawLabel ?? "").trim() === "";
2580
-
2581
- if (isBlankLabel) {
2582
- this.#label.remove();
2583
- } else {
2584
- if (!this.#hasCustomLabel) {
2585
- this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
2586
- }
2587
- if (this.#label.parentElement !== this.#field) {
2588
- this.#field.prepend(this.#label);
2589
- }
2590
- }
2591
-
2592
- this.#field.setAttribute(
2593
- "direction",
2594
- this.getAttribute("direction") || "horizontal",
2675
+ #syncSurface() {
2676
+ if (!this.#surface || !this.#label || !this.#input) return;
2677
+ const labelId = figLabSyncPropskitLabel(
2678
+ this,
2679
+ this.#surface,
2680
+ this.#label,
2681
+ this.#hasCustomLabel,
2595
2682
  );
2683
+ figLabSyncPropskitControlLabel(this, this.#input, labelId, "Text");
2596
2684
  }
2597
2685
 
2598
2686
  #getForwardedInputAttrNames() {
2599
2687
  const reserved = new Set([
2600
2688
  "label",
2601
2689
  "direction",
2602
- "size",
2603
2690
  "oninput",
2604
2691
  "onchange",
2605
2692
  "class",
2606
2693
  "style",
2607
2694
  "id",
2695
+ "size",
2696
+ "type",
2697
+ "aria-label",
2698
+ "multiline",
2699
+ "autoresize",
2700
+ "resizable",
2608
2701
  "default",
2609
2702
  "variant",
2610
2703
  ]);
@@ -2616,7 +2709,8 @@ class PropskitNumber extends HTMLElement {
2616
2709
  #syncInputAttributes() {
2617
2710
  if (!this.#input) return;
2618
2711
  const inputAttrs = this.#getForwardedInputAttrNames();
2619
- const nextManaged = new Set(inputAttrs);
2712
+ const defaultEnabledAttrs = ["multiline", "autoresize"];
2713
+ const nextManaged = new Set([...inputAttrs, ...defaultEnabledAttrs, "type"]);
2620
2714
 
2621
2715
  for (const attrName of this.#managedInputAttrs) {
2622
2716
  if (!nextManaged.has(attrName)) this.#input.removeAttribute(attrName);
@@ -2624,6 +2718,10 @@ class PropskitNumber extends HTMLElement {
2624
2718
  for (const attrName of inputAttrs) {
2625
2719
  this.#input.setAttribute(attrName, this.getAttribute(attrName) ?? "");
2626
2720
  }
2721
+ for (const attrName of defaultEnabledAttrs) {
2722
+ this.#input.setAttribute(attrName, this.getAttribute(attrName) ?? "");
2723
+ }
2724
+ this.#input.setAttribute("type", "text");
2627
2725
 
2628
2726
  this.#managedInputAttrs = nextManaged;
2629
2727
  }
@@ -2649,28 +2747,16 @@ class PropskitNumber extends HTMLElement {
2649
2747
  #forwardInputEvent(type, event) {
2650
2748
  event.stopImmediatePropagation();
2651
2749
  if (figLabBooleanAttribute(this, "disabled")) return;
2652
- const detail =
2653
- event instanceof CustomEvent && event.detail !== undefined
2654
- ? event.detail
2655
- : this.#input?.value;
2656
- if (this.#input?.value !== undefined) {
2657
- this.setAttribute("value", String(this.#input.value));
2658
- }
2659
- this.dispatchEvent(
2660
- new CustomEvent(type, {
2661
- detail,
2662
- bubbles: true,
2663
- cancelable: true,
2664
- composed: true,
2665
- }),
2666
- );
2750
+ const value = this.#input?.value ?? "";
2751
+ this.setAttribute("value", String(value));
2752
+ figLabDispatchPropskitEvent(this, type);
2667
2753
  }
2668
2754
 
2669
2755
  #handleClick(event) {
2670
2756
  if (figLabBooleanAttribute(this, "disabled")) return;
2671
2757
  if (
2672
2758
  event.target instanceof Element &&
2673
- event.target.closest("fig-input-number, fig-menu")
2759
+ event.target.closest("fig-input-text, fig-menu")
2674
2760
  ) {
2675
2761
  return;
2676
2762
  }
@@ -2682,7 +2768,7 @@ class PropskitNumber extends HTMLElement {
2682
2768
  }
2683
2769
 
2684
2770
  set value(nextValue) {
2685
- if (nextValue === null || nextValue === undefined || nextValue === "") {
2771
+ if (nextValue === null || nextValue === undefined) {
2686
2772
  this.removeAttribute("value");
2687
2773
  if (this.#input) this.#input.value = "";
2688
2774
  } else {
@@ -2693,7 +2779,7 @@ class PropskitNumber extends HTMLElement {
2693
2779
  }
2694
2780
 
2695
2781
  get defaultValue() {
2696
- return this.getAttribute("default") ?? this.#initialValue ?? "0";
2782
+ return this.getAttribute("default") ?? this.#initialValue ?? "";
2697
2783
  }
2698
2784
 
2699
2785
  get isDefault() {
@@ -2703,119 +2789,353 @@ class PropskitNumber extends HTMLElement {
2703
2789
  resetToDefault() {
2704
2790
  const value = this.defaultValue;
2705
2791
  this.value = value;
2706
- figLabEmitPropskitReset(this, this.#input?.value ?? value);
2792
+ figLabEmitPropskitReset(this);
2707
2793
  }
2708
2794
 
2709
2795
  focus(options) {
2710
2796
  this.#input?.focus(options);
2711
2797
  }
2712
2798
  }
2713
- figLabDefineElement("propskit-number", PropskitNumber);
2714
-
2715
- /**
2716
- * Compact X/Y editor.
2717
- *
2718
- * @attr {number} x - Horizontal value.
2719
- * @attr {number} y - Vertical value.
2720
- * @attr {string} default - JSON reset value with x and y.
2721
- * @attr {string} label - Field label. Empty values use "Position".
2722
- * @attr {string} units - Set to "percent" to show percentage units.
2723
- * @attr {boolean|string} disabled - Disables both number inputs.
2724
- * @attr {string} size - Defaults to the large row; set to "small" for compact.
2725
- * @fires input - Composed event with { x, y }.
2726
- * @fires change - Composed event with { x, y }.
2727
- */
2728
- class PropskitPosition extends HTMLElement {
2729
- static observedAttributes = [
2730
- "x",
2731
- "y",
2732
- "default",
2733
- "label",
2734
- "units",
2735
- "disabled",
2736
- "size",
2737
- ];
2799
+ figLabDefineElement("propskit-text", PropskitText);
2738
2800
 
2739
- #field = null;
2740
- #xInput = null;
2741
- #yInput = null;
2742
- #initialValue = null;
2743
- #initialized = false;
2744
- #reflecting = false;
2745
- #boundHandleInput = this.#handleInputEvent.bind(this, "input");
2746
- #boundHandleChange = this.#handleInputEvent.bind(this, "change");
2801
+ /* PropsKit number surface */
2802
+ class PropskitNumber extends FigLabPropskitElement {
2803
+ #surface = null;
2804
+ #label = null;
2805
+ #input = null;
2806
+ #hasCustomLabel = false;
2807
+ #observer = null;
2808
+ #managedInputAttrs = new Set();
2809
+ #boundHandleInput = null;
2810
+ #boundHandleChange = null;
2747
2811
  #boundHandleClick = this.#handleClick.bind(this);
2812
+ #boundHandleKeyDown = this.#handleKeyDown.bind(this);
2813
+ #initialValue = null;
2814
+
2815
+ static get observedAttributes() {
2816
+ return ["label", "aria-label"];
2817
+ }
2748
2818
 
2749
2819
  connectedCallback() {
2750
- if (!this.#initialized) {
2751
- this.#initialValue = this.#readValue();
2752
- this.#reflectValue(this.#initialValue);
2753
- this.#initialized = true;
2754
- }
2755
- if (!this.#field) this.#render();
2756
- this.#syncLabel();
2757
- this.#syncInputs();
2758
- this.#bindEvents();
2820
+ if (!this.#surface) this.#initialize();
2821
+ this.#syncSurface();
2822
+ this.#syncInputAttributes();
2823
+ this.#bindInputEvents();
2759
2824
  this.removeEventListener("click", this.#boundHandleClick);
2760
2825
  this.addEventListener("click", this.#boundHandleClick);
2826
+ this.removeEventListener("keydown", this.#boundHandleKeyDown);
2827
+ this.addEventListener("keydown", this.#boundHandleKeyDown);
2761
2828
  figLabConnectPropskitResetMenu(this);
2829
+
2830
+ if (!this.#observer) {
2831
+ this.#observer = new MutationObserver((mutations) => {
2832
+ let syncSurface = false;
2833
+ let syncInput = false;
2834
+
2835
+ for (const mutation of mutations) {
2836
+ if (mutation.type !== "attributes") continue;
2837
+ if (
2838
+ mutation.attributeName === "label" ||
2839
+ mutation.attributeName === "aria-label"
2840
+ ) {
2841
+ syncSurface = true;
2842
+ } else if (mutation.attributeName === "direction") {
2843
+ continue;
2844
+ } else {
2845
+ syncInput = true;
2846
+ }
2847
+ }
2848
+
2849
+ if (syncSurface) this.#syncSurface();
2850
+ if (syncInput) this.#syncInputAttributes();
2851
+ });
2852
+ }
2853
+
2854
+ this.#observer.observe(this, { attributes: true });
2762
2855
  }
2763
2856
 
2764
2857
  disconnectedCallback() {
2765
- this.#unbindEvents();
2858
+ this.#observer?.disconnect();
2859
+ this.#unbindInputEvents();
2766
2860
  this.removeEventListener("click", this.#boundHandleClick);
2861
+ this.removeEventListener("keydown", this.#boundHandleKeyDown);
2767
2862
  figLabDisconnectPropskitResetMenu(this);
2768
2863
  }
2769
2864
 
2770
2865
  attributeChangedCallback(name, oldValue, newValue) {
2771
- if (oldValue === newValue || !this.#initialized) return;
2772
- if ((name === "x" || name === "y") && !this.#reflecting) {
2773
- this.#syncInputs();
2774
- } else if (name === "label") {
2775
- this.#syncLabel();
2776
- } else if (name === "units") {
2777
- this.#syncUnits();
2778
- } else if (name === "disabled") {
2779
- this.#syncDisabled();
2780
- }
2866
+ if (oldValue === newValue || !this.#surface) return;
2867
+ if (name === "label" || name === "aria-label") this.#syncSurface();
2781
2868
  }
2782
2869
 
2783
- #finiteNumber(value, fallback = 50) {
2784
- const number = Number(value);
2785
- return Number.isFinite(number) ? number : fallback;
2786
- }
2870
+ #initialize() {
2871
+ this.#initialValue =
2872
+ this.getAttribute("value") ?? this.getAttribute("min") ?? "0";
2873
+ const initialChildren = Array.from(this.childNodes).filter(
2874
+ (node) =>
2875
+ node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
2876
+ );
2877
+ const customLabel = initialChildren.find(
2878
+ (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
2879
+ );
2880
+ const surface = figLabCreateElement("div", {
2881
+ className: "propskit-number-surface",
2882
+ });
2883
+ const label = customLabel || document.createElement("label");
2884
+ const input = document.createElement("fig-input-number");
2787
2885
 
2788
- #readValue() {
2789
- const x = this.getAttribute("x");
2790
- const y = this.getAttribute("y");
2791
- return {
2792
- x: x === null || !x.trim() ? 50 : this.#finiteNumber(x, 50),
2793
- y: y === null || !y.trim() ? 50 : this.#finiteNumber(y, 50),
2794
- };
2795
- }
2886
+ surface.append(label, input);
2887
+ this.#surface = surface;
2888
+ this.#label = label;
2889
+ this.#input = input;
2890
+ this.#hasCustomLabel = Boolean(customLabel);
2891
+ this.replaceChildren(surface);
2796
2892
 
2797
- #parseDefault(value) {
2798
- if (typeof value !== "string" || !value.trim()) return null;
2799
- try {
2800
- const parsed = JSON.parse(value);
2801
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2802
- return null;
2803
- }
2804
- return {
2805
- x: this.#finiteNumber(parsed.x, this.#initialValue?.x ?? 50),
2806
- y: this.#finiteNumber(parsed.y, this.#initialValue?.y ?? 50),
2807
- };
2808
- } catch {
2809
- return null;
2893
+ for (const node of initialChildren) {
2894
+ if (node !== customLabel) input.appendChild(node);
2810
2895
  }
2811
2896
  }
2812
2897
 
2813
- #reflectValue(value) {
2814
- const current = this.#readValue();
2815
- const normalized = {
2816
- x: this.#finiteNumber(value?.x, current.x),
2817
- y: this.#finiteNumber(value?.y, current.y),
2818
- };
2898
+ #syncSurface() {
2899
+ const labelId = figLabSyncPropskitLabel(
2900
+ this,
2901
+ this.#surface,
2902
+ this.#label,
2903
+ this.#hasCustomLabel,
2904
+ );
2905
+ figLabSyncPropskitControlLabel(this, this.#input, labelId, "Number");
2906
+ }
2907
+
2908
+ #getForwardedInputAttrNames() {
2909
+ const reserved = new Set([
2910
+ "label",
2911
+ "direction",
2912
+ "size",
2913
+ "aria-label",
2914
+ "oninput",
2915
+ "onchange",
2916
+ "class",
2917
+ "style",
2918
+ "id",
2919
+ "default",
2920
+ "variant",
2921
+ ]);
2922
+ return this.getAttributeNames().filter(
2923
+ (name) => !reserved.has(name) && !name.startsWith("data-"),
2924
+ );
2925
+ }
2926
+
2927
+ #syncInputAttributes() {
2928
+ if (!this.#input) return;
2929
+ const inputAttrs = this.#getForwardedInputAttrNames();
2930
+ const nextManaged = new Set(inputAttrs);
2931
+
2932
+ for (const attrName of this.#managedInputAttrs) {
2933
+ if (!nextManaged.has(attrName)) this.#input.removeAttribute(attrName);
2934
+ }
2935
+ for (const attrName of inputAttrs) {
2936
+ this.#input.setAttribute(attrName, this.getAttribute(attrName) ?? "");
2937
+ }
2938
+
2939
+ this.#managedInputAttrs = nextManaged;
2940
+ }
2941
+
2942
+ #bindInputEvents() {
2943
+ if (!this.#input) return;
2944
+ this.#boundHandleInput ??= this.#forwardInputEvent.bind(this, "input");
2945
+ this.#boundHandleChange ??= this.#forwardInputEvent.bind(this, "change");
2946
+ this.#input.addEventListener("input", this.#boundHandleInput);
2947
+ this.#input.addEventListener("change", this.#boundHandleChange);
2948
+ }
2949
+
2950
+ #unbindInputEvents() {
2951
+ if (!this.#input) return;
2952
+ if (this.#boundHandleInput) {
2953
+ this.#input.removeEventListener("input", this.#boundHandleInput);
2954
+ }
2955
+ if (this.#boundHandleChange) {
2956
+ this.#input.removeEventListener("change", this.#boundHandleChange);
2957
+ }
2958
+ }
2959
+
2960
+ #forwardInputEvent(type, event) {
2961
+ event.stopImmediatePropagation();
2962
+ if (figLabBooleanAttribute(this, "disabled")) return;
2963
+ const value = figLabFiniteNumberOrNull(this.#input?.value);
2964
+ if (value === null) this.removeAttribute("value");
2965
+ else this.setAttribute("value", String(value));
2966
+ figLabDispatchPropskitEvent(this, type);
2967
+ }
2968
+
2969
+ #handleClick(event) {
2970
+ if (figLabBooleanAttribute(this, "disabled")) return;
2971
+ if (
2972
+ event.target instanceof Element &&
2973
+ event.target.closest("fig-input-number, fig-menu")
2974
+ ) {
2975
+ return;
2976
+ }
2977
+ this.focus();
2978
+ }
2979
+
2980
+ #handleKeyDown(event) {
2981
+ if (
2982
+ !["ArrowUp", "ArrowDown"].includes(event.key) ||
2983
+ !(event.target instanceof Element) ||
2984
+ !event.target.closest("fig-input-number")
2985
+ ) {
2986
+ return;
2987
+ }
2988
+ event.stopPropagation();
2989
+ const input = this.#input?.querySelector("input");
2990
+ queueMicrotask(() => {
2991
+ if (this.isConnected && !figLabBooleanAttribute(this, "disabled")) {
2992
+ input?.focus({ preventScroll: true });
2993
+ }
2994
+ });
2995
+ }
2996
+
2997
+ get value() {
2998
+ return figLabFiniteNumberOrNull(
2999
+ this.#input?.value ?? this.getAttribute("value"),
3000
+ );
3001
+ }
3002
+
3003
+ set value(nextValue) {
3004
+ if (nextValue === null || nextValue === undefined || nextValue === "") {
3005
+ this.removeAttribute("value");
3006
+ if (this.#input) this.#input.value = "";
3007
+ } else {
3008
+ const next = String(nextValue);
3009
+ this.setAttribute("value", next);
3010
+ if (this.#input) this.#input.value = next;
3011
+ }
3012
+ }
3013
+
3014
+ get defaultValue() {
3015
+ return this.getAttribute("default") ?? this.#initialValue ?? "0";
3016
+ }
3017
+
3018
+ get isDefault() {
3019
+ return figLabPropskitValuesEqual(this.value, this.defaultValue);
3020
+ }
3021
+
3022
+ resetToDefault() {
3023
+ const value = this.defaultValue;
3024
+ this.value = value;
3025
+ figLabEmitPropskitReset(this);
3026
+ }
3027
+
3028
+ focus(options) {
3029
+ this.#input?.focus(options);
3030
+ }
3031
+ }
3032
+ figLabDefineElement("propskit-number", PropskitNumber);
3033
+
3034
+ /**
3035
+ * Compact X/Y editor.
3036
+ *
3037
+ * @attr {number} x - Horizontal value.
3038
+ * @attr {number} y - Vertical value.
3039
+ * @attr {string} default - JSON reset value with x and y.
3040
+ * @attr {string} label - Surface label. Omitted values use "Label"; empty hides it.
3041
+ * @attr {string} units - Set to "percent" to show percentage units.
3042
+ * @attr {boolean|string} disabled - Disables both number inputs.
3043
+ * @fires input - Composed event with { x, y }.
3044
+ * @fires change - Composed event with { x, y }.
3045
+ */
3046
+ class PropskitPosition extends FigLabPropskitElement {
3047
+ static observedAttributes = [
3048
+ "x",
3049
+ "y",
3050
+ "default",
3051
+ "label",
3052
+ "aria-label",
3053
+ "units",
3054
+ "disabled",
3055
+ ];
3056
+
3057
+ #surface = null;
3058
+ #label = null;
3059
+ #hasCustomLabel = false;
3060
+ #xInput = null;
3061
+ #yInput = null;
3062
+ #initialValue = null;
3063
+ #initialized = false;
3064
+ #reflecting = false;
3065
+ #boundHandleInput = this.#handleInputEvent.bind(this, "input");
3066
+ #boundHandleChange = this.#handleInputEvent.bind(this, "change");
3067
+ #boundHandleClick = this.#handleClick.bind(this);
3068
+
3069
+ connectedCallback() {
3070
+ if (!this.#initialized) {
3071
+ this.#initialValue = this.#readValue();
3072
+ this.#reflectValue(this.#initialValue);
3073
+ this.#initialized = true;
3074
+ }
3075
+ if (!this.#surface) this.#render();
3076
+ this.#syncLabel();
3077
+ this.#syncInputs();
3078
+ this.#bindEvents();
3079
+ this.removeEventListener("click", this.#boundHandleClick);
3080
+ this.addEventListener("click", this.#boundHandleClick);
3081
+ figLabConnectPropskitResetMenu(this);
3082
+ }
3083
+
3084
+ disconnectedCallback() {
3085
+ this.#unbindEvents();
3086
+ this.removeEventListener("click", this.#boundHandleClick);
3087
+ figLabDisconnectPropskitResetMenu(this);
3088
+ }
3089
+
3090
+ attributeChangedCallback(name, oldValue, newValue) {
3091
+ if (oldValue === newValue || !this.#initialized) return;
3092
+ if ((name === "x" || name === "y") && !this.#reflecting) {
3093
+ this.#syncInputs();
3094
+ } else if (name === "label" || name === "aria-label") {
3095
+ this.#syncLabel();
3096
+ } else if (name === "units") {
3097
+ this.#syncUnits();
3098
+ } else if (name === "disabled") {
3099
+ this.#syncDisabled();
3100
+ }
3101
+ }
3102
+
3103
+ #finiteNumber(value, fallback = 50) {
3104
+ const number = Number(value);
3105
+ return Number.isFinite(number) ? number : fallback;
3106
+ }
3107
+
3108
+ #readValue() {
3109
+ const x = this.getAttribute("x");
3110
+ const y = this.getAttribute("y");
3111
+ return {
3112
+ x: x === null || !x.trim() ? 50 : this.#finiteNumber(x, 50),
3113
+ y: y === null || !y.trim() ? 50 : this.#finiteNumber(y, 50),
3114
+ };
3115
+ }
3116
+
3117
+ #parseDefault(value) {
3118
+ if (typeof value !== "string" || !value.trim()) return null;
3119
+ try {
3120
+ const parsed = JSON.parse(value);
3121
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3122
+ return null;
3123
+ }
3124
+ return {
3125
+ x: this.#finiteNumber(parsed.x, this.#initialValue?.x ?? 50),
3126
+ y: this.#finiteNumber(parsed.y, this.#initialValue?.y ?? 50),
3127
+ };
3128
+ } catch {
3129
+ return null;
3130
+ }
3131
+ }
3132
+
3133
+ #reflectValue(value) {
3134
+ const current = this.#readValue();
3135
+ const normalized = {
3136
+ x: this.#finiteNumber(value?.x, current.x),
3137
+ y: this.#finiteNumber(value?.y, current.y),
3138
+ };
2819
3139
  this.#reflecting = true;
2820
3140
  this.setAttribute("x", String(normalized.x));
2821
3141
  this.setAttribute("y", String(normalized.y));
@@ -2840,142 +3160,1000 @@ class PropskitPosition extends HTMLElement {
2840
3160
  if (figLabBooleanAttribute(this, "disabled")) {
2841
3161
  input.setAttribute("disabled", "");
2842
3162
  }
2843
- return input;
3163
+ return input;
3164
+ }
3165
+
3166
+ #render() {
3167
+ const value = this.#readValue();
3168
+ const customLabel = this.querySelector(":scope > label");
3169
+ const surface = figLabCreateElement("div", {
3170
+ className: "propskit-position-surface",
3171
+ role: "group",
3172
+ });
3173
+ const label = customLabel || document.createElement("label");
3174
+ const xInput = this.#createNumber("x", value.x);
3175
+ const yInput = this.#createNumber("y", value.y);
3176
+ surface.append(label, xInput, yInput);
3177
+ this.#surface = surface;
3178
+ this.#label = label;
3179
+ this.#hasCustomLabel = Boolean(customLabel);
3180
+ this.#xInput = xInput;
3181
+ this.#yInput = yInput;
3182
+ this.replaceChildren(surface);
3183
+ figLabConnectPropskitResetMenu(this);
3184
+ }
3185
+
3186
+ #syncLabel() {
3187
+ const labelId = figLabSyncPropskitLabel(
3188
+ this,
3189
+ this.#surface,
3190
+ this.#label,
3191
+ this.#hasCustomLabel,
3192
+ );
3193
+ figLabSyncPropskitControlLabel(this, this.#surface, labelId, "Position");
3194
+ }
3195
+
3196
+ #syncInputs() {
3197
+ const value = this.#readValue();
3198
+ this.#xInput?.setAttribute("value", String(value.x));
3199
+ this.#yInput?.setAttribute("value", String(value.y));
3200
+ this.#syncUnits();
3201
+ this.#syncDisabled();
3202
+ }
3203
+
3204
+ #syncUnits() {
3205
+ const units = this.units === "percent" ? "%" : null;
3206
+ for (const input of [this.#xInput, this.#yInput]) {
3207
+ if (!input) continue;
3208
+ if (units) input.setAttribute("units", units);
3209
+ else input.removeAttribute("units");
3210
+ }
3211
+ }
3212
+
3213
+ #syncDisabled() {
3214
+ const disabled = figLabBooleanAttribute(this, "disabled");
3215
+ this.#xInput?.toggleAttribute("disabled", disabled);
3216
+ this.#yInput?.toggleAttribute("disabled", disabled);
3217
+ }
3218
+
3219
+ #bindEvents() {
3220
+ this.#unbindEvents();
3221
+ this.#xInput?.addEventListener("input", this.#boundHandleInput);
3222
+ this.#xInput?.addEventListener("change", this.#boundHandleChange);
3223
+ this.#yInput?.addEventListener("input", this.#boundHandleInput);
3224
+ this.#yInput?.addEventListener("change", this.#boundHandleChange);
3225
+ }
3226
+
3227
+ #unbindEvents() {
3228
+ this.#xInput?.removeEventListener("input", this.#boundHandleInput);
3229
+ this.#xInput?.removeEventListener("change", this.#boundHandleChange);
3230
+ this.#yInput?.removeEventListener("input", this.#boundHandleInput);
3231
+ this.#yInput?.removeEventListener("change", this.#boundHandleChange);
3232
+ }
3233
+
3234
+ #handleInputEvent(type, event) {
3235
+ event.stopImmediatePropagation();
3236
+ if (figLabBooleanAttribute(this, "disabled")) return;
3237
+ const axis = event.currentTarget?.getAttribute("data-propskit-position-axis");
3238
+ if (axis !== "x" && axis !== "y") return;
3239
+ const value = this.value;
3240
+ value[axis] = this.#finiteNumber(event.currentTarget.value, value[axis]);
3241
+ this.#reflectValue(value);
3242
+ figLabDispatchPropskitEvent(this, type);
3243
+ }
3244
+
3245
+ #handleClick(event) {
3246
+ if (figLabBooleanAttribute(this, "disabled")) return;
3247
+ if (
3248
+ event.target instanceof Element &&
3249
+ event.target.closest("fig-input-number, fig-menu")
3250
+ ) {
3251
+ return;
3252
+ }
3253
+ this.focus();
3254
+ }
3255
+
3256
+ get x() {
3257
+ return this.#readValue().x;
3258
+ }
3259
+
3260
+ set x(value) {
3261
+ this.setAttribute("x", String(this.#finiteNumber(value, 50)));
3262
+ }
3263
+
3264
+ get y() {
3265
+ return this.#readValue().y;
3266
+ }
3267
+
3268
+ set y(value) {
3269
+ this.setAttribute("y", String(this.#finiteNumber(value, 50)));
3270
+ }
3271
+
3272
+ get units() {
3273
+ return this.getAttribute("units")?.trim().toLowerCase() === "percent"
3274
+ ? "percent"
3275
+ : "none";
3276
+ }
3277
+
3278
+ set units(value) {
3279
+ this.setAttribute(
3280
+ "units",
3281
+ String(value).trim().toLowerCase() === "none" ? "none" : "percent",
3282
+ );
3283
+ }
3284
+
3285
+ get value() {
3286
+ return { ...this.#readValue() };
3287
+ }
3288
+
3289
+ set value(value) {
3290
+ const normalized = this.#reflectValue(value);
3291
+ this.#syncInputs(normalized);
3292
+ }
3293
+
3294
+ get defaultValue() {
3295
+ return (
3296
+ this.#parseDefault(this.getAttribute("default")) || {
3297
+ ...(this.#initialValue || { x: 50, y: 50 }),
3298
+ }
3299
+ );
3300
+ }
3301
+
3302
+ get isDefault() {
3303
+ return figLabPropskitJsonValuesEqual(this.value, this.defaultValue);
3304
+ }
3305
+
3306
+ resetToDefault() {
3307
+ const value = this.defaultValue;
3308
+ this.value = value;
3309
+ figLabEmitPropskitReset(this);
3310
+ }
3311
+
3312
+ focus(options) {
3313
+ this.#xInput?.focus(options);
3314
+ }
3315
+ }
3316
+ figLabDefineElement("propskit-position", PropskitPosition);
3317
+
3318
+ /**
3319
+ * Labeled two-axis joystick.
3320
+ *
3321
+ * @attr {string} value - JSON object with percentage x and y coordinates.
3322
+ * @attr {string} default - JSON reset value with percentage x and y coordinates.
3323
+ * @attr {string} label - Surface label. Omitted values use "Label"; empty hides it.
3324
+ * @attr {string} axis-labels - Forwarded to the inner fig-joystick.
3325
+ * @attr {string} coordinates - Forwarded coordinate mode: "screen" or "math".
3326
+ * @attr {number} precision - Forwarded number-field display precision.
3327
+ * @attr {boolean|string} disabled - Disables the joystick and both fields.
3328
+ * @fires input - Shared PropsKit event with a typed { x, y } value.
3329
+ * @fires change - Shared PropsKit event with a typed { x, y } value.
3330
+ */
3331
+ class PropskitJoystick extends FigLabPropskitElement {
3332
+ static observedAttributes = [
3333
+ "value",
3334
+ "default",
3335
+ "label",
3336
+ "aria-label",
3337
+ "axis-labels",
3338
+ "coordinates",
3339
+ "precision",
3340
+ "disabled",
3341
+ ];
3342
+
3343
+ #surface = null;
3344
+ #label = null;
3345
+ #hasCustomLabel = false;
3346
+ #joystick = null;
3347
+ #observer = null;
3348
+ #initialValue = { x: 50, y: 50 };
3349
+ #reflecting = false;
3350
+ #boundInput = this.#handlePrimitiveEvent.bind(this, "input");
3351
+ #boundChange = this.#handlePrimitiveEvent.bind(this, "change");
3352
+
3353
+ connectedCallback() {
3354
+ if (!this.#surface) {
3355
+ this.#initialValue = this.#normalizeValue(
3356
+ this.getAttribute("value"),
3357
+ this.#initialValue,
3358
+ );
3359
+ this.#reflectValue(this.#initialValue);
3360
+ this.#render();
3361
+ }
3362
+ this.#syncLabel();
3363
+ this.#syncPrimitive();
3364
+ this.#bindEvents();
3365
+ this.#observer?.observe(this.#joystick, { childList: true, subtree: true });
3366
+ figLabConnectPropskitResetMenu(this);
3367
+ }
3368
+
3369
+ disconnectedCallback() {
3370
+ this.#unbindEvents();
3371
+ this.#observer?.disconnect();
3372
+ figLabDisconnectPropskitResetMenu(this);
3373
+ }
3374
+
3375
+ attributeChangedCallback(name, oldValue, newValue) {
3376
+ if (oldValue === newValue || !this.#surface) return;
3377
+ if (name === "value" && !this.#reflecting) {
3378
+ this.#reflectValue(this.#normalizeValue(newValue, this.value));
3379
+ this.#syncPrimitiveValue();
3380
+ } else if (name === "label" || name === "aria-label") {
3381
+ this.#syncLabel();
3382
+ } else if (
3383
+ name === "axis-labels" ||
3384
+ name === "coordinates" ||
3385
+ name === "precision"
3386
+ ) {
3387
+ this.#syncForwardedAttributes();
3388
+ } else if (name === "disabled") {
3389
+ this.#syncDisabled();
3390
+ }
3391
+ }
3392
+
3393
+ #finitePercent(value, fallback = 50) {
3394
+ const number = Number(value);
3395
+ return Number.isFinite(number)
3396
+ ? Math.max(0, Math.min(100, number))
3397
+ : fallback;
3398
+ }
3399
+
3400
+ #normalizeValue(value, fallback = { x: 50, y: 50 }) {
3401
+ let parsed = value;
3402
+ if (typeof value === "string") {
3403
+ const trimmed = value.trim();
3404
+ if (!trimmed) return { ...fallback };
3405
+ try {
3406
+ parsed = JSON.parse(trimmed);
3407
+ } catch {
3408
+ const parts = trimmed.split(/[\s,]+/).filter(Boolean);
3409
+ const parseAxis = (token, axisFallback) => {
3410
+ if (!token) return axisFallback;
3411
+ const numeric = Number.parseFloat(token.replace(/%/g, ""));
3412
+ if (!Number.isFinite(numeric)) return axisFallback;
3413
+ return token.includes("%") || Math.abs(numeric) > 1
3414
+ ? numeric
3415
+ : numeric * 100;
3416
+ };
3417
+ parsed = {
3418
+ x: parseAxis(parts[0], fallback.x),
3419
+ y: parseAxis(parts[1] ?? parts[0], fallback.y),
3420
+ };
3421
+ }
3422
+ }
3423
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3424
+ return { ...fallback };
3425
+ }
3426
+ return {
3427
+ x: this.#finitePercent(parsed.x, fallback.x),
3428
+ y: this.#finitePercent(parsed.y, fallback.y),
3429
+ };
3430
+ }
3431
+
3432
+ #reflectValue(value) {
3433
+ const normalized = this.#normalizeValue(value, this.#initialValue);
3434
+ const serialized = JSON.stringify(normalized);
3435
+ if (this.getAttribute("value") !== serialized) {
3436
+ this.#reflecting = true;
3437
+ this.setAttribute("value", serialized);
3438
+ this.#reflecting = false;
3439
+ }
3440
+ return normalized;
3441
+ }
3442
+
3443
+ #render() {
3444
+ const customLabel = this.querySelector(":scope > label");
3445
+ const surface = figLabCreateElement("div", {
3446
+ className: "propskit-joystick-surface",
3447
+ role: "group",
3448
+ });
3449
+ const label = customLabel || document.createElement("label");
3450
+ const joystick = document.createElement("fig-joystick");
3451
+ joystick.setAttribute("fields", "true");
3452
+ joystick.setAttribute("aspect-ratio", "1 / 1");
3453
+ surface.append(label, joystick);
3454
+ this.#surface = surface;
3455
+ this.#label = label;
3456
+ this.#hasCustomLabel = Boolean(customLabel);
3457
+ this.#joystick = joystick;
3458
+ this.replaceChildren(surface);
3459
+ this.#observer = new MutationObserver(() => this.#syncDescendantState());
3460
+ this.#observer.observe(joystick, { childList: true, subtree: true });
3461
+ }
3462
+
3463
+ #syncLabel() {
3464
+ const labelId = figLabSyncPropskitLabel(
3465
+ this,
3466
+ this.#surface,
3467
+ this.#label,
3468
+ this.#hasCustomLabel,
3469
+ );
3470
+ figLabSyncPropskitControlLabel(this, this.#joystick, labelId, "Joystick");
3471
+ this.#syncDescendantState();
3472
+ }
3473
+
3474
+ #syncPrimitive() {
3475
+ this.#syncForwardedAttributes();
3476
+ this.#syncPrimitiveValue();
3477
+ this.#syncDisabled();
3478
+ }
3479
+
3480
+ #syncForwardedAttributes() {
3481
+ if (!this.#joystick) return;
3482
+ this.#joystick.setAttribute("fields", "true");
3483
+ this.#joystick.setAttribute("aspect-ratio", "1 / 1");
3484
+ for (const name of ["axis-labels", "coordinates", "precision"]) {
3485
+ if (this.hasAttribute(name)) {
3486
+ this.#joystick.setAttribute(name, this.getAttribute(name) ?? "");
3487
+ } else {
3488
+ this.#joystick.removeAttribute(name);
3489
+ }
3490
+ }
3491
+ }
3492
+
3493
+ #syncPrimitiveValue() {
3494
+ if (!this.#joystick) return;
3495
+ const value = this.value;
3496
+ const serialized = `${value.x}% ${value.y}%`;
3497
+ if (this.#joystick.getAttribute("value") !== serialized) {
3498
+ this.#joystick.setAttribute("value", serialized);
3499
+ }
3500
+ }
3501
+
3502
+ #syncDisabled() {
3503
+ if (!this.#joystick || !this.#surface) return;
3504
+ const disabled = figLabBooleanAttribute(this, "disabled");
3505
+ this.#surface.setAttribute("aria-disabled", String(disabled));
3506
+ this.#joystick.toggleAttribute("disabled", disabled);
3507
+ this.#joystick.inert = disabled;
3508
+ this.#syncDescendantState();
3509
+ }
3510
+
3511
+ #syncDescendantState() {
3512
+ if (!this.#joystick) return;
3513
+ const disabled = figLabBooleanAttribute(this, "disabled");
3514
+ const labelId = this.#label?.isConnected ? this.#label.id : "";
3515
+ const explicitLabel = this.getAttribute("aria-label")?.trim();
3516
+ const handle = this.#joystick.querySelector("fig-handle");
3517
+ if (handle) {
3518
+ handle.toggleAttribute("disabled", disabled);
3519
+ if (explicitLabel) {
3520
+ handle.setAttribute("aria-label", explicitLabel);
3521
+ handle.removeAttribute("aria-labelledby");
3522
+ } else if (labelId) {
3523
+ handle.setAttribute("aria-labelledby", labelId);
3524
+ handle.removeAttribute("aria-label");
3525
+ } else {
3526
+ handle.setAttribute("aria-label", "Joystick");
3527
+ handle.removeAttribute("aria-labelledby");
3528
+ }
3529
+ }
3530
+ for (const input of this.#joystick.querySelectorAll("fig-input-number")) {
3531
+ input.toggleAttribute("disabled", disabled);
3532
+ }
3533
+ }
3534
+
3535
+ #bindEvents() {
3536
+ this.#unbindEvents();
3537
+ this.#joystick?.addEventListener("input", this.#boundInput);
3538
+ this.#joystick?.addEventListener("change", this.#boundChange);
3539
+ }
3540
+
3541
+ #unbindEvents() {
3542
+ this.#joystick?.removeEventListener("input", this.#boundInput);
3543
+ this.#joystick?.removeEventListener("change", this.#boundChange);
3544
+ }
3545
+
3546
+ #handlePrimitiveEvent(type, event) {
3547
+ event.stopImmediatePropagation();
3548
+ if (
3549
+ event.target !== this.#joystick ||
3550
+ figLabBooleanAttribute(this, "disabled")
3551
+ ) {
3552
+ return;
3553
+ }
3554
+ const value = this.#normalizeValue(event.detail?.value, this.value);
3555
+ this.#reflectValue(value);
3556
+ this.#syncPrimitiveValue();
3557
+ figLabDispatchPropskitEvent(this, type);
3558
+ }
3559
+
3560
+ get value() {
3561
+ return this.#normalizeValue(
3562
+ this.getAttribute("value"),
3563
+ this.#initialValue,
3564
+ );
3565
+ }
3566
+
3567
+ set value(value) {
3568
+ this.#reflectValue(value);
3569
+ this.#syncPrimitiveValue();
3570
+ }
3571
+
3572
+ get defaultValue() {
3573
+ return this.#normalizeValue(
3574
+ this.getAttribute("default"),
3575
+ this.#initialValue,
3576
+ );
3577
+ }
3578
+
3579
+ get isDefault() {
3580
+ return figLabPropskitJsonValuesEqual(this.value, this.defaultValue);
3581
+ }
3582
+
3583
+ resetToDefault() {
3584
+ this.value = this.defaultValue;
3585
+ figLabEmitPropskitReset(this);
3586
+ }
3587
+
3588
+ focus(options) {
3589
+ if (figLabBooleanAttribute(this, "disabled")) return;
3590
+ this.#joystick?.focus(options);
3591
+ }
3592
+ }
3593
+ figLabDefineElement("propskit-joystick", PropskitJoystick);
3594
+
3595
+ /**
3596
+ * Labeled transform-origin grid.
3597
+ *
3598
+ * @attr {string} value - JSON object with percentage x and y coordinates.
3599
+ * @attr {string} default - JSON reset value with percentage x and y coordinates.
3600
+ * @attr {string} label - Surface label. Omitted values use "Label"; empty hides it.
3601
+ * @attr {number} precision - Forwarded number-field display precision.
3602
+ * @attr {boolean|string} drag - Forwarded drag behavior.
3603
+ * @attr {boolean|string} disabled - Disables the origin grid and both fields.
3604
+ * @fires input - Shared PropsKit event with a typed { x, y } value.
3605
+ * @fires change - Shared PropsKit event with a typed { x, y } value.
3606
+ */
3607
+ class PropskitOrigin extends FigLabPropskitElement {
3608
+ static observedAttributes = [
3609
+ "value",
3610
+ "default",
3611
+ "label",
3612
+ "aria-label",
3613
+ "precision",
3614
+ "drag",
3615
+ "disabled",
3616
+ ];
3617
+
3618
+ #surface = null;
3619
+ #label = null;
3620
+ #hasCustomLabel = false;
3621
+ #origin = null;
3622
+ #observer = null;
3623
+ #initialValue = { x: 50, y: 50 };
3624
+ #reflecting = false;
3625
+ #boundInput = this.#handlePrimitiveEvent.bind(this, "input");
3626
+ #boundChange = this.#handlePrimitiveEvent.bind(this, "change");
3627
+
3628
+ connectedCallback() {
3629
+ if (!this.#surface) {
3630
+ this.#initialValue = this.#normalizeValue(
3631
+ this.getAttribute("value"),
3632
+ this.#initialValue,
3633
+ );
3634
+ this.#reflectValue(this.#initialValue);
3635
+ this.#render();
3636
+ }
3637
+ this.#syncLabel();
3638
+ this.#syncPrimitive();
3639
+ this.#bindEvents();
3640
+ this.#observer?.observe(this.#origin, { childList: true, subtree: true });
3641
+ figLabConnectPropskitResetMenu(this);
3642
+ }
3643
+
3644
+ disconnectedCallback() {
3645
+ this.#unbindEvents();
3646
+ this.#observer?.disconnect();
3647
+ figLabDisconnectPropskitResetMenu(this);
3648
+ }
3649
+
3650
+ attributeChangedCallback(name, oldValue, newValue) {
3651
+ if (oldValue === newValue || !this.#surface) return;
3652
+ if (name === "value" && !this.#reflecting) {
3653
+ this.#reflectValue(this.#normalizeValue(newValue, this.value));
3654
+ this.#syncPrimitiveValue();
3655
+ } else if (name === "label" || name === "aria-label") {
3656
+ this.#syncLabel();
3657
+ } else if (name === "precision" || name === "drag") {
3658
+ this.#syncForwardedAttributes();
3659
+ } else if (name === "disabled") {
3660
+ this.#syncDisabled();
3661
+ }
3662
+ }
3663
+
3664
+ #finiteNumber(value, fallback = 50) {
3665
+ const number = Number(value);
3666
+ return Number.isFinite(number) ? number : fallback;
3667
+ }
3668
+
3669
+ #parseAxisToken(token, axis, fallback) {
3670
+ const normalized = String(token ?? "").trim().toLowerCase();
3671
+ const keywords =
3672
+ axis === "x"
3673
+ ? { left: 0, center: 50, right: 100 }
3674
+ : { top: 0, center: 50, bottom: 100 };
3675
+ if (normalized in keywords) return keywords[normalized];
3676
+ const number = Number.parseFloat(normalized.replace(/%/g, ""));
3677
+ return Number.isFinite(number) ? number : fallback;
3678
+ }
3679
+
3680
+ #normalizeValue(value, fallback = { x: 50, y: 50 }) {
3681
+ let parsed = value;
3682
+ if (typeof value === "string") {
3683
+ const trimmed = value.trim();
3684
+ if (!trimmed) return { ...fallback };
3685
+ try {
3686
+ parsed = JSON.parse(trimmed);
3687
+ } catch {
3688
+ const parts = trimmed.replace(/,/g, " ").split(/\s+/).filter(Boolean);
3689
+ parsed = {
3690
+ x: this.#parseAxisToken(parts[0], "x", fallback.x),
3691
+ y: this.#parseAxisToken(parts[1] ?? parts[0], "y", fallback.y),
3692
+ };
3693
+ }
3694
+ }
3695
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3696
+ return { ...fallback };
3697
+ }
3698
+ return {
3699
+ x: this.#finiteNumber(parsed.x, fallback.x),
3700
+ y: this.#finiteNumber(parsed.y, fallback.y),
3701
+ };
3702
+ }
3703
+
3704
+ #reflectValue(value) {
3705
+ const normalized = this.#normalizeValue(value, this.#initialValue);
3706
+ const serialized = JSON.stringify(normalized);
3707
+ if (this.getAttribute("value") !== serialized) {
3708
+ this.#reflecting = true;
3709
+ this.setAttribute("value", serialized);
3710
+ this.#reflecting = false;
3711
+ }
3712
+ return normalized;
3713
+ }
3714
+
3715
+ #render() {
3716
+ const customLabel = this.querySelector(":scope > label");
3717
+ const surface = figLabCreateElement("div", {
3718
+ className: "propskit-origin-surface",
3719
+ role: "group",
3720
+ });
3721
+ const label = customLabel || document.createElement("label");
3722
+ const origin = document.createElement("fig-origin-grid");
3723
+ origin.setAttribute("fields", "true");
3724
+ origin.setAttribute("aspect-ratio", "1 / 1");
3725
+ surface.append(label, origin);
3726
+ this.#surface = surface;
3727
+ this.#label = label;
3728
+ this.#hasCustomLabel = Boolean(customLabel);
3729
+ this.#origin = origin;
3730
+ this.replaceChildren(surface);
3731
+ this.#observer = new MutationObserver(() => this.#syncDescendantState());
3732
+ this.#observer.observe(origin, { childList: true, subtree: true });
3733
+ }
3734
+
3735
+ #syncLabel() {
3736
+ const labelId = figLabSyncPropskitLabel(
3737
+ this,
3738
+ this.#surface,
3739
+ this.#label,
3740
+ this.#hasCustomLabel,
3741
+ );
3742
+ figLabSyncPropskitControlLabel(this, this.#origin, labelId, "Origin");
3743
+ this.#syncDescendantState();
3744
+ }
3745
+
3746
+ #syncPrimitive() {
3747
+ this.#syncForwardedAttributes();
3748
+ this.#syncPrimitiveValue();
3749
+ this.#syncDisabled();
3750
+ }
3751
+
3752
+ #syncForwardedAttributes() {
3753
+ if (!this.#origin) return;
3754
+ this.#origin.setAttribute("fields", "true");
3755
+ this.#origin.setAttribute("aspect-ratio", "1 / 1");
3756
+ for (const name of ["precision", "drag"]) {
3757
+ if (this.hasAttribute(name)) {
3758
+ this.#origin.setAttribute(name, this.getAttribute(name) ?? "");
3759
+ } else {
3760
+ this.#origin.removeAttribute(name);
3761
+ }
3762
+ }
3763
+ }
3764
+
3765
+ #syncPrimitiveValue() {
3766
+ if (!this.#origin) return;
3767
+ const value = this.value;
3768
+ const serialized = `${value.x}% ${value.y}%`;
3769
+ if (this.#origin.getAttribute("value") !== serialized) {
3770
+ this.#origin.setAttribute("value", serialized);
3771
+ }
3772
+ }
3773
+
3774
+ #syncDisabled() {
3775
+ if (!this.#origin || !this.#surface) return;
3776
+ const disabled = figLabBooleanAttribute(this, "disabled");
3777
+ this.#surface.setAttribute("aria-disabled", String(disabled));
3778
+ this.#origin.toggleAttribute("disabled", disabled);
3779
+ this.#origin.inert = disabled;
3780
+ this.#syncDescendantState();
3781
+ }
3782
+
3783
+ #syncDescendantState() {
3784
+ if (!this.#origin) return;
3785
+ const disabled = figLabBooleanAttribute(this, "disabled");
3786
+ const labelId = this.#label?.isConnected ? this.#label.id : "";
3787
+ const explicitLabel = this.getAttribute("aria-label")?.trim();
3788
+ const handle = this.#origin.querySelector("fig-handle");
3789
+ if (handle) {
3790
+ handle.toggleAttribute("disabled", disabled);
3791
+ if (explicitLabel) {
3792
+ handle.setAttribute("aria-label", explicitLabel);
3793
+ handle.removeAttribute("aria-labelledby");
3794
+ } else if (labelId) {
3795
+ handle.setAttribute("aria-labelledby", labelId);
3796
+ handle.removeAttribute("aria-label");
3797
+ } else {
3798
+ handle.setAttribute("aria-label", "Origin");
3799
+ handle.removeAttribute("aria-labelledby");
3800
+ }
3801
+ }
3802
+ for (const input of this.#origin.querySelectorAll("fig-input-number")) {
3803
+ input.toggleAttribute("disabled", disabled);
3804
+ }
3805
+ }
3806
+
3807
+ #bindEvents() {
3808
+ this.#unbindEvents();
3809
+ this.#origin?.addEventListener("input", this.#boundInput);
3810
+ this.#origin?.addEventListener("change", this.#boundChange);
3811
+ }
3812
+
3813
+ #unbindEvents() {
3814
+ this.#origin?.removeEventListener("input", this.#boundInput);
3815
+ this.#origin?.removeEventListener("change", this.#boundChange);
3816
+ }
3817
+
3818
+ #handlePrimitiveEvent(type, event) {
3819
+ event.stopImmediatePropagation();
3820
+ if (
3821
+ event.target !== this.#origin ||
3822
+ figLabBooleanAttribute(this, "disabled")
3823
+ ) {
3824
+ return;
3825
+ }
3826
+ const value = this.#normalizeValue(event.detail, this.value);
3827
+ this.#reflectValue(value);
3828
+ this.#syncPrimitiveValue();
3829
+ figLabDispatchPropskitEvent(this, type);
3830
+ }
3831
+
3832
+ get value() {
3833
+ return this.#normalizeValue(
3834
+ this.getAttribute("value"),
3835
+ this.#initialValue,
3836
+ );
3837
+ }
3838
+
3839
+ set value(value) {
3840
+ this.#reflectValue(value);
3841
+ this.#syncPrimitiveValue();
3842
+ }
3843
+
3844
+ get defaultValue() {
3845
+ return this.#normalizeValue(
3846
+ this.getAttribute("default"),
3847
+ this.#initialValue,
3848
+ );
3849
+ }
3850
+
3851
+ get isDefault() {
3852
+ return figLabPropskitJsonValuesEqual(this.value, this.defaultValue);
3853
+ }
3854
+
3855
+ resetToDefault() {
3856
+ this.value = this.defaultValue;
3857
+ figLabEmitPropskitReset(this);
3858
+ }
3859
+
3860
+ focus(options) {
3861
+ if (figLabBooleanAttribute(this, "disabled")) return;
3862
+ this.#origin?.querySelector("fig-handle")?.focus(options);
3863
+ }
3864
+ }
3865
+ figLabDefineElement("propskit-origin", PropskitOrigin);
3866
+
3867
+ /**
3868
+ * Shared labeled curve editor for PropsKit easing and spring controls.
3869
+ */
3870
+ class PropskitCurve extends FigLabPropskitElement {
3871
+ static observedAttributes = [
3872
+ "value",
3873
+ "default",
3874
+ "label",
3875
+ "aria-label",
3876
+ "precision",
3877
+ "edit",
3878
+ "disabled",
3879
+ ];
3880
+
3881
+ #surface = null;
3882
+ #label = null;
3883
+ #hasCustomLabel = false;
3884
+ #curve = null;
3885
+ #observer = null;
3886
+ #initialValue = null;
3887
+ #reflecting = false;
3888
+ #boundInput = this.#handlePrimitiveEvent.bind(this, "input");
3889
+ #boundChange = this.#handlePrimitiveEvent.bind(this, "change");
3890
+
3891
+ connectedCallback() {
3892
+ if (!this.#surface) {
3893
+ this.#initialValue = this.#normalizeValue(
3894
+ this.getAttribute("value"),
3895
+ this.#fallbackValue(),
3896
+ );
3897
+ this.#reflectValue(this.#initialValue);
3898
+ this.#render();
3899
+ }
3900
+ this.#syncLabel();
3901
+ this.#syncPrimitive();
3902
+ this.#bindEvents();
3903
+ this.#observer?.observe(this.#curve, { childList: true, subtree: true });
3904
+ figLabConnectPropskitResetMenu(this);
3905
+ }
3906
+
3907
+ disconnectedCallback() {
3908
+ this.#unbindEvents();
3909
+ this.#observer?.disconnect();
3910
+ figLabDisconnectPropskitResetMenu(this);
3911
+ }
3912
+
3913
+ attributeChangedCallback(name, oldValue, newValue) {
3914
+ if (oldValue === newValue || !this.#surface) return;
3915
+ if (name === "value" && !this.#reflecting) {
3916
+ this.#reflectValue(this.#normalizeValue(newValue, this.value));
3917
+ this.#syncPrimitiveValue();
3918
+ } else if (name === "label" || name === "aria-label") {
3919
+ this.#syncLabel();
3920
+ } else if (name === "precision" || name === "edit") {
3921
+ this.#syncForwardedAttributes();
3922
+ } else if (name === "disabled") {
3923
+ this.#syncDisabled();
3924
+ }
3925
+ }
3926
+
3927
+ #isSpring() {
3928
+ return this.localName === "propskit-spring";
3929
+ }
3930
+
3931
+ #fallbackValue() {
3932
+ return this.#isSpring()
3933
+ ? { stiffness: 200, damping: 15, mass: 1 }
3934
+ : { x1: 0.42, y1: 0, x2: 0.58, y2: 1 };
3935
+ }
3936
+
3937
+ #finiteNumber(value, fallback) {
3938
+ const number = Number(value);
3939
+ return Number.isFinite(number) ? number : fallback;
3940
+ }
3941
+
3942
+ #positiveNumber(value, fallback) {
3943
+ const number = this.#finiteNumber(value, fallback);
3944
+ return number > 0 ? number : fallback;
3945
+ }
3946
+
3947
+ #normalizeValue(value, fallback = this.#fallbackValue()) {
3948
+ let parsed = value;
3949
+ if (typeof value === "string") {
3950
+ const trimmed = value.trim();
3951
+ if (!trimmed) return { ...fallback };
3952
+ try {
3953
+ parsed = JSON.parse(trimmed);
3954
+ } catch {
3955
+ if (this.#isSpring()) {
3956
+ const match = trimmed.match(
3957
+ /^spring\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
3958
+ );
3959
+ parsed = match
3960
+ ? {
3961
+ stiffness: Number(match[1]),
3962
+ damping: Number(match[2]),
3963
+ mass: Number(match[3]),
3964
+ }
3965
+ : null;
3966
+ } else {
3967
+ const raw = trimmed
3968
+ .replace(/^cubic-bezier\(\s*/i, "")
3969
+ .replace(/\s*\)$/, "");
3970
+ const parts = raw.split(",").map((part) => Number(part.trim()));
3971
+ parsed =
3972
+ parts.length === 4 && parts.every(Number.isFinite)
3973
+ ? { x1: parts[0], y1: parts[1], x2: parts[2], y2: parts[3] }
3974
+ : null;
3975
+ }
3976
+ }
3977
+ }
3978
+
3979
+ if (this.#isSpring()) {
3980
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3981
+ return { ...fallback };
3982
+ }
3983
+ return {
3984
+ stiffness: this.#positiveNumber(parsed.stiffness, fallback.stiffness),
3985
+ damping: this.#positiveNumber(parsed.damping, fallback.damping),
3986
+ mass: this.#positiveNumber(parsed.mass, fallback.mass),
3987
+ };
3988
+ }
3989
+
3990
+ if (Array.isArray(parsed)) {
3991
+ parsed = {
3992
+ x1: parsed[0],
3993
+ y1: parsed[1],
3994
+ x2: parsed[2],
3995
+ y2: parsed[3],
3996
+ };
3997
+ }
3998
+ if (!parsed || typeof parsed !== "object") return { ...fallback };
3999
+ return {
4000
+ x1: Math.max(
4001
+ 0,
4002
+ Math.min(1, this.#finiteNumber(parsed.x1, fallback.x1)),
4003
+ ),
4004
+ y1: this.#finiteNumber(parsed.y1, fallback.y1),
4005
+ x2: Math.max(
4006
+ 0,
4007
+ Math.min(1, this.#finiteNumber(parsed.x2, fallback.x2)),
4008
+ ),
4009
+ y2: this.#finiteNumber(parsed.y2, fallback.y2),
4010
+ };
4011
+ }
4012
+
4013
+ #reflectValue(value) {
4014
+ const fallback = this.#initialValue || this.#fallbackValue();
4015
+ const normalized = this.#normalizeValue(value, fallback);
4016
+ const serialized = JSON.stringify(normalized);
4017
+ if (this.getAttribute("value") !== serialized) {
4018
+ this.#reflecting = true;
4019
+ this.setAttribute("value", serialized);
4020
+ this.#reflecting = false;
4021
+ }
4022
+ return normalized;
2844
4023
  }
2845
4024
 
2846
4025
  #render() {
2847
- const value = this.#readValue();
2848
- const field = document.createElement("fig-field");
2849
- field.className = "propskit-position-row";
2850
- field.setAttribute("direction", "horizontal");
2851
- const label = document.createElement("label");
2852
- const xInput = this.#createNumber("x", value.x);
2853
- const yInput = this.#createNumber("y", value.y);
2854
- field.append(label, xInput, yInput);
2855
- this.#field = field;
2856
- this.#xInput = xInput;
2857
- this.#yInput = yInput;
2858
- this.replaceChildren(field);
2859
- figLabConnectPropskitResetMenu(this);
4026
+ const customLabel = this.querySelector(":scope > label");
4027
+ const surface = figLabCreateElement("div", {
4028
+ className: `${this.localName}-surface`,
4029
+ role: "group",
4030
+ });
4031
+ const label = customLabel || document.createElement("label");
4032
+ const curve = document.createElement("fig-easing-curve");
4033
+ curve.setAttribute("mode", this.#isSpring() ? "spring" : "bezier");
4034
+ curve.setAttribute("aspect-ratio", "1 / 1");
4035
+ surface.append(label, curve);
4036
+ this.#surface = surface;
4037
+ this.#label = label;
4038
+ this.#hasCustomLabel = Boolean(customLabel);
4039
+ this.#curve = curve;
4040
+ this.replaceChildren(surface);
4041
+ this.#observer = new MutationObserver(() => this.#syncDescendantState());
4042
+ this.#observer.observe(curve, { childList: true, subtree: true });
2860
4043
  }
2861
4044
 
2862
4045
  #syncLabel() {
2863
- const label = this.#field?.querySelector(":scope > label");
2864
- if (!label) return;
2865
- const rawLabel = this.getAttribute("label");
2866
- label.textContent = rawLabel?.trim() || "Position";
4046
+ const labelId = figLabSyncPropskitLabel(
4047
+ this,
4048
+ this.#surface,
4049
+ this.#label,
4050
+ this.#hasCustomLabel,
4051
+ );
4052
+ figLabSyncPropskitControlLabel(
4053
+ this,
4054
+ this.#curve,
4055
+ labelId,
4056
+ this.#isSpring() ? "Spring" : "Easing",
4057
+ );
4058
+ this.#syncDescendantState();
2867
4059
  }
2868
4060
 
2869
- #syncInputs() {
2870
- const value = this.#readValue();
2871
- this.#xInput?.setAttribute("value", String(value.x));
2872
- this.#yInput?.setAttribute("value", String(value.y));
2873
- this.#syncUnits();
4061
+ #syncPrimitive() {
4062
+ this.#syncForwardedAttributes();
4063
+ this.#syncPrimitiveValue();
2874
4064
  this.#syncDisabled();
2875
4065
  }
2876
4066
 
2877
- #syncUnits() {
2878
- const units = this.units === "percent" ? "%" : null;
2879
- for (const input of [this.#xInput, this.#yInput]) {
2880
- if (!input) continue;
2881
- if (units) input.setAttribute("units", units);
2882
- else input.removeAttribute("units");
4067
+ #syncForwardedAttributes() {
4068
+ if (!this.#curve) return;
4069
+ this.#curve.setAttribute("mode", this.#isSpring() ? "spring" : "bezier");
4070
+ this.#curve.setAttribute("aspect-ratio", "1 / 1");
4071
+ for (const name of ["precision", "edit"]) {
4072
+ if (this.hasAttribute(name)) {
4073
+ this.#curve.setAttribute(name, this.getAttribute(name) ?? "");
4074
+ } else {
4075
+ this.#curve.removeAttribute(name);
4076
+ }
4077
+ }
4078
+ }
4079
+
4080
+ #syncPrimitiveValue() {
4081
+ if (!this.#curve) return;
4082
+ const value = this.value;
4083
+ const serialized = this.#isSpring()
4084
+ ? `spring(${value.stiffness}, ${value.damping}, ${value.mass})`
4085
+ : `${value.x1}, ${value.y1}, ${value.x2}, ${value.y2}`;
4086
+ if (this.#curve.getAttribute("value") !== serialized) {
4087
+ this.#curve.setAttribute("value", serialized);
2883
4088
  }
2884
4089
  }
2885
4090
 
2886
4091
  #syncDisabled() {
4092
+ if (!this.#curve || !this.#surface) return;
2887
4093
  const disabled = figLabBooleanAttribute(this, "disabled");
2888
- this.#xInput?.toggleAttribute("disabled", disabled);
2889
- this.#yInput?.toggleAttribute("disabled", disabled);
4094
+ this.#surface.setAttribute("aria-disabled", String(disabled));
4095
+ this.#curve.toggleAttribute("disabled", disabled);
4096
+ this.#curve.inert = disabled;
4097
+ this.#syncDescendantState();
4098
+ }
4099
+
4100
+ #syncDescendantState() {
4101
+ if (!this.#curve) return;
4102
+ const disabled = figLabBooleanAttribute(this, "disabled");
4103
+ this.#curve
4104
+ .querySelector("fig-select")
4105
+ ?.setAttribute("variant", "ghost");
4106
+ for (const control of this.#curve.querySelectorAll(
4107
+ "fig-handle, fig-select, fig-dropdown, fig-input-text",
4108
+ )) {
4109
+ control.toggleAttribute("disabled", disabled);
4110
+ }
4111
+ for (const control of this.#curve.querySelectorAll("input, select")) {
4112
+ control.disabled = disabled;
4113
+ }
2890
4114
  }
2891
4115
 
2892
4116
  #bindEvents() {
2893
4117
  this.#unbindEvents();
2894
- this.#xInput?.addEventListener("input", this.#boundHandleInput);
2895
- this.#xInput?.addEventListener("change", this.#boundHandleChange);
2896
- this.#yInput?.addEventListener("input", this.#boundHandleInput);
2897
- this.#yInput?.addEventListener("change", this.#boundHandleChange);
4118
+ this.#curve?.addEventListener("input", this.#boundInput);
4119
+ this.#curve?.addEventListener("change", this.#boundChange);
2898
4120
  }
2899
4121
 
2900
4122
  #unbindEvents() {
2901
- this.#xInput?.removeEventListener("input", this.#boundHandleInput);
2902
- this.#xInput?.removeEventListener("change", this.#boundHandleChange);
2903
- this.#yInput?.removeEventListener("input", this.#boundHandleInput);
2904
- this.#yInput?.removeEventListener("change", this.#boundHandleChange);
4123
+ this.#curve?.removeEventListener("input", this.#boundInput);
4124
+ this.#curve?.removeEventListener("change", this.#boundChange);
2905
4125
  }
2906
4126
 
2907
- #handleInputEvent(type, event) {
4127
+ #handlePrimitiveEvent(type, event) {
2908
4128
  event.stopImmediatePropagation();
2909
- if (figLabBooleanAttribute(this, "disabled")) return;
2910
- const axis = event.currentTarget?.getAttribute("data-propskit-position-axis");
2911
- if (axis !== "x" && axis !== "y") return;
2912
- const value = this.value;
2913
- value[axis] = this.#finiteNumber(event.currentTarget.value, value[axis]);
2914
- const detail = this.#reflectValue(value);
2915
- this.dispatchEvent(
2916
- new CustomEvent(type, {
2917
- detail: { ...detail, units: this.units },
2918
- bubbles: true,
2919
- cancelable: true,
2920
- composed: true,
2921
- }),
2922
- );
2923
- }
2924
-
2925
- #handleClick(event) {
2926
- if (figLabBooleanAttribute(this, "disabled")) return;
2927
4129
  if (
2928
- event.target instanceof Element &&
2929
- event.target.closest("fig-input-number, fig-menu")
4130
+ event.target !== this.#curve ||
4131
+ figLabBooleanAttribute(this, "disabled")
2930
4132
  ) {
2931
4133
  return;
2932
4134
  }
2933
- this.focus();
2934
- }
2935
-
2936
- get x() {
2937
- return this.#readValue().x;
2938
- }
2939
-
2940
- set x(value) {
2941
- this.setAttribute("x", String(this.#finiteNumber(value, 50)));
2942
- }
2943
-
2944
- get y() {
2945
- return this.#readValue().y;
2946
- }
2947
-
2948
- set y(value) {
2949
- this.setAttribute("y", String(this.#finiteNumber(value, 50)));
2950
- }
2951
-
2952
- get units() {
2953
- return this.getAttribute("units")?.trim().toLowerCase() === "percent"
2954
- ? "percent"
2955
- : "none";
2956
- }
2957
-
2958
- set units(value) {
2959
- this.setAttribute(
2960
- "units",
2961
- String(value).trim().toLowerCase() === "none" ? "none" : "percent",
2962
- );
4135
+ const value = this.#normalizeValue(event.detail?.value, this.value);
4136
+ this.#reflectValue(value);
4137
+ this.#syncPrimitiveValue();
4138
+ figLabDispatchPropskitEvent(this, type);
2963
4139
  }
2964
4140
 
2965
4141
  get value() {
2966
- return { ...this.#readValue() };
4142
+ return this.#normalizeValue(
4143
+ this.getAttribute("value"),
4144
+ this.#initialValue || this.#fallbackValue(),
4145
+ );
2967
4146
  }
2968
4147
 
2969
4148
  set value(value) {
2970
- const normalized = this.#reflectValue(value);
2971
- this.#syncInputs(normalized);
4149
+ this.#reflectValue(value);
4150
+ this.#syncPrimitiveValue();
2972
4151
  }
2973
4152
 
2974
4153
  get defaultValue() {
2975
- return (
2976
- this.#parseDefault(this.getAttribute("default")) || {
2977
- ...(this.#initialValue || { x: 50, y: 50 }),
2978
- }
4154
+ return this.#normalizeValue(
4155
+ this.getAttribute("default"),
4156
+ this.#initialValue || this.#fallbackValue(),
2979
4157
  );
2980
4158
  }
2981
4159
 
@@ -2984,16 +4162,29 @@ class PropskitPosition extends HTMLElement {
2984
4162
  }
2985
4163
 
2986
4164
  resetToDefault() {
2987
- const value = this.defaultValue;
2988
- this.value = value;
2989
- figLabEmitPropskitReset(this, { ...value, units: this.units });
4165
+ this.value = this.defaultValue;
4166
+ figLabEmitPropskitReset(this);
2990
4167
  }
2991
4168
 
2992
4169
  focus(options) {
2993
- this.#xInput?.focus(options);
4170
+ if (figLabBooleanAttribute(this, "disabled")) return;
4171
+ this.#curve
4172
+ ?.querySelector("fig-select, fig-dropdown, fig-handle, fig-input-text")
4173
+ ?.focus(options);
2994
4174
  }
2995
4175
  }
2996
- figLabDefineElement("propskit-position", PropskitPosition);
4176
+
4177
+ /**
4178
+ * Labeled cubic-bezier easing editor with a typed { x1, y1, x2, y2 } value.
4179
+ */
4180
+ class PropskitEasing extends PropskitCurve {}
4181
+ figLabDefineElement("propskit-easing", PropskitEasing);
4182
+
4183
+ /**
4184
+ * Labeled spring editor with a typed { stiffness, damping, mass } value.
4185
+ */
4186
+ class PropskitSpring extends PropskitCurve {}
4187
+ figLabDefineElement("propskit-spring", PropskitSpring);
2997
4188
 
2998
4189
  /**
2999
4190
  * Collapsible color-point group composed from color and position controls.
@@ -3001,19 +4192,17 @@ figLabDefineElement("propskit-position", PropskitPosition);
3001
4192
  * @attr {string} label - Passed to the internal fig-group name.
3002
4193
  * @attr {boolean|string} collapsible - Internal group collapsibility; defaults true.
3003
4194
  * @attr {boolean|string} open - Internal group expanded state; defaults true.
3004
- * @attr {string} size - Passed to both internal PropsKit controls.
3005
4195
  * @attr {boolean|string} disabled - Disables both internal controls.
3006
4196
  * @attr {string} value - JSON object with x, y, and color.
3007
4197
  * @fires input - Composed event with { x, y, color }.
3008
4198
  * @fires change - Composed event with { x, y, color }.
3009
4199
  * @fires openchange - Composed event mirroring the internal fig-group.
3010
4200
  */
3011
- class PropskitColorPoint extends HTMLElement {
4201
+ class PropskitColorPoint extends FigLabPropskitElement {
3012
4202
  static observedAttributes = [
3013
4203
  "label",
3014
4204
  "collapsible",
3015
4205
  "open",
3016
- "size",
3017
4206
  "disabled",
3018
4207
  "value",
3019
4208
  ];
@@ -3035,7 +4224,6 @@ class PropskitColorPoint extends HTMLElement {
3035
4224
  }
3036
4225
  if (!this.#group) this.#render();
3037
4226
  this.#syncGroupAttributes();
3038
- this.#syncSize();
3039
4227
  this.#syncDisabled();
3040
4228
  this.#syncControls(this.#readValue());
3041
4229
  this.removeEventListener("input", this.#boundHandleInput);
@@ -3061,10 +4249,6 @@ class PropskitColorPoint extends HTMLElement {
3061
4249
  }
3062
4250
  return;
3063
4251
  }
3064
- if (name === "size") {
3065
- this.#syncSize();
3066
- return;
3067
- }
3068
4252
  if (name === "disabled") {
3069
4253
  this.#syncDisabled();
3070
4254
  return;
@@ -3131,7 +4315,6 @@ class PropskitColorPoint extends HTMLElement {
3131
4315
  this.#colorControl = color;
3132
4316
  this.#positionControl = position;
3133
4317
  this.#syncGroupAttributes();
3134
- this.#syncSize();
3135
4318
  this.#syncDisabled();
3136
4319
  group.append(color, position);
3137
4320
  this.replaceChildren(group);
@@ -3151,15 +4334,6 @@ class PropskitColorPoint extends HTMLElement {
3151
4334
  }
3152
4335
  }
3153
4336
 
3154
- #syncSize() {
3155
- const size = this.getAttribute("size");
3156
- for (const control of [this.#colorControl, this.#positionControl]) {
3157
- if (!control) continue;
3158
- if (size === null) control.removeAttribute("size");
3159
- else control.setAttribute("size", size);
3160
- }
3161
- }
3162
-
3163
4337
  #syncDisabled() {
3164
4338
  figLabSyncDisabledControls(this, [
3165
4339
  this.#colorControl,
@@ -3196,15 +4370,8 @@ class PropskitColorPoint extends HTMLElement {
3196
4370
  } else {
3197
4371
  return;
3198
4372
  }
3199
- const detail = this.#reflectValue(value);
3200
- this.dispatchEvent(
3201
- new CustomEvent(type, {
3202
- detail: { ...detail, units: "percent" },
3203
- bubbles: true,
3204
- cancelable: true,
3205
- composed: true,
3206
- }),
3207
- );
4373
+ this.#reflectValue(value);
4374
+ figLabDispatchPropskitEvent(this, type);
3208
4375
  }
3209
4376
 
3210
4377
  #handleOpenChange(event) {
@@ -3262,7 +4429,6 @@ figLabDefineElement("propskit-color-point", PropskitColorPoint);
3262
4429
  * @attr {string} label - Passed to the internal fig-group name.
3263
4430
  * @attr {boolean|string} collapsible - Internal group collapsibility; defaults true.
3264
4431
  * @attr {boolean|string} open - Internal group expanded state; defaults true.
3265
- * @attr {string} size - Passed to both internal PropsKit controls.
3266
4432
  * @attr {string} units - Passed to the internal position and radius controls.
3267
4433
  * @attr {boolean|string} disabled - Disables both internal controls.
3268
4434
  * @attr {string} value - JSON object with x, y, and radius.
@@ -3270,12 +4436,11 @@ figLabDefineElement("propskit-color-point", PropskitColorPoint);
3270
4436
  * @fires change - Composed event with { x, y, radius }.
3271
4437
  * @fires openchange - Composed event mirroring the internal fig-group.
3272
4438
  */
3273
- class PropskitPointRadius extends HTMLElement {
4439
+ class PropskitPointRadius extends FigLabPropskitElement {
3274
4440
  static observedAttributes = [
3275
4441
  "label",
3276
4442
  "collapsible",
3277
4443
  "open",
3278
- "size",
3279
4444
  "units",
3280
4445
  "disabled",
3281
4446
  "value",
@@ -3298,7 +4463,6 @@ class PropskitPointRadius extends HTMLElement {
3298
4463
  }
3299
4464
  if (!this.#group) this.#render();
3300
4465
  this.#syncGroupAttributes();
3301
- this.#syncSize();
3302
4466
  this.#syncUnits();
3303
4467
  this.#syncDisabled();
3304
4468
  this.#syncControls(this.#readValue());
@@ -3325,10 +4489,6 @@ class PropskitPointRadius extends HTMLElement {
3325
4489
  }
3326
4490
  return;
3327
4491
  }
3328
- if (name === "size") {
3329
- this.#syncSize();
3330
- return;
3331
- }
3332
4492
  if (name === "units") {
3333
4493
  this.#syncUnits();
3334
4494
  return;
@@ -3418,7 +4578,6 @@ class PropskitPointRadius extends HTMLElement {
3418
4578
  this.#positionControl = position;
3419
4579
  this.#radiusControl = radius;
3420
4580
  this.#syncGroupAttributes();
3421
- this.#syncSize();
3422
4581
  this.#syncDisabled();
3423
4582
  group.append(position, radius);
3424
4583
  this.replaceChildren(group);
@@ -3438,15 +4597,6 @@ class PropskitPointRadius extends HTMLElement {
3438
4597
  }
3439
4598
  }
3440
4599
 
3441
- #syncSize() {
3442
- const size = this.getAttribute("size");
3443
- for (const control of [this.#positionControl, this.#radiusControl]) {
3444
- if (!control) continue;
3445
- if (size === null) control.removeAttribute("size");
3446
- else control.setAttribute("size", size);
3447
- }
3448
- }
3449
-
3450
4600
  #syncUnits() {
3451
4601
  if (this.hasAttribute("units")) {
3452
4602
  this.#positionControl?.setAttribute("units", this.units);
@@ -3508,19 +4658,8 @@ class PropskitPointRadius extends HTMLElement {
3508
4658
  } else {
3509
4659
  return;
3510
4660
  }
3511
- const detail = this.#reflectValue(value);
3512
- this.dispatchEvent(
3513
- new CustomEvent(type, {
3514
- detail: {
3515
- ...detail,
3516
- radius: this.#radiusParts(detail.radius).value,
3517
- units: this.units,
3518
- },
3519
- bubbles: true,
3520
- cancelable: true,
3521
- composed: true,
3522
- }),
3523
- );
4661
+ this.#reflectValue(value);
4662
+ figLabDispatchPropskitEvent(this, type);
3524
4663
  }
3525
4664
 
3526
4665
  #handleOpenChange(event) {
@@ -3591,7 +4730,6 @@ figLabDefineElement("propskit-point-radius", PropskitPointRadius);
3591
4730
  * @attr {string} label - Passed to the internal fig-group name.
3592
4731
  * @attr {boolean|string} collapsible - Internal group collapsibility; defaults true.
3593
4732
  * @attr {boolean|string} open - Internal group expanded state; defaults true.
3594
- * @attr {string} size - Passed to every internal PropsKit control.
3595
4733
  * @attr {string} units - Passed to the internal position and radius controls.
3596
4734
  * @attr {boolean|string} disabled - Disables every internal control.
3597
4735
  * @attr {string} value - JSON object with x, y, radius, and angle.
@@ -3599,12 +4737,11 @@ figLabDefineElement("propskit-point-radius", PropskitPointRadius);
3599
4737
  * @fires change - Composed event with { x, y, radius, angle }.
3600
4738
  * @fires openchange - Composed event mirroring the internal fig-group.
3601
4739
  */
3602
- class PropskitPointRadiusAngle extends HTMLElement {
4740
+ class PropskitPointRadiusAngle extends FigLabPropskitElement {
3603
4741
  static observedAttributes = [
3604
4742
  "label",
3605
4743
  "collapsible",
3606
4744
  "open",
3607
- "size",
3608
4745
  "units",
3609
4746
  "disabled",
3610
4747
  "value",
@@ -3628,7 +4765,6 @@ class PropskitPointRadiusAngle extends HTMLElement {
3628
4765
  }
3629
4766
  if (!this.#group) this.#render();
3630
4767
  this.#syncGroupAttributes();
3631
- this.#syncSize();
3632
4768
  this.#syncUnits();
3633
4769
  this.#syncDisabled();
3634
4770
  this.#syncControls(this.#readValue());
@@ -3655,10 +4791,6 @@ class PropskitPointRadiusAngle extends HTMLElement {
3655
4791
  }
3656
4792
  return;
3657
4793
  }
3658
- if (name === "size") {
3659
- this.#syncSize();
3660
- return;
3661
- }
3662
4794
  if (name === "units") {
3663
4795
  this.#syncUnits();
3664
4796
  return;
@@ -3760,7 +4892,6 @@ class PropskitPointRadiusAngle extends HTMLElement {
3760
4892
  this.#radiusControl = radius;
3761
4893
  this.#angleControl = angle;
3762
4894
  this.#syncGroupAttributes();
3763
- this.#syncSize();
3764
4895
  this.#syncDisabled();
3765
4896
  group.append(position, radius, angle);
3766
4897
  this.replaceChildren(group);
@@ -3780,19 +4911,6 @@ class PropskitPointRadiusAngle extends HTMLElement {
3780
4911
  }
3781
4912
  }
3782
4913
 
3783
- #syncSize() {
3784
- const size = this.getAttribute("size");
3785
- for (const control of [
3786
- this.#positionControl,
3787
- this.#radiusControl,
3788
- this.#angleControl,
3789
- ]) {
3790
- if (!control) continue;
3791
- if (size === null) control.removeAttribute("size");
3792
- else control.setAttribute("size", size);
3793
- }
3794
- }
3795
-
3796
4914
  #syncUnits() {
3797
4915
  if (this.hasAttribute("units")) {
3798
4916
  this.#positionControl?.setAttribute("units", this.units);
@@ -3862,19 +4980,8 @@ class PropskitPointRadiusAngle extends HTMLElement {
3862
4980
  } else {
3863
4981
  return;
3864
4982
  }
3865
- const detail = this.#reflectValue(value);
3866
- this.dispatchEvent(
3867
- new CustomEvent(type, {
3868
- detail: {
3869
- ...detail,
3870
- radius: this.#radiusParts(detail.radius).value,
3871
- units: this.units,
3872
- },
3873
- bubbles: true,
3874
- cancelable: true,
3875
- composed: true,
3876
- }),
3877
- );
4983
+ this.#reflectValue(value);
4984
+ figLabDispatchPropskitEvent(this, type);
3878
4985
  }
3879
4986
 
3880
4987
  #handleOpenChange(event) {
@@ -3948,7 +5055,6 @@ figLabDefineElement(
3948
5055
  * @attr {string} label - Passed to the internal fig-group name.
3949
5056
  * @attr {boolean|string} collapsible - Internal group collapsibility; defaults true.
3950
5057
  * @attr {boolean|string} open - Internal group expanded state; defaults true.
3951
- * @attr {string} size - Passed to both internal position controls.
3952
5058
  * @attr {string} units - Passed to both internal position controls.
3953
5059
  * @attr {boolean|string} disabled - Disables both internal position controls.
3954
5060
  * @attr {string} value - JSON object with x, y, x2, and y2.
@@ -3956,12 +5062,11 @@ figLabDefineElement(
3956
5062
  * @fires change - Composed event with { x, y, x2, y2 }.
3957
5063
  * @fires openchange - Composed event mirroring the internal fig-group.
3958
5064
  */
3959
- class PropskitPointPoint extends HTMLElement {
5065
+ class PropskitPointPoint extends FigLabPropskitElement {
3960
5066
  static observedAttributes = [
3961
5067
  "label",
3962
5068
  "collapsible",
3963
5069
  "open",
3964
- "size",
3965
5070
  "units",
3966
5071
  "disabled",
3967
5072
  "value",
@@ -3984,7 +5089,6 @@ class PropskitPointPoint extends HTMLElement {
3984
5089
  }
3985
5090
  if (!this.#group) this.#render();
3986
5091
  this.#syncGroupAttributes();
3987
- this.#syncSize();
3988
5092
  this.#syncUnits();
3989
5093
  this.#syncDisabled();
3990
5094
  this.#syncControls(this.#readValue());
@@ -4011,10 +5115,6 @@ class PropskitPointPoint extends HTMLElement {
4011
5115
  }
4012
5116
  return;
4013
5117
  }
4014
- if (name === "size") {
4015
- this.#syncSize();
4016
- return;
4017
- }
4018
5118
  if (name === "units") {
4019
5119
  this.#syncUnits();
4020
5120
  return;
@@ -4085,7 +5185,6 @@ class PropskitPointPoint extends HTMLElement {
4085
5185
  this.#startControl = start;
4086
5186
  this.#endControl = end;
4087
5187
  this.#syncGroupAttributes();
4088
- this.#syncSize();
4089
5188
  this.#syncDisabled();
4090
5189
  group.append(start, end);
4091
5190
  this.replaceChildren(group);
@@ -4105,15 +5204,6 @@ class PropskitPointPoint extends HTMLElement {
4105
5204
  }
4106
5205
  }
4107
5206
 
4108
- #syncSize() {
4109
- const size = this.getAttribute("size");
4110
- for (const control of [this.#startControl, this.#endControl]) {
4111
- if (!control) continue;
4112
- if (size === null) control.removeAttribute("size");
4113
- else control.setAttribute("size", size);
4114
- }
4115
- }
4116
-
4117
5207
  #syncUnits() {
4118
5208
  for (const control of [this.#startControl, this.#endControl]) {
4119
5209
  if (!control) continue;
@@ -4165,15 +5255,8 @@ class PropskitPointPoint extends HTMLElement {
4165
5255
  } else {
4166
5256
  return;
4167
5257
  }
4168
- const detail = this.#reflectValue(value);
4169
- this.dispatchEvent(
4170
- new CustomEvent(type, {
4171
- detail: { ...detail, units: this.units },
4172
- bubbles: true,
4173
- cancelable: true,
4174
- composed: true,
4175
- }),
4176
- );
5258
+ this.#reflectValue(value);
5259
+ figLabDispatchPropskitEvent(this, type);
4177
5260
  }
4178
5261
 
4179
5262
  #handleOpenChange(event) {
@@ -4239,14 +5322,17 @@ class PropskitPointPoint extends HTMLElement {
4239
5322
  figLabDefineElement("propskit-point-point", PropskitPointPoint);
4240
5323
 
4241
5324
  /* Collapsible property group — always collapsible (no collapsible attr). */
4242
- class PropskitGroup extends HTMLElement {
4243
- static observedAttributes = ["name", "open", "show-reset", "disabled", "size"];
5325
+ class PropskitGroup extends FigLabPropskitElement {
5326
+ static observedAttributes = ["name", "open", "show-reset", "disabled"];
4244
5327
 
4245
5328
  static #CONTROL_SELECTOR = [
4246
5329
  "propskit-color",
4247
5330
  "propskit-fill",
4248
5331
  "propskit-gradient",
5332
+ "propskit-easing",
5333
+ "propskit-joystick",
4249
5334
  "propskit-number",
5335
+ "propskit-origin",
4250
5336
  "propskit-position",
4251
5337
  "propskit-color-point",
4252
5338
  "propskit-point-radius",
@@ -4254,6 +5340,7 @@ class PropskitGroup extends HTMLElement {
4254
5340
  "propskit-point-point",
4255
5341
  "propskit-select",
4256
5342
  "propskit-slider",
5343
+ "propskit-spring",
4257
5344
  "propskit-switch",
4258
5345
  "propskit-text",
4259
5346
  "propskit-wheel",
@@ -4270,7 +5357,6 @@ class PropskitGroup extends HTMLElement {
4270
5357
  connectedCallback() {
4271
5358
  this.#render();
4272
5359
  this.#syncDisabled();
4273
- this.#syncSize();
4274
5360
  this.#bindDirtyListeners();
4275
5361
  requestAnimationFrame(() => {
4276
5362
  this.#syncDirtyState();
@@ -4304,10 +5390,6 @@ class PropskitGroup extends HTMLElement {
4304
5390
  this.#syncDisabled();
4305
5391
  return;
4306
5392
  }
4307
- if (name === "size") {
4308
- this.#syncSize();
4309
- return;
4310
- }
4311
5393
  this.#render();
4312
5394
  }
4313
5395
 
@@ -4364,7 +5446,6 @@ class PropskitGroup extends HTMLElement {
4364
5446
  if (!this.#childObserver) {
4365
5447
  this.#childObserver = new MutationObserver(() => {
4366
5448
  this.#syncDisabled();
4367
- this.#syncSize();
4368
5449
  this.#queueDirtySync();
4369
5450
  });
4370
5451
  }
@@ -4455,26 +5536,6 @@ class PropskitGroup extends HTMLElement {
4455
5536
  ?.toggleAttribute("disabled", disabled);
4456
5537
  }
4457
5538
 
4458
- #syncSize() {
4459
- const small = this.getAttribute("size") === "small";
4460
- for (const control of this.#controls()) {
4461
- const generated = control.hasAttribute("data-propskit-group-size");
4462
- if (small && !control.hasAttribute("size")) {
4463
- control.setAttribute("data-propskit-group-size", "");
4464
- control.setAttribute("size", "small");
4465
- } else if (
4466
- small &&
4467
- generated &&
4468
- control.getAttribute("size") !== "small"
4469
- ) {
4470
- control.setAttribute("size", "small");
4471
- } else if (!small && generated) {
4472
- control.removeAttribute("size");
4473
- control.removeAttribute("data-propskit-group-size");
4474
- }
4475
- }
4476
- }
4477
-
4478
5539
  #controlIsDirty(el) {
4479
5540
  return el.isDefault === false;
4480
5541
  }
@@ -4627,13 +5688,13 @@ class PropskitGroup extends HTMLElement {
4627
5688
  }
4628
5689
  figLabDefineElement("propskit-group", PropskitGroup);
4629
5690
 
4630
- /* Field + Slider wrapper */
4631
- class PropskitSlider extends HTMLElement {
5691
+ /* PropsKit slider surface */
5692
+ class PropskitSlider extends FigLabPropskitElement {
4632
5693
  static #DRAG_THRESHOLD_PX = 4;
4633
5694
  static #DRAGGING_BODY_CLASS = "propskit-slider-dragging";
4634
5695
  static #TEXT_MEASURE_CANVAS = null;
4635
5696
 
4636
- #field = null;
5697
+ #surface = null;
4637
5698
  #label = null;
4638
5699
  #slider = null;
4639
5700
  #hasCustomLabel = false;
@@ -4693,15 +5754,15 @@ class PropskitSlider extends HTMLElement {
4693
5754
  ]);
4694
5755
 
4695
5756
  static get observedAttributes() {
4696
- return ["label", "direction"];
5757
+ return ["label", "aria-label"];
4697
5758
  }
4698
5759
 
4699
5760
  connectedCallback() {
4700
- if (!this.#field) {
5761
+ if (!this.#surface) {
4701
5762
  this.#initialize();
4702
5763
  }
4703
5764
 
4704
- this.#syncField();
5765
+ this.#syncSurface();
4705
5766
  this.#syncSliderAttributes();
4706
5767
  this.#bindSliderEvents();
4707
5768
  this.#queueFocusDelegationSync();
@@ -4735,7 +5796,7 @@ class PropskitSlider extends HTMLElement {
4735
5796
 
4736
5797
  if (!this.#observer) {
4737
5798
  this.#observer = new MutationObserver((mutations) => {
4738
- let syncField = false;
5799
+ let syncSurface = false;
4739
5800
  let syncSlider = false;
4740
5801
 
4741
5802
  for (const mutation of mutations) {
@@ -4756,16 +5817,21 @@ class PropskitSlider extends HTMLElement {
4756
5817
  }
4757
5818
  if (
4758
5819
  mutation.attributeName === "label" ||
4759
- mutation.attributeName === "direction"
5820
+ mutation.attributeName === "aria-label"
4760
5821
  ) {
4761
- syncField = true;
5822
+ syncSurface = true;
5823
+ } else if (mutation.attributeName === "direction") {
5824
+ continue;
4762
5825
  } else {
4763
5826
  syncSlider = true;
4764
5827
  }
4765
5828
  }
4766
5829
  }
4767
5830
 
4768
- if (syncField) this.#syncField();
5831
+ if (syncSurface) {
5832
+ this.#syncSurface();
5833
+ this.#queueFocusDelegationSync();
5834
+ }
4769
5835
  if (syncSlider) {
4770
5836
  this.#syncSliderAttributes();
4771
5837
  this.#queueFocusDelegationSync();
@@ -4811,9 +5877,10 @@ class PropskitSlider extends HTMLElement {
4811
5877
  }
4812
5878
 
4813
5879
  attributeChangedCallback(name, oldValue, newValue) {
4814
- if (oldValue === newValue || !this.#field) return;
4815
- if (name === "label" || name === "direction") {
4816
- this.#syncField();
5880
+ if (oldValue === newValue || !this.#surface) return;
5881
+ if (name === "label" || name === "aria-label") {
5882
+ this.#syncSurface();
5883
+ this.#queueFocusDelegationSync();
4817
5884
  }
4818
5885
  }
4819
5886
 
@@ -4832,7 +5899,9 @@ class PropskitSlider extends HTMLElement {
4832
5899
  const customLabel = initialChildren.find(
4833
5900
  (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
4834
5901
  );
4835
- const field = document.createElement("fig-field");
5902
+ const surface = figLabCreateElement("div", {
5903
+ className: "propskit-slider-surface",
5904
+ });
4836
5905
  const label = customLabel || document.createElement("label");
4837
5906
  const slider = document.createElement("fig-slider");
4838
5907
  slider.setAttribute("text", "true");
@@ -4841,14 +5910,14 @@ class PropskitSlider extends HTMLElement {
4841
5910
  slider.setAttribute(attrName, value ?? "");
4842
5911
  }
4843
5912
 
4844
- field.append(label, slider);
5913
+ surface.append(label, slider);
4845
5914
 
4846
- this.#field = field;
5915
+ this.#surface = surface;
4847
5916
  this.#label = label;
4848
5917
  this.#slider = slider;
4849
5918
  this.#hasCustomLabel = Boolean(customLabel);
4850
5919
 
4851
- this.replaceChildren(field);
5920
+ this.replaceChildren(surface);
4852
5921
  this.#setupContextMenu();
4853
5922
 
4854
5923
  for (const node of initialChildren) {
@@ -4872,28 +5941,12 @@ class PropskitSlider extends HTMLElement {
4872
5941
  this.appendChild(menu);
4873
5942
  }
4874
5943
 
4875
- #syncField() {
4876
- if (!this.#field || !this.#label) return;
4877
- const hasLabelAttr = this.hasAttribute("label");
4878
- const rawLabel = this.getAttribute("label");
4879
- const isBlankLabel = hasLabelAttr && (rawLabel ?? "").trim() === "";
4880
-
4881
- if (isBlankLabel) {
4882
- if (this.#label.parentElement === this.#field) {
4883
- this.#label.remove();
4884
- }
4885
- } else {
4886
- if (!this.#hasCustomLabel) {
4887
- this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
4888
- }
4889
- if (this.#label.parentElement !== this.#field) {
4890
- this.#field.prepend(this.#label);
4891
- }
4892
- }
4893
-
4894
- this.#field.setAttribute(
4895
- "direction",
4896
- this.getAttribute("direction") || "horizontal",
5944
+ #syncSurface() {
5945
+ figLabSyncPropskitLabel(
5946
+ this,
5947
+ this.#surface,
5948
+ this.#label,
5949
+ this.#hasCustomLabel,
4897
5950
  );
4898
5951
  this.#queueHandleProximitySync();
4899
5952
  }
@@ -5094,7 +6147,7 @@ class PropskitSlider extends HTMLElement {
5094
6147
  const handleRadius = this.#handleRadius;
5095
6148
  const distances = [];
5096
6149
  const labelRect =
5097
- this.#label?.parentElement === this.#field
6150
+ this.#label?.parentElement === this.#surface
5098
6151
  ? this.#contentRect(this.#label)
5099
6152
  : null;
5100
6153
  const numberInput = this.#slider.querySelector("fig-input-number input");
@@ -5281,15 +6334,19 @@ class PropskitSlider extends HTMLElement {
5281
6334
  if (rangeInput) {
5282
6335
  rangeInput.removeAttribute("tabindex");
5283
6336
  rangeInput.removeAttribute("aria-hidden");
5284
- const label =
5285
- this.getAttribute("aria-label") ||
5286
- this.#label?.textContent?.trim() ||
5287
- "Slider";
5288
- if (
5289
- !rangeInput.hasAttribute("aria-label") &&
5290
- !rangeInput.hasAttribute("aria-labelledby")
6337
+ const explicitLabel = this.getAttribute("aria-label")?.trim();
6338
+ if (explicitLabel) {
6339
+ rangeInput.setAttribute("aria-label", explicitLabel);
6340
+ rangeInput.removeAttribute("aria-labelledby");
6341
+ } else if (
6342
+ this.#label?.parentElement === this.#surface &&
6343
+ this.#label.id
5291
6344
  ) {
5292
- rangeInput.setAttribute("aria-label", label);
6345
+ rangeInput.setAttribute("aria-labelledby", this.#label.id);
6346
+ rangeInput.removeAttribute("aria-label");
6347
+ } else {
6348
+ rangeInput.setAttribute("aria-label", "Slider");
6349
+ rangeInput.removeAttribute("aria-labelledby");
5293
6350
  }
5294
6351
  }
5295
6352
  if (numberInput) {
@@ -5668,14 +6725,7 @@ class PropskitSlider extends HTMLElement {
5668
6725
  if (!this.#slider || value === null || value === undefined) return;
5669
6726
  this.#slider.value = value;
5670
6727
  this.setAttribute("value", String(this.#slider.value));
5671
- this.dispatchEvent(
5672
- new CustomEvent(eventType, {
5673
- detail: this.#slider.value,
5674
- bubbles: true,
5675
- cancelable: true,
5676
- composed: true,
5677
- }),
5678
- );
6728
+ figLabDispatchPropskitEvent(this, eventType);
5679
6729
  }
5680
6730
 
5681
6731
  #syncSteppersToNumberInput() {
@@ -5729,10 +6779,6 @@ class PropskitSlider extends HTMLElement {
5729
6779
  if (type === "change") {
5730
6780
  this.#resetElasticPull();
5731
6781
  }
5732
- const detail =
5733
- event instanceof CustomEvent && event.detail !== undefined
5734
- ? event.detail
5735
- : this.#slider?.value;
5736
6782
  if (this.#slider?.value !== undefined) {
5737
6783
  const next = String(this.#slider.value);
5738
6784
  if (this.getAttribute("value") !== next) {
@@ -5740,14 +6786,7 @@ class PropskitSlider extends HTMLElement {
5740
6786
  }
5741
6787
  }
5742
6788
  this.#syncProgressStyles();
5743
- this.dispatchEvent(
5744
- new CustomEvent(type, {
5745
- detail,
5746
- bubbles: true,
5747
- cancelable: true,
5748
- composed: true,
5749
- }),
5750
- );
6789
+ figLabDispatchPropskitEvent(this, type);
5751
6790
  }
5752
6791
 
5753
6792
  focus(options) {
@@ -5758,7 +6797,9 @@ class PropskitSlider extends HTMLElement {
5758
6797
  }
5759
6798
 
5760
6799
  get value() {
5761
- return this.getAttribute("value") ?? this.#slider?.value ?? "";
6800
+ return figLabFiniteNumberOrNull(
6801
+ this.getAttribute("value") ?? this.#slider?.value,
6802
+ );
5762
6803
  }
5763
6804
 
5764
6805
  set value(nextValue) {
@@ -6592,18 +7633,16 @@ figLabDefineElement("fig-input-wheel", FigInputWheel);
6592
7633
  /**
6593
7634
  * Labeled numeric wheel with an optional editable number field.
6594
7635
  *
6595
- * @attr {string} label - Field label. Defaults to "Value".
7636
+ * @attr {string} label - Surface label. Defaults to "Label"; empty hides it.
6596
7637
  * @attr {string} default - Reset target.
6597
7638
  * @attr {number} precision - Number field display decimals.
6598
7639
  * @attr {boolean|string} elastic - Enables resisted row stretching. Defaults to true.
6599
7640
  * @attr {boolean|string} spin - Keeps wheel ticks synchronized to value. Defaults to true.
6600
7641
  * @attr {boolean|string} text - Shows the number field. Defaults to true.
6601
- * @attr {string} size - Set to "small" for compact sizing.
6602
- * @attr {string} variant - Set to "minimal" for compact chrome.
6603
7642
  * @fires input - Retargeted numeric composed event.
6604
7643
  * @fires change - Retargeted numeric composed event.
6605
7644
  */
6606
- class PropskitWheel extends HTMLElement {
7645
+ class PropskitWheel extends FigLabPropskitElement {
6607
7646
  static #RESERVED_ATTRS = new Set([
6608
7647
  "label",
6609
7648
  "size",
@@ -6623,6 +7662,7 @@ class PropskitWheel extends HTMLElement {
6623
7662
  static get observedAttributes() {
6624
7663
  return [
6625
7664
  "label",
7665
+ "aria-label",
6626
7666
  "units",
6627
7667
  "value",
6628
7668
  "disabled",
@@ -6712,7 +7752,7 @@ class PropskitWheel extends HTMLElement {
6712
7752
 
6713
7753
  attributeChangedCallback(name, oldValue, newValue) {
6714
7754
  if (oldValue === newValue || !this.#surface) return;
6715
- if (name === "label") this.#syncLabel();
7755
+ if (name === "label" || name === "aria-label") this.#syncLabel();
6716
7756
  if (name === "text") this.#syncText();
6717
7757
  if (name === "elastic" && newValue === "false") {
6718
7758
  this.#stopElasticTracking();
@@ -6755,10 +7795,6 @@ class PropskitWheel extends HTMLElement {
6755
7795
  const label = customLabel || document.createElement("label");
6756
7796
  const wheel = document.createElement("fig-input-wheel");
6757
7797
  const input = document.createElement("fig-input-number");
6758
- const labelId = figLabUniqueId("propskit-wheel-label");
6759
- label.id = labelId;
6760
- wheel.setAttribute("aria-labelledby", labelId);
6761
- input.setAttribute("aria-labelledby", labelId);
6762
7798
  surface.append(label, wheel, input);
6763
7799
  this.#surface = surface;
6764
7800
  this.#wheel = wheel;
@@ -6772,16 +7808,14 @@ class PropskitWheel extends HTMLElement {
6772
7808
  }
6773
7809
 
6774
7810
  #syncLabel() {
6775
- if (!this.#label) return;
6776
- if (!this.#hasCustomLabel) {
6777
- this.#label.textContent = this.hasAttribute("label")
6778
- ? (this.getAttribute("label") ?? "")
6779
- : "Value";
6780
- }
6781
- this.toggleAttribute(
6782
- "data-label-empty",
6783
- !(this.#label.textContent ?? "").trim(),
7811
+ const labelId = figLabSyncPropskitLabel(
7812
+ this,
7813
+ this.#surface,
7814
+ this.#label,
7815
+ this.#hasCustomLabel,
6784
7816
  );
7817
+ figLabSyncPropskitControlLabel(this, this.#wheel, labelId, "Wheel");
7818
+ figLabSyncPropskitControlLabel(this, this.#input, labelId, "Number");
6785
7819
  }
6786
7820
 
6787
7821
  #syncText() {
@@ -6813,13 +7847,15 @@ class PropskitWheel extends HTMLElement {
6813
7847
  "disabled",
6814
7848
  figLabBooleanAttribute(this, "disabled"),
6815
7849
  );
6816
- this.#wheel.value = this.getAttribute("value") ?? "0";
6817
- const normalized = this.#wheel.value;
6818
- if (this.getAttribute("value") !== normalized) {
7850
+ const publicValue = figLabFiniteNumberOrNull(this.getAttribute("value"));
7851
+ this.#wheel.value = publicValue ?? 0;
7852
+ const normalized = String(this.#wheel.value);
7853
+ if (publicValue !== null && this.getAttribute("value") !== normalized) {
6819
7854
  this.setAttribute("value", normalized);
6820
7855
  }
6821
- if (this.#input?.getAttribute("value") !== normalized) {
6822
- this.#input?.setAttribute("value", normalized);
7856
+ const inputValue = publicValue === null ? "" : normalized;
7857
+ if (this.#input?.getAttribute("value") !== inputValue) {
7858
+ this.#input?.setAttribute("value", inputValue);
6823
7859
  }
6824
7860
  this.#syncPrimitiveAria();
6825
7861
  }
@@ -6909,7 +7945,7 @@ class PropskitWheel extends HTMLElement {
6909
7945
  "disabled",
6910
7946
  figLabBooleanAttribute(this, "disabled"),
6911
7947
  );
6912
- this.#input.setAttribute("value", this.#wheel.value);
7948
+ this.#input.setAttribute("value", String(this.value ?? ""));
6913
7949
  this.#managedInputAttrs = nextManaged;
6914
7950
  }
6915
7951
 
@@ -6952,15 +7988,20 @@ class PropskitWheel extends HTMLElement {
6952
7988
  value,
6953
7989
  { spin = false, animateHandle = false } = {},
6954
7990
  ) {
6955
- const parsed = Number(value);
7991
+ const parsed = figLabFiniteNumberOrNull(value);
7992
+ if (parsed === null) {
7993
+ this.removeAttribute("value");
7994
+ if (this.#wheel) this.#wheel.value = 0;
7995
+ this.#input?.setAttribute("value", "");
7996
+ this.#syncPrimitiveAria();
7997
+ return null;
7998
+ }
6956
7999
  if (!this.#wheel) {
6957
- const normalized = Number.isFinite(parsed) ? parsed : 0;
6958
- this.setAttribute("value", String(normalized));
6959
- return normalized;
8000
+ this.setAttribute("value", String(parsed));
8001
+ return parsed;
6960
8002
  }
6961
- const nextValue = Number.isFinite(parsed) ? parsed : 0;
6962
- if (spin) this.#wheel.spinTo(nextValue, { animateHandle });
6963
- else this.#wheel.value = nextValue;
8003
+ if (spin) this.#wheel.spinTo(parsed, { animateHandle });
8004
+ else this.#wheel.value = parsed;
6964
8005
  const normalized = this.#wheel.value;
6965
8006
  if (this.getAttribute("value") !== normalized) {
6966
8007
  this.setAttribute("value", normalized);
@@ -6972,21 +8013,14 @@ class PropskitWheel extends HTMLElement {
6972
8013
  return Number(normalized);
6973
8014
  }
6974
8015
 
6975
- #emit(type, value) {
6976
- this.dispatchEvent(
6977
- new CustomEvent(type, {
6978
- detail: value,
6979
- bubbles: true,
6980
- cancelable: true,
6981
- composed: true,
6982
- }),
6983
- );
8016
+ #emit(type) {
8017
+ figLabDispatchPropskitEvent(this, type);
6984
8018
  }
6985
8019
 
6986
8020
  #handlePrimitiveEvent(type, event) {
6987
8021
  event.stopImmediatePropagation();
6988
- const value = this.#setSynchronizedValue(event.detail);
6989
- this.#emit(type, value);
8022
+ this.#setSynchronizedValue(event.detail);
8023
+ this.#emit(type);
6990
8024
  }
6991
8025
 
6992
8026
  #handleNumberEvent(type, event) {
@@ -6996,12 +8030,12 @@ class PropskitWheel extends HTMLElement {
6996
8030
  event instanceof CustomEvent && event.detail !== undefined
6997
8031
  ? event.detail
6998
8032
  : this.#input?.value;
6999
- const value = this.#setSynchronizedValue(raw, {
8033
+ this.#setSynchronizedValue(raw, {
7000
8034
  spin: type === "input",
7001
8035
  animateHandle: type === "input" && this.#animateNumberInputHandle,
7002
8036
  });
7003
8037
  if (type === "input") this.#animateNumberInputHandle = false;
7004
- this.#emit(type, value);
8038
+ this.#emit(type);
7005
8039
  }
7006
8040
 
7007
8041
  #handleNumberKeyDown(event) {
@@ -7292,12 +8326,11 @@ class PropskitWheel extends HTMLElement {
7292
8326
  }
7293
8327
 
7294
8328
  get value() {
7295
- return this.getAttribute("value") ?? this.#wheel?.value ?? "0";
8329
+ return figLabFiniteNumberOrNull(this.getAttribute("value"));
7296
8330
  }
7297
8331
 
7298
8332
  set value(nextValue) {
7299
- const parsed = Number(nextValue);
7300
- this.#setSynchronizedValue(Number.isFinite(parsed) ? parsed : 0);
8333
+ this.#setSynchronizedValue(nextValue);
7301
8334
  }
7302
8335
 
7303
8336
  get min() {
@@ -7337,11 +8370,8 @@ class PropskitWheel extends HTMLElement {
7337
8370
  }
7338
8371
 
7339
8372
  resetToDefault() {
7340
- const parsed = Number(this.defaultValue);
7341
- const value = this.#setSynchronizedValue(
7342
- Number.isFinite(parsed) ? parsed : 0,
7343
- );
7344
- figLabEmitPropskitReset(this, value);
8373
+ this.#setSynchronizedValue(this.defaultValue);
8374
+ figLabEmitPropskitReset(this);
7345
8375
  }
7346
8376
 
7347
8377
  focus(options) {
@@ -8802,7 +9832,7 @@ figLabDefineElement("fig-canvas-control", FigCanvasControl);
8802
9832
  * @attr {string} aspect-ratio - SVG editor aspect ratio.
8803
9833
  * @attr {boolean} edit - Whether to show the editor and number fields. Defaults to true.
8804
9834
  */
8805
- class PropskitOscillator extends HTMLElement {
9835
+ class PropskitOscillator extends FigLabPropskitElement {
8806
9836
  #waves = [PropskitOscillator.#defaultWave()];
8807
9837
  #activeWaveIndex = 0;
8808
9838
  #precision = 2;
@@ -9304,7 +10334,6 @@ class PropskitOscillator extends HTMLElement {
9304
10334
  return figLabCreateElement("propskit-slider", {
9305
10335
  className: "propskit-oscillator-field",
9306
10336
  label,
9307
- direction: "horizontal",
9308
10337
  name,
9309
10338
  "data-wave-index": index,
9310
10339
  value: this.#round(wave[name]),
@@ -9538,7 +10567,9 @@ class PropskitOscillator extends HTMLElement {
9538
10567
  this.#applyFieldValue(
9539
10568
  this.#indexFromElement(field),
9540
10569
  field.getAttribute("name"),
9541
- event.detail ?? event.currentTarget?.value ?? event.target?.value,
10570
+ event.detail?.value ??
10571
+ event.currentTarget?.value ??
10572
+ event.target?.value,
9542
10573
  "input",
9543
10574
  );
9544
10575
  } finally {
@@ -9550,7 +10581,9 @@ class PropskitOscillator extends HTMLElement {
9550
10581
  this.#applyFieldValue(
9551
10582
  this.#indexFromElement(field),
9552
10583
  field.getAttribute("name"),
9553
- event.detail ?? event.currentTarget?.value ?? event.target?.value,
10584
+ event.detail?.value ??
10585
+ event.currentTarget?.value ??
10586
+ event.target?.value,
9554
10587
  "change",
9555
10588
  );
9556
10589
  });
@@ -9886,16 +10919,7 @@ class PropskitOscillator extends HTMLElement {
9886
10919
  }
9887
10920
 
9888
10921
  #emit(type) {
9889
- this.dispatchEvent(
9890
- new CustomEvent(type, {
9891
- bubbles: true,
9892
- detail: {
9893
- value: this.value,
9894
- data: this.data,
9895
- preset: PropskitOscillator.#labelForType(this.#activeWave.type),
9896
- },
9897
- }),
9898
- );
10922
+ figLabDispatchPropskitEvent(this, type);
9899
10923
  }
9900
10924
  }
9901
10925
  figLabDefineElement("propskit-oscillator", PropskitOscillator);
@@ -10588,7 +11612,8 @@ class FigReorder extends HTMLElement {
10588
11612
  "fig-easing-curve",
10589
11613
  "fig-input-angle",
10590
11614
  "fig-input-wheel",
10591
- "fig-input-joystick",
11615
+ "fig-joystick",
11616
+ "fig-origin-grid",
10592
11617
  "fig-canvas-control",
10593
11618
  "propskit-color-point",
10594
11619
  "propskit-number",