@rogieking/figui3 6.10.0 → 6.12.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
@@ -1,244 +1,15 @@
1
- import "./fig-layer.js";
2
-
3
- function figEditorIsWebKitOrIOSBrowser() {
4
- if (typeof navigator === "undefined") {
5
- return false;
6
- }
7
- const userAgent = navigator.userAgent || "";
8
- const isIOSBrowser =
9
- /\b(iPad|iPhone|iPod)\b/.test(userAgent) ||
10
- (/\bMacintosh\b/.test(userAgent) && /\bMobile\b/.test(userAgent));
11
- const isDesktopWebKit =
12
- /\bAppleWebKit\b/.test(userAgent) &&
13
- !/\b(Chrome|Chromium|Edg|OPR|SamsungBrowser)\b/.test(userAgent);
14
- return isIOSBrowser || isDesktopWebKit;
1
+ import "./fig.js";
2
+ import "./fig-lab.js";
3
+
4
+ function figEditorEscapeAttribute(value) {
5
+ return String(value ?? "")
6
+ .replace(/&/g, "&")
7
+ .replace(/"/g, """)
8
+ .replace(/'/g, "'")
9
+ .replace(/</g, "&lt;")
10
+ .replace(/>/g, "&gt;");
15
11
  }
16
12
 
17
- function figEditorSupportsCustomizedBuiltIns() {
18
- if (
19
- typeof window === "undefined" ||
20
- !window.customElements ||
21
- typeof HTMLButtonElement === "undefined"
22
- ) {
23
- return false;
24
- }
25
-
26
- const testName = `fig-editor-builtin-probe-${Math.random().toString(36).slice(2)}`;
27
- class FigEditorCustomizedBuiltInProbe extends HTMLButtonElement {}
28
-
29
- try {
30
- customElements.define(testName, FigEditorCustomizedBuiltInProbe, {
31
- extends: "button",
32
- });
33
- const probe = document.createElement("button", { is: testName });
34
- return probe instanceof FigEditorCustomizedBuiltInProbe;
35
- } catch (_error) {
36
- return false;
37
- }
38
- }
39
-
40
- const figEditorNeedsBuiltInPolyfill =
41
- figEditorIsWebKitOrIOSBrowser() && !figEditorSupportsCustomizedBuiltIns();
42
- const figEditorBuiltInPolyfillReady = (
43
- figEditorNeedsBuiltInPolyfill
44
- ? import("./polyfills/custom-elements-webkit.js")
45
- : Promise.resolve()
46
- )
47
- .then(() => {})
48
- .catch((error) => {
49
- throw error;
50
- });
51
-
52
- function figEditorDefineCustomizedBuiltIn(name, constructor, options) {
53
- const define = () => {
54
- if (!customElements.get(name)) {
55
- customElements.define(name, constructor, options);
56
- }
57
- };
58
-
59
- if (!figEditorNeedsBuiltInPolyfill) {
60
- define();
61
- return;
62
- }
63
-
64
- figEditorBuiltInPolyfillReady.then(define).catch((error) => {
65
- console.error(
66
- `[figui3] Failed to load customized built-in polyfill for "${name}".`,
67
- error,
68
- );
69
- });
70
- }
71
-
72
- /* Toast */
73
- /**
74
- * A toast notification element for non-modal, time-based messages.
75
- * Always positioned at bottom center of the screen.
76
- * @attr {number} duration - Auto-dismiss duration in ms (0 = no auto-dismiss, default: 5000)
77
- * @attr {number} offset - Distance from bottom edge in pixels (default: 16)
78
- * @attr {string} theme - Visual theme: "dark" (default), "light", "danger", "brand"
79
- * @attr {boolean} open - Whether the toast is visible
80
- */
81
- class FigToast extends HTMLDialogElement {
82
- constructor() {
83
- super();
84
- this._figInit();
85
- }
86
-
87
- _figInit() {
88
- if (this._figInitialized) return;
89
- this._figInitialized = true;
90
- this._defaultOffset = 16;
91
- this._autoCloseTimer = null;
92
- this._boundHandleClose = this.handleClose.bind(this);
93
- }
94
-
95
- getOffset() {
96
- return parseInt(this.getAttribute("offset") ?? this._defaultOffset);
97
- }
98
-
99
- connectedCallback() {
100
- this._figInit();
101
-
102
- if (!this.hasAttribute("theme")) {
103
- this.setAttribute("theme", "dark");
104
- }
105
-
106
- this.syncLiveRegion();
107
-
108
- const shouldOpen =
109
- this.getAttribute("open") === "true" || this.getAttribute("open") === "";
110
- if (this.hasAttribute("open") && !shouldOpen) {
111
- this.removeAttribute("open");
112
- }
113
-
114
- if (!shouldOpen) {
115
- this.close();
116
- }
117
-
118
- requestAnimationFrame(() => {
119
- this.addCloseListeners();
120
- this.applyPosition();
121
-
122
- if (shouldOpen) {
123
- this.showToast();
124
- }
125
- });
126
- }
127
-
128
- disconnectedCallback() {
129
- this._figInit();
130
- this.clearAutoClose();
131
- }
132
-
133
- addCloseListeners() {
134
- this.querySelectorAll("[close-toast]").forEach((button) => {
135
- button.removeEventListener("click", this._boundHandleClose);
136
- button.addEventListener("click", this._boundHandleClose);
137
- });
138
- }
139
-
140
- handleClose() {
141
- this.hideToast();
142
- }
143
-
144
- applyPosition() {
145
- this.style.position = "fixed";
146
- this.style.margin = "0";
147
- this.style.top = "auto";
148
- this.style.bottom = `${this.getOffset()}px`;
149
- this.style.left = "50%";
150
- this.style.right = "auto";
151
- this.style.transform = "translateX(-50%)";
152
- }
153
-
154
- startAutoClose() {
155
- this.clearAutoClose();
156
-
157
- const duration = parseInt(this.getAttribute("duration") ?? "5000");
158
- if (duration > 0) {
159
- this._autoCloseTimer = setTimeout(() => {
160
- this.hideToast();
161
- }, duration);
162
- }
163
- }
164
-
165
- syncLiveRegion() {
166
- const assertive =
167
- this.getAttribute("live") === "assertive" ||
168
- this.getAttribute("theme") === "danger";
169
- if (!this.hasAttribute("role")) {
170
- this.setAttribute("role", assertive ? "alert" : "status");
171
- }
172
- if (!this.hasAttribute("aria-live")) {
173
- this.setAttribute("aria-live", assertive ? "assertive" : "polite");
174
- }
175
- if (!this.hasAttribute("aria-atomic")) {
176
- this.setAttribute("aria-atomic", "true");
177
- }
178
- }
179
-
180
- clearAutoClose() {
181
- if (this._autoCloseTimer) {
182
- clearTimeout(this._autoCloseTimer);
183
- this._autoCloseTimer = null;
184
- }
185
- }
186
-
187
- _resolveAutoTheme() {
188
- if (this.getAttribute("theme") !== "auto") return;
189
- const cs = getComputedStyle(document.documentElement).colorScheme || "";
190
- const isDark = cs.includes("dark");
191
- this.style.colorScheme = isDark ? "light" : "dark";
192
- }
193
-
194
- showToast() {
195
- this._resolveAutoTheme();
196
- if (!this.open) this.show();
197
- this.applyPosition();
198
- this.startAutoClose();
199
- this.dispatchEvent(new CustomEvent("toast-show", { bubbles: true }));
200
- }
201
-
202
- hideToast() {
203
- this.clearAutoClose();
204
- this.close();
205
- this.dispatchEvent(new CustomEvent("toast-hide", { bubbles: true }));
206
- }
207
-
208
- static get observedAttributes() {
209
- return ["duration", "offset", "open", "theme", "live"];
210
- }
211
-
212
- attributeChangedCallback(name, oldValue, newValue) {
213
- this._figInit();
214
- if (!this.isConnected) return;
215
- if (name === "offset") {
216
- this.applyPosition();
217
- }
218
-
219
- if (name === "open") {
220
- if (newValue !== null && newValue !== "false") {
221
- this.showToast();
222
- } else {
223
- this.hideToast();
224
- }
225
- }
226
-
227
- if (name === "theme") {
228
- if (newValue === "auto") {
229
- this._resolveAutoTheme();
230
- } else {
231
- this.style.removeProperty("color-scheme");
232
- }
233
- }
234
-
235
- if (name === "theme" || name === "live") {
236
- this.syncLiveRegion();
237
- }
238
- }
239
- }
240
- figEditorDefineCustomizedBuiltIn("fig-toast", FigToast, { extends: "dialog" });
241
-
242
13
  // FigFillPicker
243
14
  const GRADIENT_INTERPOLATION_SPACES = [
244
15
  "srgb",
@@ -345,9 +116,11 @@ class FigFillPicker extends HTMLElement {
345
116
  #hueSlider = null;
346
117
  #opacitySlider = null;
347
118
  #isDraggingColor = false;
119
+ #syncingGradientBar = false;
348
120
  #teardownColorAreaEvents = null;
349
- #dialogOpenObserver = null;
350
- #webcamTabObserver = null;
121
+ #valueAtOpen = null;
122
+ #webcamStart = null;
123
+ #webcamRequestId = 0;
351
124
  #boundTriggerClick = null;
352
125
  #boundTriggerKeydown = null;
353
126
 
@@ -382,24 +155,10 @@ class FigFillPicker extends HTMLElement {
382
155
  }
383
156
 
384
157
  disconnectedCallback() {
385
- if (this.#teardownColorAreaEvents) {
386
- this.#teardownColorAreaEvents();
387
- this.#teardownColorAreaEvents = null;
388
- }
389
- if (this.#dialogOpenObserver) {
390
- this.#dialogOpenObserver.disconnect();
391
- this.#dialogOpenObserver = null;
392
- }
393
- if (this.#webcamTabObserver) {
394
- this.#webcamTabObserver.disconnect();
395
- this.#webcamTabObserver = null;
396
- }
397
- if (this.#webcam.stream) {
398
- this.#webcam.stream.getTracks().forEach((track) => track.stop());
399
- this.#webcam.stream = null;
400
- }
158
+ this.#discardDialog();
401
159
  if (this.#webcam.snapshot?.startsWith("blob:")) {
402
160
  URL.revokeObjectURL(this.#webcam.snapshot);
161
+ if (this.#image.url === this.#webcam.snapshot) this.#image.url = null;
403
162
  this.#webcam.snapshot = null;
404
163
  }
405
164
  if (this.#video.url && this.#video.url.startsWith("blob:")) {
@@ -410,11 +169,6 @@ class FigFillPicker extends HTMLElement {
410
169
  this.#trigger.removeEventListener("click", this.#boundTriggerClick);
411
170
  this.#trigger.removeEventListener("keydown", this.#boundTriggerKeydown);
412
171
  }
413
- if (this.#dialog) {
414
- this.#dialog.close();
415
- this.#dialog.remove();
416
- this.#dialog = null;
417
- }
418
172
  }
419
173
 
420
174
  #setupTrigger() {
@@ -485,7 +239,11 @@ class FigFillPicker extends HTMLElement {
485
239
  }
486
240
 
487
241
  #handleTriggerClick(e) {
488
- if (this.hasAttribute("disabled")) return;
242
+ if (
243
+ this.hasAttribute("disabled") &&
244
+ this.getAttribute("disabled") !== "false"
245
+ )
246
+ return;
489
247
  e.stopPropagation();
490
248
  e.preventDefault();
491
249
  this.#openDialog();
@@ -493,7 +251,11 @@ class FigFillPicker extends HTMLElement {
493
251
 
494
252
  #handleTriggerKeydown(e) {
495
253
  if (e.key !== "Enter" && e.key !== " ") return;
496
- if (this.hasAttribute("disabled")) return;
254
+ if (
255
+ this.hasAttribute("disabled") &&
256
+ this.getAttribute("disabled") !== "false"
257
+ )
258
+ return;
497
259
  e.preventDefault();
498
260
  e.stopPropagation();
499
261
  this.#openDialog();
@@ -625,7 +387,8 @@ class FigFillPicker extends HTMLElement {
625
387
  this.#createDialog();
626
388
  }
627
389
 
628
- this.#switchTab(this.#fillType);
390
+ this.#valueAtOpen = JSON.stringify(this.value);
391
+ this.#switchTab(this.#fillType, { emit: false });
629
392
 
630
393
  const gamutEl = this.#dialog.querySelector(".fig-fill-picker-gamut");
631
394
  if (gamutEl) gamutEl.value = this.#gamut;
@@ -650,6 +413,43 @@ class FigFillPicker extends HTMLElement {
650
413
  if (this.#dialog) this.#dialog.open = false;
651
414
  }
652
415
 
416
+ #restoreCustomSlotContent() {
417
+ if (!this.#dialog) return;
418
+ for (const [modeName, { element }] of Object.entries(this.#customSlots)) {
419
+ const container = Array.from(
420
+ this.#dialog.querySelectorAll(".fig-fill-picker-tab"),
421
+ ).find((candidate) => candidate.dataset.tab === modeName);
422
+ if (!container) continue;
423
+ while (container.firstChild) element.appendChild(container.firstChild);
424
+ }
425
+ }
426
+
427
+ #discardDialog() {
428
+ if (this.#teardownColorAreaEvents) {
429
+ this.#teardownColorAreaEvents();
430
+ this.#teardownColorAreaEvents = null;
431
+ }
432
+ this.#stopWebcam();
433
+ if (!this.#dialog) return;
434
+ this.#restoreCustomSlotContent();
435
+ this.#dialog.remove();
436
+ this.#dialog = null;
437
+ this.#webcamStart = null;
438
+ this.#valueAtOpen = null;
439
+ }
440
+
441
+ #stopWebcam() {
442
+ this.#webcamRequestId += 1;
443
+ if (this.#webcam.stream) {
444
+ this.#webcam.stream.getTracks().forEach((track) => track.stop());
445
+ this.#webcam.stream = null;
446
+ }
447
+ const video = this.#dialog?.querySelector(
448
+ ".fig-fill-picker-webcam-video",
449
+ );
450
+ if (video) video.srcObject = null;
451
+ }
452
+
653
453
  #createDialog() {
654
454
  // Collect slotted custom mode content before any DOM changes
655
455
  this.#customSlots = {};
@@ -709,23 +509,31 @@ class FigFillPicker extends HTMLElement {
709
509
  }
710
510
 
711
511
  const experimental = this.getAttribute("experimental");
712
- const expAttr = experimental ? `experimental="${experimental}"` : "";
512
+ const expAttr = experimental
513
+ ? `experimental="${figEditorEscapeAttribute(experimental)}"`
514
+ : "";
713
515
 
714
516
  let headerContent;
715
517
  if (allowedModes.length === 1) {
716
- headerContent = `<h3 class="fig-fill-picker-type-label">${modeLabels[allowedModes[0]]}</h3>`;
518
+ headerContent = `<h3 class="fig-fill-picker-type-label">${figEditorEscapeAttribute(modeLabels[allowedModes[0]])}</h3>`;
717
519
  } else {
718
520
  const options = allowedModes
719
- .map((m) => `<option value="${m}">${modeLabels[m]}</option>`)
521
+ .map(
522
+ (m) =>
523
+ `<option value="${figEditorEscapeAttribute(m)}">${figEditorEscapeAttribute(modeLabels[m])}</option>`,
524
+ )
720
525
  .join("\n ");
721
- headerContent = `<fig-dropdown class="fig-fill-picker-type" label="Fill type" ${expAttr} value="${this.#fillType}">
526
+ headerContent = `<fig-dropdown class="fig-fill-picker-type" label="Fill type" ${expAttr} value="${figEditorEscapeAttribute(this.#fillType)}">
722
527
  ${options}
723
528
  </fig-dropdown>`;
724
529
  }
725
530
 
726
531
  // Generate tab containers for all allowed modes
727
532
  const tabDivs = allowedModes
728
- .map((m) => `<div class="fig-fill-picker-tab" data-tab="${m}"></div>`)
533
+ .map(
534
+ (m) =>
535
+ `<div class="fig-fill-picker-tab" data-tab="${figEditorEscapeAttribute(m)}"></div>`,
536
+ )
729
537
  .join("\n ");
730
538
 
731
539
  const gamutDropdown = `<fig-dropdown class="fig-fill-picker-gamut" label="Color gamut" ${expAttr} value="${this.#gamut}">
@@ -750,7 +558,9 @@ class FigFillPicker extends HTMLElement {
750
558
 
751
559
  // Populate custom tab containers and emit modeready
752
560
  for (const [modeName, { element }] of Object.entries(this.#customSlots)) {
753
- const container = this.#dialog.querySelector(`[data-tab="${modeName}"]`);
561
+ const container = Array.from(
562
+ this.#dialog.querySelectorAll(".fig-fill-picker-tab"),
563
+ ).find((candidate) => candidate.dataset.tab === modeName);
754
564
  if (!container) continue;
755
565
 
756
566
  // Move children (not the element itself) for vanilla HTML usage
@@ -785,7 +595,6 @@ class FigFillPicker extends HTMLElement {
785
595
  this.#onGamutChange();
786
596
  }
787
597
  };
788
- gamutEl.addEventListener("input", handleGamutChange);
789
598
  gamutEl.addEventListener("change", handleGamutChange);
790
599
  }
791
600
 
@@ -797,22 +606,18 @@ class FigFillPicker extends HTMLElement {
797
606
 
798
607
  const onDialogClose = () => {
799
608
  if (this.#swatch) this.#swatch.removeAttribute("selected");
800
- this.#emitChange();
609
+ this.#stopWebcam();
610
+ if (
611
+ this.#valueAtOpen !== null &&
612
+ this.#valueAtOpen !== JSON.stringify(this.value)
613
+ ) {
614
+ this.#emitChange();
615
+ }
616
+ this.#valueAtOpen = null;
801
617
  this.dispatchEvent(new CustomEvent("close"));
802
618
  };
803
619
  this.#dialog.addEventListener("close", onDialogClose);
804
620
 
805
- this.#dialogOpenObserver = new MutationObserver(() => {
806
- const isOpen =
807
- this.#dialog.hasAttribute("open") &&
808
- this.#dialog.getAttribute("open") !== "false";
809
- if (!isOpen) onDialogClose();
810
- });
811
- this.#dialogOpenObserver.observe(this.#dialog, {
812
- attributes: true,
813
- attributeFilter: ["open"],
814
- });
815
-
816
621
  // Initialize built-in tabs (skip any overridden by custom slots)
817
622
  const builtinInits = {
818
623
  solid: () => this.#initSolidTab(),
@@ -828,7 +633,9 @@ class FigFillPicker extends HTMLElement {
828
633
  // Listen for input/change from custom tab content
829
634
  for (const modeName of Object.keys(this.#customSlots)) {
830
635
  if (builtinModes.includes(modeName)) continue;
831
- const container = this.#dialog.querySelector(`[data-tab="${modeName}"]`);
636
+ const container = Array.from(
637
+ this.#dialog.querySelectorAll(".fig-fill-picker-tab"),
638
+ ).find((candidate) => candidate.dataset.tab === modeName);
832
639
  if (!container) continue;
833
640
  container.addEventListener("input", (e) => {
834
641
  if (e.target === this) return;
@@ -845,15 +652,19 @@ class FigFillPicker extends HTMLElement {
845
652
  }
846
653
  }
847
654
 
848
- #switchTab(tabName) {
655
+ #switchTab(tabName, { emit = true } = {}) {
849
656
  // Only allow switching to modes that have a tab container in the dialog
850
- const tab = this.#dialog?.querySelector(
851
- `.fig-fill-picker-tab[data-tab="${tabName}"]`,
852
- );
657
+ const tab = Array.from(
658
+ this.#dialog?.querySelectorAll(".fig-fill-picker-tab") ?? [],
659
+ ).find((candidate) => candidate.dataset.tab === tabName);
853
660
  if (!tab) return;
854
661
 
662
+ const previousTab = this.#activeTab;
855
663
  this.#activeTab = tabName;
856
664
  this.#fillType = tabName;
665
+ if (previousTab === "webcam" && tabName !== "webcam") {
666
+ this.#stopWebcam();
667
+ }
857
668
 
858
669
  // Update dropdown selection (only exists if not locked)
859
670
  const typeDropdown = this.#dialog.querySelector(".fig-fill-picker-type");
@@ -890,8 +701,10 @@ class FigFillPicker extends HTMLElement {
890
701
  });
891
702
  }
892
703
 
704
+ if (tabName === "webcam") this.#webcamStart?.();
705
+
893
706
  this.#updateSwatch();
894
- this.#emitInput();
707
+ if (emit) this.#emitInput();
895
708
  }
896
709
 
897
710
  // ============ SOLID TAB ============
@@ -978,7 +791,7 @@ class FigFillPicker extends HTMLElement {
978
791
 
979
792
  // Setup color input mode dropdown
980
793
  const modeDropdown = container.querySelector(".fig-fill-picker-input-mode");
981
- modeDropdown.addEventListener("input", (e) => {
794
+ modeDropdown.addEventListener("change", (e) => {
982
795
  this.#colorInputMode = e.target.value;
983
796
  this.#rebuildColorInputFields();
984
797
  });
@@ -1439,28 +1252,6 @@ class FigFillPicker extends HTMLElement {
1439
1252
  <fig-preview class="fig-fill-picker-gradient-preview">
1440
1253
  <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>
1441
1254
  </fig-preview>
1442
- <fig-field class="fig-fill-picker-gradient-interpolation">
1443
- <label>Mixing</label>
1444
- <fig-dropdown class="fig-fill-picker-gradient-space" label="Gradient mixing" full ${expAttr} value="${
1445
- this.#gradient.interpolationSpace === "oklch"
1446
- ? `oklch-${this.#gradient.hueInterpolation || "shorter"}`
1447
- : this.#gradient.interpolationSpace
1448
- }">
1449
- <optgroup label="sRGB">
1450
- <option value="srgb">Classic</option>
1451
- <option value="srgb-linear">Linear</option>
1452
- </optgroup>
1453
- <optgroup label="OKLab">
1454
- <option value="oklab">Perceptual</option>
1455
- </optgroup>
1456
- <optgroup label="OKLCH">
1457
- <option value="oklch-shorter">Shorter hue</option>
1458
- <option value="oklch-longer">Longer hue</option>
1459
- <option value="oklch-increasing">Increasing hue</option>
1460
- <option value="oklch-decreasing">Decreasing hue</option>
1461
- </optgroup>
1462
- </fig-dropdown>
1463
- </fig-field>
1464
1255
  <div class="fig-fill-picker-gradient-stops">
1465
1256
  <fig-header class="fig-fill-picker-gradient-stops-header" borderless>
1466
1257
  <span>Stops</span>
@@ -1468,7 +1259,35 @@ class FigFillPicker extends HTMLElement {
1468
1259
  <fig-icon name="add"></fig-icon>
1469
1260
  </fig-button>
1470
1261
  </fig-header>
1471
- <div class="fig-fill-picker-gradient-stops-list"></div>
1262
+ <div class="fig-fill-picker-gradient-stops-list">
1263
+ <fig-reorder></fig-reorder>
1264
+ </div>
1265
+ </div>
1266
+ <div class="fig-fill-picker-gradient-interpolation">
1267
+ <fig-header class="fig-fill-picker-gradient-interpolation-header" borderless>
1268
+ <span>Color interpolation</span>
1269
+ </fig-header>
1270
+ <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>
1290
+ </fig-field>
1472
1291
  </div>
1473
1292
  `;
1474
1293
 
@@ -1489,7 +1308,6 @@ class FigFillPicker extends HTMLElement {
1489
1308
  this.#updateGradientUI();
1490
1309
  this.#emitInput();
1491
1310
  };
1492
- typeDropdown.addEventListener("input", handleTypeChange);
1493
1311
  typeDropdown.addEventListener("change", handleTypeChange);
1494
1312
 
1495
1313
  const interpolationDropdown = container.querySelector(
@@ -1511,7 +1329,6 @@ class FigFillPicker extends HTMLElement {
1511
1329
  this.#updateGradientUI();
1512
1330
  this.#emitInput();
1513
1331
  };
1514
- interpolationDropdown?.addEventListener("input", handleInterpolationChange);
1515
1332
  interpolationDropdown?.addEventListener(
1516
1333
  "change",
1517
1334
  handleInterpolationChange,
@@ -1532,12 +1349,14 @@ class FigFillPicker extends HTMLElement {
1532
1349
  const cxInput = container.querySelector(".fig-fill-picker-gradient-cx");
1533
1350
  const cyInput = container.querySelector(".fig-fill-picker-gradient-cy");
1534
1351
  cxInput?.addEventListener("input", (e) => {
1535
- this.#gradient.centerX = parseFloat(e.target.value) || 50;
1352
+ const value = Number.parseFloat(e.target.value);
1353
+ this.#gradient.centerX = Number.isFinite(value) ? value : 50;
1536
1354
  this.#updateGradientPreview();
1537
1355
  this.#emitInput();
1538
1356
  });
1539
1357
  cyInput?.addEventListener("input", (e) => {
1540
- this.#gradient.centerY = parseFloat(e.target.value) || 50;
1358
+ const value = Number.parseFloat(e.target.value);
1359
+ this.#gradient.centerY = Number.isFinite(value) ? value : 50;
1541
1360
  this.#updateGradientPreview();
1542
1361
  this.#emitInput();
1543
1362
  });
@@ -1576,6 +1395,7 @@ class FigFillPicker extends HTMLElement {
1576
1395
  if (gradientBarInput) {
1577
1396
  const syncFromBarInput = (e) => {
1578
1397
  e.stopPropagation();
1398
+ if (this.#syncingGradientBar) return;
1579
1399
  const detail = e.detail;
1580
1400
  if (!detail?.gradient) return;
1581
1401
  this.#gradient = normalizeGradientConfig({
@@ -1594,6 +1414,152 @@ class FigFillPicker extends HTMLElement {
1594
1414
  this.#emitChange();
1595
1415
  });
1596
1416
  }
1417
+
1418
+ const stopsReorder = container.querySelector(
1419
+ ".fig-fill-picker-gradient-stops-list > fig-reorder",
1420
+ );
1421
+ stopsReorder?.addEventListener("reorder", (event) => {
1422
+ this.#handleGradientStopsReorder(event);
1423
+ });
1424
+ }
1425
+
1426
+ #formatStopColorValue(stop) {
1427
+ const hex = String(stop.color || "#888888")
1428
+ .replace(/^#/, "")
1429
+ .slice(0, 6);
1430
+ const opacity = stop.opacity ?? 100;
1431
+ if (opacity >= 100) return `#${hex}`;
1432
+ const alpha = Math.round((opacity / 100) * 255)
1433
+ .toString(16)
1434
+ .padStart(2, "0");
1435
+ return `#${hex}${alpha}`;
1436
+ }
1437
+
1438
+ #readGradientStopColor(colorInput) {
1439
+ if (!(colorInput instanceof HTMLElement)) return "#888888";
1440
+
1441
+ if (colorInput.hexOpaque) {
1442
+ return this.#normalizeStopHex(colorInput.hexOpaque);
1443
+ }
1444
+
1445
+ const liveValue = colorInput.value;
1446
+ if (typeof liveValue === "string" && liveValue.startsWith("#")) {
1447
+ return this.#normalizeStopHex(
1448
+ liveValue.length > 7 ? liveValue.slice(0, 7) : liveValue,
1449
+ );
1450
+ }
1451
+
1452
+ const textInput = colorInput.querySelector(
1453
+ 'fig-input-text:not([type="number"])',
1454
+ );
1455
+ const textHex = String(
1456
+ textInput?.value ?? textInput?.getAttribute("value") ?? "",
1457
+ )
1458
+ .replace(/#/g, "")
1459
+ .slice(0, 6);
1460
+ if (/^[0-9A-Fa-f]{6}$/.test(textHex)) {
1461
+ return `#${textHex.toUpperCase()}`;
1462
+ }
1463
+
1464
+ const attr = colorInput.getAttribute("value");
1465
+ if (typeof attr === "string" && attr.startsWith("#")) {
1466
+ return this.#normalizeStopHex(
1467
+ attr.length > 7 ? attr.slice(0, 7) : attr,
1468
+ );
1469
+ }
1470
+
1471
+ return "#888888";
1472
+ }
1473
+
1474
+ #normalizeStopHex(color) {
1475
+ const hex = String(color || "#888888")
1476
+ .replace(/^#/, "")
1477
+ .slice(0, 6);
1478
+ if (!/^[0-9A-Fa-f]{6}$/.test(hex)) return "#888888";
1479
+ return `#${hex.toUpperCase()}`;
1480
+ }
1481
+
1482
+ #readGradientStopFromRow(row) {
1483
+ const posInput = row.querySelector(".fig-fill-picker-stop-position");
1484
+ const colorInput = row.querySelector(".fig-fill-picker-stop-color");
1485
+
1486
+ const position =
1487
+ parseFloat(posInput?.value ?? posInput?.getAttribute("value") ?? "0") ||
1488
+ 0;
1489
+
1490
+ const color = this.#readGradientStopColor(colorInput);
1491
+ let opacity = 100;
1492
+
1493
+ if (colorInput instanceof HTMLElement && colorInput.rgba?.a !== undefined) {
1494
+ opacity = Math.round(colorInput.rgba.a * 100);
1495
+ } else {
1496
+ const oldIndex = parseInt(row.dataset.index, 10);
1497
+ if (!isNaN(oldIndex) && this.#gradient.stops[oldIndex]) {
1498
+ opacity = this.#gradient.stops[oldIndex].opacity ?? 100;
1499
+ }
1500
+ }
1501
+
1502
+ return { position, color, opacity };
1503
+ }
1504
+
1505
+ #syncGradientStopRow(row) {
1506
+ const index = parseInt(row.dataset.index, 10);
1507
+ if (isNaN(index) || index < 0 || index >= this.#gradient.stops.length) {
1508
+ return;
1509
+ }
1510
+ const stop = {
1511
+ ...this.#gradient.stops[index],
1512
+ ...this.#readGradientStopFromRow(row),
1513
+ };
1514
+ this.#gradient.stops[index] = stop;
1515
+ // Persist to the input value attributes so that reordering (which detaches
1516
+ // and re-attaches the row's DOM nodes) doesn't reset them to stale markup.
1517
+ this.#persistGradientStopRowAttrs(row, stop);
1518
+ }
1519
+
1520
+ #persistGradientStopRowAttrs(row, stop) {
1521
+ const posInput = row.querySelector(".fig-fill-picker-stop-position");
1522
+ if (posInput) posInput.setAttribute("value", String(stop.position));
1523
+ const colorInput = row.querySelector(".fig-fill-picker-stop-color");
1524
+ if (colorInput) {
1525
+ colorInput.setAttribute("value", this.#formatStopColorValue(stop));
1526
+ }
1527
+ }
1528
+
1529
+ #handleGradientStopsReorder(event) {
1530
+ const { oldIndex, newIndex } = event.detail ?? {};
1531
+ if (
1532
+ oldIndex == null ||
1533
+ newIndex == null ||
1534
+ oldIndex === newIndex ||
1535
+ oldIndex < 0 ||
1536
+ newIndex < 0 ||
1537
+ oldIndex >= this.#gradient.stops.length ||
1538
+ newIndex >= this.#gradient.stops.length
1539
+ ) {
1540
+ return;
1541
+ }
1542
+
1543
+ const reorder = event.currentTarget;
1544
+ const rows = reorder.querySelectorAll(".fig-fill-picker-gradient-stop-row");
1545
+
1546
+ // Rows still carry their pre-drag data-index; flush live inputs first.
1547
+ rows.forEach((row) => this.#syncGradientStopRow(row));
1548
+
1549
+ const [stop] = this.#gradient.stops.splice(oldIndex, 1);
1550
+ this.#gradient.stops.splice(newIndex, 0, stop);
1551
+
1552
+ rows.forEach((row, index) => {
1553
+ row.dataset.index = String(index);
1554
+ });
1555
+
1556
+ this.#syncingGradientBar = true;
1557
+ try {
1558
+ this.#updateGradientPreview();
1559
+ this.#emitInput();
1560
+ } finally {
1561
+ this.#syncingGradientBar = false;
1562
+ }
1597
1563
  }
1598
1564
 
1599
1565
  #updateGradientUI() {
@@ -1643,6 +1609,7 @@ class FigFillPicker extends HTMLElement {
1643
1609
  ".fig-fill-picker-gradient-bar-input",
1644
1610
  );
1645
1611
  if (barInput) {
1612
+ this.#syncingGradientBar = true;
1646
1613
  barInput.setAttribute(
1647
1614
  "value",
1648
1615
  JSON.stringify({
@@ -1650,6 +1617,7 @@ class FigFillPicker extends HTMLElement {
1650
1617
  gradient: gradientToValueShape(this.#gradient),
1651
1618
  }),
1652
1619
  );
1620
+ this.#syncingGradientBar = false;
1653
1621
  }
1654
1622
 
1655
1623
  this.#updateSwatch();
@@ -1661,9 +1629,10 @@ class FigFillPicker extends HTMLElement {
1661
1629
  const list = this.#dialog.querySelector(
1662
1630
  ".fig-fill-picker-gradient-stops-list",
1663
1631
  );
1664
- if (!list) return;
1632
+ const reorder = list?.querySelector("fig-reorder");
1633
+ if (!list || !reorder) return;
1665
1634
 
1666
- const existingRows = list.querySelectorAll(
1635
+ const existingRows = reorder.querySelectorAll(
1667
1636
  ".fig-fill-picker-gradient-stop-row",
1668
1637
  );
1669
1638
 
@@ -1674,7 +1643,9 @@ class FigFillPicker extends HTMLElement {
1674
1643
  const posInput = row.querySelector(".fig-fill-picker-stop-position");
1675
1644
  if (posInput) posInput.setAttribute("value", stop.position);
1676
1645
  const colorInput = row.querySelector(".fig-fill-picker-stop-color");
1677
- if (colorInput) colorInput.setAttribute("value", stop.color);
1646
+ if (colorInput) {
1647
+ colorInput.setAttribute("value", this.#formatStopColorValue(stop));
1648
+ }
1678
1649
  const removeBtn = row.querySelector(".fig-fill-picker-stop-remove");
1679
1650
  if (removeBtn) {
1680
1651
  if (this.#gradient.stops.length <= 2)
@@ -1689,16 +1660,19 @@ class FigFillPicker extends HTMLElement {
1689
1660
  }
1690
1661
 
1691
1662
  #rebuildGradientStopsList(list) {
1692
- list.innerHTML = this.#gradient.stops
1663
+ const reorder = list.querySelector("fig-reorder");
1664
+ if (!reorder) return;
1665
+
1666
+ reorder.innerHTML = this.#gradient.stops
1693
1667
  .map(
1694
1668
  (stop, index) => `
1695
1669
  <fig-field class="fig-fill-picker-gradient-stop-row" data-index="${index}">
1696
1670
  <fig-input-number class="fig-fill-picker-stop-position" aria-label="Gradient stop position" min="0" max="100" value="${
1697
1671
  stop.position
1698
1672
  }" units="%"></fig-input-number>
1699
- <fig-input-color class="fig-fill-picker-stop-color" aria-label="Gradient stop color" text="true" alpha="true" picker="figma" picker-dialog-position="right" value="${
1700
- stop.color
1701
- }"></fig-input-color>
1673
+ <fig-input-color class="fig-fill-picker-stop-color" aria-label="Gradient stop color" text="true" alpha="true" picker="figma" picker-dialog-position="right" value="${this.#formatStopColorValue(
1674
+ stop,
1675
+ )}"></fig-input-color>
1702
1676
  <fig-button icon variant="ghost" class="fig-fill-picker-stop-remove" ${
1703
1677
  this.#gradient.stops.length <= 2 ? "disabled" : ""
1704
1678
  } aria-label="Remove gradient stop">
@@ -1709,16 +1683,13 @@ class FigFillPicker extends HTMLElement {
1709
1683
  )
1710
1684
  .join("");
1711
1685
 
1712
- list
1686
+ reorder
1713
1687
  .querySelectorAll(".fig-fill-picker-gradient-stop-row")
1714
1688
  .forEach((row) => {
1715
- const index = parseInt(row.dataset.index);
1716
-
1717
1689
  row
1718
1690
  .querySelector(".fig-fill-picker-stop-position")
1719
- .addEventListener("input", (e) => {
1720
- this.#gradient.stops[index].position =
1721
- parseFloat(e.target.value) || 0;
1691
+ .addEventListener("input", () => {
1692
+ this.#syncGradientStopRow(row);
1722
1693
  this.#updateGradientPreview();
1723
1694
  this.#emitInput();
1724
1695
  });
@@ -1734,20 +1705,24 @@ class FigFillPicker extends HTMLElement {
1734
1705
  });
1735
1706
  }
1736
1707
 
1737
- stopColor.addEventListener("input", (e) => {
1738
- this.#gradient.stops[index].color =
1739
- e.target.hexOpaque || e.target.value;
1740
- const a = e.detail?.rgba?.a;
1741
- if (a !== undefined) {
1742
- this.#gradient.stops[index].opacity = Math.round(a * 100);
1708
+ const syncStopColor = () => {
1709
+ this.#syncGradientStopRow(row);
1710
+ this.#syncingGradientBar = true;
1711
+ try {
1712
+ this.#updateGradientPreview();
1713
+ this.#emitInput();
1714
+ } finally {
1715
+ this.#syncingGradientBar = false;
1743
1716
  }
1744
- this.#updateGradientPreview();
1745
- this.#emitInput();
1746
- });
1717
+ };
1718
+
1719
+ stopColor.addEventListener("input", syncStopColor);
1720
+ stopColor.addEventListener("change", syncStopColor);
1747
1721
 
1748
1722
  row
1749
1723
  .querySelector(".fig-fill-picker-stop-remove")
1750
1724
  .addEventListener("click", () => {
1725
+ const index = parseInt(row.dataset.index, 10);
1751
1726
  if (this.#gradient.stops.length > 2) {
1752
1727
  this.#gradient.stops.splice(index, 1);
1753
1728
  this.#updateGradientUI();
@@ -2077,23 +2052,31 @@ class FigFillPicker extends HTMLElement {
2077
2052
  );
2078
2053
 
2079
2054
  const startWebcam = async (deviceId = null) => {
2055
+ this.#stopWebcam();
2056
+ const requestId = this.#webcamRequestId;
2080
2057
  try {
2081
2058
  const constraints = {
2082
2059
  video: deviceId ? { deviceId: { exact: deviceId } } : true,
2083
2060
  };
2084
2061
 
2085
- if (this.#webcam.stream) {
2086
- this.#webcam.stream.getTracks().forEach((track) => track.stop());
2062
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
2063
+ if (
2064
+ requestId !== this.#webcamRequestId ||
2065
+ !this.isConnected ||
2066
+ this.#activeTab !== "webcam"
2067
+ ) {
2068
+ stream.getTracks().forEach((track) => track.stop());
2069
+ return;
2087
2070
  }
2088
2071
 
2089
- this.#webcam.stream =
2090
- await navigator.mediaDevices.getUserMedia(constraints);
2091
- video.srcObject = this.#webcam.stream;
2072
+ this.#webcam.stream = stream;
2073
+ video.srcObject = stream;
2092
2074
  video.style.display = "block";
2093
2075
  status.style.display = "none";
2094
2076
 
2095
2077
  // Enumerate cameras
2096
2078
  const devices = await navigator.mediaDevices.enumerateDevices();
2079
+ if (requestId !== this.#webcamRequestId) return;
2097
2080
  const cameras = devices.filter((d) => d.kind === "videoinput");
2098
2081
 
2099
2082
  if (cameras.length > 1) {
@@ -2125,6 +2108,7 @@ class FigFillPicker extends HTMLElement {
2125
2108
  .forEach((option) => option.remove());
2126
2109
  }
2127
2110
  } catch (err) {
2111
+ if (requestId !== this.#webcamRequestId) return;
2128
2112
  console.error("Webcam error:", err.name, err.message);
2129
2113
  let message = "Camera access denied";
2130
2114
  if (err.name === "NotAllowedError") {
@@ -2138,21 +2122,15 @@ class FigFillPicker extends HTMLElement {
2138
2122
  } else if (!window.isSecureContext) {
2139
2123
  message = "Camera requires secure context";
2140
2124
  }
2141
- status.innerHTML = `<span>${message}</span>`;
2125
+ status.replaceChildren();
2126
+ const messageElement = document.createElement("span");
2127
+ messageElement.textContent = message;
2128
+ status.appendChild(messageElement);
2142
2129
  status.style.display = "flex";
2143
2130
  video.style.display = "none";
2144
2131
  }
2145
2132
  };
2146
-
2147
- this.#webcamTabObserver = new MutationObserver(() => {
2148
- if (container.style.display !== "none" && !this.#webcam.stream) {
2149
- startWebcam();
2150
- }
2151
- });
2152
- this.#webcamTabObserver.observe(container, {
2153
- attributes: true,
2154
- attributeFilter: ["style"],
2155
- });
2133
+ this.#webcamStart = startWebcam;
2156
2134
 
2157
2135
  cameraSelect.addEventListener("change", (e) => {
2158
2136
  startWebcam(e.target.value);
@@ -2548,6 +2526,15 @@ class FigFillPicker extends HTMLElement {
2548
2526
  case "disabled":
2549
2527
  this.#syncTriggerA11y();
2550
2528
  break;
2529
+ case "alpha":
2530
+ case "mode":
2531
+ case "experimental": {
2532
+ if (!this.#dialog) break;
2533
+ const wasOpen = this.#dialog.open;
2534
+ this.#discardDialog();
2535
+ if (wasOpen && this.isConnected) this.#openDialog();
2536
+ break;
2537
+ }
2551
2538
  case "aria-label":
2552
2539
  case "aria-labelledby":
2553
2540
  case "aria-describedby":