@rogieking/figui3 6.20.2 → 6.21.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-editor.js CHANGED
@@ -17,6 +17,7 @@ const GRADIENT_INTERPOLATION_SPACES = [
17
17
  "display-p3",
18
18
  "oklab",
19
19
  "oklch",
20
+ "hsl",
20
21
  ];
21
22
  const GRADIENT_HUE_INTERPOLATIONS = [
22
23
  "shorter",
@@ -24,6 +25,7 @@ const GRADIENT_HUE_INTERPOLATIONS = [
24
25
  "increasing",
25
26
  "decreasing",
26
27
  ];
28
+ const GRADIENT_HUE_SPACES = new Set(["oklch", "hsl"]);
27
29
 
28
30
  function normalizeGradientConfig(gradient) {
29
31
  const next = { ...(gradient ?? {}) };
@@ -50,7 +52,7 @@ function gradientToValueShape(gradient) {
50
52
  ...normalized,
51
53
  interpolationSpace: normalized.interpolationSpace,
52
54
  };
53
- if (normalized.interpolationSpace === "oklch") {
55
+ if (GRADIENT_HUE_SPACES.has(normalized.interpolationSpace)) {
54
56
  output.hueInterpolation = normalized.hueInterpolation;
55
57
  } else {
56
58
  delete output.hueInterpolation;
@@ -63,12 +65,37 @@ function gradientInterpolationClause(gradient) {
63
65
  if (normalized.interpolationSpace === "srgb") {
64
66
  return "";
65
67
  }
66
- if (normalized.interpolationSpace === "oklch") {
67
- return `in oklch ${normalized.hueInterpolation} hue`;
68
+ if (GRADIENT_HUE_SPACES.has(normalized.interpolationSpace)) {
69
+ return `in ${normalized.interpolationSpace} ${normalized.hueInterpolation} hue`;
68
70
  }
69
71
  return `in ${normalized.interpolationSpace}`;
70
72
  }
71
73
 
74
+ function gradientInterpolationSelectValue(gradient) {
75
+ const normalized = normalizeGradientConfig(gradient);
76
+ if (GRADIENT_HUE_SPACES.has(normalized.interpolationSpace)) {
77
+ return `${normalized.interpolationSpace}-${normalized.hueInterpolation || "shorter"}`;
78
+ }
79
+ return normalized.interpolationSpace;
80
+ }
81
+
82
+ function parseGradientInterpolationSelectValue(val) {
83
+ const raw = String(val ?? "");
84
+ for (const space of GRADIENT_HUE_SPACES) {
85
+ const prefix = `${space}-`;
86
+ if (raw.startsWith(prefix)) {
87
+ return {
88
+ interpolationSpace: space,
89
+ hueInterpolation: raw.slice(prefix.length) || "shorter",
90
+ };
91
+ }
92
+ }
93
+ return {
94
+ interpolationSpace: raw || "srgb",
95
+ hueInterpolation: "shorter",
96
+ };
97
+ }
98
+
72
99
  /**
73
100
  * A comprehensive fill picker component supporting solid colors, gradients, images, video, and webcam.
74
101
  * Uses display: contents and wraps a trigger element that opens a dialog picker.
@@ -118,6 +145,7 @@ class FigFillPicker extends HTMLElement {
118
145
  #isDraggingColor = false;
119
146
  #syncingGradientBar = false;
120
147
  #teardownColorAreaEvents = null;
148
+ #gradientInterpolationOpenObserver = null;
121
149
  #valueAtOpen = null;
122
150
  #webcamStart = null;
123
151
  #webcamRequestId = 0;
@@ -285,9 +313,8 @@ class FigFillPicker extends HTMLElement {
285
313
  if (parsed.opacity !== undefined) {
286
314
  this.#color.a = parsed.opacity / 100;
287
315
  }
288
- if (parsed.colorSpace === "display-p3" || parsed.colorSpace === "srgb") {
289
- this.#gamut = parsed.colorSpace;
290
- }
316
+ // Gamut UI hidden for now lock to sRGB.
317
+ this.#gamut = "srgb";
291
318
  if (parsed.gradient) {
292
319
  this.#gradient = normalizeGradientConfig({
293
320
  ...this.#gradient,
@@ -390,9 +417,6 @@ class FigFillPicker extends HTMLElement {
390
417
  this.#valueAtOpen = JSON.stringify(this.value);
391
418
  this.#switchTab(this.#fillType, { emit: false });
392
419
 
393
- const gamutEl = this.#dialog.querySelector(".fig-fill-picker-gamut");
394
- if (gamutEl) gamutEl.value = this.#gamut;
395
-
396
420
  if (this.#swatch) this.#swatch.setAttribute("selected", "true");
397
421
 
398
422
  this.#dialog.open = true;
@@ -429,6 +453,8 @@ class FigFillPicker extends HTMLElement {
429
453
  this.#teardownColorAreaEvents();
430
454
  this.#teardownColorAreaEvents = null;
431
455
  }
456
+ this.#gradientInterpolationOpenObserver?.disconnect();
457
+ this.#gradientInterpolationOpenObserver = null;
432
458
  this.#stopWebcam();
433
459
  if (!this.#dialog) return;
434
460
  this.#restoreCustomSlotContent();
@@ -508,11 +534,6 @@ class FigFillPicker extends HTMLElement {
508
534
  this.#activeTab = allowedModes[0];
509
535
  }
510
536
 
511
- const experimental = this.getAttribute("experimental");
512
- const expAttr = experimental
513
- ? `experimental="${figEditorEscapeAttribute(experimental)}"`
514
- : "";
515
-
516
537
  let headerContent;
517
538
  if (allowedModes.length === 1) {
518
539
  headerContent = `<h3 class="fig-fill-picker-type-label">${figEditorEscapeAttribute(modeLabels[allowedModes[0]])}</h3>`;
@@ -520,12 +541,14 @@ class FigFillPicker extends HTMLElement {
520
541
  const options = allowedModes
521
542
  .map(
522
543
  (m) =>
523
- `<option value="${figEditorEscapeAttribute(m)}">${figEditorEscapeAttribute(modeLabels[m])}</option>`,
544
+ `<fig-select-option value="${figEditorEscapeAttribute(m)}">${figEditorEscapeAttribute(modeLabels[m])}</fig-select-option>`,
524
545
  )
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>`;
546
+ .join("\n ");
547
+ headerContent = `<fig-select class="fig-fill-picker-type" label="Fill type" value="${figEditorEscapeAttribute(this.#fillType)}">
548
+ <fig-select-options>
549
+ ${options}
550
+ </fig-select-options>
551
+ </fig-select>`;
529
552
  }
530
553
 
531
554
  // Generate tab containers for all allowed modes
@@ -536,15 +559,9 @@ class FigFillPicker extends HTMLElement {
536
559
  )
537
560
  .join("\n ");
538
561
 
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
562
  this.#dialog.innerHTML = `
545
563
  <fig-header>
546
564
  ${headerContent}
547
- ${gamutDropdown}
548
565
  <fig-button icon variant="ghost" class="fig-fill-picker-close" aria-label="Close fill picker">
549
566
  <fig-icon name="close"></fig-icon>
550
567
  </fig-button>
@@ -577,27 +594,16 @@ class FigFillPicker extends HTMLElement {
577
594
  );
578
595
  }
579
596
 
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);
597
+ // Setup type select switching (only if not locked)
598
+ const typeSelect = this.#dialog.querySelector(".fig-fill-picker-type");
599
+ if (typeSelect) {
600
+ typeSelect.addEventListener("change", (e) => {
601
+ const next =
602
+ typeof e.detail === "string" ? e.detail : e.target?.value;
603
+ if (next) this.#switchTab(next);
585
604
  });
586
605
  }
587
606
 
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
607
  this.#dialog
602
608
  .querySelector(".fig-fill-picker-close")
603
609
  .addEventListener("click", () => {
@@ -666,10 +672,10 @@ class FigFillPicker extends HTMLElement {
666
672
  this.#stopWebcam();
667
673
  }
668
674
 
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;
675
+ // Update type select (only exists if not locked)
676
+ const typeSelect = this.#dialog.querySelector(".fig-fill-picker-type");
677
+ if (typeSelect && typeSelect.value !== tabName) {
678
+ typeSelect.value = tabName;
673
679
  }
674
680
 
675
681
  // Show/hide tab content
@@ -711,8 +717,6 @@ class FigFillPicker extends HTMLElement {
711
717
  #initSolidTab() {
712
718
  const container = this.#dialog.querySelector('[data-tab="solid"]');
713
719
  const showAlpha = this.getAttribute("alpha") !== "false";
714
- const experimental = this.getAttribute("experimental");
715
- const expAttr = experimental ? `experimental="${experimental}"` : "";
716
720
 
717
721
  container.innerHTML = `
718
722
  <fig-preview class="fig-fill-picker-color-area">
@@ -728,28 +732,31 @@ class FigFillPicker extends HTMLElement {
728
732
  drag-snapping="modifier"
729
733
  ></fig-handle>
730
734
  </fig-preview>
731
- <div class="fig-fill-picker-sliders">
735
+ <div class="fig-fill-picker-sliders${showAlpha ? "" : " is-hue-only"}">
732
736
  <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="${
737
+ <fig-slider type="hue" variant="classic" text="false" min="0" max="360" aria-label="Hue" value="${
734
738
  this.#color.h
735
739
  }"></fig-slider>
736
740
  ${
737
741
  showAlpha
738
- ? `<fig-slider type="opacity" text="true" units="%" min="0" max="100" aria-label="Opacity" value="${
742
+ ? `<fig-slider type="opacity" variant="classic" text="false" min="0" max="100" aria-label="Opacity" value="${
739
743
  this.#color.a * 100
740
744
  }" color="${this.#hsvToHex(this.#color)}"></fig-slider>`
741
745
  : ""
742
746
  }
743
747
  </div>
744
748
  <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>
749
+ <fig-select class="fig-fill-picker-input-mode" label="Color value format" value="${figEditorEscapeAttribute(this.#colorInputMode)}">
750
+ <fig-select-options>
751
+ <fig-select-option value="hex">Hex</fig-select-option>
752
+ <fig-select-option value="rgb">RGB</fig-select-option>
753
+ <fig-select-option value="css">CSS</fig-select-option>
754
+ <fig-select-option value="hsl">HSL</fig-select-option>
755
+ <fig-select-option value="hsb">HSB</fig-select-option>
756
+ <fig-select-option value="lab">LAB</fig-select-option>
757
+ <fig-select-option value="lch">LCH</fig-select-option>
758
+ </fig-select-options>
759
+ </fig-select>
753
760
  <span class="fig-fill-picker-input-fields"></span>
754
761
  </fig-field>
755
762
  `;
@@ -789,10 +796,13 @@ class FigFillPicker extends HTMLElement {
789
796
  });
790
797
  }
791
798
 
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;
799
+ // Setup color input mode select
800
+ const modeSelect = container.querySelector(".fig-fill-picker-input-mode");
801
+ modeSelect.addEventListener("change", (e) => {
802
+ const next =
803
+ typeof e.detail === "string" ? e.detail : e.target?.value;
804
+ if (!next) return;
805
+ this.#colorInputMode = next;
796
806
  this.#rebuildColorInputFields();
797
807
  });
798
808
 
@@ -1025,8 +1035,17 @@ class FigFillPicker extends HTMLElement {
1025
1035
  const wrap = (tooltip, html) =>
1026
1036
  `<fig-tooltip text="${tooltip}">${html}</fig-tooltip>`;
1027
1037
 
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>`;
1038
+ const num = (cls, label, min, max, step, units) =>
1039
+ `<fig-input-number class="${cls}" aria-label="${label}" min="${min}" max="${max}"${step != null ? ` step="${step}"` : ""}${units ? ` units="${units}"` : ""}></fig-input-number>`;
1040
+
1041
+ const showAlpha = this.getAttribute("alpha") !== "false";
1042
+ const alphaField = () =>
1043
+ showAlpha
1044
+ ? wrap(
1045
+ "Alpha",
1046
+ num("fig-fill-picker-ci-a", "Alpha", 0, 100, 0.1, "%"),
1047
+ )
1048
+ : "";
1030
1049
 
1031
1050
  let html;
1032
1051
  switch (this.#colorInputMode) {
@@ -1035,6 +1054,7 @@ class FigFillPicker extends HTMLElement {
1035
1054
  ${wrap("Red", num("fig-fill-picker-ci-r", "Red", 0, 255))}
1036
1055
  ${wrap("Green", num("fig-fill-picker-ci-g", "Green", 0, 255))}
1037
1056
  ${wrap("Blue", num("fig-fill-picker-ci-b", "Blue", 0, 255))}
1057
+ ${alphaField()}
1038
1058
  </div>`;
1039
1059
  break;
1040
1060
  case "hsl":
@@ -1042,6 +1062,7 @@ class FigFillPicker extends HTMLElement {
1042
1062
  ${wrap("Hue", num("fig-fill-picker-ci-h", "Hue", 0, 360))}
1043
1063
  ${wrap("Saturation", num("fig-fill-picker-ci-s", "Saturation", 0, 100))}
1044
1064
  ${wrap("Lightness", num("fig-fill-picker-ci-l", "Lightness", 0, 100))}
1065
+ ${alphaField()}
1045
1066
  </div>`;
1046
1067
  break;
1047
1068
  case "hsb":
@@ -1049,6 +1070,7 @@ class FigFillPicker extends HTMLElement {
1049
1070
  ${wrap("Hue", num("fig-fill-picker-ci-h", "Hue", 0, 360))}
1050
1071
  ${wrap("Saturation", num("fig-fill-picker-ci-s", "Saturation", 0, 100))}
1051
1072
  ${wrap("Brightness", num("fig-fill-picker-ci-v", "Brightness", 0, 100))}
1073
+ ${alphaField()}
1052
1074
  </div>`;
1053
1075
  break;
1054
1076
  case "lab":
@@ -1056,6 +1078,7 @@ class FigFillPicker extends HTMLElement {
1056
1078
  ${wrap("Lightness", num("fig-fill-picker-ci-okl", "Lightness", 0, 100))}
1057
1079
  ${wrap("Green-Red axis", num("fig-fill-picker-ci-oka", "Green-Red axis", -0.4, 0.4, 0.001))}
1058
1080
  ${wrap("Blue-Yellow axis", num("fig-fill-picker-ci-okb", "Blue-Yellow axis", -0.4, 0.4, 0.001))}
1081
+ ${alphaField()}
1059
1082
  </div>`;
1060
1083
  break;
1061
1084
  case "lch":
@@ -1063,10 +1086,19 @@ class FigFillPicker extends HTMLElement {
1063
1086
  ${wrap("Lightness", num("fig-fill-picker-ci-okl", "Lightness", 0, 100))}
1064
1087
  ${wrap("Chroma", num("fig-fill-picker-ci-okc", "Chroma", 0, 0.4, 0.001))}
1065
1088
  ${wrap("Hue", num("fig-fill-picker-ci-okh", "Hue", 0, 360))}
1089
+ ${alphaField()}
1066
1090
  </div>`;
1067
1091
  break;
1092
+ case "css":
1093
+ html = `<fig-input-text class="fig-fill-picker-ci-css" aria-label="CSS color" placeholder="rgba(0, 0, 0, 1)"></fig-input-text>`;
1094
+ break;
1068
1095
  default: // hex
1069
- html = `<fig-input-text class="fig-fill-picker-ci-hex" aria-label="Hex color" placeholder="FFFFFF"></fig-input-text>`;
1096
+ html = showAlpha
1097
+ ? `<div class="input-combo fig-fill-picker-ci-hex-row">
1098
+ <fig-input-text class="fig-fill-picker-ci-hex" aria-label="Hex color" placeholder="FFFFFF"></fig-input-text>
1099
+ ${alphaField()}
1100
+ </div>`
1101
+ : `<fig-input-text class="fig-fill-picker-ci-hex" aria-label="Hex color" placeholder="FFFFFF"></fig-input-text>`;
1070
1102
  break;
1071
1103
  }
1072
1104
 
@@ -1085,12 +1117,16 @@ class FigFillPicker extends HTMLElement {
1085
1117
  if (this.#isDraggingColor) return;
1086
1118
  const color = this.#readColorFromInputs();
1087
1119
  if (!color) return;
1088
- this.#color = { ...color, a: this.#color.a };
1120
+ const nextAlpha = Number.isFinite(color.a) ? color.a : this.#color.a;
1121
+ this.#color = { ...color, a: nextAlpha };
1089
1122
  this.#drawColorArea();
1090
1123
  this.#updateHandlePosition();
1091
1124
  if (this.#hueSlider) {
1092
1125
  this.#hueSlider.setAttribute("value", this.#color.h);
1093
1126
  }
1127
+ if (this.#opacitySlider && Number.isFinite(color.a)) {
1128
+ this.#opacitySlider.setAttribute("value", this.#color.a * 100);
1129
+ }
1094
1130
  this.#emitInput();
1095
1131
  };
1096
1132
 
@@ -1105,39 +1141,53 @@ class FigFillPicker extends HTMLElement {
1105
1141
  });
1106
1142
  }
1107
1143
 
1144
+ #readAlphaFromInput() {
1145
+ const el = this.#dialog?.querySelector(".fig-fill-picker-ci-a");
1146
+ if (!el) return undefined;
1147
+ const pct = parseFloat(el.value);
1148
+ if (!Number.isFinite(pct)) return undefined;
1149
+ return Math.max(0, Math.min(1, pct / 100));
1150
+ }
1151
+
1108
1152
  #readColorFromInputs() {
1109
1153
  const q = (cls) => this.#dialog?.querySelector(`.${cls}`);
1110
1154
  const val = (cls) => parseFloat(q(cls)?.value ?? 0);
1155
+ const withAlpha = (color) => {
1156
+ if (!color) return null;
1157
+ const a = this.#readAlphaFromInput();
1158
+ return { ...color, a: a ?? color.a ?? this.#color.a };
1159
+ };
1111
1160
 
1112
1161
  switch (this.#colorInputMode) {
1113
1162
  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
- });
1163
+ return withAlpha(
1164
+ this.#rgbToHSV({
1165
+ r: val("fig-fill-picker-ci-r"),
1166
+ g: val("fig-fill-picker-ci-g"),
1167
+ b: val("fig-fill-picker-ci-b"),
1168
+ }),
1169
+ );
1119
1170
  case "hsl": {
1120
1171
  const rgb = this.#hslToRGB({
1121
1172
  h: val("fig-fill-picker-ci-h"),
1122
1173
  s: val("fig-fill-picker-ci-s"),
1123
1174
  l: val("fig-fill-picker-ci-l"),
1124
1175
  });
1125
- return this.#rgbToHSV(rgb);
1176
+ return withAlpha(this.#rgbToHSV(rgb));
1126
1177
  }
1127
1178
  case "hsb":
1128
- return {
1179
+ return withAlpha({
1129
1180
  h: val("fig-fill-picker-ci-h"),
1130
1181
  s: val("fig-fill-picker-ci-s"),
1131
1182
  v: val("fig-fill-picker-ci-v"),
1132
- a: 1,
1133
- };
1183
+ });
1134
1184
  case "lab": {
1135
1185
  const rgb = this.#oklabToRGB({
1136
1186
  l: val("fig-fill-picker-ci-okl") / 100,
1137
1187
  a: val("fig-fill-picker-ci-oka"),
1138
1188
  b: val("fig-fill-picker-ci-okb"),
1139
1189
  });
1140
- return this.#rgbToHSV(rgb);
1190
+ return withAlpha(this.#rgbToHSV(rgb));
1141
1191
  }
1142
1192
  case "lch": {
1143
1193
  const rgb = this.#oklchToRGB({
@@ -1145,7 +1195,12 @@ class FigFillPicker extends HTMLElement {
1145
1195
  c: val("fig-fill-picker-ci-okc"),
1146
1196
  h: val("fig-fill-picker-ci-okh"),
1147
1197
  });
1148
- return this.#rgbToHSV(rgb);
1198
+ return withAlpha(this.#rgbToHSV(rgb));
1199
+ }
1200
+ case "css": {
1201
+ const cssEl = q("fig-fill-picker-ci-css");
1202
+ if (!cssEl) return null;
1203
+ return this.#parseCssColor(cssEl.value);
1149
1204
  }
1150
1205
  default: {
1151
1206
  // hex
@@ -1154,8 +1209,14 @@ class FigFillPicker extends HTMLElement {
1154
1209
  let hex = hexEl.value.replace(/^#/, "");
1155
1210
  if (hex.length === 3)
1156
1211
  hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
1212
+ if (hex.length === 8) {
1213
+ const alpha = parseInt(hex.slice(6, 8), 16) / 255;
1214
+ hex = hex.slice(0, 6);
1215
+ if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null;
1216
+ return { ...this.#hexToHSV(`#${hex}`), a: alpha };
1217
+ }
1157
1218
  if (hex.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(hex)) return null;
1158
- return this.#hexToHSV(`#${hex}`);
1219
+ return withAlpha(this.#hexToHSV(`#${hex}`));
1159
1220
  }
1160
1221
  }
1161
1222
  }
@@ -1203,11 +1264,22 @@ class FigFillPicker extends HTMLElement {
1203
1264
  set("fig-fill-picker-ci-okh", Math.round(lch.h));
1204
1265
  break;
1205
1266
  }
1267
+ case "css":
1268
+ set("fig-fill-picker-ci-css", this.#formatCssColor(this.#color));
1269
+ break;
1206
1270
  default: // hex
1207
1271
  set("fig-fill-picker-ci-hex", hex.replace(/^#/, "").toUpperCase());
1208
1272
  break;
1209
1273
  }
1210
1274
 
1275
+ if (this.#colorInputMode !== "css") {
1276
+ const alphaPct = Math.round(this.#color.a * 1000) / 10;
1277
+ set(
1278
+ "fig-fill-picker-ci-a",
1279
+ Number.isInteger(alphaPct) ? alphaPct : +alphaPct.toFixed(1),
1280
+ );
1281
+ }
1282
+
1211
1283
  if (this.#opacitySlider) {
1212
1284
  this.#opacitySlider.setAttribute("color", hex);
1213
1285
  }
@@ -1218,19 +1290,18 @@ class FigFillPicker extends HTMLElement {
1218
1290
  // ============ GRADIENT TAB ============
1219
1291
  #initGradientTab() {
1220
1292
  const container = this.#dialog.querySelector('[data-tab="gradient"]');
1221
- const experimental = this.getAttribute("experimental");
1222
- const expAttr = experimental ? `experimental="${experimental}"` : "";
1293
+ const interpolationValue = gradientInterpolationSelectValue(this.#gradient);
1223
1294
 
1224
1295
  container.innerHTML = `
1225
1296
  <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">
1297
+ <fig-select class="fig-fill-picker-gradient-type" label="Gradient type" value="${figEditorEscapeAttribute(this.#gradient.type)}">
1298
+ <fig-select-options>
1299
+ <fig-select-option value="linear">Linear</fig-select-option>
1300
+ <fig-select-option value="radial">Radial</fig-select-option>
1301
+ <fig-select-option value="angular">Angular</fig-select-option>
1302
+ </fig-select-options>
1303
+ </fig-select>
1304
+ <fig-tooltip text="Gradient angle">
1234
1305
  <fig-input-number class="fig-fill-picker-gradient-angle" aria-label="Gradient angle" value="${
1235
1306
  (this.#gradient.angle - 90 + 360) % 360
1236
1307
  }" min="0" max="360" units="°" wrap></fig-input-number>
@@ -1243,11 +1314,18 @@ class FigFillPicker extends HTMLElement {
1243
1314
  this.#gradient.centerY
1244
1315
  }" units="%" class="fig-fill-picker-gradient-cy"></fig-input-number>
1245
1316
  </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>
1317
+ <div class="fig-fill-picker-gradient-actions">
1318
+ <fig-tooltip text="Flip gradient">
1319
+ <fig-button icon variant="ghost" class="fig-fill-picker-gradient-flip" aria-label="Flip gradient">
1320
+ <fig-icon name="swap"></fig-icon>
1321
+ </fig-button>
1322
+ </fig-tooltip>
1323
+ <fig-tooltip text="Rotate gradient">
1324
+ <fig-button icon variant="ghost" class="fig-fill-picker-gradient-rotate" aria-label="Rotate gradient">
1325
+ <fig-icon name="rotate"></fig-icon>
1326
+ </fig-button>
1327
+ </fig-tooltip>
1328
+ </div>
1251
1329
  </fig-field>
1252
1330
  <fig-preview class="fig-fill-picker-gradient-preview">
1253
1331
  <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 +1346,11 @@ class FigFillPicker extends HTMLElement {
1268
1346
  <span>Color interpolation</span>
1269
1347
  </fig-header>
1270
1348
  <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>
1349
+ <fig-select class="fig-fill-picker-gradient-space" label="Color interpolation" full value="${figEditorEscapeAttribute(interpolationValue)}">
1350
+ <fig-select-options>
1351
+ ${this.#gradientInterpolationOptionsMarkup()}
1352
+ </fig-select-options>
1353
+ </fig-select>
1290
1354
  </fig-field>
1291
1355
  </div>
1292
1356
  `;
@@ -1295,44 +1359,141 @@ class FigFillPicker extends HTMLElement {
1295
1359
  this.#setupGradientEvents(container);
1296
1360
  }
1297
1361
 
1362
+ #gradientInterpolationOptionsMarkup() {
1363
+ const hueMethods = ["shorter", "longer", "increasing", "decreasing"];
1364
+ const groups = [
1365
+ {
1366
+ label: "Linear",
1367
+ options: [
1368
+ { value: "srgb", label: "sRGB" },
1369
+ ],
1370
+ },
1371
+ {
1372
+ label: "",
1373
+ options: [{ value: "oklab", label: "OKLAB" }],
1374
+ },
1375
+ {
1376
+ label: "Polar",
1377
+ options: hueMethods.map((method) => ({
1378
+ value: `oklch-${method}`,
1379
+ label: `OKLCH ${method.charAt(0).toUpperCase()}${method.slice(1)}`,
1380
+ })),
1381
+ },
1382
+ {
1383
+ label: "",
1384
+ separator: true,
1385
+ options: hueMethods.map((method) => ({
1386
+ value: `hsl-${method}`,
1387
+ label: `HSL ${method.charAt(0).toUpperCase()}${method.slice(1)}`,
1388
+ })),
1389
+ },
1390
+ ];
1391
+ return groups
1392
+ .map((group) => {
1393
+ const options = group.options
1394
+ .map((opt) => {
1395
+ const methodLabel = opt.method
1396
+ ? opt.method.charAt(0).toUpperCase() + opt.method.slice(1)
1397
+ : "";
1398
+ const appendLabel = opt.append || methodLabel;
1399
+ return `<fig-select-option value="${figEditorEscapeAttribute(opt.value)}" label="${figEditorEscapeAttribute(opt.label)}">
1400
+ <fig-interpolation-swatch slot="prepend" size="large" aria-hidden="true"></fig-interpolation-swatch>
1401
+ ${figEditorEscapeAttribute(opt.label)}
1402
+ ${appendLabel ? `<span slot="append">${figEditorEscapeAttribute(appendLabel)}</span>` : ""}
1403
+ </fig-select-option>`;
1404
+ })
1405
+ .join("");
1406
+ const separator = group.label || group.separator
1407
+ ? `<fig-menu-separator${group.label ? ` label="${figEditorEscapeAttribute(group.label)}"` : ""}></fig-menu-separator>`
1408
+ : "";
1409
+ return `${separator}${options}`;
1410
+ })
1411
+ .join("");
1412
+ }
1413
+
1298
1414
  #setupGradientEvents(container) {
1299
- // Type dropdown
1300
- const typeDropdown = container.querySelector(
1301
- ".fig-fill-picker-gradient-type",
1415
+ const getSelectValue = (event) =>
1416
+ typeof event.detail === "string" ? event.detail : event.target?.value;
1417
+ const gradientBarInput = container.querySelector(
1418
+ ".fig-fill-picker-gradient-bar-input",
1302
1419
  );
1303
- const getDropdownValue = (event) =>
1304
- event.currentTarget?.value ?? event.target?.value ?? event.detail;
1420
+ const setGradientBarPreview = (gradient) => {
1421
+ if (!gradientBarInput) return;
1422
+ this.#syncingGradientBar = true;
1423
+ try {
1424
+ gradientBarInput.setAttribute(
1425
+ "value",
1426
+ JSON.stringify({
1427
+ type: "gradient",
1428
+ gradient: gradientToValueShape(gradient),
1429
+ }),
1430
+ );
1431
+ } finally {
1432
+ this.#syncingGradientBar = false;
1433
+ }
1434
+ };
1435
+ const restoreGradientBarPreview = () => {
1436
+ setGradientBarPreview(this.#gradient);
1437
+ };
1305
1438
 
1306
- const handleTypeChange = (e) => {
1307
- this.#gradient.type = getDropdownValue(e);
1439
+ const typeSelect = container.querySelector(".fig-fill-picker-gradient-type");
1440
+ typeSelect?.addEventListener("change", (e) => {
1441
+ const next = getSelectValue(e);
1442
+ if (!next) return;
1443
+ this.#gradient.type = next;
1308
1444
  this.#updateGradientUI();
1309
1445
  this.#emitInput();
1310
- };
1311
- typeDropdown.addEventListener("change", handleTypeChange);
1446
+ });
1312
1447
 
1313
- const interpolationDropdown = container.querySelector(
1448
+ const interpolationSelect = container.querySelector(
1314
1449
  ".fig-fill-picker-gradient-space",
1315
1450
  );
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
- }
1451
+ interpolationSelect
1452
+ ?.querySelectorAll("fig-select-option")
1453
+ .forEach((option) => {
1454
+ const previewOption = () => {
1455
+ const parsed = parseGradientInterpolationSelectValue(
1456
+ option.getAttribute("value") || "srgb",
1457
+ );
1458
+ setGradientBarPreview(
1459
+ normalizeGradientConfig({
1460
+ ...this.#gradient,
1461
+ ...parsed,
1462
+ }),
1463
+ );
1464
+ };
1465
+ option.addEventListener("pointerenter", previewOption);
1466
+ option.addEventListener("pointerleave", () => {
1467
+ if (document.activeElement !== option) restoreGradientBarPreview();
1468
+ });
1469
+ option.addEventListener("focus", previewOption);
1470
+ option.addEventListener("blur", () => {
1471
+ if (!option.matches(":hover")) restoreGradientBarPreview();
1472
+ });
1473
+ });
1474
+ this.#gradientInterpolationOpenObserver?.disconnect();
1475
+ if (interpolationSelect) {
1476
+ this.#gradientInterpolationOpenObserver = new MutationObserver(() => {
1477
+ if (!interpolationSelect.hasAttribute("open")) {
1478
+ restoreGradientBarPreview();
1479
+ }
1480
+ });
1481
+ this.#gradientInterpolationOpenObserver.observe(interpolationSelect, {
1482
+ attributes: true,
1483
+ attributeFilter: ["open"],
1484
+ });
1485
+ }
1486
+ interpolationSelect?.addEventListener("change", (e) => {
1487
+ const val = getSelectValue(e);
1488
+ if (!val) return;
1489
+ const parsed = parseGradientInterpolationSelectValue(val);
1324
1490
  this.#gradient = normalizeGradientConfig({
1325
1491
  ...this.#gradient,
1326
- interpolationSpace: space,
1327
- hueInterpolation: hue,
1492
+ ...parsed,
1328
1493
  });
1329
1494
  this.#updateGradientUI();
1330
1495
  this.#emitInput();
1331
- };
1332
- interpolationDropdown?.addEventListener(
1333
- "change",
1334
- handleInterpolationChange,
1335
- );
1496
+ });
1336
1497
 
1337
1498
  // Angle input
1338
1499
  const angleInput = container.querySelector(
@@ -1361,6 +1522,15 @@ class FigFillPicker extends HTMLElement {
1361
1522
  this.#emitInput();
1362
1523
  });
1363
1524
 
1525
+ // Rotate 90° clockwise
1526
+ container
1527
+ .querySelector(".fig-fill-picker-gradient-rotate")
1528
+ ?.addEventListener("click", () => {
1529
+ this.#gradient.angle = (Number(this.#gradient.angle) + 90) % 360;
1530
+ this.#updateGradientUI();
1531
+ this.#emitInput();
1532
+ });
1533
+
1364
1534
  // Flip button
1365
1535
  container
1366
1536
  .querySelector(".fig-fill-picker-gradient-flip")
@@ -1389,9 +1559,6 @@ class FigFillPicker extends HTMLElement {
1389
1559
  });
1390
1560
 
1391
1561
  // Embedded gradient bar input
1392
- const gradientBarInput = container.querySelector(
1393
- ".fig-fill-picker-gradient-bar-input",
1394
- );
1395
1562
  if (gradientBarInput) {
1396
1563
  const syncFromBarInput = (e) => {
1397
1564
  e.stopPropagation();
@@ -1403,6 +1570,7 @@ class FigFillPicker extends HTMLElement {
1403
1570
  ...detail.gradient,
1404
1571
  });
1405
1572
  this.#updateSwatch();
1573
+ this.#updateGradientInterpolationSwatches();
1406
1574
  this.#updateGradientStopsList();
1407
1575
  };
1408
1576
  gradientBarInput.addEventListener("input", (e) => {
@@ -1555,6 +1723,7 @@ class FigFillPicker extends HTMLElement {
1555
1723
 
1556
1724
  this.#syncingGradientBar = true;
1557
1725
  try {
1726
+ this.#updateGradientInterpolationSwatches();
1558
1727
  this.#updateGradientPreview();
1559
1728
  this.#emitInput();
1560
1729
  } finally {
@@ -1573,35 +1742,79 @@ class FigFillPicker extends HTMLElement {
1573
1742
  const angleInput = container.querySelector(
1574
1743
  ".fig-fill-picker-gradient-angle",
1575
1744
  );
1745
+ const rotateBtn = container.querySelector(
1746
+ ".fig-fill-picker-gradient-rotate",
1747
+ );
1576
1748
  const centerInputs = container.querySelector(
1577
1749
  ".fig-fill-picker-gradient-center",
1578
1750
  );
1579
1751
 
1580
1752
  if (this.#gradient.type === "radial") {
1581
1753
  angleInput.style.display = "none";
1754
+ if (rotateBtn) rotateBtn.style.display = "none";
1582
1755
  centerInputs.style.display = "flex";
1583
1756
  } else {
1584
- angleInput.style.display = "block";
1757
+ angleInput.style.removeProperty("display");
1758
+ rotateBtn?.style.removeProperty("display");
1585
1759
  centerInputs.style.display = "none";
1586
1760
  // Sync angle input value (convert CSS angle to picker angle)
1587
1761
  const pickerAngle = (this.#gradient.angle - 90 + 360) % 360;
1588
1762
  angleInput.setAttribute("value", pickerAngle);
1589
1763
  }
1590
1764
 
1591
- const interpolationDropdown = container.querySelector(
1765
+ const interpolationSelect = container.querySelector(
1592
1766
  ".fig-fill-picker-gradient-space",
1593
1767
  );
1594
- if (interpolationDropdown) {
1595
- interpolationDropdown.value =
1596
- this.#gradient.interpolationSpace === "oklch"
1597
- ? `oklch-${this.#gradient.hueInterpolation || "shorter"}`
1598
- : this.#gradient.interpolationSpace;
1768
+ if (interpolationSelect) {
1769
+ interpolationSelect.value = gradientInterpolationSelectValue(
1770
+ this.#gradient,
1771
+ );
1599
1772
  }
1600
1773
 
1774
+ this.#updateGradientInterpolationSwatches();
1601
1775
  this.#updateGradientPreview();
1602
1776
  this.#updateGradientStopsList();
1603
1777
  }
1604
1778
 
1779
+ #interpolationPreviewStops() {
1780
+ const stops = Array.isArray(this.#gradient.stops)
1781
+ ? [...this.#gradient.stops].sort(
1782
+ (a, b) => (a.position ?? 0) - (b.position ?? 0),
1783
+ )
1784
+ : [];
1785
+ if (stops.length < 2) {
1786
+ return [
1787
+ { color: "#FF0000", position: 0 },
1788
+ { color: "#4F9EFF", position: 100 },
1789
+ ];
1790
+ }
1791
+ return stops.map((stop) => ({
1792
+ color: String(stop.color || "#D9D9D9").replace(/^(#(?:[0-9a-f]{6})).*/i, "$1"),
1793
+ position: stop.position ?? 0,
1794
+ }));
1795
+ }
1796
+
1797
+ #updateGradientInterpolationSwatches() {
1798
+ if (!this.#dialog) return;
1799
+ const stops = this.#interpolationPreviewStops();
1800
+ this.#dialog
1801
+ .querySelectorAll("fig-interpolation-swatch")
1802
+ .forEach((swatch) => {
1803
+ const optionVal =
1804
+ swatch.closest("fig-select-option")?.getAttribute("value") || "srgb";
1805
+ const parsed = parseGradientInterpolationSelectValue(optionVal);
1806
+ const gradient = {
1807
+ type: "linear",
1808
+ stops,
1809
+ interpolationSpace: parsed.interpolationSpace,
1810
+ };
1811
+ if (GRADIENT_HUE_SPACES.has(parsed.interpolationSpace)) {
1812
+ gradient.hueInterpolation = parsed.hueInterpolation;
1813
+ }
1814
+ swatch.value = { type: "gradient", gradient };
1815
+ });
1816
+ }
1817
+
1605
1818
  #updateGradientPreview() {
1606
1819
  if (!this.#dialog) return;
1607
1820
 
@@ -1690,6 +1903,7 @@ class FigFillPicker extends HTMLElement {
1690
1903
  .querySelector(".fig-fill-picker-stop-position")
1691
1904
  .addEventListener("input", () => {
1692
1905
  this.#syncGradientStopRow(row);
1906
+ this.#updateGradientInterpolationSwatches();
1693
1907
  this.#updateGradientPreview();
1694
1908
  this.#emitInput();
1695
1909
  });
@@ -1709,6 +1923,7 @@ class FigFillPicker extends HTMLElement {
1709
1923
  this.#syncGradientStopRow(row);
1710
1924
  this.#syncingGradientBar = true;
1711
1925
  try {
1926
+ this.#updateGradientInterpolationSwatches();
1712
1927
  this.#updateGradientPreview();
1713
1928
  this.#emitInput();
1714
1929
  } finally {
@@ -1787,19 +2002,17 @@ class FigFillPicker extends HTMLElement {
1787
2002
  // ============ IMAGE TAB ============
1788
2003
  #initImageTab() {
1789
2004
  const container = this.#dialog.querySelector('[data-tab="image"]');
1790
- const experimental = this.getAttribute("experimental");
1791
- const expAttr = experimental ? `experimental="${experimental}"` : "";
1792
2005
 
1793
2006
  container.innerHTML = `
1794
2007
  <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>
2008
+ <fig-select class="fig-fill-picker-scale-mode" label="Image scale mode" value="${figEditorEscapeAttribute(this.#image.scaleMode)}">
2009
+ <fig-select-options>
2010
+ <fig-select-option value="fill">Fill</fig-select-option>
2011
+ <fig-select-option value="fit">Fit</fig-select-option>
2012
+ <fig-select-option value="crop">Crop</fig-select-option>
2013
+ <fig-select-option value="tile">Tile</fig-select-option>
2014
+ </fig-select-options>
2015
+ </fig-select>
1803
2016
  <fig-input-number class="fig-fill-picker-scale" aria-label="Image tile scale" min="1" max="200" value="${
1804
2017
  this.#image.scale
1805
2018
  }" units="%" ${
@@ -1816,15 +2029,18 @@ class FigFillPicker extends HTMLElement {
1816
2029
  }
1817
2030
 
1818
2031
  #setupImageEvents(container) {
1819
- const scaleModeDropdown = container.querySelector(
2032
+ const scaleModeSelect = container.querySelector(
1820
2033
  ".fig-fill-picker-scale-mode",
1821
2034
  );
1822
2035
  const scaleInput = container.querySelector(".fig-fill-picker-scale");
1823
2036
  const preview = container.querySelector(".fig-fill-picker-image-preview");
1824
2037
 
1825
- scaleModeDropdown.addEventListener("change", (e) => {
1826
- this.#image.scaleMode = e.target.value;
1827
- scaleInput.style.display = e.target.value === "tile" ? "block" : "none";
2038
+ scaleModeSelect.addEventListener("change", (e) => {
2039
+ const next =
2040
+ typeof e.detail === "string" ? e.detail : e.target?.value;
2041
+ if (!next) return;
2042
+ this.#image.scaleMode = next;
2043
+ scaleInput.style.display = next === "tile" ? "block" : "none";
1828
2044
  this.#updateImagePreview(preview);
1829
2045
  this.#updateSwatch();
1830
2046
  this.#emitInput();
@@ -1955,18 +2171,16 @@ class FigFillPicker extends HTMLElement {
1955
2171
  // ============ VIDEO TAB ============
1956
2172
  #initVideoTab() {
1957
2173
  const container = this.#dialog.querySelector('[data-tab="video"]');
1958
- const experimental = this.getAttribute("experimental");
1959
- const expAttr = experimental ? `experimental="${experimental}"` : "";
1960
2174
 
1961
2175
  container.innerHTML = `
1962
2176
  <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>
2177
+ <fig-select class="fig-fill-picker-scale-mode" label="Video scale mode" value="${figEditorEscapeAttribute(this.#video.scaleMode)}">
2178
+ <fig-select-options>
2179
+ <fig-select-option value="fill">Fill</fig-select-option>
2180
+ <fig-select-option value="fit">Fit</fig-select-option>
2181
+ <fig-select-option value="crop">Crop</fig-select-option>
2182
+ </fig-select-options>
2183
+ </fig-select>
1970
2184
  <fig-button class="fig-fill-picker-media-rotate" icon variant="ghost" aria-label="Rotate">
1971
2185
  <fig-icon name="rotate"></fig-icon>
1972
2186
  </fig-button>
@@ -1978,13 +2192,16 @@ class FigFillPicker extends HTMLElement {
1978
2192
  }
1979
2193
 
1980
2194
  #setupVideoEvents(container) {
1981
- const scaleModeDropdown = container.querySelector(
2195
+ const scaleModeSelect = container.querySelector(
1982
2196
  ".fig-fill-picker-scale-mode",
1983
2197
  );
1984
2198
  const preview = container.querySelector(".fig-fill-picker-video-preview");
1985
2199
 
1986
- scaleModeDropdown.addEventListener("change", (e) => {
1987
- this.#video.scaleMode = e.target.value;
2200
+ scaleModeSelect.addEventListener("change", (e) => {
2201
+ const next =
2202
+ typeof e.detail === "string" ? e.detail : e.target?.value;
2203
+ if (!next) return;
2204
+ this.#video.scaleMode = next;
1988
2205
  this.#updateVideoPreviewStyle(preview);
1989
2206
  this.#updateSwatch();
1990
2207
  this.#emitInput();
@@ -2014,13 +2231,12 @@ class FigFillPicker extends HTMLElement {
2014
2231
  // ============ WEBCAM TAB ============
2015
2232
  #initWebcamTab() {
2016
2233
  const container = this.#dialog.querySelector('[data-tab="webcam"]');
2017
- const experimental = this.getAttribute("experimental");
2018
- const expAttr = experimental ? `experimental="${experimental}"` : "";
2019
2234
 
2020
2235
  container.innerHTML = `
2021
2236
  <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>
2237
+ <fig-select class="fig-fill-picker-camera-select" label="Camera" full>
2238
+ <fig-select-options></fig-select-options>
2239
+ </fig-select>
2024
2240
  </fig-field>
2025
2241
  <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
2242
  <video class="fig-fill-picker-webcam-video" autoplay muted playsinline></video>
@@ -2081,11 +2297,14 @@ class FigFillPicker extends HTMLElement {
2081
2297
 
2082
2298
  if (cameras.length > 1) {
2083
2299
  cameraField.style.display = "";
2084
- cameraSelect
2085
- .querySelectorAll(":scope > option, :scope > optgroup")
2086
- .forEach((option) => option.remove());
2300
+ let panel = cameraSelect.querySelector(":scope > fig-select-options");
2301
+ if (!panel) {
2302
+ panel = document.createElement("fig-select-options");
2303
+ cameraSelect.append(panel);
2304
+ }
2305
+ panel.replaceChildren();
2087
2306
  cameras.forEach((cam, i) => {
2088
- const option = document.createElement("option");
2307
+ const option = document.createElement("fig-select-option");
2089
2308
  option.value = cam.deviceId;
2090
2309
  const label =
2091
2310
  cam.label || (cameras.length > 1 ? `Camera ${i + 1}` : "Camera");
@@ -2098,14 +2317,14 @@ class FigFillPicker extends HTMLElement {
2098
2317
  return ` ${displayId}`;
2099
2318
  },
2100
2319
  );
2101
- cameraSelect.append(option);
2320
+ panel.append(option);
2102
2321
  });
2103
2322
  if (deviceId) cameraSelect.value = deviceId;
2104
2323
  } else {
2105
2324
  cameraField.style.display = "none";
2106
2325
  cameraSelect
2107
- .querySelectorAll(":scope > option, :scope > optgroup")
2108
- .forEach((option) => option.remove());
2326
+ .querySelector(":scope > fig-select-options")
2327
+ ?.replaceChildren();
2109
2328
  }
2110
2329
  } catch (err) {
2111
2330
  if (requestId !== this.#webcamRequestId) return;
@@ -2133,7 +2352,9 @@ class FigFillPicker extends HTMLElement {
2133
2352
  this.#webcamStart = startWebcam;
2134
2353
 
2135
2354
  cameraSelect.addEventListener("change", (e) => {
2136
- startWebcam(e.target.value);
2355
+ const next =
2356
+ typeof e.detail === "string" ? e.detail : e.target?.value;
2357
+ if (next) startWebcam(next);
2137
2358
  });
2138
2359
 
2139
2360
  captureBtn.addEventListener("click", async () => {
@@ -2287,6 +2508,55 @@ class FigFillPicker extends HTMLElement {
2287
2508
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
2288
2509
  }
2289
2510
 
2511
+ #formatCssAlpha(alpha) {
2512
+ const a = Math.max(0, Math.min(1, Number(alpha) || 0));
2513
+ const rounded = Math.round(a * 1000) / 1000;
2514
+ return String(rounded);
2515
+ }
2516
+
2517
+ #formatCssColor(color = this.#color) {
2518
+ const rgb = this.#hsvToRGB(color);
2519
+ return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${this.#formatCssAlpha(color.a)})`;
2520
+ }
2521
+
2522
+ /** Parse CSS color strings (rgba/rgb/hex) into HSV(+alpha). */
2523
+ #parseCssColor(raw) {
2524
+ const value = String(raw ?? "").trim();
2525
+ if (!value) return null;
2526
+
2527
+ const hexMatch = value.match(/^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
2528
+ if (hexMatch) {
2529
+ let hex = hexMatch[1];
2530
+ if (hex.length === 3) {
2531
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
2532
+ }
2533
+ let alpha = 1;
2534
+ if (hex.length === 8) {
2535
+ alpha = parseInt(hex.slice(6, 8), 16) / 255;
2536
+ hex = hex.slice(0, 6);
2537
+ }
2538
+ const hsv = this.#hexToHSV(`#${hex}`);
2539
+ return { ...hsv, a: alpha };
2540
+ }
2541
+
2542
+ const rgbMatch = value.match(
2543
+ /^rgba?\(\s*([+-]?(?:\d+\.?\d*|\.\d+))\s*,\s*([+-]?(?:\d+\.?\d*|\.\d+))\s*,\s*([+-]?(?:\d+\.?\d*|\.\d+))(?:\s*,\s*([+-]?(?:\d+\.?\d*|\.\d+))\s*)?\)$/i,
2544
+ );
2545
+ if (rgbMatch) {
2546
+ const r = Math.max(0, Math.min(255, Math.round(parseFloat(rgbMatch[1]))));
2547
+ const g = Math.max(0, Math.min(255, Math.round(parseFloat(rgbMatch[2]))));
2548
+ const b = Math.max(0, Math.min(255, Math.round(parseFloat(rgbMatch[3]))));
2549
+ const alpha =
2550
+ rgbMatch[4] !== undefined
2551
+ ? Math.max(0, Math.min(1, parseFloat(rgbMatch[4])))
2552
+ : 1;
2553
+ if (![r, g, b, alpha].every(Number.isFinite)) return null;
2554
+ return { ...this.#rgbToHSV({ r, g, b }), a: alpha };
2555
+ }
2556
+
2557
+ return null;
2558
+ }
2559
+
2290
2560
  #hexToP3(hex, alpha = 1) {
2291
2561
  const r = +(parseInt(hex.slice(1, 3), 16) / 255).toFixed(4);
2292
2562
  const g = +(parseInt(hex.slice(3, 5), 16) / 255).toFixed(4);