@rogieking/figui3 6.21.0 → 6.22.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,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, "&")
@@ -105,10 +111,13 @@ function parseGradientInterpolationSelectValue(val) {
105
111
  * @attr {boolean} alpha - Whether to show alpha/opacity controls (default: true)
106
112
  * @attr {string} dialog-position - Position of the popup (default: "left")
107
113
  */
114
+ let figFillPickerDialogId = 0;
115
+
108
116
  class FigFillPicker extends HTMLElement {
109
117
  #trigger = null;
110
118
  #swatch = null;
111
119
  #dialog = null;
120
+ #dialogId = `fig-fill-picker-dialog-${++figFillPickerDialogId}`;
112
121
  #activeTab = "solid";
113
122
  anchorElement = null;
114
123
 
@@ -147,10 +156,13 @@ class FigFillPicker extends HTMLElement {
147
156
  #teardownColorAreaEvents = null;
148
157
  #gradientInterpolationOpenObserver = null;
149
158
  #valueAtOpen = null;
159
+ #lastChangeValue = null;
150
160
  #webcamStart = null;
151
161
  #webcamRequestId = 0;
152
162
  #boundTriggerClick = null;
153
163
  #boundTriggerKeydown = null;
164
+ #rafIds = new Set();
165
+ #ownedBlobUrls = new Set();
154
166
 
155
167
  constructor() {
156
168
  super();
@@ -164,7 +176,6 @@ class FigFillPicker extends HTMLElement {
164
176
  "disabled",
165
177
  "alpha",
166
178
  "mode",
167
- "experimental",
168
179
  "aria-label",
169
180
  "aria-labelledby",
170
181
  "aria-describedby",
@@ -175,7 +186,7 @@ class FigFillPicker extends HTMLElement {
175
186
  // Use display: contents
176
187
  this.style.display = "contents";
177
188
 
178
- requestAnimationFrame(() => {
189
+ this.#scheduleFrame(() => {
179
190
  this.#setupTrigger();
180
191
  this.#parseValue();
181
192
  this.#updateSwatch();
@@ -184,19 +195,45 @@ class FigFillPicker extends HTMLElement {
184
195
 
185
196
  disconnectedCallback() {
186
197
  this.#discardDialog();
187
- if (this.#webcam.snapshot?.startsWith("blob:")) {
188
- URL.revokeObjectURL(this.#webcam.snapshot);
189
- if (this.#image.url === this.#webcam.snapshot) this.#image.url = null;
190
- this.#webcam.snapshot = null;
191
- }
192
- if (this.#video.url && this.#video.url.startsWith("blob:")) {
193
- URL.revokeObjectURL(this.#video.url);
194
- }
198
+ this.#cancelFrames();
199
+ this.#revokeOwnedBlobUrls();
195
200
  if (this.#swatch) this.#swatch.removeAttribute("selected");
196
201
  if (this.#trigger) {
197
202
  this.#trigger.removeEventListener("click", this.#boundTriggerClick);
198
203
  this.#trigger.removeEventListener("keydown", this.#boundTriggerKeydown);
199
204
  }
205
+ this.#trigger = null;
206
+ this.#swatch = null;
207
+ }
208
+
209
+ #isDisabled() {
210
+ return (
211
+ this.hasAttribute("disabled") &&
212
+ this.getAttribute("disabled") !== "false"
213
+ );
214
+ }
215
+
216
+ #scheduleFrame(callback) {
217
+ const id = requestAnimationFrame(() => {
218
+ this.#rafIds.delete(id);
219
+ if (this.isConnected) callback();
220
+ });
221
+ this.#rafIds.add(id);
222
+ return id;
223
+ }
224
+
225
+ #cancelFrames() {
226
+ this.#rafIds.forEach((id) => cancelAnimationFrame(id));
227
+ this.#rafIds.clear();
228
+ }
229
+
230
+ #revokeOwnedBlobUrls() {
231
+ this.#ownedBlobUrls.forEach((url) => URL.revokeObjectURL(url));
232
+ this.#ownedBlobUrls.clear();
233
+ if (this.#webcam.snapshot?.startsWith("blob:")) {
234
+ if (this.#image.url === this.#webcam.snapshot) this.#image.url = null;
235
+ this.#webcam.snapshot = null;
236
+ }
200
237
  }
201
238
 
202
239
  #setupTrigger() {
@@ -228,7 +265,7 @@ class FigFillPicker extends HTMLElement {
228
265
 
229
266
  // Prevent the swatch's internal color input from opening system picker
230
267
  if (this.#swatch) {
231
- requestAnimationFrame(() => {
268
+ this.#scheduleFrame(() => {
232
269
  const input = this.#swatch.querySelector('input[type="color"]');
233
270
  if (input) {
234
271
  input.remove();
@@ -244,11 +281,15 @@ class FigFillPicker extends HTMLElement {
244
281
 
245
282
  #syncTriggerA11y() {
246
283
  if (!this.#trigger) return;
247
- const disabled = this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
284
+ const disabled = this.#isDisabled();
248
285
  const labelledBy = this.getAttribute("aria-labelledby");
249
286
  if (!this.#trigger.hasAttribute("role")) this.#trigger.setAttribute("role", "button");
250
287
  this.#trigger.setAttribute("tabindex", disabled ? "-1" : "0");
251
288
  this.#trigger.setAttribute("aria-disabled", disabled ? "true" : "false");
289
+ this.#trigger.setAttribute("aria-haspopup", "dialog");
290
+ this.#trigger.setAttribute("aria-expanded", this.#dialog?.open ? "true" : "false");
291
+ this.#trigger.setAttribute("aria-controls", this.#dialogId);
292
+ this.#trigger.removeAttribute("aria-hidden");
252
293
  if (labelledBy) {
253
294
  this.#trigger.setAttribute("aria-labelledby", labelledBy);
254
295
  this.#trigger.removeAttribute("aria-label");
@@ -267,11 +308,7 @@ class FigFillPicker extends HTMLElement {
267
308
  }
268
309
 
269
310
  #handleTriggerClick(e) {
270
- if (
271
- this.hasAttribute("disabled") &&
272
- this.getAttribute("disabled") !== "false"
273
- )
274
- return;
311
+ if (this.#isDisabled()) return;
275
312
  e.stopPropagation();
276
313
  e.preventDefault();
277
314
  this.#openDialog();
@@ -279,11 +316,7 @@ class FigFillPicker extends HTMLElement {
279
316
 
280
317
  #handleTriggerKeydown(e) {
281
318
  if (e.key !== "Enter" && e.key !== " ") return;
282
- if (
283
- this.hasAttribute("disabled") &&
284
- this.getAttribute("disabled") !== "false"
285
- )
286
- return;
319
+ if (this.#isDisabled()) return;
287
320
  e.preventDefault();
288
321
  e.stopPropagation();
289
322
  this.#openDialog();
@@ -309,9 +342,15 @@ class FigFillPicker extends HTMLElement {
309
342
  this.#color = parsed.color;
310
343
  }
311
344
  }
312
- // Parse opacity (0-100) and convert to alpha (0-1)
313
- if (parsed.opacity !== undefined) {
314
- this.#color.a = parsed.opacity / 100;
345
+ // `alpha` is canonical (0-1); retain `opacity` (0-100) compatibility.
346
+ const parsedAlpha =
347
+ parsed.alpha !== undefined
348
+ ? Number(parsed.alpha)
349
+ : parsed.opacity !== undefined
350
+ ? Number(parsed.opacity) / 100
351
+ : undefined;
352
+ if (Number.isFinite(parsedAlpha)) {
353
+ this.#color.a = Math.max(0, Math.min(1, parsedAlpha));
315
354
  }
316
355
  // Gamut UI hidden for now — lock to sRGB.
317
356
  this.#gamut = "srgb";
@@ -410,19 +449,26 @@ class FigFillPicker extends HTMLElement {
410
449
  }
411
450
 
412
451
  #openDialog() {
452
+ if (this.#isDisabled()) return;
413
453
  if (!this.#dialog) {
414
454
  this.#createDialog();
415
455
  }
416
456
 
417
457
  this.#valueAtOpen = JSON.stringify(this.value);
458
+ this.#lastChangeValue = this.#valueAtOpen;
418
459
  this.#switchTab(this.#fillType, { emit: false });
419
460
 
420
461
  if (this.#swatch) this.#swatch.setAttribute("selected", "true");
421
462
 
422
463
  this.#dialog.open = true;
464
+ this.#syncTriggerA11y();
423
465
 
424
- requestAnimationFrame(() => {
425
- requestAnimationFrame(() => {
466
+ this.#scheduleFrame(() => {
467
+ const focusTarget = this.#dialog?.querySelector(
468
+ ".fig-fill-picker-close, .fig-fill-picker-type, fig-button, button, input, [tabindex='0']",
469
+ );
470
+ focusTarget?.focus?.();
471
+ this.#scheduleFrame(() => {
426
472
  this.#drawColorArea();
427
473
  this.#updateHandlePosition();
428
474
  });
@@ -434,7 +480,10 @@ class FigFillPicker extends HTMLElement {
434
480
  }
435
481
 
436
482
  close() {
437
- if (this.#dialog) this.#dialog.open = false;
483
+ if (this.#dialog) {
484
+ this.#dialog.open = false;
485
+ this.#syncTriggerA11y();
486
+ }
438
487
  }
439
488
 
440
489
  #restoreCustomSlotContent() {
@@ -462,6 +511,13 @@ class FigFillPicker extends HTMLElement {
462
511
  this.#dialog = null;
463
512
  this.#webcamStart = null;
464
513
  this.#valueAtOpen = null;
514
+ this.#lastChangeValue = null;
515
+ this.#colorArea = null;
516
+ this.#colorAreaHandle = null;
517
+ this.#hueSlider = null;
518
+ this.#opacitySlider = null;
519
+ this.#syncingGradientBar = false;
520
+ this.#syncTriggerA11y();
465
521
  }
466
522
 
467
523
  #stopWebcam() {
@@ -491,6 +547,8 @@ class FigFillPicker extends HTMLElement {
491
547
 
492
548
  this.#dialog = document.createElement("dialog", { is: "fig-popup" });
493
549
  this.#dialog.setAttribute("is", "fig-popup");
550
+ this.#dialog.id = this.#dialogId;
551
+ this.#dialog.setAttribute("aria-label", this.#triggerLabel());
494
552
  this.#dialog.setAttribute("drag", "true");
495
553
  this.#dialog.setAttribute("handle", "fig-header");
496
554
  this.#dialog.setAttribute("autoresize", "false");
@@ -613,13 +671,19 @@ class FigFillPicker extends HTMLElement {
613
671
  const onDialogClose = () => {
614
672
  if (this.#swatch) this.#swatch.removeAttribute("selected");
615
673
  this.#stopWebcam();
616
- if (
617
- this.#valueAtOpen !== null &&
618
- this.#valueAtOpen !== JSON.stringify(this.value)
619
- ) {
674
+ const closingValue = JSON.stringify(this.value);
675
+ if (this.#lastChangeValue !== null && this.#lastChangeValue !== closingValue) {
620
676
  this.#emitChange();
621
677
  }
622
678
  this.#valueAtOpen = null;
679
+ this.#lastChangeValue = null;
680
+ this.#syncTriggerA11y();
681
+ const returnTarget = this.#trigger;
682
+ this.#scheduleFrame(() => {
683
+ if (returnTarget?.isConnected) {
684
+ HTMLElement.prototype.focus.call(returnTarget);
685
+ }
686
+ });
623
687
  this.dispatchEvent(new CustomEvent("close"));
624
688
  };
625
689
  this.#dialog.addEventListener("close", onDialogClose);
@@ -697,11 +761,11 @@ class FigFillPicker extends HTMLElement {
697
761
  // Update tab-specific UI after visibility change
698
762
  if (tabName === "gradient") {
699
763
  // Use RAF to ensure layout is complete before updating angle input
700
- requestAnimationFrame(() => {
764
+ this.#scheduleFrame(() => {
701
765
  this.#updateGradientUI();
702
766
  const barInput = tab.querySelector(".fig-fill-picker-gradient-bar-input");
703
767
  barInput?.refreshLayout?.();
704
- requestAnimationFrame(() => {
768
+ this.#scheduleFrame(() => {
705
769
  barInput?.refreshLayout?.();
706
770
  });
707
771
  });
@@ -713,6 +777,40 @@ class FigFillPicker extends HTMLElement {
713
777
  if (emit) this.#emitInput();
714
778
  }
715
779
 
780
+ #refreshDialogUI() {
781
+ if (!this.#dialog?.open) return;
782
+ this.#switchTab(this.#fillType, { emit: false });
783
+
784
+ this.#drawColorArea();
785
+ this.#updateHandlePosition();
786
+ this.#updateColorInputs();
787
+ if (this.#hueSlider) this.#hueSlider.setAttribute("value", this.#color.h);
788
+ if (this.#opacitySlider) {
789
+ this.#opacitySlider.setAttribute("value", this.#color.a * 100);
790
+ this.#opacitySlider.setAttribute("color", this.#hsvToHex(this.#color));
791
+ }
792
+
793
+ this.#updateGradientUI();
794
+
795
+ const imageTab = this.#dialog.querySelector('[data-tab="image"]');
796
+ const imageMode = imageTab?.querySelector(".fig-fill-picker-scale-mode");
797
+ const imageScale = imageTab?.querySelector(".fig-fill-picker-scale");
798
+ const imagePreview = imageTab?.querySelector(".fig-fill-picker-image-preview");
799
+ if (imageMode) imageMode.value = this.#image.scaleMode;
800
+ if (imageScale) {
801
+ imageScale.setAttribute("value", this.#image.scale);
802
+ imageScale.style.display =
803
+ this.#image.scaleMode === "tile" ? "block" : "none";
804
+ }
805
+ if (imagePreview) this.#updateImagePreview(imagePreview);
806
+
807
+ const videoTab = this.#dialog.querySelector('[data-tab="video"]');
808
+ const videoMode = videoTab?.querySelector(".fig-fill-picker-scale-mode");
809
+ const videoPreview = videoTab?.querySelector(".fig-fill-picker-video-preview");
810
+ if (videoMode) videoMode.value = this.#video.scaleMode;
811
+ if (videoPreview) this.#updateVideoPreviewStyle(videoPreview);
812
+ }
813
+
716
814
  // ============ SOLID TAB ============
717
815
  #initSolidTab() {
718
816
  const container = this.#dialog.querySelector('[data-tab="solid"]');
@@ -723,6 +821,9 @@ class FigFillPicker extends HTMLElement {
723
821
  <canvas width="200" height="200"></canvas>
724
822
  <fig-handle
725
823
  aria-label="Color saturation and brightness"
824
+ role="slider"
825
+ aria-valuemin="0"
826
+ aria-valuemax="100"
726
827
  type="color"
727
828
  color="${this.#hsvToHex({ ...this.#color, a: 1 })}"
728
829
  data-no-color-picker
@@ -764,6 +865,7 @@ class FigFillPicker extends HTMLElement {
764
865
  // Setup color area
765
866
  this.#colorArea = container.querySelector("canvas");
766
867
  this.#colorAreaHandle = container.querySelector("fig-handle");
868
+ this.#syncColorAreaA11y();
767
869
  this.#drawColorArea();
768
870
  this.#updateHandlePosition();
769
871
  this.#setupColorAreaEvents();
@@ -894,12 +996,13 @@ class FigFillPicker extends HTMLElement {
894
996
 
895
997
  #updateHandlePosition(retryCount = 0) {
896
998
  if (!this.#colorAreaHandle || !this.#colorArea) return;
999
+ this.#syncColorAreaA11y();
897
1000
 
898
1001
  const rect = this.#colorArea.getBoundingClientRect();
899
1002
 
900
1003
  // If the canvas isn't visible yet (0 dimensions), schedule a retry (max 5 attempts)
901
1004
  if ((rect.width === 0 || rect.height === 0) && retryCount < 5) {
902
- requestAnimationFrame(() => this.#updateHandlePosition(retryCount + 1));
1005
+ this.#scheduleFrame(() => this.#updateHandlePosition(retryCount + 1));
903
1006
  return;
904
1007
  }
905
1008
 
@@ -913,6 +1016,19 @@ class FigFillPicker extends HTMLElement {
913
1016
  );
914
1017
  }
915
1018
 
1019
+ #syncColorAreaA11y() {
1020
+ if (!this.#colorAreaHandle) return;
1021
+ this.#colorAreaHandle.setAttribute(
1022
+ "aria-valuenow",
1023
+ String(Math.round(this.#color.v)),
1024
+ );
1025
+ this.#colorAreaHandle.setAttribute(
1026
+ "aria-valuetext",
1027
+ `Saturation ${Math.round(this.#color.s)}%, brightness ${Math.round(this.#color.v)}%`,
1028
+ );
1029
+ this.#colorAreaHandle.removeAttribute("aria-pressed");
1030
+ }
1031
+
916
1032
  #updateColorFromAreaPosition(x, y, opts = {}) {
917
1033
  const { updateHandle = true, emitInput = true, emitChange = false } = opts;
918
1034
  this.#color.s = Math.max(0, Math.min(100, x * 100));
@@ -922,6 +1038,7 @@ class FigFillPicker extends HTMLElement {
922
1038
  "color",
923
1039
  this.#hsvToHex({ ...this.#color, a: 1 }),
924
1040
  );
1041
+ this.#syncColorAreaA11y();
925
1042
  }
926
1043
  if (updateHandle) this.#updateHandlePosition();
927
1044
  this.#updateColorInputs();
@@ -1104,7 +1221,7 @@ class FigFillPicker extends HTMLElement {
1104
1221
 
1105
1222
  container.innerHTML = html;
1106
1223
  this.#wireColorInputEvents();
1107
- requestAnimationFrame(() => this.#updateColorInputs());
1224
+ this.#scheduleFrame(() => this.#updateColorInputs());
1108
1225
  }
1109
1226
 
1110
1227
  #wireColorInputEvents() {
@@ -1913,7 +2030,7 @@ class FigFillPicker extends HTMLElement {
1913
2030
  if (stopFillPicker) {
1914
2031
  stopFillPicker.anchorElement = this.#dialog;
1915
2032
  } else {
1916
- requestAnimationFrame(() => {
2033
+ this.#scheduleFrame(() => {
1917
2034
  const fp = stopColor.querySelector("fig-fill-picker");
1918
2035
  if (fp) fp.anchorElement = this.#dialog;
1919
2036
  });
@@ -2018,9 +2135,6 @@ class FigFillPicker extends HTMLElement {
2018
2135
  }" units="%" ${
2019
2136
  this.#image.scaleMode === "tile" ? "" : 'style="display: none;"'
2020
2137
  }></fig-input-number>
2021
- <fig-button class="fig-fill-picker-media-rotate" icon variant="ghost" aria-label="Rotate">
2022
- <fig-icon name="rotate"></fig-icon>
2023
- </fig-button>
2024
2138
  </fig-field>
2025
2139
  <fig-image class="fig-fill-picker-media-preview fig-fill-picker-image-preview" upload="true" label="Upload from computer" alt="Image fill preview" size="auto" aspect-ratio="1/1" fit="cover" checkerboard="true"></fig-image>
2026
2140
  `;
@@ -2181,9 +2295,6 @@ class FigFillPicker extends HTMLElement {
2181
2295
  <fig-select-option value="crop">Crop</fig-select-option>
2182
2296
  </fig-select-options>
2183
2297
  </fig-select>
2184
- <fig-button class="fig-fill-picker-media-rotate" icon variant="ghost" aria-label="Rotate">
2185
- <fig-icon name="rotate"></fig-icon>
2186
- </fig-button>
2187
2298
  </fig-field>
2188
2299
  <fig-media class="fig-fill-picker-media-preview fig-fill-picker-video-preview" type="video" upload="true" label="Upload from computer" aria-label="Video fill preview" size="auto" aspect-ratio="1/1" fit="cover" checkerboard="true" autoplay="true" controls muted="true" loop="true"></fig-media>
2189
2300
  `;
@@ -2240,12 +2351,12 @@ class FigFillPicker extends HTMLElement {
2240
2351
  </fig-field>
2241
2352
  <fig-video class="fig-fill-picker-webcam-preview" aria-label="Webcam preview" aspect-ratio="1/1" fit="cover" checkerboard="true" autoplay="true" muted="true">
2242
2353
  <video class="fig-fill-picker-webcam-video" autoplay muted playsinline></video>
2243
- <div class="fig-fill-picker-webcam-status">
2354
+ <div class="fig-fill-picker-webcam-status" role="status" aria-live="polite">
2244
2355
  <span>Camera access required</span>
2245
2356
  </div>
2246
2357
  </fig-video>
2247
2358
  <div class="fig-fill-picker-webcam-controls">
2248
- <fig-button class="fig-fill-picker-webcam-capture" variant="secondary" full>
2359
+ <fig-button class="fig-fill-picker-webcam-capture" variant="secondary" full disabled>
2249
2360
  Capture
2250
2361
  </fig-button>
2251
2362
  </div>
@@ -2266,10 +2377,31 @@ class FigFillPicker extends HTMLElement {
2266
2377
  const cameraSelect = container.querySelector(
2267
2378
  ".fig-fill-picker-camera-select",
2268
2379
  );
2380
+ const setCaptureReady = (ready) => {
2381
+ if (ready) captureBtn.removeAttribute("disabled");
2382
+ else captureBtn.setAttribute("disabled", "");
2383
+ };
2384
+ const updateFrameReadiness = () => {
2385
+ const ready =
2386
+ !!this.#webcam.stream &&
2387
+ video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
2388
+ video.videoWidth > 0 &&
2389
+ video.videoHeight > 0;
2390
+ setCaptureReady(ready);
2391
+ if (ready) {
2392
+ status.querySelector("span").textContent = "Camera ready";
2393
+ status.style.display = "none";
2394
+ }
2395
+ };
2396
+ video.addEventListener("loadedmetadata", updateFrameReadiness);
2397
+ video.addEventListener("canplay", updateFrameReadiness);
2269
2398
 
2270
2399
  const startWebcam = async (deviceId = null) => {
2271
2400
  this.#stopWebcam();
2272
2401
  const requestId = this.#webcamRequestId;
2402
+ setCaptureReady(false);
2403
+ status.querySelector("span").textContent = "Starting camera";
2404
+ status.style.display = "flex";
2273
2405
  try {
2274
2406
  const constraints = {
2275
2407
  video: deviceId ? { deviceId: { exact: deviceId } } : true,
@@ -2288,7 +2420,7 @@ class FigFillPicker extends HTMLElement {
2288
2420
  this.#webcam.stream = stream;
2289
2421
  video.srcObject = stream;
2290
2422
  video.style.display = "block";
2291
- status.style.display = "none";
2423
+ updateFrameReadiness();
2292
2424
 
2293
2425
  // Enumerate cameras
2294
2426
  const devices = await navigator.mediaDevices.enumerateDevices();
@@ -2347,6 +2479,7 @@ class FigFillPicker extends HTMLElement {
2347
2479
  status.appendChild(messageElement);
2348
2480
  status.style.display = "flex";
2349
2481
  video.style.display = "none";
2482
+ setCaptureReady(false);
2350
2483
  }
2351
2484
  };
2352
2485
  this.#webcamStart = startWebcam;
@@ -2373,8 +2506,10 @@ class FigFillPicker extends HTMLElement {
2373
2506
 
2374
2507
  if (this.#webcam.snapshot?.startsWith("blob:")) {
2375
2508
  URL.revokeObjectURL(this.#webcam.snapshot);
2509
+ this.#ownedBlobUrls.delete(this.#webcam.snapshot);
2376
2510
  }
2377
2511
  this.#webcam.snapshot = URL.createObjectURL(blob);
2512
+ this.#ownedBlobUrls.add(this.#webcam.snapshot);
2378
2513
  this.#image.url = this.#webcam.snapshot;
2379
2514
 
2380
2515
  const imagePreview = this.#dialog.querySelector(
@@ -2701,6 +2836,7 @@ class FigFillPicker extends HTMLElement {
2701
2836
 
2702
2837
  // ============ EVENT EMITTERS ============
2703
2838
  #emitInput() {
2839
+ if (this.#isDisabled()) return;
2704
2840
  this.#updateSwatch();
2705
2841
  this.dispatchEvent(
2706
2842
  new CustomEvent("input", {
@@ -2711,6 +2847,8 @@ class FigFillPicker extends HTMLElement {
2711
2847
  }
2712
2848
 
2713
2849
  #emitChange() {
2850
+ if (this.#isDisabled()) return;
2851
+ this.#lastChangeValue = JSON.stringify(this.value);
2714
2852
  this.dispatchEvent(
2715
2853
  new CustomEvent("change", {
2716
2854
  bubbles: true,
@@ -2772,33 +2910,17 @@ class FigFillPicker extends HTMLElement {
2772
2910
  case "value":
2773
2911
  this.#parseValue();
2774
2912
  this.#updateSwatch();
2775
- if (this.#dialog) {
2776
- // Update dialog UI if open - but don't rebuild if user is dragging
2777
- if (!this.#isDraggingColor) {
2778
- // Just update the handle position and color inputs without rebuilding
2779
- this.#updateHandlePosition();
2780
- this.#updateColorInputs();
2781
- // Update hue slider
2782
- if (this.#hueSlider) {
2783
- this.#hueSlider.setAttribute("value", this.#color.h);
2784
- }
2785
- // Update opacity slider
2786
- if (this.#opacitySlider) {
2787
- this.#opacitySlider.setAttribute("value", this.#color.a * 100);
2788
- this.#opacitySlider.setAttribute(
2789
- "color",
2790
- this.#hsvToHex(this.#color),
2791
- );
2792
- }
2793
- }
2913
+ if (this.#dialog?.open && !this.#isDraggingColor) {
2914
+ this.#refreshDialogUI();
2915
+ this.#lastChangeValue = JSON.stringify(this.value);
2794
2916
  }
2795
2917
  break;
2796
2918
  case "disabled":
2797
2919
  this.#syncTriggerA11y();
2920
+ if (this.#isDisabled() && this.#dialog?.open) this.close();
2798
2921
  break;
2799
2922
  case "alpha":
2800
- case "mode":
2801
- case "experimental": {
2923
+ case "mode": {
2802
2924
  if (!this.#dialog) break;
2803
2925
  const wasOpen = this.#dialog.open;
2804
2926
  this.#discardDialog();
@@ -2806,6 +2928,9 @@ class FigFillPicker extends HTMLElement {
2806
2928
  break;
2807
2929
  }
2808
2930
  case "aria-label":
2931
+ if (this.#dialog) {
2932
+ this.#dialog.setAttribute("aria-label", this.#triggerLabel());
2933
+ }
2809
2934
  case "aria-labelledby":
2810
2935
  case "aria-describedby":
2811
2936
  this.#syncTriggerA11y();
@@ -2813,4 +2938,4 @@ class FigFillPicker extends HTMLElement {
2813
2938
  }
2814
2939
  }
2815
2940
  }
2816
- customElements.define("fig-fill-picker", FigFillPicker);
2941
+ figEditorDefineElement("fig-fill-picker", FigFillPicker);