@rogieking/figui3 6.20.2 → 6.21.1

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-editor.js CHANGED
@@ -1,6 +1,12 @@
1
1
  import "./fig.js";
2
2
  import "./fig-lab.js";
3
3
 
4
+ function figEditorDefineElement(name, constructor) {
5
+ if (!customElements.get(name)) {
6
+ customElements.define(name, constructor);
7
+ }
8
+ }
9
+
4
10
  function figEditorEscapeAttribute(value) {
5
11
  return String(value ?? "")
6
12
  .replace(/&/g, "&")
@@ -17,6 +23,7 @@ const GRADIENT_INTERPOLATION_SPACES = [
17
23
  "display-p3",
18
24
  "oklab",
19
25
  "oklch",
26
+ "hsl",
20
27
  ];
21
28
  const GRADIENT_HUE_INTERPOLATIONS = [
22
29
  "shorter",
@@ -24,6 +31,7 @@ const GRADIENT_HUE_INTERPOLATIONS = [
24
31
  "increasing",
25
32
  "decreasing",
26
33
  ];
34
+ const GRADIENT_HUE_SPACES = new Set(["oklch", "hsl"]);
27
35
 
28
36
  function normalizeGradientConfig(gradient) {
29
37
  const next = { ...(gradient ?? {}) };
@@ -50,7 +58,7 @@ function gradientToValueShape(gradient) {
50
58
  ...normalized,
51
59
  interpolationSpace: normalized.interpolationSpace,
52
60
  };
53
- if (normalized.interpolationSpace === "oklch") {
61
+ if (GRADIENT_HUE_SPACES.has(normalized.interpolationSpace)) {
54
62
  output.hueInterpolation = normalized.hueInterpolation;
55
63
  } else {
56
64
  delete output.hueInterpolation;
@@ -63,12 +71,37 @@ function gradientInterpolationClause(gradient) {
63
71
  if (normalized.interpolationSpace === "srgb") {
64
72
  return "";
65
73
  }
66
- if (normalized.interpolationSpace === "oklch") {
67
- return `in oklch ${normalized.hueInterpolation} hue`;
74
+ if (GRADIENT_HUE_SPACES.has(normalized.interpolationSpace)) {
75
+ return `in ${normalized.interpolationSpace} ${normalized.hueInterpolation} hue`;
68
76
  }
69
77
  return `in ${normalized.interpolationSpace}`;
70
78
  }
71
79
 
80
+ function gradientInterpolationSelectValue(gradient) {
81
+ const normalized = normalizeGradientConfig(gradient);
82
+ if (GRADIENT_HUE_SPACES.has(normalized.interpolationSpace)) {
83
+ return `${normalized.interpolationSpace}-${normalized.hueInterpolation || "shorter"}`;
84
+ }
85
+ return normalized.interpolationSpace;
86
+ }
87
+
88
+ function parseGradientInterpolationSelectValue(val) {
89
+ const raw = String(val ?? "");
90
+ for (const space of GRADIENT_HUE_SPACES) {
91
+ const prefix = `${space}-`;
92
+ if (raw.startsWith(prefix)) {
93
+ return {
94
+ interpolationSpace: space,
95
+ hueInterpolation: raw.slice(prefix.length) || "shorter",
96
+ };
97
+ }
98
+ }
99
+ return {
100
+ interpolationSpace: raw || "srgb",
101
+ hueInterpolation: "shorter",
102
+ };
103
+ }
104
+
72
105
  /**
73
106
  * A comprehensive fill picker component supporting solid colors, gradients, images, video, and webcam.
74
107
  * Uses display: contents and wraps a trigger element that opens a dialog picker.
@@ -118,6 +151,7 @@ class FigFillPicker extends HTMLElement {
118
151
  #isDraggingColor = false;
119
152
  #syncingGradientBar = false;
120
153
  #teardownColorAreaEvents = null;
154
+ #gradientInterpolationOpenObserver = null;
121
155
  #valueAtOpen = null;
122
156
  #webcamStart = null;
123
157
  #webcamRequestId = 0;
@@ -285,9 +319,8 @@ class FigFillPicker extends HTMLElement {
285
319
  if (parsed.opacity !== undefined) {
286
320
  this.#color.a = parsed.opacity / 100;
287
321
  }
288
- if (parsed.colorSpace === "display-p3" || parsed.colorSpace === "srgb") {
289
- this.#gamut = parsed.colorSpace;
290
- }
322
+ // Gamut UI hidden for now lock to sRGB.
323
+ this.#gamut = "srgb";
291
324
  if (parsed.gradient) {
292
325
  this.#gradient = normalizeGradientConfig({
293
326
  ...this.#gradient,
@@ -390,9 +423,6 @@ class FigFillPicker extends HTMLElement {
390
423
  this.#valueAtOpen = JSON.stringify(this.value);
391
424
  this.#switchTab(this.#fillType, { emit: false });
392
425
 
393
- const gamutEl = this.#dialog.querySelector(".fig-fill-picker-gamut");
394
- if (gamutEl) gamutEl.value = this.#gamut;
395
-
396
426
  if (this.#swatch) this.#swatch.setAttribute("selected", "true");
397
427
 
398
428
  this.#dialog.open = true;
@@ -429,6 +459,8 @@ class FigFillPicker extends HTMLElement {
429
459
  this.#teardownColorAreaEvents();
430
460
  this.#teardownColorAreaEvents = null;
431
461
  }
462
+ this.#gradientInterpolationOpenObserver?.disconnect();
463
+ this.#gradientInterpolationOpenObserver = null;
432
464
  this.#stopWebcam();
433
465
  if (!this.#dialog) return;
434
466
  this.#restoreCustomSlotContent();
@@ -508,11 +540,6 @@ class FigFillPicker extends HTMLElement {
508
540
  this.#activeTab = allowedModes[0];
509
541
  }
510
542
 
511
- const experimental = this.getAttribute("experimental");
512
- const expAttr = experimental
513
- ? `experimental="${figEditorEscapeAttribute(experimental)}"`
514
- : "";
515
-
516
543
  let headerContent;
517
544
  if (allowedModes.length === 1) {
518
545
  headerContent = `<h3 class="fig-fill-picker-type-label">${figEditorEscapeAttribute(modeLabels[allowedModes[0]])}</h3>`;
@@ -520,12 +547,14 @@ class FigFillPicker extends HTMLElement {
520
547
  const options = allowedModes
521
548
  .map(
522
549
  (m) =>
523
- `<option value="${figEditorEscapeAttribute(m)}">${figEditorEscapeAttribute(modeLabels[m])}</option>`,
550
+ `<fig-select-option value="${figEditorEscapeAttribute(m)}">${figEditorEscapeAttribute(modeLabels[m])}</fig-select-option>`,
524
551
  )
525
- .join("\n ");
526
- headerContent = `<fig-dropdown class="fig-fill-picker-type" label="Fill type" ${expAttr} value="${figEditorEscapeAttribute(this.#fillType)}">
527
- ${options}
528
- </fig-dropdown>`;
552
+ .join("\n ");
553
+ headerContent = `<fig-select class="fig-fill-picker-type" label="Fill type" value="${figEditorEscapeAttribute(this.#fillType)}">
554
+ <fig-select-options>
555
+ ${options}
556
+ </fig-select-options>
557
+ </fig-select>`;
529
558
  }
530
559
 
531
560
  // Generate tab containers for all allowed modes
@@ -536,15 +565,9 @@ class FigFillPicker extends HTMLElement {
536
565
  )
537
566
  .join("\n ");
538
567
 
539
- const gamutDropdown = `<fig-dropdown class="fig-fill-picker-gamut" label="Color gamut" ${expAttr} value="${this.#gamut}">
540
- <option value="srgb">sRGB</option>
541
- <option value="display-p3">Display P3</option>
542
- </fig-dropdown>`;
543
-
544
568
  this.#dialog.innerHTML = `
545
569
  <fig-header>
546
570
  ${headerContent}
547
- ${gamutDropdown}
548
571
  <fig-button icon variant="ghost" class="fig-fill-picker-close" aria-label="Close fill picker">
549
572
  <fig-icon name="close"></fig-icon>
550
573
  </fig-button>
@@ -577,27 +600,16 @@ class FigFillPicker extends HTMLElement {
577
600
  );
578
601
  }
579
602
 
580
- // Setup type dropdown switching (only if not locked)
581
- const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type");
582
- if (typeDropdown) {
583
- typeDropdown.addEventListener("change", (e) => {
584
- this.#switchTab(e.target.value);
603
+ // Setup type select switching (only if not locked)
604
+ const typeSelect = this.#dialog.querySelector(".fig-fill-picker-type");
605
+ if (typeSelect) {
606
+ typeSelect.addEventListener("change", (e) => {
607
+ const next =
608
+ typeof e.detail === "string" ? e.detail : e.target?.value;
609
+ if (next) this.#switchTab(next);
585
610
  });
586
611
  }
587
612
 
588
- // Setup gamut dropdown
589
- const gamutEl = this.#dialog.querySelector(".fig-fill-picker-gamut");
590
- if (gamutEl) {
591
- const handleGamutChange = (e) => {
592
- const val = e.currentTarget?.value ?? e.target?.value ?? e.detail;
593
- if (val && val !== this.#gamut) {
594
- this.#gamut = val;
595
- this.#onGamutChange();
596
- }
597
- };
598
- gamutEl.addEventListener("change", handleGamutChange);
599
- }
600
-
601
613
  this.#dialog
602
614
  .querySelector(".fig-fill-picker-close")
603
615
  .addEventListener("click", () => {
@@ -666,10 +678,10 @@ class FigFillPicker extends HTMLElement {
666
678
  this.#stopWebcam();
667
679
  }
668
680
 
669
- // Update dropdown selection (only exists if not locked)
670
- const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type");
671
- if (typeDropdown && typeDropdown.value !== tabName) {
672
- typeDropdown.value = tabName;
681
+ // Update type select (only exists if not locked)
682
+ const typeSelect = this.#dialog.querySelector(".fig-fill-picker-type");
683
+ if (typeSelect && typeSelect.value !== tabName) {
684
+ typeSelect.value = tabName;
673
685
  }
674
686
 
675
687
  // Show/hide tab content
@@ -711,8 +723,6 @@ class FigFillPicker extends HTMLElement {
711
723
  #initSolidTab() {
712
724
  const container = this.#dialog.querySelector('[data-tab="solid"]');
713
725
  const showAlpha = this.getAttribute("alpha") !== "false";
714
- const experimental = this.getAttribute("experimental");
715
- const expAttr = experimental ? `experimental="${experimental}"` : "";
716
726
 
717
727
  container.innerHTML = `
718
728
  <fig-preview class="fig-fill-picker-color-area">
@@ -728,28 +738,31 @@ class FigFillPicker extends HTMLElement {
728
738
  drag-snapping="modifier"
729
739
  ></fig-handle>
730
740
  </fig-preview>
731
- <div class="fig-fill-picker-sliders">
741
+ <div class="fig-fill-picker-sliders${showAlpha ? "" : " is-hue-only"}">
732
742
  <fig-tooltip text="Sample color"><fig-button icon variant="ghost" class="fig-fill-picker-eyedropper" aria-label="Sample color"><fig-icon name="eyedropper"></fig-icon></fig-button></fig-tooltip>
733
- <fig-slider type="hue" text="false" min="0" max="360" aria-label="Hue" value="${
743
+ <fig-slider type="hue" variant="classic" text="false" min="0" max="360" aria-label="Hue" value="${
734
744
  this.#color.h
735
745
  }"></fig-slider>
736
746
  ${
737
747
  showAlpha
738
- ? `<fig-slider type="opacity" text="true" units="%" min="0" max="100" aria-label="Opacity" value="${
748
+ ? `<fig-slider type="opacity" variant="classic" text="false" min="0" max="100" aria-label="Opacity" value="${
739
749
  this.#color.a * 100
740
750
  }" color="${this.#hsvToHex(this.#color)}"></fig-slider>`
741
751
  : ""
742
752
  }
743
753
  </div>
744
754
  <fig-field class="fig-fill-picker-inputs">
745
- <fig-dropdown class="fig-fill-picker-input-mode" label="Color value format" ${expAttr} value="${this.#colorInputMode}">
746
- <option value="hex">Hex</option>
747
- <option value="rgb">RGB</option>
748
- <option value="hsl">HSL</option>
749
- <option value="hsb">HSB</option>
750
- <option value="lab">LAB</option>
751
- <option value="lch">LCH</option>
752
- </fig-dropdown>
755
+ <fig-select class="fig-fill-picker-input-mode" label="Color value format" value="${figEditorEscapeAttribute(this.#colorInputMode)}">
756
+ <fig-select-options>
757
+ <fig-select-option value="hex">Hex</fig-select-option>
758
+ <fig-select-option value="rgb">RGB</fig-select-option>
759
+ <fig-select-option value="css">CSS</fig-select-option>
760
+ <fig-select-option value="hsl">HSL</fig-select-option>
761
+ <fig-select-option value="hsb">HSB</fig-select-option>
762
+ <fig-select-option value="lab">LAB</fig-select-option>
763
+ <fig-select-option value="lch">LCH</fig-select-option>
764
+ </fig-select-options>
765
+ </fig-select>
753
766
  <span class="fig-fill-picker-input-fields"></span>
754
767
  </fig-field>
755
768
  `;
@@ -789,10 +802,13 @@ class FigFillPicker extends HTMLElement {
789
802
  });
790
803
  }
791
804
 
792
- // Setup color input mode dropdown
793
- const modeDropdown = container.querySelector(".fig-fill-picker-input-mode");
794
- modeDropdown.addEventListener("change", (e) => {
795
- this.#colorInputMode = e.target.value;
805
+ // Setup color input mode select
806
+ const modeSelect = container.querySelector(".fig-fill-picker-input-mode");
807
+ modeSelect.addEventListener("change", (e) => {
808
+ const next =
809
+ typeof e.detail === "string" ? e.detail : e.target?.value;
810
+ if (!next) return;
811
+ this.#colorInputMode = next;
796
812
  this.#rebuildColorInputFields();
797
813
  });
798
814
 
@@ -1025,8 +1041,17 @@ class FigFillPicker extends HTMLElement {
1025
1041
  const wrap = (tooltip, html) =>
1026
1042
  `<fig-tooltip text="${tooltip}">${html}</fig-tooltip>`;
1027
1043
 
1028
- const num = (cls, label, min, max, step) =>
1029
- `<fig-input-number class="${cls}" aria-label="${label}" min="${min}" max="${max}"${step != null ? ` step="${step}"` : ""}></fig-input-number>`;
1044
+ const num = (cls, label, min, max, step, units) =>
1045
+ `<fig-input-number class="${cls}" aria-label="${label}" min="${min}" max="${max}"${step != null ? ` step="${step}"` : ""}${units ? ` units="${units}"` : ""}></fig-input-number>`;
1046
+
1047
+ const showAlpha = this.getAttribute("alpha") !== "false";
1048
+ const alphaField = () =>
1049
+ showAlpha
1050
+ ? wrap(
1051
+ "Alpha",
1052
+ num("fig-fill-picker-ci-a", "Alpha", 0, 100, 0.1, "%"),
1053
+ )
1054
+ : "";
1030
1055
 
1031
1056
  let html;
1032
1057
  switch (this.#colorInputMode) {
@@ -1035,6 +1060,7 @@ class FigFillPicker extends HTMLElement {
1035
1060
  ${wrap("Red", num("fig-fill-picker-ci-r", "Red", 0, 255))}
1036
1061
  ${wrap("Green", num("fig-fill-picker-ci-g", "Green", 0, 255))}
1037
1062
  ${wrap("Blue", num("fig-fill-picker-ci-b", "Blue", 0, 255))}
1063
+ ${alphaField()}
1038
1064
  </div>`;
1039
1065
  break;
1040
1066
  case "hsl":
@@ -1042,6 +1068,7 @@ class FigFillPicker extends HTMLElement {
1042
1068
  ${wrap("Hue", num("fig-fill-picker-ci-h", "Hue", 0, 360))}
1043
1069
  ${wrap("Saturation", num("fig-fill-picker-ci-s", "Saturation", 0, 100))}
1044
1070
  ${wrap("Lightness", num("fig-fill-picker-ci-l", "Lightness", 0, 100))}
1071
+ ${alphaField()}
1045
1072
  </div>`;
1046
1073
  break;
1047
1074
  case "hsb":
@@ -1049,6 +1076,7 @@ class FigFillPicker extends HTMLElement {
1049
1076
  ${wrap("Hue", num("fig-fill-picker-ci-h", "Hue", 0, 360))}
1050
1077
  ${wrap("Saturation", num("fig-fill-picker-ci-s", "Saturation", 0, 100))}
1051
1078
  ${wrap("Brightness", num("fig-fill-picker-ci-v", "Brightness", 0, 100))}
1079
+ ${alphaField()}
1052
1080
  </div>`;
1053
1081
  break;
1054
1082
  case "lab":
@@ -1056,6 +1084,7 @@ class FigFillPicker extends HTMLElement {
1056
1084
  ${wrap("Lightness", num("fig-fill-picker-ci-okl", "Lightness", 0, 100))}
1057
1085
  ${wrap("Green-Red axis", num("fig-fill-picker-ci-oka", "Green-Red axis", -0.4, 0.4, 0.001))}
1058
1086
  ${wrap("Blue-Yellow axis", num("fig-fill-picker-ci-okb", "Blue-Yellow axis", -0.4, 0.4, 0.001))}
1087
+ ${alphaField()}
1059
1088
  </div>`;
1060
1089
  break;
1061
1090
  case "lch":
@@ -1063,10 +1092,19 @@ class FigFillPicker extends HTMLElement {
1063
1092
  ${wrap("Lightness", num("fig-fill-picker-ci-okl", "Lightness", 0, 100))}
1064
1093
  ${wrap("Chroma", num("fig-fill-picker-ci-okc", "Chroma", 0, 0.4, 0.001))}
1065
1094
  ${wrap("Hue", num("fig-fill-picker-ci-okh", "Hue", 0, 360))}
1095
+ ${alphaField()}
1066
1096
  </div>`;
1067
1097
  break;
1098
+ case "css":
1099
+ html = `<fig-input-text class="fig-fill-picker-ci-css" aria-label="CSS color" placeholder="rgba(0, 0, 0, 1)"></fig-input-text>`;
1100
+ break;
1068
1101
  default: // hex
1069
- html = `<fig-input-text class="fig-fill-picker-ci-hex" aria-label="Hex color" placeholder="FFFFFF"></fig-input-text>`;
1102
+ html = showAlpha
1103
+ ? `<div class="input-combo fig-fill-picker-ci-hex-row">
1104
+ <fig-input-text class="fig-fill-picker-ci-hex" aria-label="Hex color" placeholder="FFFFFF"></fig-input-text>
1105
+ ${alphaField()}
1106
+ </div>`
1107
+ : `<fig-input-text class="fig-fill-picker-ci-hex" aria-label="Hex color" placeholder="FFFFFF"></fig-input-text>`;
1070
1108
  break;
1071
1109
  }
1072
1110
 
@@ -1085,12 +1123,16 @@ class FigFillPicker extends HTMLElement {
1085
1123
  if (this.#isDraggingColor) return;
1086
1124
  const color = this.#readColorFromInputs();
1087
1125
  if (!color) return;
1088
- this.#color = { ...color, a: this.#color.a };
1126
+ const nextAlpha = Number.isFinite(color.a) ? color.a : this.#color.a;
1127
+ this.#color = { ...color, a: nextAlpha };
1089
1128
  this.#drawColorArea();
1090
1129
  this.#updateHandlePosition();
1091
1130
  if (this.#hueSlider) {
1092
1131
  this.#hueSlider.setAttribute("value", this.#color.h);
1093
1132
  }
1133
+ if (this.#opacitySlider && Number.isFinite(color.a)) {
1134
+ this.#opacitySlider.setAttribute("value", this.#color.a * 100);
1135
+ }
1094
1136
  this.#emitInput();
1095
1137
  };
1096
1138
 
@@ -1105,39 +1147,53 @@ class FigFillPicker extends HTMLElement {
1105
1147
  });
1106
1148
  }
1107
1149
 
1150
+ #readAlphaFromInput() {
1151
+ const el = this.#dialog?.querySelector(".fig-fill-picker-ci-a");
1152
+ if (!el) return undefined;
1153
+ const pct = parseFloat(el.value);
1154
+ if (!Number.isFinite(pct)) return undefined;
1155
+ return Math.max(0, Math.min(1, pct / 100));
1156
+ }
1157
+
1108
1158
  #readColorFromInputs() {
1109
1159
  const q = (cls) => this.#dialog?.querySelector(`.${cls}`);
1110
1160
  const val = (cls) => parseFloat(q(cls)?.value ?? 0);
1161
+ const withAlpha = (color) => {
1162
+ if (!color) return null;
1163
+ const a = this.#readAlphaFromInput();
1164
+ return { ...color, a: a ?? color.a ?? this.#color.a };
1165
+ };
1111
1166
 
1112
1167
  switch (this.#colorInputMode) {
1113
1168
  case "rgb":
1114
- return this.#rgbToHSV({
1115
- r: val("fig-fill-picker-ci-r"),
1116
- g: val("fig-fill-picker-ci-g"),
1117
- b: val("fig-fill-picker-ci-b"),
1118
- });
1169
+ return withAlpha(
1170
+ this.#rgbToHSV({
1171
+ r: val("fig-fill-picker-ci-r"),
1172
+ g: val("fig-fill-picker-ci-g"),
1173
+ b: val("fig-fill-picker-ci-b"),
1174
+ }),
1175
+ );
1119
1176
  case "hsl": {
1120
1177
  const rgb = this.#hslToRGB({
1121
1178
  h: val("fig-fill-picker-ci-h"),
1122
1179
  s: val("fig-fill-picker-ci-s"),
1123
1180
  l: val("fig-fill-picker-ci-l"),
1124
1181
  });
1125
- return this.#rgbToHSV(rgb);
1182
+ return withAlpha(this.#rgbToHSV(rgb));
1126
1183
  }
1127
1184
  case "hsb":
1128
- return {
1185
+ return withAlpha({
1129
1186
  h: val("fig-fill-picker-ci-h"),
1130
1187
  s: val("fig-fill-picker-ci-s"),
1131
1188
  v: val("fig-fill-picker-ci-v"),
1132
- a: 1,
1133
- };
1189
+ });
1134
1190
  case "lab": {
1135
1191
  const rgb = this.#oklabToRGB({
1136
1192
  l: val("fig-fill-picker-ci-okl") / 100,
1137
1193
  a: val("fig-fill-picker-ci-oka"),
1138
1194
  b: val("fig-fill-picker-ci-okb"),
1139
1195
  });
1140
- return this.#rgbToHSV(rgb);
1196
+ return withAlpha(this.#rgbToHSV(rgb));
1141
1197
  }
1142
1198
  case "lch": {
1143
1199
  const rgb = this.#oklchToRGB({
@@ -1145,7 +1201,12 @@ class FigFillPicker extends HTMLElement {
1145
1201
  c: val("fig-fill-picker-ci-okc"),
1146
1202
  h: val("fig-fill-picker-ci-okh"),
1147
1203
  });
1148
- return this.#rgbToHSV(rgb);
1204
+ return withAlpha(this.#rgbToHSV(rgb));
1205
+ }
1206
+ case "css": {
1207
+ const cssEl = q("fig-fill-picker-ci-css");
1208
+ if (!cssEl) return null;
1209
+ return this.#parseCssColor(cssEl.value);
1149
1210
  }
1150
1211
  default: {
1151
1212
  // hex
@@ -1154,8 +1215,14 @@ class FigFillPicker extends HTMLElement {
1154
1215
  let hex = hexEl.value.replace(/^#/, "");
1155
1216
  if (hex.length === 3)
1156
1217
  hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
1218
+ if (hex.length === 8) {
1219
+ const alpha = parseInt(hex.slice(6, 8), 16) / 255;
1220
+ hex = hex.slice(0, 6);
1221
+ if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null;
1222
+ return { ...this.#hexToHSV(`#${hex}`), a: alpha };
1223
+ }
1157
1224
  if (hex.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(hex)) return null;
1158
- return this.#hexToHSV(`#${hex}`);
1225
+ return withAlpha(this.#hexToHSV(`#${hex}`));
1159
1226
  }
1160
1227
  }
1161
1228
  }
@@ -1203,11 +1270,22 @@ class FigFillPicker extends HTMLElement {
1203
1270
  set("fig-fill-picker-ci-okh", Math.round(lch.h));
1204
1271
  break;
1205
1272
  }
1273
+ case "css":
1274
+ set("fig-fill-picker-ci-css", this.#formatCssColor(this.#color));
1275
+ break;
1206
1276
  default: // hex
1207
1277
  set("fig-fill-picker-ci-hex", hex.replace(/^#/, "").toUpperCase());
1208
1278
  break;
1209
1279
  }
1210
1280
 
1281
+ if (this.#colorInputMode !== "css") {
1282
+ const alphaPct = Math.round(this.#color.a * 1000) / 10;
1283
+ set(
1284
+ "fig-fill-picker-ci-a",
1285
+ Number.isInteger(alphaPct) ? alphaPct : +alphaPct.toFixed(1),
1286
+ );
1287
+ }
1288
+
1211
1289
  if (this.#opacitySlider) {
1212
1290
  this.#opacitySlider.setAttribute("color", hex);
1213
1291
  }
@@ -1218,19 +1296,18 @@ class FigFillPicker extends HTMLElement {
1218
1296
  // ============ GRADIENT TAB ============
1219
1297
  #initGradientTab() {
1220
1298
  const container = this.#dialog.querySelector('[data-tab="gradient"]');
1221
- const experimental = this.getAttribute("experimental");
1222
- const expAttr = experimental ? `experimental="${experimental}"` : "";
1299
+ const interpolationValue = gradientInterpolationSelectValue(this.#gradient);
1223
1300
 
1224
1301
  container.innerHTML = `
1225
1302
  <fig-field class="fig-fill-picker-gradient-header">
1226
- <fig-dropdown class="fig-fill-picker-gradient-type" label="Gradient type" ${expAttr} value="${
1227
- this.#gradient.type
1228
- }">
1229
- <option value="linear" selected>Linear</option>
1230
- <option value="radial">Radial</option>
1231
- <option value="angular">Angular</option>
1232
- </fig-dropdown>
1233
- <fig-tooltip text="Rotate gradient">
1303
+ <fig-select class="fig-fill-picker-gradient-type" label="Gradient type" value="${figEditorEscapeAttribute(this.#gradient.type)}">
1304
+ <fig-select-options>
1305
+ <fig-select-option value="linear">Linear</fig-select-option>
1306
+ <fig-select-option value="radial">Radial</fig-select-option>
1307
+ <fig-select-option value="angular">Angular</fig-select-option>
1308
+ </fig-select-options>
1309
+ </fig-select>
1310
+ <fig-tooltip text="Gradient angle">
1234
1311
  <fig-input-number class="fig-fill-picker-gradient-angle" aria-label="Gradient angle" value="${
1235
1312
  (this.#gradient.angle - 90 + 360) % 360
1236
1313
  }" min="0" max="360" units="°" wrap></fig-input-number>
@@ -1243,11 +1320,18 @@ class FigFillPicker extends HTMLElement {
1243
1320
  this.#gradient.centerY
1244
1321
  }" units="%" class="fig-fill-picker-gradient-cy"></fig-input-number>
1245
1322
  </div>
1246
- <fig-tooltip text="Flip gradient">
1247
- <fig-button icon variant="ghost" class="fig-fill-picker-gradient-flip" aria-label="Flip gradient">
1248
- <fig-icon name="swap"></fig-icon>
1249
- </fig-button>
1250
- </fig-tooltip>
1323
+ <div class="fig-fill-picker-gradient-actions">
1324
+ <fig-tooltip text="Flip gradient">
1325
+ <fig-button icon variant="ghost" class="fig-fill-picker-gradient-flip" aria-label="Flip gradient">
1326
+ <fig-icon name="swap"></fig-icon>
1327
+ </fig-button>
1328
+ </fig-tooltip>
1329
+ <fig-tooltip text="Rotate gradient">
1330
+ <fig-button icon variant="ghost" class="fig-fill-picker-gradient-rotate" aria-label="Rotate gradient">
1331
+ <fig-icon name="rotate"></fig-icon>
1332
+ </fig-button>
1333
+ </fig-tooltip>
1334
+ </div>
1251
1335
  </fig-field>
1252
1336
  <fig-preview class="fig-fill-picker-gradient-preview">
1253
1337
  <fig-input-gradient class="fig-fill-picker-gradient-bar-input" aria-label="Gradient stops" edit="true" mode="tip" size="large" value='${JSON.stringify({ type: "gradient", gradient: gradientToValueShape(this.#gradient) })}'></fig-input-gradient>
@@ -1268,25 +1352,11 @@ class FigFillPicker extends HTMLElement {
1268
1352
  <span>Color interpolation</span>
1269
1353
  </fig-header>
1270
1354
  <fig-field class="fig-fill-picker-gradient-interpolation-field">
1271
- <fig-dropdown class="fig-fill-picker-gradient-space" label="Color interpolation" full ${expAttr} value="${
1272
- this.#gradient.interpolationSpace === "oklch"
1273
- ? `oklch-${this.#gradient.hueInterpolation || "shorter"}`
1274
- : this.#gradient.interpolationSpace
1275
- }">
1276
- <optgroup label="sRGB">
1277
- <option value="srgb">Classic</option>
1278
- <option value="srgb-linear">Linear</option>
1279
- </optgroup>
1280
- <optgroup label="OKLab">
1281
- <option value="oklab">Perceptual</option>
1282
- </optgroup>
1283
- <optgroup label="OKLCH">
1284
- <option value="oklch-shorter">Shorter hue</option>
1285
- <option value="oklch-longer">Longer hue</option>
1286
- <option value="oklch-increasing">Increasing hue</option>
1287
- <option value="oklch-decreasing">Decreasing hue</option>
1288
- </optgroup>
1289
- </fig-dropdown>
1355
+ <fig-select class="fig-fill-picker-gradient-space" label="Color interpolation" full value="${figEditorEscapeAttribute(interpolationValue)}">
1356
+ <fig-select-options>
1357
+ ${this.#gradientInterpolationOptionsMarkup()}
1358
+ </fig-select-options>
1359
+ </fig-select>
1290
1360
  </fig-field>
1291
1361
  </div>
1292
1362
  `;
@@ -1295,44 +1365,141 @@ class FigFillPicker extends HTMLElement {
1295
1365
  this.#setupGradientEvents(container);
1296
1366
  }
1297
1367
 
1368
+ #gradientInterpolationOptionsMarkup() {
1369
+ const hueMethods = ["shorter", "longer", "increasing", "decreasing"];
1370
+ const groups = [
1371
+ {
1372
+ label: "Linear",
1373
+ options: [
1374
+ { value: "srgb", label: "sRGB" },
1375
+ ],
1376
+ },
1377
+ {
1378
+ label: "",
1379
+ options: [{ value: "oklab", label: "OKLAB" }],
1380
+ },
1381
+ {
1382
+ label: "Polar",
1383
+ options: hueMethods.map((method) => ({
1384
+ value: `oklch-${method}`,
1385
+ label: `OKLCH ${method.charAt(0).toUpperCase()}${method.slice(1)}`,
1386
+ })),
1387
+ },
1388
+ {
1389
+ label: "",
1390
+ separator: true,
1391
+ options: hueMethods.map((method) => ({
1392
+ value: `hsl-${method}`,
1393
+ label: `HSL ${method.charAt(0).toUpperCase()}${method.slice(1)}`,
1394
+ })),
1395
+ },
1396
+ ];
1397
+ return groups
1398
+ .map((group) => {
1399
+ const options = group.options
1400
+ .map((opt) => {
1401
+ const methodLabel = opt.method
1402
+ ? opt.method.charAt(0).toUpperCase() + opt.method.slice(1)
1403
+ : "";
1404
+ const appendLabel = opt.append || methodLabel;
1405
+ return `<fig-select-option value="${figEditorEscapeAttribute(opt.value)}" label="${figEditorEscapeAttribute(opt.label)}">
1406
+ <fig-interpolation-swatch slot="prepend" size="large" aria-hidden="true"></fig-interpolation-swatch>
1407
+ ${figEditorEscapeAttribute(opt.label)}
1408
+ ${appendLabel ? `<span slot="append">${figEditorEscapeAttribute(appendLabel)}</span>` : ""}
1409
+ </fig-select-option>`;
1410
+ })
1411
+ .join("");
1412
+ const separator = group.label || group.separator
1413
+ ? `<fig-menu-separator${group.label ? ` label="${figEditorEscapeAttribute(group.label)}"` : ""}></fig-menu-separator>`
1414
+ : "";
1415
+ return `${separator}${options}`;
1416
+ })
1417
+ .join("");
1418
+ }
1419
+
1298
1420
  #setupGradientEvents(container) {
1299
- // Type dropdown
1300
- const typeDropdown = container.querySelector(
1301
- ".fig-fill-picker-gradient-type",
1421
+ const getSelectValue = (event) =>
1422
+ typeof event.detail === "string" ? event.detail : event.target?.value;
1423
+ const gradientBarInput = container.querySelector(
1424
+ ".fig-fill-picker-gradient-bar-input",
1302
1425
  );
1303
- const getDropdownValue = (event) =>
1304
- event.currentTarget?.value ?? event.target?.value ?? event.detail;
1426
+ const setGradientBarPreview = (gradient) => {
1427
+ if (!gradientBarInput) return;
1428
+ this.#syncingGradientBar = true;
1429
+ try {
1430
+ gradientBarInput.setAttribute(
1431
+ "value",
1432
+ JSON.stringify({
1433
+ type: "gradient",
1434
+ gradient: gradientToValueShape(gradient),
1435
+ }),
1436
+ );
1437
+ } finally {
1438
+ this.#syncingGradientBar = false;
1439
+ }
1440
+ };
1441
+ const restoreGradientBarPreview = () => {
1442
+ setGradientBarPreview(this.#gradient);
1443
+ };
1305
1444
 
1306
- const handleTypeChange = (e) => {
1307
- this.#gradient.type = getDropdownValue(e);
1445
+ const typeSelect = container.querySelector(".fig-fill-picker-gradient-type");
1446
+ typeSelect?.addEventListener("change", (e) => {
1447
+ const next = getSelectValue(e);
1448
+ if (!next) return;
1449
+ this.#gradient.type = next;
1308
1450
  this.#updateGradientUI();
1309
1451
  this.#emitInput();
1310
- };
1311
- typeDropdown.addEventListener("change", handleTypeChange);
1452
+ });
1312
1453
 
1313
- const interpolationDropdown = container.querySelector(
1454
+ const interpolationSelect = container.querySelector(
1314
1455
  ".fig-fill-picker-gradient-space",
1315
1456
  );
1316
- const handleInterpolationChange = (e) => {
1317
- const val = getDropdownValue(e);
1318
- let space = val;
1319
- let hue = "shorter";
1320
- if (val.startsWith("oklch-")) {
1321
- space = "oklch";
1322
- hue = val.slice(6);
1323
- }
1457
+ interpolationSelect
1458
+ ?.querySelectorAll("fig-select-option")
1459
+ .forEach((option) => {
1460
+ const previewOption = () => {
1461
+ const parsed = parseGradientInterpolationSelectValue(
1462
+ option.getAttribute("value") || "srgb",
1463
+ );
1464
+ setGradientBarPreview(
1465
+ normalizeGradientConfig({
1466
+ ...this.#gradient,
1467
+ ...parsed,
1468
+ }),
1469
+ );
1470
+ };
1471
+ option.addEventListener("pointerenter", previewOption);
1472
+ option.addEventListener("pointerleave", () => {
1473
+ if (document.activeElement !== option) restoreGradientBarPreview();
1474
+ });
1475
+ option.addEventListener("focus", previewOption);
1476
+ option.addEventListener("blur", () => {
1477
+ if (!option.matches(":hover")) restoreGradientBarPreview();
1478
+ });
1479
+ });
1480
+ this.#gradientInterpolationOpenObserver?.disconnect();
1481
+ if (interpolationSelect) {
1482
+ this.#gradientInterpolationOpenObserver = new MutationObserver(() => {
1483
+ if (!interpolationSelect.hasAttribute("open")) {
1484
+ restoreGradientBarPreview();
1485
+ }
1486
+ });
1487
+ this.#gradientInterpolationOpenObserver.observe(interpolationSelect, {
1488
+ attributes: true,
1489
+ attributeFilter: ["open"],
1490
+ });
1491
+ }
1492
+ interpolationSelect?.addEventListener("change", (e) => {
1493
+ const val = getSelectValue(e);
1494
+ if (!val) return;
1495
+ const parsed = parseGradientInterpolationSelectValue(val);
1324
1496
  this.#gradient = normalizeGradientConfig({
1325
1497
  ...this.#gradient,
1326
- interpolationSpace: space,
1327
- hueInterpolation: hue,
1498
+ ...parsed,
1328
1499
  });
1329
1500
  this.#updateGradientUI();
1330
1501
  this.#emitInput();
1331
- };
1332
- interpolationDropdown?.addEventListener(
1333
- "change",
1334
- handleInterpolationChange,
1335
- );
1502
+ });
1336
1503
 
1337
1504
  // Angle input
1338
1505
  const angleInput = container.querySelector(
@@ -1361,6 +1528,15 @@ class FigFillPicker extends HTMLElement {
1361
1528
  this.#emitInput();
1362
1529
  });
1363
1530
 
1531
+ // Rotate 90° clockwise
1532
+ container
1533
+ .querySelector(".fig-fill-picker-gradient-rotate")
1534
+ ?.addEventListener("click", () => {
1535
+ this.#gradient.angle = (Number(this.#gradient.angle) + 90) % 360;
1536
+ this.#updateGradientUI();
1537
+ this.#emitInput();
1538
+ });
1539
+
1364
1540
  // Flip button
1365
1541
  container
1366
1542
  .querySelector(".fig-fill-picker-gradient-flip")
@@ -1389,9 +1565,6 @@ class FigFillPicker extends HTMLElement {
1389
1565
  });
1390
1566
 
1391
1567
  // Embedded gradient bar input
1392
- const gradientBarInput = container.querySelector(
1393
- ".fig-fill-picker-gradient-bar-input",
1394
- );
1395
1568
  if (gradientBarInput) {
1396
1569
  const syncFromBarInput = (e) => {
1397
1570
  e.stopPropagation();
@@ -1403,6 +1576,7 @@ class FigFillPicker extends HTMLElement {
1403
1576
  ...detail.gradient,
1404
1577
  });
1405
1578
  this.#updateSwatch();
1579
+ this.#updateGradientInterpolationSwatches();
1406
1580
  this.#updateGradientStopsList();
1407
1581
  };
1408
1582
  gradientBarInput.addEventListener("input", (e) => {
@@ -1555,6 +1729,7 @@ class FigFillPicker extends HTMLElement {
1555
1729
 
1556
1730
  this.#syncingGradientBar = true;
1557
1731
  try {
1732
+ this.#updateGradientInterpolationSwatches();
1558
1733
  this.#updateGradientPreview();
1559
1734
  this.#emitInput();
1560
1735
  } finally {
@@ -1573,35 +1748,79 @@ class FigFillPicker extends HTMLElement {
1573
1748
  const angleInput = container.querySelector(
1574
1749
  ".fig-fill-picker-gradient-angle",
1575
1750
  );
1751
+ const rotateBtn = container.querySelector(
1752
+ ".fig-fill-picker-gradient-rotate",
1753
+ );
1576
1754
  const centerInputs = container.querySelector(
1577
1755
  ".fig-fill-picker-gradient-center",
1578
1756
  );
1579
1757
 
1580
1758
  if (this.#gradient.type === "radial") {
1581
1759
  angleInput.style.display = "none";
1760
+ if (rotateBtn) rotateBtn.style.display = "none";
1582
1761
  centerInputs.style.display = "flex";
1583
1762
  } else {
1584
- angleInput.style.display = "block";
1763
+ angleInput.style.removeProperty("display");
1764
+ rotateBtn?.style.removeProperty("display");
1585
1765
  centerInputs.style.display = "none";
1586
1766
  // Sync angle input value (convert CSS angle to picker angle)
1587
1767
  const pickerAngle = (this.#gradient.angle - 90 + 360) % 360;
1588
1768
  angleInput.setAttribute("value", pickerAngle);
1589
1769
  }
1590
1770
 
1591
- const interpolationDropdown = container.querySelector(
1771
+ const interpolationSelect = container.querySelector(
1592
1772
  ".fig-fill-picker-gradient-space",
1593
1773
  );
1594
- if (interpolationDropdown) {
1595
- interpolationDropdown.value =
1596
- this.#gradient.interpolationSpace === "oklch"
1597
- ? `oklch-${this.#gradient.hueInterpolation || "shorter"}`
1598
- : this.#gradient.interpolationSpace;
1774
+ if (interpolationSelect) {
1775
+ interpolationSelect.value = gradientInterpolationSelectValue(
1776
+ this.#gradient,
1777
+ );
1599
1778
  }
1600
1779
 
1780
+ this.#updateGradientInterpolationSwatches();
1601
1781
  this.#updateGradientPreview();
1602
1782
  this.#updateGradientStopsList();
1603
1783
  }
1604
1784
 
1785
+ #interpolationPreviewStops() {
1786
+ const stops = Array.isArray(this.#gradient.stops)
1787
+ ? [...this.#gradient.stops].sort(
1788
+ (a, b) => (a.position ?? 0) - (b.position ?? 0),
1789
+ )
1790
+ : [];
1791
+ if (stops.length < 2) {
1792
+ return [
1793
+ { color: "#FF0000", position: 0 },
1794
+ { color: "#4F9EFF", position: 100 },
1795
+ ];
1796
+ }
1797
+ return stops.map((stop) => ({
1798
+ color: String(stop.color || "#D9D9D9").replace(/^(#(?:[0-9a-f]{6})).*/i, "$1"),
1799
+ position: stop.position ?? 0,
1800
+ }));
1801
+ }
1802
+
1803
+ #updateGradientInterpolationSwatches() {
1804
+ if (!this.#dialog) return;
1805
+ const stops = this.#interpolationPreviewStops();
1806
+ this.#dialog
1807
+ .querySelectorAll("fig-interpolation-swatch")
1808
+ .forEach((swatch) => {
1809
+ const optionVal =
1810
+ swatch.closest("fig-select-option")?.getAttribute("value") || "srgb";
1811
+ const parsed = parseGradientInterpolationSelectValue(optionVal);
1812
+ const gradient = {
1813
+ type: "linear",
1814
+ stops,
1815
+ interpolationSpace: parsed.interpolationSpace,
1816
+ };
1817
+ if (GRADIENT_HUE_SPACES.has(parsed.interpolationSpace)) {
1818
+ gradient.hueInterpolation = parsed.hueInterpolation;
1819
+ }
1820
+ swatch.value = { type: "gradient", gradient };
1821
+ });
1822
+ }
1823
+
1605
1824
  #updateGradientPreview() {
1606
1825
  if (!this.#dialog) return;
1607
1826
 
@@ -1690,6 +1909,7 @@ class FigFillPicker extends HTMLElement {
1690
1909
  .querySelector(".fig-fill-picker-stop-position")
1691
1910
  .addEventListener("input", () => {
1692
1911
  this.#syncGradientStopRow(row);
1912
+ this.#updateGradientInterpolationSwatches();
1693
1913
  this.#updateGradientPreview();
1694
1914
  this.#emitInput();
1695
1915
  });
@@ -1709,6 +1929,7 @@ class FigFillPicker extends HTMLElement {
1709
1929
  this.#syncGradientStopRow(row);
1710
1930
  this.#syncingGradientBar = true;
1711
1931
  try {
1932
+ this.#updateGradientInterpolationSwatches();
1712
1933
  this.#updateGradientPreview();
1713
1934
  this.#emitInput();
1714
1935
  } finally {
@@ -1787,19 +2008,17 @@ class FigFillPicker extends HTMLElement {
1787
2008
  // ============ IMAGE TAB ============
1788
2009
  #initImageTab() {
1789
2010
  const container = this.#dialog.querySelector('[data-tab="image"]');
1790
- const experimental = this.getAttribute("experimental");
1791
- const expAttr = experimental ? `experimental="${experimental}"` : "";
1792
2011
 
1793
2012
  container.innerHTML = `
1794
2013
  <fig-field class="fig-fill-picker-media-header">
1795
- <fig-dropdown class="fig-fill-picker-scale-mode" label="Image scale mode" ${expAttr} value="${
1796
- this.#image.scaleMode
1797
- }">
1798
- <option value="fill" selected>Fill</option>
1799
- <option value="fit">Fit</option>
1800
- <option value="crop">Crop</option>
1801
- <option value="tile">Tile</option>
1802
- </fig-dropdown>
2014
+ <fig-select class="fig-fill-picker-scale-mode" label="Image scale mode" value="${figEditorEscapeAttribute(this.#image.scaleMode)}">
2015
+ <fig-select-options>
2016
+ <fig-select-option value="fill">Fill</fig-select-option>
2017
+ <fig-select-option value="fit">Fit</fig-select-option>
2018
+ <fig-select-option value="crop">Crop</fig-select-option>
2019
+ <fig-select-option value="tile">Tile</fig-select-option>
2020
+ </fig-select-options>
2021
+ </fig-select>
1803
2022
  <fig-input-number class="fig-fill-picker-scale" aria-label="Image tile scale" min="1" max="200" value="${
1804
2023
  this.#image.scale
1805
2024
  }" units="%" ${
@@ -1816,15 +2035,18 @@ class FigFillPicker extends HTMLElement {
1816
2035
  }
1817
2036
 
1818
2037
  #setupImageEvents(container) {
1819
- const scaleModeDropdown = container.querySelector(
2038
+ const scaleModeSelect = container.querySelector(
1820
2039
  ".fig-fill-picker-scale-mode",
1821
2040
  );
1822
2041
  const scaleInput = container.querySelector(".fig-fill-picker-scale");
1823
2042
  const preview = container.querySelector(".fig-fill-picker-image-preview");
1824
2043
 
1825
- scaleModeDropdown.addEventListener("change", (e) => {
1826
- this.#image.scaleMode = e.target.value;
1827
- scaleInput.style.display = e.target.value === "tile" ? "block" : "none";
2044
+ scaleModeSelect.addEventListener("change", (e) => {
2045
+ const next =
2046
+ typeof e.detail === "string" ? e.detail : e.target?.value;
2047
+ if (!next) return;
2048
+ this.#image.scaleMode = next;
2049
+ scaleInput.style.display = next === "tile" ? "block" : "none";
1828
2050
  this.#updateImagePreview(preview);
1829
2051
  this.#updateSwatch();
1830
2052
  this.#emitInput();
@@ -1955,18 +2177,16 @@ class FigFillPicker extends HTMLElement {
1955
2177
  // ============ VIDEO TAB ============
1956
2178
  #initVideoTab() {
1957
2179
  const container = this.#dialog.querySelector('[data-tab="video"]');
1958
- const experimental = this.getAttribute("experimental");
1959
- const expAttr = experimental ? `experimental="${experimental}"` : "";
1960
2180
 
1961
2181
  container.innerHTML = `
1962
2182
  <fig-field class="fig-fill-picker-media-header">
1963
- <fig-dropdown class="fig-fill-picker-scale-mode" label="Video scale mode" ${expAttr} value="${
1964
- this.#video.scaleMode
1965
- }">
1966
- <option value="fill" selected>Fill</option>
1967
- <option value="fit">Fit</option>
1968
- <option value="crop">Crop</option>
1969
- </fig-dropdown>
2183
+ <fig-select class="fig-fill-picker-scale-mode" label="Video scale mode" value="${figEditorEscapeAttribute(this.#video.scaleMode)}">
2184
+ <fig-select-options>
2185
+ <fig-select-option value="fill">Fill</fig-select-option>
2186
+ <fig-select-option value="fit">Fit</fig-select-option>
2187
+ <fig-select-option value="crop">Crop</fig-select-option>
2188
+ </fig-select-options>
2189
+ </fig-select>
1970
2190
  <fig-button class="fig-fill-picker-media-rotate" icon variant="ghost" aria-label="Rotate">
1971
2191
  <fig-icon name="rotate"></fig-icon>
1972
2192
  </fig-button>
@@ -1978,13 +2198,16 @@ class FigFillPicker extends HTMLElement {
1978
2198
  }
1979
2199
 
1980
2200
  #setupVideoEvents(container) {
1981
- const scaleModeDropdown = container.querySelector(
2201
+ const scaleModeSelect = container.querySelector(
1982
2202
  ".fig-fill-picker-scale-mode",
1983
2203
  );
1984
2204
  const preview = container.querySelector(".fig-fill-picker-video-preview");
1985
2205
 
1986
- scaleModeDropdown.addEventListener("change", (e) => {
1987
- this.#video.scaleMode = e.target.value;
2206
+ scaleModeSelect.addEventListener("change", (e) => {
2207
+ const next =
2208
+ typeof e.detail === "string" ? e.detail : e.target?.value;
2209
+ if (!next) return;
2210
+ this.#video.scaleMode = next;
1988
2211
  this.#updateVideoPreviewStyle(preview);
1989
2212
  this.#updateSwatch();
1990
2213
  this.#emitInput();
@@ -2014,13 +2237,12 @@ class FigFillPicker extends HTMLElement {
2014
2237
  // ============ WEBCAM TAB ============
2015
2238
  #initWebcamTab() {
2016
2239
  const container = this.#dialog.querySelector('[data-tab="webcam"]');
2017
- const experimental = this.getAttribute("experimental");
2018
- const expAttr = experimental ? `experimental="${experimental}"` : "";
2019
2240
 
2020
2241
  container.innerHTML = `
2021
2242
  <fig-field class="fig-fill-picker-webcam-camera" style="display: none;">
2022
- <fig-dropdown class="fig-fill-picker-camera-select" label="Camera" full ${expAttr}>
2023
- </fig-dropdown>
2243
+ <fig-select class="fig-fill-picker-camera-select" label="Camera" full>
2244
+ <fig-select-options></fig-select-options>
2245
+ </fig-select>
2024
2246
  </fig-field>
2025
2247
  <fig-video class="fig-fill-picker-webcam-preview" aria-label="Webcam preview" aspect-ratio="1/1" fit="cover" checkerboard="true" autoplay="true" muted="true">
2026
2248
  <video class="fig-fill-picker-webcam-video" autoplay muted playsinline></video>
@@ -2081,11 +2303,14 @@ class FigFillPicker extends HTMLElement {
2081
2303
 
2082
2304
  if (cameras.length > 1) {
2083
2305
  cameraField.style.display = "";
2084
- cameraSelect
2085
- .querySelectorAll(":scope > option, :scope > optgroup")
2086
- .forEach((option) => option.remove());
2306
+ let panel = cameraSelect.querySelector(":scope > fig-select-options");
2307
+ if (!panel) {
2308
+ panel = document.createElement("fig-select-options");
2309
+ cameraSelect.append(panel);
2310
+ }
2311
+ panel.replaceChildren();
2087
2312
  cameras.forEach((cam, i) => {
2088
- const option = document.createElement("option");
2313
+ const option = document.createElement("fig-select-option");
2089
2314
  option.value = cam.deviceId;
2090
2315
  const label =
2091
2316
  cam.label || (cameras.length > 1 ? `Camera ${i + 1}` : "Camera");
@@ -2098,14 +2323,14 @@ class FigFillPicker extends HTMLElement {
2098
2323
  return ` ${displayId}`;
2099
2324
  },
2100
2325
  );
2101
- cameraSelect.append(option);
2326
+ panel.append(option);
2102
2327
  });
2103
2328
  if (deviceId) cameraSelect.value = deviceId;
2104
2329
  } else {
2105
2330
  cameraField.style.display = "none";
2106
2331
  cameraSelect
2107
- .querySelectorAll(":scope > option, :scope > optgroup")
2108
- .forEach((option) => option.remove());
2332
+ .querySelector(":scope > fig-select-options")
2333
+ ?.replaceChildren();
2109
2334
  }
2110
2335
  } catch (err) {
2111
2336
  if (requestId !== this.#webcamRequestId) return;
@@ -2133,7 +2358,9 @@ class FigFillPicker extends HTMLElement {
2133
2358
  this.#webcamStart = startWebcam;
2134
2359
 
2135
2360
  cameraSelect.addEventListener("change", (e) => {
2136
- startWebcam(e.target.value);
2361
+ const next =
2362
+ typeof e.detail === "string" ? e.detail : e.target?.value;
2363
+ if (next) startWebcam(next);
2137
2364
  });
2138
2365
 
2139
2366
  captureBtn.addEventListener("click", async () => {
@@ -2287,6 +2514,55 @@ class FigFillPicker extends HTMLElement {
2287
2514
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
2288
2515
  }
2289
2516
 
2517
+ #formatCssAlpha(alpha) {
2518
+ const a = Math.max(0, Math.min(1, Number(alpha) || 0));
2519
+ const rounded = Math.round(a * 1000) / 1000;
2520
+ return String(rounded);
2521
+ }
2522
+
2523
+ #formatCssColor(color = this.#color) {
2524
+ const rgb = this.#hsvToRGB(color);
2525
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${this.#formatCssAlpha(color.a)})`;
2526
+ }
2527
+
2528
+ /** Parse CSS color strings (rgba/rgb/hex) into HSV(+alpha). */
2529
+ #parseCssColor(raw) {
2530
+ const value = String(raw ?? "").trim();
2531
+ if (!value) return null;
2532
+
2533
+ const hexMatch = value.match(/^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
2534
+ if (hexMatch) {
2535
+ let hex = hexMatch[1];
2536
+ if (hex.length === 3) {
2537
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
2538
+ }
2539
+ let alpha = 1;
2540
+ if (hex.length === 8) {
2541
+ alpha = parseInt(hex.slice(6, 8), 16) / 255;
2542
+ hex = hex.slice(0, 6);
2543
+ }
2544
+ const hsv = this.#hexToHSV(`#${hex}`);
2545
+ return { ...hsv, a: alpha };
2546
+ }
2547
+
2548
+ const rgbMatch = value.match(
2549
+ /^rgba?\(\s*([+-]?(?:\d+\.?\d*|\.\d+))\s*,\s*([+-]?(?:\d+\.?\d*|\.\d+))\s*,\s*([+-]?(?:\d+\.?\d*|\.\d+))(?:\s*,\s*([+-]?(?:\d+\.?\d*|\.\d+))\s*)?\)$/i,
2550
+ );
2551
+ if (rgbMatch) {
2552
+ const r = Math.max(0, Math.min(255, Math.round(parseFloat(rgbMatch[1]))));
2553
+ const g = Math.max(0, Math.min(255, Math.round(parseFloat(rgbMatch[2]))));
2554
+ const b = Math.max(0, Math.min(255, Math.round(parseFloat(rgbMatch[3]))));
2555
+ const alpha =
2556
+ rgbMatch[4] !== undefined
2557
+ ? Math.max(0, Math.min(1, parseFloat(rgbMatch[4])))
2558
+ : 1;
2559
+ if (![r, g, b, alpha].every(Number.isFinite)) return null;
2560
+ return { ...this.#rgbToHSV({ r, g, b }), a: alpha };
2561
+ }
2562
+
2563
+ return null;
2564
+ }
2565
+
2290
2566
  #hexToP3(hex, alpha = 1) {
2291
2567
  const r = +(parseInt(hex.slice(1, 3), 16) / 255).toFixed(4);
2292
2568
  const g = +(parseInt(hex.slice(3, 5), 16) / 255).toFixed(4);
@@ -2543,4 +2819,4 @@ class FigFillPicker extends HTMLElement {
2543
2819
  }
2544
2820
  }
2545
2821
  }
2546
- customElements.define("fig-fill-picker", FigFillPicker);
2822
+ figEditorDefineElement("fig-fill-picker", FigFillPicker);