@rogieking/figui3 6.4.5 → 6.4.7

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,3 +1,244 @@
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;
15
+ }
16
+
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
+
1
242
  // FigFillPicker
2
243
  const GRADIENT_INTERPOLATION_SPACES = [
3
244
  "srgb",
@@ -16,13 +257,10 @@ const GRADIENT_HUE_INTERPOLATIONS = [
16
257
  function normalizeGradientConfig(gradient) {
17
258
  const next = { ...(gradient ?? {}) };
18
259
  let interpolationSpace = String(
19
- next.interpolationSpace ?? "oklab",
260
+ next.interpolationSpace ?? "srgb",
20
261
  ).toLowerCase();
21
262
  if (!GRADIENT_INTERPOLATION_SPACES.includes(interpolationSpace)) {
22
- interpolationSpace = "oklab";
23
- }
24
- if (interpolationSpace === "srgb" || interpolationSpace === "display-p3") {
25
- interpolationSpace = "oklab";
263
+ interpolationSpace = "srgb";
26
264
  }
27
265
  next.interpolationSpace = interpolationSpace;
28
266
 
@@ -51,6 +289,9 @@ function gradientToValueShape(gradient) {
51
289
 
52
290
  function gradientInterpolationClause(gradient) {
53
291
  const normalized = normalizeGradientConfig(gradient);
292
+ if (normalized.interpolationSpace === "srgb") {
293
+ return "";
294
+ }
54
295
  if (normalized.interpolationSpace === "oklch") {
55
296
  return `in oklch ${normalized.hueInterpolation} hue`;
56
297
  }
@@ -83,7 +324,7 @@ class FigFillPicker extends HTMLElement {
83
324
  angle: 0,
84
325
  centerX: 50,
85
326
  centerY: 50,
86
- interpolationSpace: "oklab",
327
+ interpolationSpace: "srgb",
87
328
  hueInterpolation: "shorter",
88
329
  stops: [
89
330
  { position: 0, color: "#D9D9D9", opacity: 100 },
@@ -107,13 +348,26 @@ class FigFillPicker extends HTMLElement {
107
348
  #teardownColorAreaEvents = null;
108
349
  #dialogOpenObserver = null;
109
350
  #webcamTabObserver = null;
351
+ #boundTriggerClick = null;
352
+ #boundTriggerKeydown = null;
110
353
 
111
354
  constructor() {
112
355
  super();
356
+ this.#boundTriggerClick = this.#handleTriggerClick.bind(this);
357
+ this.#boundTriggerKeydown = this.#handleTriggerKeydown.bind(this);
113
358
  }
114
359
 
115
360
  static get observedAttributes() {
116
- return ["value", "disabled", "alpha", "mode", "experimental"];
361
+ return [
362
+ "value",
363
+ "disabled",
364
+ "alpha",
365
+ "mode",
366
+ "experimental",
367
+ "aria-label",
368
+ "aria-labelledby",
369
+ "aria-describedby",
370
+ ];
117
371
  }
118
372
 
119
373
  connectedCallback() {
@@ -152,6 +406,10 @@ class FigFillPicker extends HTMLElement {
152
406
  URL.revokeObjectURL(this.#video.url);
153
407
  }
154
408
  if (this.#chit) this.#chit.removeAttribute("selected");
409
+ if (this.#trigger) {
410
+ this.#trigger.removeEventListener("click", this.#boundTriggerClick);
411
+ this.#trigger.removeEventListener("keydown", this.#boundTriggerKeydown);
412
+ }
155
413
  if (this.#dialog) {
156
414
  this.#dialog.close();
157
415
  this.#dialog.remove();
@@ -180,24 +438,67 @@ class FigFillPicker extends HTMLElement {
180
438
  this.#chit = null;
181
439
  }
182
440
 
183
- this.#trigger.addEventListener("click", (e) => {
184
- if (this.hasAttribute("disabled")) return;
185
- e.stopPropagation();
186
- e.preventDefault();
187
- this.#openDialog();
188
- });
441
+ this.#syncTriggerA11y();
442
+ this.#trigger.removeEventListener("click", this.#boundTriggerClick);
443
+ this.#trigger.addEventListener("click", this.#boundTriggerClick);
444
+ this.#trigger.removeEventListener("keydown", this.#boundTriggerKeydown);
445
+ this.#trigger.addEventListener("keydown", this.#boundTriggerKeydown);
189
446
 
190
447
  // Prevent fig-chit's internal color input from opening system picker
191
448
  if (this.#chit) {
192
449
  requestAnimationFrame(() => {
193
450
  const input = this.#chit.querySelector('input[type="color"]');
194
451
  if (input) {
195
- input.style.pointerEvents = "none";
452
+ input.remove();
196
453
  }
454
+ this.#syncTriggerA11y();
197
455
  });
198
456
  }
199
457
  }
200
458
 
459
+ #triggerLabel() {
460
+ return this.getAttribute("aria-label") || "Fill picker";
461
+ }
462
+
463
+ #syncTriggerA11y() {
464
+ if (!this.#trigger) return;
465
+ const disabled = this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
466
+ const labelledBy = this.getAttribute("aria-labelledby");
467
+ if (!this.#trigger.hasAttribute("role")) this.#trigger.setAttribute("role", "button");
468
+ this.#trigger.setAttribute("tabindex", disabled ? "-1" : "0");
469
+ this.#trigger.setAttribute("aria-disabled", disabled ? "true" : "false");
470
+ if (labelledBy) {
471
+ this.#trigger.setAttribute("aria-labelledby", labelledBy);
472
+ this.#trigger.removeAttribute("aria-label");
473
+ } else if (this.hasAttribute("aria-label")) {
474
+ this.#trigger.setAttribute("aria-label", `Open ${this.#triggerLabel()}`);
475
+ this.#trigger.removeAttribute("aria-labelledby");
476
+ } else {
477
+ this.#trigger.removeAttribute("aria-labelledby");
478
+ if (!this.#trigger.hasAttribute("aria-label")) {
479
+ this.#trigger.setAttribute("aria-label", `Open ${this.#triggerLabel()}`);
480
+ }
481
+ }
482
+ const describedBy = this.getAttribute("aria-describedby");
483
+ if (describedBy) this.#trigger.setAttribute("aria-describedby", describedBy);
484
+ else this.#trigger.removeAttribute("aria-describedby");
485
+ }
486
+
487
+ #handleTriggerClick(e) {
488
+ if (this.hasAttribute("disabled")) return;
489
+ e.stopPropagation();
490
+ e.preventDefault();
491
+ this.#openDialog();
492
+ }
493
+
494
+ #handleTriggerKeydown(e) {
495
+ if (e.key !== "Enter" && e.key !== " ") return;
496
+ if (this.hasAttribute("disabled")) return;
497
+ e.preventDefault();
498
+ e.stopPropagation();
499
+ this.#openDialog();
500
+ }
501
+
201
502
  #parseValue() {
202
503
  const valueAttr = this.getAttribute("value");
203
504
  if (!valueAttr) return;
@@ -417,7 +718,7 @@ class FigFillPicker extends HTMLElement {
417
718
  const options = allowedModes
418
719
  .map((m) => `<option value="${m}">${modeLabels[m]}</option>`)
419
720
  .join("\n ");
420
- headerContent = `<fig-dropdown class="fig-fill-picker-type" ${expAttr} value="${this.#fillType}">
721
+ headerContent = `<fig-dropdown class="fig-fill-picker-type" label="Fill type" ${expAttr} value="${this.#fillType}">
421
722
  ${options}
422
723
  </fig-dropdown>`;
423
724
  }
@@ -427,7 +728,7 @@ class FigFillPicker extends HTMLElement {
427
728
  .map((m) => `<div class="fig-fill-picker-tab" data-tab="${m}"></div>`)
428
729
  .join("\n ");
429
730
 
430
- const gamutDropdown = `<fig-dropdown class="fig-fill-picker-gamut" ${expAttr} value="${this.#gamut}">
731
+ const gamutDropdown = `<fig-dropdown class="fig-fill-picker-gamut" label="Color gamut" ${expAttr} value="${this.#gamut}">
431
732
  <option value="srgb">sRGB</option>
432
733
  <option value="display-p3">Display P3</option>
433
734
  </fig-dropdown>`;
@@ -436,7 +737,7 @@ class FigFillPicker extends HTMLElement {
436
737
  <fig-header>
437
738
  ${headerContent}
438
739
  ${gamutDropdown}
439
- <fig-button icon variant="ghost" class="fig-fill-picker-close">
740
+ <fig-button icon variant="ghost" class="fig-fill-picker-close" aria-label="Close fill picker">
440
741
  <fig-icon name="close"></fig-icon>
441
742
  </fig-button>
442
743
  </fig-header>
@@ -604,6 +905,7 @@ class FigFillPicker extends HTMLElement {
604
905
  <fig-preview class="fig-fill-picker-color-area">
605
906
  <canvas width="200" height="200"></canvas>
606
907
  <fig-handle
908
+ aria-label="Color saturation and brightness"
607
909
  type="color"
608
910
  color="${this.#hsvToHex({ ...this.#color, a: 1 })}"
609
911
  data-no-color-picker
@@ -614,20 +916,20 @@ class FigFillPicker extends HTMLElement {
614
916
  ></fig-handle>
615
917
  </fig-preview>
616
918
  <div class="fig-fill-picker-sliders">
617
- <fig-tooltip text="Sample color"><fig-button icon variant="ghost" class="fig-fill-picker-eyedropper"><fig-icon name="eyedropper"></fig-icon></fig-button></fig-tooltip>
618
- <fig-slider type="hue" text="false" min="0" max="360" value="${
919
+ <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>
920
+ <fig-slider type="hue" text="false" min="0" max="360" aria-label="Hue" value="${
619
921
  this.#color.h
620
922
  }"></fig-slider>
621
923
  ${
622
924
  showAlpha
623
- ? `<fig-slider type="opacity" text="true" units="%" min="0" max="100" value="${
925
+ ? `<fig-slider type="opacity" text="true" units="%" min="0" max="100" aria-label="Opacity" value="${
624
926
  this.#color.a * 100
625
927
  }" color="${this.#hsvToHex(this.#color)}"></fig-slider>`
626
928
  : ""
627
929
  }
628
930
  </div>
629
931
  <fig-field class="fig-fill-picker-inputs">
630
- <fig-dropdown class="fig-fill-picker-input-mode" ${expAttr} value="${this.#colorInputMode}">
932
+ <fig-dropdown class="fig-fill-picker-input-mode" label="Color value format" ${expAttr} value="${this.#colorInputMode}">
631
933
  <option value="hex">Hex</option>
632
934
  <option value="rgb">RGB</option>
633
935
  <option value="hsl">HSL</option>
@@ -910,48 +1212,48 @@ class FigFillPicker extends HTMLElement {
910
1212
  const wrap = (tooltip, html) =>
911
1213
  `<fig-tooltip text="${tooltip}">${html}</fig-tooltip>`;
912
1214
 
913
- const num = (cls, min, max, step) =>
914
- `<fig-input-number class="${cls}" min="${min}" max="${max}"${step != null ? ` step="${step}"` : ""}></fig-input-number>`;
1215
+ const num = (cls, label, min, max, step) =>
1216
+ `<fig-input-number class="${cls}" aria-label="${label}" min="${min}" max="${max}"${step != null ? ` step="${step}"` : ""}></fig-input-number>`;
915
1217
 
916
1218
  let html;
917
1219
  switch (this.#colorInputMode) {
918
1220
  case "rgb":
919
1221
  html = `<div class="input-combo">
920
- ${wrap("Red", num("fig-fill-picker-ci-r", 0, 255))}
921
- ${wrap("Green", num("fig-fill-picker-ci-g", 0, 255))}
922
- ${wrap("Blue", num("fig-fill-picker-ci-b", 0, 255))}
1222
+ ${wrap("Red", num("fig-fill-picker-ci-r", "Red", 0, 255))}
1223
+ ${wrap("Green", num("fig-fill-picker-ci-g", "Green", 0, 255))}
1224
+ ${wrap("Blue", num("fig-fill-picker-ci-b", "Blue", 0, 255))}
923
1225
  </div>`;
924
1226
  break;
925
1227
  case "hsl":
926
1228
  html = `<div class="input-combo">
927
- ${wrap("Hue", num("fig-fill-picker-ci-h", 0, 360))}
928
- ${wrap("Saturation", num("fig-fill-picker-ci-s", 0, 100))}
929
- ${wrap("Lightness", num("fig-fill-picker-ci-l", 0, 100))}
1229
+ ${wrap("Hue", num("fig-fill-picker-ci-h", "Hue", 0, 360))}
1230
+ ${wrap("Saturation", num("fig-fill-picker-ci-s", "Saturation", 0, 100))}
1231
+ ${wrap("Lightness", num("fig-fill-picker-ci-l", "Lightness", 0, 100))}
930
1232
  </div>`;
931
1233
  break;
932
1234
  case "hsb":
933
1235
  html = `<div class="input-combo">
934
- ${wrap("Hue", num("fig-fill-picker-ci-h", 0, 360))}
935
- ${wrap("Saturation", num("fig-fill-picker-ci-s", 0, 100))}
936
- ${wrap("Brightness", num("fig-fill-picker-ci-v", 0, 100))}
1236
+ ${wrap("Hue", num("fig-fill-picker-ci-h", "Hue", 0, 360))}
1237
+ ${wrap("Saturation", num("fig-fill-picker-ci-s", "Saturation", 0, 100))}
1238
+ ${wrap("Brightness", num("fig-fill-picker-ci-v", "Brightness", 0, 100))}
937
1239
  </div>`;
938
1240
  break;
939
1241
  case "lab":
940
1242
  html = `<div class="input-combo">
941
- ${wrap("Lightness", num("fig-fill-picker-ci-okl", 0, 100))}
942
- ${wrap("Green-Red axis", num("fig-fill-picker-ci-oka", -0.4, 0.4, 0.001))}
943
- ${wrap("Blue-Yellow axis", num("fig-fill-picker-ci-okb", -0.4, 0.4, 0.001))}
1243
+ ${wrap("Lightness", num("fig-fill-picker-ci-okl", "Lightness", 0, 100))}
1244
+ ${wrap("Green-Red axis", num("fig-fill-picker-ci-oka", "Green-Red axis", -0.4, 0.4, 0.001))}
1245
+ ${wrap("Blue-Yellow axis", num("fig-fill-picker-ci-okb", "Blue-Yellow axis", -0.4, 0.4, 0.001))}
944
1246
  </div>`;
945
1247
  break;
946
1248
  case "lch":
947
1249
  html = `<div class="input-combo">
948
- ${wrap("Lightness", num("fig-fill-picker-ci-okl", 0, 100))}
949
- ${wrap("Chroma", num("fig-fill-picker-ci-okc", 0, 0.4, 0.001))}
950
- ${wrap("Hue", num("fig-fill-picker-ci-okh", 0, 360))}
1250
+ ${wrap("Lightness", num("fig-fill-picker-ci-okl", "Lightness", 0, 100))}
1251
+ ${wrap("Chroma", num("fig-fill-picker-ci-okc", "Chroma", 0, 0.4, 0.001))}
1252
+ ${wrap("Hue", num("fig-fill-picker-ci-okh", "Hue", 0, 360))}
951
1253
  </div>`;
952
1254
  break;
953
1255
  default: // hex
954
- html = `<fig-input-text class="fig-fill-picker-ci-hex" placeholder="FFFFFF"></fig-input-text>`;
1256
+ html = `<fig-input-text class="fig-fill-picker-ci-hex" aria-label="Hex color" placeholder="FFFFFF"></fig-input-text>`;
955
1257
  break;
956
1258
  }
957
1259
 
@@ -1108,7 +1410,7 @@ class FigFillPicker extends HTMLElement {
1108
1410
 
1109
1411
  container.innerHTML = `
1110
1412
  <fig-field class="fig-fill-picker-gradient-header">
1111
- <fig-dropdown class="fig-fill-picker-gradient-type" ${expAttr} value="${
1413
+ <fig-dropdown class="fig-fill-picker-gradient-type" label="Gradient type" ${expAttr} value="${
1112
1414
  this.#gradient.type
1113
1415
  }">
1114
1416
  <option value="linear" selected>Linear</option>
@@ -1116,35 +1418,36 @@ class FigFillPicker extends HTMLElement {
1116
1418
  <option value="angular">Angular</option>
1117
1419
  </fig-dropdown>
1118
1420
  <fig-tooltip text="Rotate gradient">
1119
- <fig-input-number class="fig-fill-picker-gradient-angle" value="${
1421
+ <fig-input-number class="fig-fill-picker-gradient-angle" aria-label="Gradient angle" value="${
1120
1422
  (this.#gradient.angle - 90 + 360) % 360
1121
1423
  }" min="0" max="360" units="°" wrap></fig-input-number>
1122
1424
  </fig-tooltip>
1123
1425
  <div class="fig-fill-picker-gradient-center input-combo" style="display: none;">
1124
- <fig-input-number min="0" max="100" value="${
1426
+ <fig-input-number min="0" max="100" aria-label="Gradient center X" value="${
1125
1427
  this.#gradient.centerX
1126
1428
  }" units="%" class="fig-fill-picker-gradient-cx"></fig-input-number>
1127
- <fig-input-number min="0" max="100" value="${
1429
+ <fig-input-number min="0" max="100" aria-label="Gradient center Y" value="${
1128
1430
  this.#gradient.centerY
1129
1431
  }" units="%" class="fig-fill-picker-gradient-cy"></fig-input-number>
1130
1432
  </div>
1131
1433
  <fig-tooltip text="Flip gradient">
1132
- <fig-button icon variant="ghost" class="fig-fill-picker-gradient-flip">
1434
+ <fig-button icon variant="ghost" class="fig-fill-picker-gradient-flip" aria-label="Flip gradient">
1133
1435
  <fig-icon name="swap"></fig-icon>
1134
1436
  </fig-button>
1135
1437
  </fig-tooltip>
1136
1438
  </fig-field>
1137
1439
  <fig-preview class="fig-fill-picker-gradient-preview">
1138
- <fig-input-gradient class="fig-fill-picker-gradient-bar-input" edit="true" mode="tip" size="large" value='${JSON.stringify({ type: "gradient", gradient: gradientToValueShape(this.#gradient) })}'></fig-input-gradient>
1440
+ <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>
1139
1441
  </fig-preview>
1140
1442
  <fig-field class="fig-fill-picker-gradient-interpolation">
1141
1443
  <label>Mixing</label>
1142
- <fig-dropdown class="fig-fill-picker-gradient-space" full ${expAttr} value="${
1444
+ <fig-dropdown class="fig-fill-picker-gradient-space" label="Gradient mixing" full ${expAttr} value="${
1143
1445
  this.#gradient.interpolationSpace === "oklch"
1144
1446
  ? `oklch-${this.#gradient.hueInterpolation || "shorter"}`
1145
1447
  : this.#gradient.interpolationSpace
1146
1448
  }">
1147
1449
  <optgroup label="sRGB">
1450
+ <option value="srgb">Classic</option>
1148
1451
  <option value="srgb-linear">Linear</option>
1149
1452
  </optgroup>
1150
1453
  <optgroup label="OKLab">
@@ -1161,7 +1464,7 @@ class FigFillPicker extends HTMLElement {
1161
1464
  <div class="fig-fill-picker-gradient-stops">
1162
1465
  <fig-header class="fig-fill-picker-gradient-stops-header" borderless>
1163
1466
  <span>Stops</span>
1164
- <fig-button icon variant="ghost" class="fig-fill-picker-gradient-add" title="Add stop">
1467
+ <fig-button icon variant="ghost" class="fig-fill-picker-gradient-add" aria-label="Add gradient stop" title="Add stop">
1165
1468
  <fig-icon name="add"></fig-icon>
1166
1469
  </fig-button>
1167
1470
  </fig-header>
@@ -1390,15 +1693,15 @@ class FigFillPicker extends HTMLElement {
1390
1693
  .map(
1391
1694
  (stop, index) => `
1392
1695
  <fig-field class="fig-fill-picker-gradient-stop-row" data-index="${index}">
1393
- <fig-input-number class="fig-fill-picker-stop-position" min="0" max="100" value="${
1696
+ <fig-input-number class="fig-fill-picker-stop-position" aria-label="Gradient stop position" min="0" max="100" value="${
1394
1697
  stop.position
1395
1698
  }" units="%"></fig-input-number>
1396
- <fig-input-color class="fig-fill-picker-stop-color" text="true" alpha="true" picker="figma" picker-dialog-position="right" value="${
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="${
1397
1700
  stop.color
1398
1701
  }"></fig-input-color>
1399
1702
  <fig-button icon variant="ghost" class="fig-fill-picker-stop-remove" ${
1400
1703
  this.#gradient.stops.length <= 2 ? "disabled" : ""
1401
- }>
1704
+ } aria-label="Remove gradient stop">
1402
1705
  <fig-icon name="minus"></fig-icon>
1403
1706
  </fig-button>
1404
1707
  </fig-field>
@@ -1470,9 +1773,9 @@ class FigFillPicker extends HTMLElement {
1470
1773
  return `${color} ${s.position}%`;
1471
1774
  })
1472
1775
  .join(", ");
1473
- const interpolation = includeInterpolation
1474
- ? ` ${gradientInterpolationClause(gradient)}`
1475
- : "";
1776
+ const interpolationClause = gradientInterpolationClause(gradient);
1777
+ const interpolation =
1778
+ includeInterpolation && interpolationClause ? ` ${interpolationClause}` : "";
1476
1779
  switch (gradient.type) {
1477
1780
  case "linear":
1478
1781
  return `linear-gradient(${gradient.angle}deg${interpolation}, ${stops})`;
@@ -1514,7 +1817,7 @@ class FigFillPicker extends HTMLElement {
1514
1817
 
1515
1818
  container.innerHTML = `
1516
1819
  <fig-field class="fig-fill-picker-media-header">
1517
- <fig-dropdown class="fig-fill-picker-scale-mode" ${expAttr} value="${
1820
+ <fig-dropdown class="fig-fill-picker-scale-mode" label="Image scale mode" ${expAttr} value="${
1518
1821
  this.#image.scaleMode
1519
1822
  }">
1520
1823
  <option value="fill" selected>Fill</option>
@@ -1522,7 +1825,7 @@ class FigFillPicker extends HTMLElement {
1522
1825
  <option value="crop">Crop</option>
1523
1826
  <option value="tile">Tile</option>
1524
1827
  </fig-dropdown>
1525
- <fig-input-number class="fig-fill-picker-scale" min="1" max="200" value="${
1828
+ <fig-input-number class="fig-fill-picker-scale" aria-label="Image tile scale" min="1" max="200" value="${
1526
1829
  this.#image.scale
1527
1830
  }" units="%" ${
1528
1831
  this.#image.scaleMode === "tile" ? "" : 'style="display: none;"'
@@ -1531,7 +1834,7 @@ class FigFillPicker extends HTMLElement {
1531
1834
  <fig-icon name="rotate"></fig-icon>
1532
1835
  </fig-button>
1533
1836
  </fig-field>
1534
- <fig-image class="fig-fill-picker-media-preview fig-fill-picker-image-preview" upload="true" label="Upload from computer" size="auto" aspect-ratio="1/1" fit="cover" checkerboard="true"></fig-image>
1837
+ <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>
1535
1838
  `;
1536
1839
 
1537
1840
  this.#setupImageEvents(container);
@@ -1682,7 +1985,7 @@ class FigFillPicker extends HTMLElement {
1682
1985
 
1683
1986
  container.innerHTML = `
1684
1987
  <fig-field class="fig-fill-picker-media-header">
1685
- <fig-dropdown class="fig-fill-picker-scale-mode" ${expAttr} value="${
1988
+ <fig-dropdown class="fig-fill-picker-scale-mode" label="Video scale mode" ${expAttr} value="${
1686
1989
  this.#video.scaleMode
1687
1990
  }">
1688
1991
  <option value="fill" selected>Fill</option>
@@ -1693,7 +1996,7 @@ class FigFillPicker extends HTMLElement {
1693
1996
  <fig-icon name="rotate"></fig-icon>
1694
1997
  </fig-button>
1695
1998
  </fig-field>
1696
- <fig-media class="fig-fill-picker-media-preview fig-fill-picker-video-preview" type="video" upload="true" label="Upload from computer" size="auto" aspect-ratio="1/1" fit="cover" checkerboard="true" autoplay="true" muted="true" loop="true"></fig-media>
1999
+ <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>
1697
2000
  `;
1698
2001
 
1699
2002
  this.#setupVideoEvents(container);
@@ -1741,10 +2044,10 @@ class FigFillPicker extends HTMLElement {
1741
2044
 
1742
2045
  container.innerHTML = `
1743
2046
  <fig-field class="fig-fill-picker-webcam-camera" style="display: none;">
1744
- <fig-dropdown class="fig-fill-picker-camera-select" full ${expAttr}>
2047
+ <fig-dropdown class="fig-fill-picker-camera-select" label="Camera" full ${expAttr}>
1745
2048
  </fig-dropdown>
1746
2049
  </fig-field>
1747
- <fig-video class="fig-fill-picker-webcam-preview" aspect-ratio="1/1" fit="cover" checkerboard="true" autoplay="true" muted="true">
2050
+ <fig-video class="fig-fill-picker-webcam-preview" aria-label="Webcam preview" aspect-ratio="1/1" fit="cover" checkerboard="true" autoplay="true" muted="true">
1748
2051
  <video class="fig-fill-picker-webcam-video" autoplay muted playsinline></video>
1749
2052
  <div class="fig-fill-picker-webcam-status">
1750
2053
  <span>Camera access required</span>
@@ -2243,7 +2546,12 @@ class FigFillPicker extends HTMLElement {
2243
2546
  }
2244
2547
  break;
2245
2548
  case "disabled":
2246
- // Handled in click listener
2549
+ this.#syncTriggerA11y();
2550
+ break;
2551
+ case "aria-label":
2552
+ case "aria-labelledby":
2553
+ case "aria-describedby":
2554
+ this.#syncTriggerA11y();
2247
2555
  break;
2248
2556
  }
2249
2557
  }