@rogieking/figui3 6.4.6 → 6.4.8

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.js CHANGED
@@ -1,3 +1,5 @@
1
+ import "./fig-layer.js";
2
+
1
3
  /**
2
4
  * Generates a unique ID string using timestamp and random values
3
5
  * @returns {string} A unique identifier
@@ -132,15 +134,20 @@ function figSupportsPopover() {
132
134
  class FigButton extends HTMLElement {
133
135
  type;
134
136
  #selected;
137
+ #a11yAttributes = ["aria-label", "aria-labelledby", "aria-describedby", "title"];
138
+ #boundHandleControlKeydown = this.#handleControlKeydown.bind(this);
135
139
  constructor() {
136
140
  super();
137
141
  this.attachShadow({ mode: "open", delegatesFocus: true });
138
142
  }
139
143
  connectedCallback() {
140
144
  this.type = this.getAttribute("type") || "button";
145
+ const isControlWrapper = this.type === "select" || this.type === "upload";
146
+ const controlTag = isControlWrapper ? "span" : "button";
147
+ const typeAttr = isControlWrapper ? "" : ` type="${this.type}"`;
141
148
  this.shadowRoot.innerHTML = `
142
149
  <style>
143
- button, button:hover, button:active {
150
+ button, button:hover, button:active, .fig-button-control {
144
151
  padding: 0 var(--spacer-2);
145
152
  appearance: none;
146
153
  display: flex;
@@ -162,36 +169,38 @@ class FigButton extends HTMLElement {
162
169
  width: 100%;
163
170
  min-width: 0;
164
171
  }
165
- :host([size="large"]) button {
172
+ :host([size="large"]) button,
173
+ :host([size="large"]) .fig-button-control {
166
174
  height: var(--spacer-5);
167
175
  }
168
- :host([size="large"][icon]) button {
176
+ :host([size="large"][icon]) button,
177
+ :host([size="large"][icon]) .fig-button-control {
169
178
  padding: 0;
170
179
  }
171
180
  </style>
172
- <button type="${this.type}">
181
+ <${controlTag} class="fig-button-control"${typeAttr}>
173
182
  <slot></slot>
174
- </button>
183
+ </${controlTag}>
175
184
  `;
176
185
 
177
186
  this.#selected =
178
187
  this.hasAttribute("selected") &&
179
188
  this.getAttribute("selected") !== "false";
180
189
 
181
- requestAnimationFrame(() => {
182
- this.button = this.shadowRoot.querySelector("button");
183
- this.button.addEventListener("click", this.#handleClick.bind(this));
190
+ this.button = this.shadowRoot.querySelector("button, .fig-button-control");
191
+ this.#syncButtonAttributes();
192
+ this.button.addEventListener("click", this.#handleClick.bind(this));
184
193
 
185
- // Forward focus-visible state to host element
186
- this.button.addEventListener("focus", () => {
187
- if (this.button.matches(":focus-visible")) {
188
- this.setAttribute("data-focus-visible", "");
189
- }
190
- });
191
- this.button.addEventListener("blur", () => {
192
- this.removeAttribute("data-focus-visible");
193
- });
194
+ // Forward focus-visible state to host element
195
+ this.button.addEventListener("focus", () => {
196
+ if (this.button.matches(":focus-visible")) {
197
+ this.setAttribute("data-focus-visible", "");
198
+ }
194
199
  });
200
+ this.button.addEventListener("blur", () => {
201
+ this.removeAttribute("data-focus-visible");
202
+ });
203
+ this.addEventListener("keydown", this.#boundHandleControlKeydown);
195
204
  }
196
205
 
197
206
  get type() {
@@ -207,7 +216,12 @@ class FigButton extends HTMLElement {
207
216
  this.setAttribute("selected", value);
208
217
  }
209
218
 
219
+ #isDisabled() {
220
+ return this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
221
+ }
222
+
210
223
  #handleClick() {
224
+ if (this.#isDisabled()) return;
211
225
  if (this.type === "toggle") {
212
226
  this.toggleAttribute("selected", !this.hasAttribute("selected"));
213
227
  }
@@ -229,28 +243,103 @@ class FigButton extends HTMLElement {
229
243
  }
230
244
  }
231
245
  }
246
+ #getSlottedControl() {
247
+ if (this.type === "select") {
248
+ return this.querySelector("select, fig-dropdown");
249
+ }
250
+ if (this.type === "upload") {
251
+ return this.querySelector('input[type="file"], input');
252
+ }
253
+ return null;
254
+ }
255
+ #getSlottedSelect() {
256
+ const control = this.#getSlottedControl();
257
+ if (control instanceof HTMLSelectElement) return control;
258
+ if (control?.tagName === "FIG-DROPDOWN") {
259
+ return control.select || control.querySelector?.("select") || null;
260
+ }
261
+ return null;
262
+ }
263
+ #handleControlKeydown(e) {
264
+ if (this.type !== "select") return;
265
+ if (e.key !== "Enter") return;
266
+ if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
267
+ const select = this.#getSlottedSelect();
268
+ if (!select || select.disabled || select.multiple) return;
269
+ if (typeof select.showPicker !== "function") return;
270
+ e.preventDefault();
271
+ try {
272
+ select.showPicker();
273
+ } catch {
274
+ // showPicker can be blocked outside trusted user activation.
275
+ }
276
+ }
277
+ #syncPressedState() {
278
+ if (!this.button) return;
279
+ if (this.type !== "toggle") {
280
+ this.removeAttribute("aria-pressed");
281
+ this.button.removeAttribute("aria-pressed");
282
+ return;
283
+ }
284
+ const pressed = this.hasAttribute("selected") && this.getAttribute("selected") !== "false";
285
+ this.setAttribute("aria-pressed", pressed ? "true" : "false");
286
+ this.button.setAttribute("aria-pressed", pressed ? "true" : "false");
287
+ }
288
+ #syncA11yAttributes() {
289
+ if (!this.button) return;
290
+ if (!(this.button instanceof HTMLButtonElement)) return;
291
+ this.#a11yAttributes.forEach((name) => {
292
+ const value = this.getAttribute(name);
293
+ if (value === null) {
294
+ this.button.removeAttribute(name);
295
+ } else {
296
+ this.button.setAttribute(name, value);
297
+ }
298
+ });
299
+ }
300
+ #syncButtonAttributes() {
301
+ if (!this.button) return;
302
+ const disabled =
303
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
304
+ this.disabled = disabled;
305
+ if (this.button instanceof HTMLButtonElement) {
306
+ this.button.disabled = disabled;
307
+ this.button.type = this.type;
308
+ this.button.setAttribute("type", this.type);
309
+ }
310
+ this.#syncA11yAttributes();
311
+ this.#syncPressedState();
312
+ }
232
313
  static get observedAttributes() {
233
- return ["disabled", "selected"];
314
+ return [
315
+ "disabled",
316
+ "selected",
317
+ "type",
318
+ "aria-label",
319
+ "aria-labelledby",
320
+ "aria-describedby",
321
+ "title",
322
+ ];
234
323
  }
235
324
  attributeChangedCallback(name, oldValue, newValue) {
236
- if (this.button) {
237
- this.button[name] = newValue;
238
- switch (name) {
239
- case "disabled":
240
- this.disabled = this.button.disabled =
241
- newValue !== null && newValue !== "false";
242
- break;
243
- case "type":
244
- this.type = newValue;
245
- this.button.type = this.type;
246
- this.button.setAttribute("type", this.type);
247
- break;
248
- case "selected":
249
- this.#selected = newValue === "true";
250
- break;
251
- }
325
+ if (oldValue === newValue) return;
326
+ switch (name) {
327
+ case "disabled":
328
+ case "type":
329
+ this.#syncButtonAttributes();
330
+ break;
331
+ case "selected":
332
+ this.#selected = newValue !== null && newValue !== "false";
333
+ this.#syncPressedState();
334
+ break;
335
+ default:
336
+ this.#syncA11yAttributes();
337
+ break;
252
338
  }
253
339
  }
340
+ disconnectedCallback() {
341
+ this.removeEventListener("keydown", this.#boundHandleControlKeydown);
342
+ }
254
343
  }
255
344
  customElements.define("fig-button", FigButton);
256
345
 
@@ -264,6 +353,7 @@ class FigDropdown extends HTMLElement {
264
353
  #selectedValue = null; // Stores last selected value for dropdown type
265
354
  #boundHandleSelectInput;
266
355
  #boundHandleSelectChange;
356
+ #boundHandleSelectKeydown;
267
357
  #selectedContentEnabled = false;
268
358
  #selectedContentEl = null;
269
359
 
@@ -281,6 +371,7 @@ class FigDropdown extends HTMLElement {
281
371
  this.attachShadow({ mode: "open" });
282
372
  this.#boundHandleSelectInput = this.#handleSelectInput.bind(this);
283
373
  this.#boundHandleSelectChange = this.#handleSelectChange.bind(this);
374
+ this.#boundHandleSelectKeydown = this.#handleSelectKeydown.bind(this);
284
375
  this.#boundSlotChange = this.slotChange.bind(this);
285
376
  }
286
377
 
@@ -333,6 +424,7 @@ class FigDropdown extends HTMLElement {
333
424
  #addEventListeners() {
334
425
  this.select.addEventListener("input", this.#boundHandleSelectInput);
335
426
  this.select.addEventListener("change", this.#boundHandleSelectChange);
427
+ this.select.addEventListener("keydown", this.#boundHandleSelectKeydown);
336
428
  }
337
429
 
338
430
  #hasPersistentControl(optionEl) {
@@ -360,6 +452,7 @@ class FigDropdown extends HTMLElement {
360
452
 
361
453
  this.#label = this.getAttribute("label") || this.#label;
362
454
  this.select.setAttribute("aria-label", this.#label);
455
+ this.#syncDisabled();
363
456
 
364
457
  this.appendChild(this.select);
365
458
  this.shadowRoot.appendChild(this.optionsSlot);
@@ -448,6 +541,21 @@ class FigDropdown extends HTMLElement {
448
541
  );
449
542
  }
450
543
 
544
+ #handleSelectKeydown(e) {
545
+ if (this.closest('fig-button[type="select"]')) return;
546
+ if (e.key !== "Enter" || e.defaultPrevented) return;
547
+ if (this.#selectedContentEnabled && this.select.matches(":open")) return;
548
+ if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
549
+ if (this.select.disabled || this.select.multiple) return;
550
+ if (typeof this.select.showPicker !== "function") return;
551
+ e.preventDefault();
552
+ try {
553
+ this.select.showPicker();
554
+ } catch {
555
+ // showPicker can be unavailable during non-user-initiated key events.
556
+ }
557
+ }
558
+
451
559
  focus() {
452
560
  this.select.focus();
453
561
  }
@@ -469,7 +577,12 @@ class FigDropdown extends HTMLElement {
469
577
  this.setAttribute("value", value);
470
578
  }
471
579
  static get observedAttributes() {
472
- return ["value", "type", "experimental"];
580
+ return ["value", "type", "experimental", "label", "disabled"];
581
+ }
582
+ #syncDisabled() {
583
+ const disabled =
584
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
585
+ this.select.disabled = disabled;
473
586
  }
474
587
  #syncSelectedValue(value) {
475
588
  // For dropdown type, don't sync the visual selection - it should always show the hidden placeholder
@@ -499,12 +612,16 @@ class FigDropdown extends HTMLElement {
499
612
  this.#label = newValue;
500
613
  this.select.setAttribute("aria-label", this.#label);
501
614
  }
615
+ if (name === "disabled") {
616
+ this.#syncDisabled();
617
+ }
502
618
  }
503
619
 
504
620
  disconnectedCallback() {
505
621
  this.optionsSlot.removeEventListener("slotchange", this.#boundSlotChange);
506
622
  this.select.removeEventListener("input", this.#boundHandleSelectInput);
507
623
  this.select.removeEventListener("change", this.#boundHandleSelectChange);
624
+ this.select.removeEventListener("keydown", this.#boundHandleSelectKeydown);
508
625
  }
509
626
  }
510
627
 
@@ -533,6 +650,7 @@ class FigTooltip extends HTMLElement {
533
650
  #boundHandleTouchEnd;
534
651
  #boundHandleTouchCancel;
535
652
  #boundHandleDialogClose;
653
+ #boundHandleEscape;
536
654
  #parentDialog = null;
537
655
  #touchTimeout;
538
656
  #isTouching = false;
@@ -550,6 +668,7 @@ class FigTooltip extends HTMLElement {
550
668
  this.#boundHandleTouchMove = this.#handleTouchMove.bind(this);
551
669
  this.#boundHandleTouchEnd = this.#handleTouchEnd.bind(this);
552
670
  this.#boundHandleTouchCancel = this.#handleTouchCancel.bind(this);
671
+ this.#boundHandleEscape = this.#handleEscape.bind(this);
553
672
  this.#boundHandleDialogClose = () => {
554
673
  clearTimeout(this.timeout);
555
674
  this.destroy();
@@ -573,6 +692,7 @@ class FigTooltip extends HTMLElement {
573
692
  this.#boundHideOnChromeOpen,
574
693
  true,
575
694
  );
695
+ document.removeEventListener("keydown", this.#boundHandleEscape, true);
576
696
  if (this.#parentDialog) {
577
697
  this.#parentDialog.removeEventListener("close", this.#boundHandleDialogClose);
578
698
  this.#parentDialog = null;
@@ -620,7 +740,7 @@ class FigTooltip extends HTMLElement {
620
740
  this.popup.setAttribute("variant", "tooltip");
621
741
  this.popup.setAttribute("data-tooltip-managed", "");
622
742
  this.popup.setAttribute("role", "tooltip");
623
- this.popup.setAttribute("closedby", "none");
743
+ this.popup.setAttribute("closedby", "closerequest");
624
744
  if (supportsPopover) this.popup.setAttribute("popover", "manual");
625
745
 
626
746
  const tooltipId = figUniqueId();
@@ -708,6 +828,7 @@ class FigTooltip extends HTMLElement {
708
828
  }
709
829
 
710
830
  document.addEventListener("mousedown", this.#boundHideOnChromeOpen, true);
831
+ document.addEventListener("keydown", this.#boundHandleEscape, true);
711
832
  }
712
833
 
713
834
  get #showPersisted() {
@@ -804,6 +925,13 @@ class FigTooltip extends HTMLElement {
804
925
  }
805
926
  }
806
927
 
928
+ #handleEscape(event) {
929
+ if ((!this.isOpen && !this.popup) || event.key !== "Escape") return;
930
+ event.preventDefault();
931
+ this.hidePopup();
932
+ this.firstElementChild?.focus?.();
933
+ }
934
+
807
935
  static get observedAttributes() {
808
936
  return ["action", "delay", "open", "pointer", "show", "text", "theme"];
809
937
  }
@@ -912,7 +1040,7 @@ class FigTooltip extends HTMLElement {
912
1040
  popup.setAttribute("variant", "tooltip");
913
1041
  popup.setAttribute("data-tooltip-managed", "");
914
1042
  popup.setAttribute("role", "tooltip");
915
- popup.setAttribute("closedby", "none");
1043
+ popup.setAttribute("closedby", "closerequest");
916
1044
  if (supportsPopover) popup.setAttribute("popover", "manual");
917
1045
  const content = document.createElement("span");
918
1046
  content.innerText = text;
@@ -1068,6 +1196,8 @@ class FigDialog extends HTMLDialogElement {
1068
1196
  this._boundIframeMessage = this._handleIframeMessage.bind(this);
1069
1197
  this._boundContentMutation = this._scheduleAutoResize.bind(this);
1070
1198
  this._boundContentResize = this._scheduleAutoResize.bind(this);
1199
+ this._boundRestoreFocus = this._restoreFocus.bind(this);
1200
+ this._previousFocus = null;
1071
1201
  }
1072
1202
 
1073
1203
  get autoresize() {
@@ -1093,6 +1223,7 @@ class FigDialog extends HTMLDialogElement {
1093
1223
  this._setupDragListeners();
1094
1224
  this._applyPosition();
1095
1225
  this._syncAutoResize();
1226
+ this._syncA11y();
1096
1227
  });
1097
1228
 
1098
1229
  window.addEventListener("message", this._boundIframeMessage);
@@ -1106,6 +1237,36 @@ class FigDialog extends HTMLDialogElement {
1106
1237
  });
1107
1238
  window.removeEventListener("message", this._boundIframeMessage);
1108
1239
  this._teardownAutoResize();
1240
+ this.removeEventListener("close", this._boundRestoreFocus);
1241
+ }
1242
+
1243
+ _captureFocusBeforeOpen() {
1244
+ const active = document.activeElement;
1245
+ this._previousFocus =
1246
+ active instanceof HTMLElement && active !== document.body && !this.contains(active)
1247
+ ? active
1248
+ : null;
1249
+ }
1250
+
1251
+ _restoreFocus() {
1252
+ const target = this._previousFocus;
1253
+ this._previousFocus = null;
1254
+ if (!target?.isConnected) return;
1255
+ const active = document.activeElement;
1256
+ if (active && active !== document.body && !this.contains(active)) return;
1257
+ requestAnimationFrame(() => target.focus?.());
1258
+ }
1259
+
1260
+ show() {
1261
+ this._captureFocusBeforeOpen();
1262
+ this.addEventListener("close", this._boundRestoreFocus, { once: true });
1263
+ return super.show();
1264
+ }
1265
+
1266
+ showModal() {
1267
+ this._captureFocusBeforeOpen();
1268
+ this.addEventListener("close", this._boundRestoreFocus, { once: true });
1269
+ return super.showModal();
1109
1270
  }
1110
1271
 
1111
1272
  _handleIframeMessage(event) {
@@ -1246,6 +1407,7 @@ class FigDialog extends HTMLDialogElement {
1246
1407
  const btn = document.createElement("fig-button");
1247
1408
  btn.setAttribute("variant", "ghost");
1248
1409
  btn.setAttribute("icon", "");
1410
+ btn.setAttribute("aria-label", "Close dialog");
1249
1411
  btn.setAttribute("close-dialog", "");
1250
1412
  btn.appendChild(createFigIcon("close"));
1251
1413
  tooltip.appendChild(btn);
@@ -1256,11 +1418,25 @@ class FigDialog extends HTMLDialogElement {
1256
1418
 
1257
1419
  _addCloseListeners() {
1258
1420
  this.querySelectorAll("fig-button[close-dialog]").forEach((button) => {
1421
+ if (!button.hasAttribute("aria-label")) {
1422
+ button.setAttribute("aria-label", "Close dialog");
1423
+ }
1259
1424
  button.removeEventListener("click", this._boundClose);
1260
1425
  button.addEventListener("click", this._boundClose);
1261
1426
  });
1262
1427
  }
1263
1428
 
1429
+ _syncA11y() {
1430
+ if (!this.hasAttribute("aria-label") && !this.hasAttribute("aria-labelledby")) {
1431
+ const heading = this.querySelector("fig-header[dialog-header] h1, fig-header[dialog-header] h2, fig-header[dialog-header] h3, fig-header[dialog-header] h4, fig-header[dialog-header] h5, fig-header[dialog-header] h6");
1432
+ if (heading) {
1433
+ const id = heading.getAttribute("id") || figUniqueId();
1434
+ heading.setAttribute("id", id);
1435
+ this.setAttribute("aria-labelledby", id);
1436
+ }
1437
+ }
1438
+ }
1439
+
1264
1440
  _applyPosition() {
1265
1441
  const position = this.getAttribute("position") || "";
1266
1442
 
@@ -1479,6 +1655,8 @@ class FigDialog extends HTMLDialogElement {
1479
1655
  "resizable",
1480
1656
  "closedby",
1481
1657
  "autoresize",
1658
+ "aria-label",
1659
+ "aria-labelledby",
1482
1660
  ];
1483
1661
  }
1484
1662
 
@@ -1525,11 +1703,125 @@ class FigDialog extends HTMLDialogElement {
1525
1703
  if (autoHeader) {
1526
1704
  autoHeader.textContent = newValue || "Dialog";
1527
1705
  }
1706
+ this._syncA11y();
1707
+ }
1708
+
1709
+ if (name === "aria-label" || name === "aria-labelledby") {
1710
+ this._syncA11y();
1528
1711
  }
1529
1712
  }
1530
1713
  }
1531
1714
  figDefineCustomizedBuiltIn("fig-dialog", FigDialog, { extends: "dialog" });
1532
1715
 
1716
+ /* Toast */
1717
+ class FigToast extends HTMLDialogElement {
1718
+ constructor() {
1719
+ super();
1720
+ this._figInit();
1721
+ }
1722
+
1723
+ _figInit() {
1724
+ if (this._figInitialized) return;
1725
+ this._figInitialized = true;
1726
+ this._defaultOffset = 16;
1727
+ this._autoCloseTimer = null;
1728
+ this._boundHandleClose = this.handleClose.bind(this);
1729
+ }
1730
+
1731
+ connectedCallback() {
1732
+ this._figInit();
1733
+ if (!this.hasAttribute("theme")) this.setAttribute("theme", "dark");
1734
+ this.syncLiveRegion();
1735
+ this.addCloseListeners();
1736
+ this.applyPosition();
1737
+ if (this.hasAttribute("open") && this.getAttribute("open") !== "false") {
1738
+ this.showToast();
1739
+ }
1740
+ }
1741
+
1742
+ disconnectedCallback() {
1743
+ this._figInit();
1744
+ this.clearAutoClose();
1745
+ }
1746
+
1747
+ addCloseListeners() {
1748
+ this.querySelectorAll("[close-toast]").forEach((button) => {
1749
+ if (!button.hasAttribute("aria-label")) button.setAttribute("aria-label", "Close notification");
1750
+ button.removeEventListener("click", this._boundHandleClose);
1751
+ button.addEventListener("click", this._boundHandleClose);
1752
+ });
1753
+ }
1754
+
1755
+ handleClose() {
1756
+ this.hideToast();
1757
+ }
1758
+
1759
+ applyPosition() {
1760
+ this.style.position = "fixed";
1761
+ this.style.margin = "0";
1762
+ this.style.top = "auto";
1763
+ this.style.bottom = `${parseInt(this.getAttribute("offset") ?? this._defaultOffset)}px`;
1764
+ this.style.left = "50%";
1765
+ this.style.right = "auto";
1766
+ this.style.transform = "translateX(-50%)";
1767
+ }
1768
+
1769
+ syncLiveRegion() {
1770
+ const assertive =
1771
+ this.getAttribute("live") === "assertive" ||
1772
+ this.getAttribute("theme") === "danger";
1773
+ if (!this.hasAttribute("role")) this.setAttribute("role", assertive ? "alert" : "status");
1774
+ if (!this.hasAttribute("aria-live")) this.setAttribute("aria-live", assertive ? "assertive" : "polite");
1775
+ if (!this.hasAttribute("aria-atomic")) this.setAttribute("aria-atomic", "true");
1776
+ }
1777
+
1778
+ startAutoClose() {
1779
+ this.clearAutoClose();
1780
+ const duration = parseInt(this.getAttribute("duration") ?? "5000");
1781
+ if (duration > 0) {
1782
+ this._autoCloseTimer = setTimeout(() => this.hideToast(), duration);
1783
+ }
1784
+ }
1785
+
1786
+ clearAutoClose() {
1787
+ if (this._autoCloseTimer) {
1788
+ clearTimeout(this._autoCloseTimer);
1789
+ this._autoCloseTimer = null;
1790
+ }
1791
+ }
1792
+
1793
+ showToast() {
1794
+ this.syncLiveRegion();
1795
+ if (!this.open) this.show();
1796
+ this.applyPosition();
1797
+ this.startAutoClose();
1798
+ this.dispatchEvent(new CustomEvent("toast-show", { bubbles: true }));
1799
+ }
1800
+
1801
+ hideToast() {
1802
+ this.clearAutoClose();
1803
+ if (this.open) this.close();
1804
+ this.dispatchEvent(new CustomEvent("toast-hide", { bubbles: true }));
1805
+ }
1806
+
1807
+ static get observedAttributes() {
1808
+ return ["duration", "offset", "open", "theme", "live"];
1809
+ }
1810
+
1811
+ attributeChangedCallback(name, oldValue, newValue) {
1812
+ this._figInit();
1813
+ if (oldValue === newValue) return;
1814
+ if (!this.isConnected) return;
1815
+ if (name === "offset") this.applyPosition();
1816
+ if (name === "open") {
1817
+ if (newValue !== null && newValue !== "false") this.showToast();
1818
+ else this.hideToast();
1819
+ }
1820
+ if (name === "theme" || name === "live") this.syncLiveRegion();
1821
+ }
1822
+ }
1823
+ figDefineCustomizedBuiltIn("fig-toast", FigToast, { extends: "dialog" });
1824
+
1533
1825
  /* Popup */
1534
1826
  /**
1535
1827
  * A floating popup foundation component based on <dialog>.
@@ -1562,14 +1854,17 @@ class FigPopup extends HTMLDialogElement {
1562
1854
  _boundPointerMove;
1563
1855
  _boundPointerUp;
1564
1856
  _wasDragged = false;
1857
+ _previousFocus = null;
1858
+ _boundDocumentKeydown;
1565
1859
 
1566
1860
  constructor() {
1567
1861
  super();
1568
1862
  this._boundReposition = this.queueReposition.bind(this);
1569
1863
  this._boundScroll = (e) => {
1864
+ const target = e.target;
1570
1865
  if (
1571
1866
  this.open &&
1572
- !this.contains(e.target) &&
1867
+ (!(target instanceof Node) || !this.contains(target)) &&
1573
1868
  this.shouldAutoReposition()
1574
1869
  ) {
1575
1870
  this.queueReposition();
@@ -1579,6 +1874,7 @@ class FigPopup extends HTMLDialogElement {
1579
1874
  this._boundPointerDown = this.handlePointerDown.bind(this);
1580
1875
  this._boundPointerMove = this.handlePointerMove.bind(this);
1581
1876
  this._boundPointerUp = this.handlePointerUp.bind(this);
1877
+ this._boundDocumentKeydown = this.handleDocumentKeydown.bind(this);
1582
1878
  }
1583
1879
 
1584
1880
  ensureInitialized() {
@@ -1603,15 +1899,17 @@ class FigPopup extends HTMLDialogElement {
1603
1899
  this._dragOffset = { x: 0, y: 0 };
1604
1900
  if (typeof this._dragThreshold !== "number") this._dragThreshold = 3;
1605
1901
  if (typeof this._wasDragged === "undefined") this._wasDragged = false;
1902
+ if (typeof this._previousFocus === "undefined") this._previousFocus = null;
1606
1903
 
1607
1904
  if (typeof this._boundReposition !== "function") {
1608
1905
  this._boundReposition = this.queueReposition.bind(this);
1609
1906
  }
1610
1907
  if (typeof this._boundScroll !== "function") {
1611
1908
  this._boundScroll = (e) => {
1909
+ const target = e.target;
1612
1910
  if (
1613
1911
  this.open &&
1614
- !this.contains(e.target) &&
1912
+ (!(target instanceof Node) || !this.contains(target)) &&
1615
1913
  this.shouldAutoReposition()
1616
1914
  ) {
1617
1915
  this.queueReposition();
@@ -1630,6 +1928,9 @@ class FigPopup extends HTMLDialogElement {
1630
1928
  if (typeof this._boundPointerUp !== "function") {
1631
1929
  this._boundPointerUp = this.handlePointerUp.bind(this);
1632
1930
  }
1931
+ if (typeof this._boundDocumentKeydown !== "function") {
1932
+ this._boundDocumentKeydown = this.handleDocumentKeydown.bind(this);
1933
+ }
1633
1934
  }
1634
1935
 
1635
1936
  static get observedAttributes() {
@@ -1699,7 +2000,10 @@ class FigPopup extends HTMLDialogElement {
1699
2000
  this.setAttribute("position", "top center");
1700
2001
  }
1701
2002
  if (!this.hasAttribute("role")) {
1702
- this.setAttribute("role", "dialog");
2003
+ this.setAttribute(
2004
+ "role",
2005
+ this.getAttribute("variant") === "tooltip" ? "tooltip" : "dialog",
2006
+ );
1703
2007
  }
1704
2008
  if (!this.hasAttribute("closedby")) {
1705
2009
  this.setAttribute("closedby", "any");
@@ -1735,6 +2039,7 @@ class FigPopup extends HTMLDialogElement {
1735
2039
  this._boundOutsidePointerDown,
1736
2040
  true,
1737
2041
  );
2042
+ document.removeEventListener("keydown", this._boundDocumentKeydown, true);
1738
2043
  if (this._rafId !== null) {
1739
2044
  cancelAnimationFrame(this._rafId);
1740
2045
  this._rafId = null;
@@ -1780,6 +2085,7 @@ class FigPopup extends HTMLDialogElement {
1780
2085
  this.style.inset = "auto";
1781
2086
  this.style.margin = "0";
1782
2087
  this.style.zIndex = String(figGetHighestZIndex() + 1);
2088
+ this.captureFocusBeforeOpen();
1783
2089
 
1784
2090
  // When the popup opts into the native popover API, prefer showPopover()
1785
2091
  // so the element is promoted into the browser's top layer (above any
@@ -1817,6 +2123,7 @@ class FigPopup extends HTMLDialogElement {
1817
2123
  this._boundOutsidePointerDown,
1818
2124
  true,
1819
2125
  );
2126
+ document.addEventListener("keydown", this._boundDocumentKeydown, true);
1820
2127
  this._wasDragged = false;
1821
2128
  this.queueReposition();
1822
2129
  this._isPopupActive = true;
@@ -1826,6 +2133,10 @@ class FigPopup extends HTMLDialogElement {
1826
2133
  }
1827
2134
 
1828
2135
  hidePopup() {
2136
+ const wasActive =
2137
+ this._isPopupActive ||
2138
+ this.matches?.(":open") ||
2139
+ this.matches?.(":popover-open");
1829
2140
  const anchor = this.resolveAnchor();
1830
2141
  if (anchor?.classList) anchor.classList.remove("has-popup-open");
1831
2142
 
@@ -1838,6 +2149,7 @@ class FigPopup extends HTMLDialogElement {
1838
2149
  this._boundOutsidePointerDown,
1839
2150
  true,
1840
2151
  );
2152
+ document.removeEventListener("keydown", this._boundDocumentKeydown, true);
1841
2153
 
1842
2154
  if (
1843
2155
  this.hasAttribute("popover") &&
@@ -1860,6 +2172,39 @@ class FigPopup extends HTMLDialogElement {
1860
2172
  // Ignore when dialog is not in an open state.
1861
2173
  }
1862
2174
  }
2175
+ if (wasActive) this.restoreFocusAfterClose();
2176
+ }
2177
+
2178
+ shouldRestoreFocus() {
2179
+ return this.getAttribute("variant") !== "tooltip";
2180
+ }
2181
+
2182
+ captureFocusBeforeOpen() {
2183
+ if (!this.shouldRestoreFocus()) return;
2184
+ const active = document.activeElement;
2185
+ this._previousFocus =
2186
+ active instanceof HTMLElement && active !== document.body && !this.contains(active)
2187
+ ? active
2188
+ : null;
2189
+ }
2190
+
2191
+ restoreFocusAfterClose() {
2192
+ if (!this.shouldRestoreFocus()) {
2193
+ this._previousFocus = null;
2194
+ return;
2195
+ }
2196
+ const anchor = this.resolveAnchor();
2197
+ const target =
2198
+ this._previousFocus?.isConnected
2199
+ ? this._previousFocus
2200
+ : anchor instanceof HTMLElement
2201
+ ? anchor
2202
+ : null;
2203
+ this._previousFocus = null;
2204
+ if (!target?.isConnected) return;
2205
+ const active = document.activeElement;
2206
+ if (active && active !== document.body && !this.contains(active)) return;
2207
+ requestAnimationFrame(() => target.focus?.());
1863
2208
  }
1864
2209
 
1865
2210
  get autoresize() {
@@ -2002,6 +2347,27 @@ class FigPopup extends HTMLDialogElement {
2002
2347
  this.open = false;
2003
2348
  }
2004
2349
 
2350
+ handleDocumentKeydown(event) {
2351
+ if (event.key !== "Escape" || event.defaultPrevented) return;
2352
+ if (!this.open) return;
2353
+ if (this.getAttribute("role") === "menu") return;
2354
+ const closedby = this.getAttribute("closedby");
2355
+ if (closedby === "none") return;
2356
+ const openPopups = Array.from(
2357
+ document.querySelectorAll('dialog[is="fig-popup"][open]'),
2358
+ ).filter((popup) => popup.open);
2359
+ const topPopup = openPopups
2360
+ .map((popup) => ({
2361
+ popup,
2362
+ z: Number.parseInt(getComputedStyle(popup).zIndex || "0", 10) || 0,
2363
+ }))
2364
+ .sort((a, b) => a.z - b.z)
2365
+ .at(-1)?.popup;
2366
+ if (topPopup && topPopup !== this) return;
2367
+ event.preventDefault();
2368
+ this.open = false;
2369
+ }
2370
+
2005
2371
  isInsideDescendantPopup(target) {
2006
2372
  const targetDialog = target.closest?.('dialog[is="fig-popup"]');
2007
2373
  if (!targetDialog || targetDialog === this) return false;
@@ -2676,14 +3042,20 @@ class FigTab extends HTMLElement {
2676
3042
  connectedCallback() {
2677
3043
  this.setAttribute("label", this.innerText);
2678
3044
  this.setAttribute("role", "tab");
2679
- this.setAttribute("tabindex", "0");
3045
+ if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
2680
3046
  this.addEventListener("click", this.#boundHandleClick);
2681
3047
 
2682
3048
  requestAnimationFrame(() => {
2683
3049
  if (typeof this.getAttribute("content") === "string") {
2684
3050
  this.content = document.querySelector(this.getAttribute("content"));
2685
3051
  if (this.content) {
3052
+ const tabId = this.getAttribute("id") || figUniqueId();
3053
+ const panelId = this.content.getAttribute("id") || figUniqueId();
3054
+ this.setAttribute("id", tabId);
3055
+ this.content.setAttribute("id", panelId);
3056
+ this.setAttribute("aria-controls", panelId);
2686
3057
  this.content.setAttribute("role", "tabpanel");
3058
+ this.content.setAttribute("aria-labelledby", tabId);
2687
3059
  if (this.#selected) {
2688
3060
  this.content.style.display = "block";
2689
3061
  this.setAttribute("aria-selected", "true");
@@ -2713,16 +3085,27 @@ class FigTab extends HTMLElement {
2713
3085
  }
2714
3086
 
2715
3087
  static get observedAttributes() {
2716
- return ["selected"];
3088
+ return ["selected", "disabled"];
2717
3089
  }
2718
3090
  attributeChangedCallback(name, oldValue, newValue) {
2719
3091
  if (name === "selected") {
2720
3092
  this.#selected = newValue !== null && newValue !== "false";
2721
3093
  this.setAttribute("aria-selected", this.#selected ? "true" : "false");
3094
+ this.setAttribute("tabindex", this.#selected ? "0" : "-1");
2722
3095
  if (this?.content) {
2723
3096
  this.content.style.display = this.#selected ? "block" : "none";
2724
3097
  }
2725
3098
  }
3099
+ if (name === "disabled") {
3100
+ const disabled = newValue !== null && newValue !== "false";
3101
+ if (disabled) {
3102
+ this.setAttribute("aria-disabled", "true");
3103
+ this.setAttribute("tabindex", "-1");
3104
+ } else {
3105
+ this.removeAttribute("aria-disabled");
3106
+ this.setAttribute("tabindex", this.#selected ? "0" : "-1");
3107
+ }
3108
+ }
2726
3109
  }
2727
3110
  }
2728
3111
  customElements.define("fig-tab", FigTab);
@@ -2770,9 +3153,24 @@ class FigTabs extends HTMLElement {
2770
3153
  } else {
2771
3154
  tab.removeAttribute("disabled");
2772
3155
  tab.removeAttribute("aria-disabled");
2773
- tab.setAttribute("tabindex", "0");
2774
3156
  }
2775
3157
  });
3158
+ this.#syncTabIndexes();
3159
+ }
3160
+
3161
+ #availableTabs() {
3162
+ return Array.from(this.querySelectorAll("fig-tab")).filter(
3163
+ (tab) => !tab.hasAttribute("disabled") || tab.getAttribute("disabled") === "false",
3164
+ );
3165
+ }
3166
+
3167
+ #syncTabIndexes() {
3168
+ const tabs = Array.from(this.querySelectorAll("fig-tab"));
3169
+ const selected = tabs.find((tab) => tab.hasAttribute("selected")) || this.#availableTabs()[0];
3170
+ tabs.forEach((tab) => {
3171
+ const disabled = tab.hasAttribute("disabled") && tab.getAttribute("disabled") !== "false";
3172
+ tab.setAttribute("tabindex", !disabled && tab === selected ? "0" : "-1");
3173
+ });
2776
3174
  }
2777
3175
 
2778
3176
  disconnectedCallback() {
@@ -2781,9 +3179,10 @@ class FigTabs extends HTMLElement {
2781
3179
  }
2782
3180
 
2783
3181
  #handleKeyDown(event) {
2784
- const tabs = Array.from(this.querySelectorAll("fig-tab"));
3182
+ const tabs = this.#availableTabs();
3183
+ if (!tabs.length) return;
2785
3184
  const currentIndex = tabs.findIndex((tab) => tab.hasAttribute("selected"));
2786
- let newIndex = currentIndex;
3185
+ let newIndex = currentIndex >= 0 ? currentIndex : 0;
2787
3186
 
2788
3187
  switch (event.key) {
2789
3188
  case "ArrowLeft":
@@ -2809,12 +3208,13 @@ class FigTabs extends HTMLElement {
2809
3208
  }
2810
3209
 
2811
3210
  if (newIndex !== currentIndex && tabs[newIndex]) {
2812
- tabs.forEach((tab) => tab.removeAttribute("selected"));
3211
+ this.querySelectorAll("fig-tab").forEach((tab) => tab.removeAttribute("selected"));
2813
3212
  this.selectedTab = tabs[newIndex];
2814
3213
  tabs[newIndex].setAttribute("selected", "true");
2815
3214
  const val = tabs[newIndex].getAttribute("value");
2816
3215
  if (val) this.setAttribute("value", val);
2817
3216
  tabs[newIndex].focus();
3217
+ this.#syncTabIndexes();
2818
3218
  }
2819
3219
  }
2820
3220
 
@@ -2836,6 +3236,7 @@ class FigTabs extends HTMLElement {
2836
3236
  tab.removeAttribute("selected");
2837
3237
  }
2838
3238
  }
3239
+ this.#syncTabIndexes();
2839
3240
  }
2840
3241
 
2841
3242
  attributeChangedCallback(name, oldValue, newValue) {
@@ -2866,6 +3267,7 @@ class FigTabs extends HTMLElement {
2866
3267
  }
2867
3268
  const val = target.getAttribute("value");
2868
3269
  if (val) this.setAttribute("value", val);
3270
+ this.#syncTabIndexes();
2869
3271
  }
2870
3272
  }
2871
3273
  customElements.define("fig-tabs", FigTabs);
@@ -2885,6 +3287,9 @@ class FigSegment extends HTMLElement {
2885
3287
  this.#boundHandleClick = this.handleClick.bind(this);
2886
3288
  }
2887
3289
  connectedCallback() {
3290
+ if (!this.hasAttribute("role")) this.setAttribute("role", "radio");
3291
+ if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
3292
+ this.#syncA11yState();
2888
3293
  this.addEventListener("click", this.#boundHandleClick);
2889
3294
  }
2890
3295
  disconnectedCallback() {
@@ -2916,7 +3321,21 @@ class FigSegment extends HTMLElement {
2916
3321
  this.setAttribute("selected", value);
2917
3322
  }
2918
3323
  static get observedAttributes() {
2919
- return ["selected", "value"];
3324
+ return ["selected", "value", "disabled"];
3325
+ }
3326
+ #syncA11yState() {
3327
+ const selected =
3328
+ this.hasAttribute("selected") && this.getAttribute("selected") !== "false";
3329
+ const disabled =
3330
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
3331
+ this.setAttribute("aria-checked", selected ? "true" : "false");
3332
+ if (disabled) {
3333
+ this.setAttribute("aria-disabled", "true");
3334
+ this.setAttribute("tabindex", "-1");
3335
+ } else {
3336
+ this.removeAttribute("aria-disabled");
3337
+ this.setAttribute("tabindex", selected ? "0" : "-1");
3338
+ }
2920
3339
  }
2921
3340
  attributeChangedCallback(name, oldValue, newValue) {
2922
3341
  switch (name) {
@@ -2925,6 +3344,10 @@ class FigSegment extends HTMLElement {
2925
3344
  break;
2926
3345
  case "selected":
2927
3346
  this.#selected = newValue;
3347
+ this.#syncA11yState();
3348
+ break;
3349
+ case "disabled":
3350
+ this.#syncA11yState();
2928
3351
  break;
2929
3352
  }
2930
3353
  }
@@ -2941,6 +3364,7 @@ customElements.define("fig-segment", FigSegment);
2941
3364
  class FigSegmentedControl extends HTMLElement {
2942
3365
  #selectedSegment = null;
2943
3366
  #boundHandleClick = this.handleClick.bind(this);
3367
+ #boundHandleKeyDown = this.#handleKeyDown.bind(this);
2944
3368
  #mutationObserver = null;
2945
3369
  #resizeObserver = null;
2946
3370
  #indicatorFrame = 0;
@@ -2957,7 +3381,9 @@ class FigSegmentedControl extends HTMLElement {
2957
3381
 
2958
3382
  connectedCallback() {
2959
3383
  this.name = this.getAttribute("name") || "segmented-control";
3384
+ if (!this.hasAttribute("role")) this.setAttribute("role", "radiogroup");
2960
3385
  this.addEventListener("click", this.#boundHandleClick);
3386
+ this.addEventListener("keydown", this.#boundHandleKeyDown);
2961
3387
  this.#applyDisabled(
2962
3388
  this.hasAttribute("disabled") &&
2963
3389
  this.getAttribute("disabled") !== "false",
@@ -2975,6 +3401,7 @@ class FigSegmentedControl extends HTMLElement {
2975
3401
 
2976
3402
  disconnectedCallback() {
2977
3403
  this.removeEventListener("click", this.#boundHandleClick);
3404
+ this.removeEventListener("keydown", this.#boundHandleKeyDown);
2978
3405
  this.#mutationObserver?.disconnect();
2979
3406
  this.#mutationObserver = null;
2980
3407
  this.#resizeObserver?.disconnect();
@@ -3004,6 +3431,7 @@ class FigSegmentedControl extends HTMLElement {
3004
3431
  }
3005
3432
  this.#selectedSegment =
3006
3433
  segment instanceof HTMLElement && this.contains(segment) ? segment : null;
3434
+ this.#syncSegmentA11y();
3007
3435
  this.#queueIndicatorSync();
3008
3436
  }
3009
3437
 
@@ -3054,39 +3482,133 @@ class FigSegmentedControl extends HTMLElement {
3054
3482
  return null;
3055
3483
  }
3056
3484
 
3057
- #selectByValue(value) {
3058
- const normalizedValue = String(value ?? "").trim();
3059
- if (!normalizedValue) return false;
3060
-
3061
- const segments = this.querySelectorAll("fig-segment");
3062
- for (const segment of segments) {
3063
- const segmentValue = this.#resolveSegmentValue(segment);
3064
- if (!segmentValue) continue;
3065
- if (segmentValue === normalizedValue) {
3066
- this.selectedSegment = segment;
3067
- return true;
3068
- }
3069
- }
3070
-
3071
- return false;
3072
- }
3073
-
3074
- #isAnimatedEnabled() {
3075
- const rawAnimated = this.getAttribute("animated");
3076
- if (rawAnimated === null) return false;
3077
- if (rawAnimated === "") return true;
3078
- return rawAnimated.trim().toLowerCase() === "true";
3485
+ #availableSegments() {
3486
+ return Array.from(this.querySelectorAll("fig-segment")).filter(
3487
+ (segment) =>
3488
+ !segment.hasAttribute("disabled") ||
3489
+ segment.getAttribute("disabled") === "false",
3490
+ );
3079
3491
  }
3080
3492
 
3081
- #queueIndicatorSync({ forceInstant = false } = {}) {
3082
- this.#indicatorSyncInstant = this.#indicatorSyncInstant || forceInstant;
3083
- if (this.#indicatorFrame) return;
3084
-
3085
- this.#indicatorFrame = requestAnimationFrame(() => {
3086
- this.#indicatorFrame = 0;
3087
- const nextForceInstant = this.#indicatorSyncInstant;
3088
- this.#indicatorSyncInstant = false;
3089
- this.#syncIndicator({ forceInstant: nextForceInstant });
3493
+ #syncSegmentA11y() {
3494
+ const segments = Array.from(this.querySelectorAll("fig-segment"));
3495
+ const selected = segments.find((segment) => segment.hasAttribute("selected"));
3496
+ segments.forEach((segment) => {
3497
+ const disabled =
3498
+ segment.hasAttribute("disabled") &&
3499
+ segment.getAttribute("disabled") !== "false";
3500
+ const isSelected = segment === selected;
3501
+ segment.setAttribute("aria-checked", isSelected ? "true" : "false");
3502
+ segment.setAttribute("tabindex", !disabled && isSelected ? "0" : "-1");
3503
+ });
3504
+ }
3505
+
3506
+ #selectSegment(segment) {
3507
+ if (!segment) return;
3508
+ const previousSegment = this.selectedSegment;
3509
+ const previousValue = this.value;
3510
+ this.selectedSegment = segment;
3511
+ const resolvedValue = this.#resolveSegmentValue(segment);
3512
+
3513
+ if (resolvedValue) {
3514
+ this.setAttribute("value", resolvedValue);
3515
+ } else {
3516
+ this.removeAttribute("value");
3517
+ }
3518
+
3519
+ const nextValue = this.value;
3520
+ if (previousSegment !== segment || previousValue !== nextValue) {
3521
+ this.#emitSelectionEvents(nextValue);
3522
+ }
3523
+ }
3524
+
3525
+ #handleKeyDown(event) {
3526
+ if (
3527
+ this.hasAttribute("disabled") &&
3528
+ this.getAttribute("disabled") !== "false"
3529
+ ) {
3530
+ return;
3531
+ }
3532
+ const segments = this.#availableSegments();
3533
+ if (!segments.length) return;
3534
+ const currentIndex = segments.findIndex((segment) =>
3535
+ segment.hasAttribute("selected"),
3536
+ );
3537
+ let nextIndex = currentIndex >= 0 ? currentIndex : 0;
3538
+
3539
+ switch (event.key) {
3540
+ case "ArrowLeft":
3541
+ case "ArrowUp":
3542
+ event.preventDefault();
3543
+ nextIndex = nextIndex > 0 ? nextIndex - 1 : segments.length - 1;
3544
+ break;
3545
+ case "ArrowRight":
3546
+ case "ArrowDown":
3547
+ event.preventDefault();
3548
+ nextIndex = nextIndex < segments.length - 1 ? nextIndex + 1 : 0;
3549
+ break;
3550
+ case "Home":
3551
+ event.preventDefault();
3552
+ nextIndex = 0;
3553
+ break;
3554
+ case "End":
3555
+ event.preventDefault();
3556
+ nextIndex = segments.length - 1;
3557
+ break;
3558
+ case " ":
3559
+ case "Enter": {
3560
+ const active = event.target.closest("fig-segment");
3561
+ if (active && this.contains(active)) {
3562
+ event.preventDefault();
3563
+ this.#selectSegment(active);
3564
+ }
3565
+ return;
3566
+ }
3567
+ default:
3568
+ return;
3569
+ }
3570
+
3571
+ const next = segments[nextIndex];
3572
+ this.#selectSegment(next);
3573
+ next.focus();
3574
+ requestAnimationFrame(() => {
3575
+ if (this.contains(next)) next.focus();
3576
+ });
3577
+ }
3578
+
3579
+ #selectByValue(value) {
3580
+ const normalizedValue = String(value ?? "").trim();
3581
+ if (!normalizedValue) return false;
3582
+
3583
+ const segments = this.querySelectorAll("fig-segment");
3584
+ for (const segment of segments) {
3585
+ const segmentValue = this.#resolveSegmentValue(segment);
3586
+ if (!segmentValue) continue;
3587
+ if (segmentValue === normalizedValue) {
3588
+ this.selectedSegment = segment;
3589
+ return true;
3590
+ }
3591
+ }
3592
+
3593
+ return false;
3594
+ }
3595
+
3596
+ #isAnimatedEnabled() {
3597
+ const rawAnimated = this.getAttribute("animated");
3598
+ if (rawAnimated === null) return false;
3599
+ if (rawAnimated === "") return true;
3600
+ return rawAnimated.trim().toLowerCase() === "true";
3601
+ }
3602
+
3603
+ #queueIndicatorSync({ forceInstant = false } = {}) {
3604
+ this.#indicatorSyncInstant = this.#indicatorSyncInstant || forceInstant;
3605
+ if (this.#indicatorFrame) return;
3606
+
3607
+ this.#indicatorFrame = requestAnimationFrame(() => {
3608
+ this.#indicatorFrame = 0;
3609
+ const nextForceInstant = this.#indicatorSyncInstant;
3610
+ this.#indicatorSyncInstant = false;
3611
+ this.#syncIndicator({ forceInstant: nextForceInstant });
3090
3612
  });
3091
3613
  }
3092
3614
 
@@ -3226,6 +3748,7 @@ class FigSegmentedControl extends HTMLElement {
3226
3748
  );
3227
3749
  this.#refreshResizeObserverTargets();
3228
3750
  this.#syncSelectionFromAttributes({ enforceFallback: true });
3751
+ this.#syncSegmentA11y();
3229
3752
  }
3230
3753
  });
3231
3754
 
@@ -3248,22 +3771,7 @@ class FigSegmentedControl extends HTMLElement {
3248
3771
  const segment = event.target.closest("fig-segment");
3249
3772
  if (!segment || !this.contains(segment)) return;
3250
3773
 
3251
- const previousSegment = this.selectedSegment;
3252
- const previousValue = this.value;
3253
-
3254
- this.selectedSegment = segment;
3255
- const resolvedValue = this.#resolveSegmentValue(segment);
3256
-
3257
- if (resolvedValue) {
3258
- this.setAttribute("value", resolvedValue);
3259
- } else {
3260
- this.removeAttribute("value");
3261
- }
3262
-
3263
- const nextValue = this.value;
3264
- if (previousSegment !== segment || previousValue !== nextValue) {
3265
- this.#emitSelectionEvents(nextValue);
3266
- }
3774
+ this.#selectSegment(segment);
3267
3775
  }
3268
3776
 
3269
3777
  #applyDisabled(disabled) {
@@ -3277,6 +3785,7 @@ class FigSegmentedControl extends HTMLElement {
3277
3785
  segment.removeAttribute("aria-disabled");
3278
3786
  }
3279
3787
  });
3788
+ this.#syncSegmentA11y();
3280
3789
  }
3281
3790
 
3282
3791
  attributeChangedCallback(name, oldValue, newValue) {
@@ -3634,6 +4143,14 @@ class FigSlider extends HTMLElement {
3634
4143
  #showEmptyTextValue = false;
3635
4144
  #isSyncingValueAttribute = false;
3636
4145
  #value = "";
4146
+ #a11yAttributes = [
4147
+ "aria-label",
4148
+ "aria-labelledby",
4149
+ "aria-describedby",
4150
+ "aria-invalid",
4151
+ "aria-required",
4152
+ "aria-valuetext",
4153
+ ];
3637
4154
  // Private fields declarations
3638
4155
  #typeDefaults = {
3639
4156
  range: { min: 0, max: 100, step: 1 },
@@ -3647,6 +4164,12 @@ class FigSlider extends HTMLElement {
3647
4164
  #boundHandleChange;
3648
4165
  #boundHandleTextInput;
3649
4166
  #boundHandleTextChange;
4167
+ #boundHandleKeyDown;
4168
+ #boundRangePointerDown;
4169
+ #boundRangePointerUp;
4170
+ #lastSliderComplete = null;
4171
+ #lastSliderDefault = null;
4172
+ #lastSliderUnchanged = null;
3650
4173
 
3651
4174
  constructor() {
3652
4175
  super();
@@ -3662,6 +4185,9 @@ class FigSlider extends HTMLElement {
3662
4185
  e.stopPropagation();
3663
4186
  this.#handleChange();
3664
4187
  };
4188
+ this.#boundHandleKeyDown = (e) => {
4189
+ this.#handleKeyDown(e);
4190
+ };
3665
4191
 
3666
4192
  this.#boundHandleTextInput = (e) => {
3667
4193
  e.stopPropagation();
@@ -3672,6 +4198,12 @@ class FigSlider extends HTMLElement {
3672
4198
  e.stopPropagation();
3673
4199
  this.#handleTextChange();
3674
4200
  };
4201
+ this.#boundRangePointerDown = () => {
4202
+ this.#isInteracting = true;
4203
+ };
4204
+ this.#boundRangePointerUp = () => {
4205
+ this.#isInteracting = false;
4206
+ };
3675
4207
  }
3676
4208
 
3677
4209
  #regenerateInnerHTML() {
@@ -3701,8 +4233,9 @@ class FigSlider extends HTMLElement {
3701
4233
  ? 0
3702
4234
  : this.min;
3703
4235
  this.#showEmptyTextValue =
3704
- rawValue === null ||
3705
- (typeof rawValue === "string" && rawValue.trim() === "");
4236
+ this.type !== "range" &&
4237
+ (rawValue === null ||
4238
+ (typeof rawValue === "string" && rawValue.trim() === ""));
3706
4239
  this.value = this.#normalizeSliderValue(rawValue);
3707
4240
 
3708
4241
  if (this.color) {
@@ -3742,86 +4275,85 @@ class FigSlider extends HTMLElement {
3742
4275
  }
3743
4276
  this.innerHTML = html;
3744
4277
 
3745
- //child nodes hack
3746
- requestAnimationFrame(() => {
3747
- this.input = this.querySelector("[type=range]");
3748
- this.inputContainer = this.querySelector(".fig-slider-input-container");
3749
- this.input.removeEventListener("input", this.#boundHandleInput);
3750
- this.input.addEventListener("input", this.#boundHandleInput);
3751
- this.input.removeEventListener("change", this.#boundHandleChange);
3752
- this.input.addEventListener("change", this.#boundHandleChange);
3753
- this.input.addEventListener("pointerdown", () => {
3754
- this.#isInteracting = true;
3755
- });
3756
- this.input.addEventListener("pointerup", () => {
3757
- this.#isInteracting = false;
3758
- });
3759
-
3760
- if (this.default) {
3761
- this.style.setProperty(
3762
- "--default",
3763
- this.#calculateNormal(this.default),
3764
- );
3765
- }
4278
+ this.input = this.querySelector("[type=range]");
4279
+ this.inputContainer = this.querySelector(".fig-slider-input-container");
4280
+ this.#syncInputA11yAttributes();
4281
+ this.input.removeEventListener("input", this.#boundHandleInput);
4282
+ this.input.addEventListener("input", this.#boundHandleInput);
4283
+ this.input.removeEventListener("change", this.#boundHandleChange);
4284
+ this.input.addEventListener("change", this.#boundHandleChange);
4285
+ this.input.removeEventListener("keydown", this.#boundHandleKeyDown);
4286
+ this.input.addEventListener("keydown", this.#boundHandleKeyDown);
4287
+ this.input.removeEventListener("pointerdown", this.#boundRangePointerDown);
4288
+ this.input.addEventListener("pointerdown", this.#boundRangePointerDown);
4289
+ this.input.removeEventListener("pointerup", this.#boundRangePointerUp);
4290
+ this.input.addEventListener("pointerup", this.#boundRangePointerUp);
4291
+
4292
+ if (this.default) {
4293
+ this.style.setProperty(
4294
+ "--default",
4295
+ this.#calculateNormal(this.default),
4296
+ );
4297
+ }
3766
4298
 
3767
- this.datalist = this.querySelector("datalist");
3768
- this.figInputNumber = this.querySelector("fig-input-number");
3769
- if (this.datalist) {
3770
- this.inputContainer.append(this.datalist);
3771
- this.datalist.setAttribute(
3772
- "id",
3773
- this.datalist.getAttribute("id") || figUniqueId(),
3774
- );
3775
- this.input.setAttribute("list", this.datalist.getAttribute("id"));
3776
- } else if (this.type === "stepper") {
3777
- this.datalist = document.createElement("datalist");
3778
- this.datalist.setAttribute("id", figUniqueId());
3779
- let steps = (this.max - this.min) / this.step + 1;
3780
- for (let i = 0; i < steps; i++) {
3781
- let option = document.createElement("option");
3782
- option.setAttribute("value", this.min + i * this.step);
3783
- this.datalist.append(option);
3784
- }
3785
- this.inputContainer.append(this.datalist);
3786
- this.input.setAttribute("list", this.datalist.getAttribute("id"));
3787
- } else if (this.type === "delta") {
3788
- this.datalist = document.createElement("datalist");
3789
- this.datalist.setAttribute("id", figUniqueId());
4299
+ this.datalist = this.querySelector("datalist");
4300
+ this.figInputNumber = this.querySelector("fig-input-number");
4301
+ if (this.datalist) {
4302
+ this.inputContainer.append(this.datalist);
4303
+ this.datalist.setAttribute(
4304
+ "id",
4305
+ this.datalist.getAttribute("id") || figUniqueId(),
4306
+ );
4307
+ this.input.setAttribute("list", this.datalist.getAttribute("id"));
4308
+ } else if (this.type === "stepper") {
4309
+ this.datalist = document.createElement("datalist");
4310
+ this.datalist.setAttribute("id", figUniqueId());
4311
+ let steps = (this.max - this.min) / this.step + 1;
4312
+ for (let i = 0; i < steps; i++) {
3790
4313
  let option = document.createElement("option");
3791
- option.setAttribute("value", this.default);
4314
+ option.setAttribute("value", this.min + i * this.step);
3792
4315
  this.datalist.append(option);
3793
- this.inputContainer.append(this.datalist);
3794
- this.input.setAttribute("list", this.datalist.getAttribute("id"));
3795
4316
  }
3796
- if (this.datalist) {
3797
- let defaultOption = this.datalist.querySelector(
3798
- `option[value='${this.default}']`,
3799
- );
3800
- if (defaultOption) {
3801
- defaultOption.setAttribute("default", "true");
3802
- }
3803
- }
3804
- if (this.figInputNumber) {
3805
- this.figInputNumber.removeEventListener(
3806
- "input",
3807
- this.#boundHandleTextInput,
3808
- );
3809
- this.figInputNumber.addEventListener(
3810
- "input",
3811
- this.#boundHandleTextInput,
3812
- );
3813
- this.figInputNumber.removeEventListener(
3814
- "change",
3815
- this.#boundHandleTextChange,
3816
- );
3817
- this.figInputNumber.addEventListener(
3818
- "change",
3819
- this.#boundHandleTextChange,
3820
- );
4317
+ this.inputContainer.append(this.datalist);
4318
+ this.input.setAttribute("list", this.datalist.getAttribute("id"));
4319
+ } else if (this.type === "delta") {
4320
+ this.datalist = document.createElement("datalist");
4321
+ this.datalist.setAttribute("id", figUniqueId());
4322
+ let option = document.createElement("option");
4323
+ option.setAttribute("value", this.default);
4324
+ this.datalist.append(option);
4325
+ this.inputContainer.append(this.datalist);
4326
+ this.input.setAttribute("list", this.datalist.getAttribute("id"));
4327
+ }
4328
+ if (this.datalist) {
4329
+ let defaultOption = this.datalist.querySelector(
4330
+ `option[value='${this.default}']`,
4331
+ );
4332
+ if (defaultOption) {
4333
+ defaultOption.setAttribute("default", "true");
3821
4334
  }
4335
+ }
4336
+ if (this.figInputNumber) {
4337
+ this.#syncTextInputA11yAttributes();
4338
+ this.figInputNumber.removeEventListener(
4339
+ "input",
4340
+ this.#boundHandleTextInput,
4341
+ );
4342
+ this.figInputNumber.addEventListener(
4343
+ "input",
4344
+ this.#boundHandleTextInput,
4345
+ );
4346
+ this.figInputNumber.removeEventListener(
4347
+ "change",
4348
+ this.#boundHandleTextChange,
4349
+ );
4350
+ this.figInputNumber.addEventListener(
4351
+ "change",
4352
+ this.#boundHandleTextChange,
4353
+ );
4354
+ }
3822
4355
 
3823
- this.#syncValue();
3824
- });
4356
+ this.#syncValue();
3825
4357
  }
3826
4358
 
3827
4359
  connectedCallback() {
@@ -3864,6 +4396,9 @@ class FigSlider extends HTMLElement {
3864
4396
  if (this.input) {
3865
4397
  this.input.removeEventListener("input", this.#boundHandleInput);
3866
4398
  this.input.removeEventListener("change", this.#boundHandleChange);
4399
+ this.input.removeEventListener("keydown", this.#boundHandleKeyDown);
4400
+ this.input.removeEventListener("pointerdown", this.#boundRangePointerDown);
4401
+ this.input.removeEventListener("pointerup", this.#boundRangePointerUp);
3867
4402
  }
3868
4403
  if (this.figInputNumber) {
3869
4404
  this.figInputNumber.removeEventListener(
@@ -3925,6 +4460,10 @@ class FigSlider extends HTMLElement {
3925
4460
  if (deltaDefault !== null) return this.#clampToBounds(deltaDefault);
3926
4461
  return this.#clampToBounds(0);
3927
4462
  }
4463
+ if (this.type === "range") {
4464
+ const { min, max } = this.#getBounds();
4465
+ return this.#clampToBounds(min + (max - min) / 2);
4466
+ }
3928
4467
  const { min } = this.#getBounds();
3929
4468
  return min;
3930
4469
  }
@@ -3934,11 +4473,22 @@ class FigSlider extends HTMLElement {
3934
4473
  return this.#clampToBounds(parsed);
3935
4474
  }
3936
4475
  #syncProperties() {
3937
- let complete = this.#calculateNormal(this.value);
3938
- this.style.setProperty("--slider-complete", complete);
3939
- let defaultValue = this.#calculateNormal(this.default);
3940
- this.style.setProperty("--default", defaultValue);
3941
- this.style.setProperty("--unchanged", complete === defaultValue ? 1 : 0);
4476
+ const complete = this.#calculateNormal(this.value);
4477
+ const defaultValue = this.#calculateNormal(this.default);
4478
+ const unchanged = complete === defaultValue ? 1 : 0;
4479
+
4480
+ if (this.#lastSliderComplete !== complete) {
4481
+ this.style.setProperty("--slider-complete", complete);
4482
+ this.#lastSliderComplete = complete;
4483
+ }
4484
+ if (this.#lastSliderDefault !== defaultValue) {
4485
+ this.style.setProperty("--default", defaultValue);
4486
+ this.#lastSliderDefault = defaultValue;
4487
+ }
4488
+ if (this.#lastSliderUnchanged !== unchanged) {
4489
+ this.style.setProperty("--unchanged", unchanged);
4490
+ this.#lastSliderUnchanged = unchanged;
4491
+ }
3942
4492
  }
3943
4493
  #syncValue() {
3944
4494
  let val = this.input.value;
@@ -3953,6 +4503,39 @@ class FigSlider extends HTMLElement {
3953
4503
  );
3954
4504
  }
3955
4505
  }
4506
+ #syncInputA11yAttributes() {
4507
+ if (!this.input) return;
4508
+ if (this.text) {
4509
+ this.input.setAttribute("aria-hidden", "true");
4510
+ ["aria-label", "aria-labelledby", "aria-describedby", "aria-valuetext"].forEach(
4511
+ (name) => this.input.removeAttribute(name),
4512
+ );
4513
+ this.#syncTextInputA11yAttributes();
4514
+ return;
4515
+ }
4516
+ this.input.removeAttribute("aria-hidden");
4517
+ this.#a11yAttributes.forEach((name) => {
4518
+ const value = this.getAttribute(name);
4519
+ if (value === null) {
4520
+ this.input.removeAttribute(name);
4521
+ } else {
4522
+ this.input.setAttribute(name, value);
4523
+ }
4524
+ });
4525
+ }
4526
+ #syncTextInputA11yAttributes() {
4527
+ if (!this.figInputNumber) return;
4528
+ ["aria-label", "aria-labelledby", "aria-describedby", "aria-invalid", "aria-required"].forEach(
4529
+ (name) => {
4530
+ const value = this.getAttribute(name);
4531
+ if (value === null) {
4532
+ this.figInputNumber.removeAttribute(name);
4533
+ } else {
4534
+ this.figInputNumber.setAttribute(name, value);
4535
+ }
4536
+ },
4537
+ );
4538
+ }
3956
4539
 
3957
4540
  #handleInput() {
3958
4541
  this.#showEmptyTextValue = false;
@@ -3971,6 +4554,34 @@ class FigSlider extends HTMLElement {
3971
4554
  );
3972
4555
  }
3973
4556
 
4557
+ #handleKeyDown(event) {
4558
+ if (this.disabled || !event.shiftKey) return;
4559
+ if (
4560
+ !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)
4561
+ ) {
4562
+ return;
4563
+ }
4564
+
4565
+ event.preventDefault();
4566
+ this.#showEmptyTextValue = false;
4567
+
4568
+ const direction =
4569
+ event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : -1;
4570
+ const current = this.#toFiniteNumber(this.input.value) ?? this.#getFallbackValue();
4571
+ const step = this.#toFiniteNumber(this.step) ?? 1;
4572
+ const nextValue = this.#normalizeSliderValue(current + step * 10 * direction);
4573
+
4574
+ this.value = nextValue;
4575
+ this.input.value = String(nextValue);
4576
+ this.#syncValue();
4577
+ this.dispatchEvent(
4578
+ new CustomEvent("input", { detail: this.value, bubbles: true }),
4579
+ );
4580
+ this.dispatchEvent(
4581
+ new CustomEvent("change", { detail: this.value, bubbles: true }),
4582
+ );
4583
+ }
4584
+
3974
4585
  #handleTextChange() {
3975
4586
  if (this.figInputNumber) {
3976
4587
  const rawTextValue = this.figInputNumber.value;
@@ -4004,10 +4615,20 @@ class FigSlider extends HTMLElement {
4004
4615
  "placeholder",
4005
4616
  "default",
4006
4617
  "precision",
4618
+ "aria-label",
4619
+ "aria-labelledby",
4620
+ "aria-describedby",
4621
+ "aria-invalid",
4622
+ "aria-required",
4623
+ "aria-valuetext",
4007
4624
  ];
4008
4625
  }
4009
4626
 
4010
4627
  focus() {
4628
+ if (this.text && this.figInputNumber) {
4629
+ this.figInputNumber.focus();
4630
+ return;
4631
+ }
4011
4632
  this.input.focus();
4012
4633
  }
4013
4634
 
@@ -4082,6 +4703,14 @@ class FigSlider extends HTMLElement {
4082
4703
  this.text = newValue !== "false";
4083
4704
  this.#regenerateInnerHTML();
4084
4705
  break;
4706
+ case "aria-label":
4707
+ case "aria-labelledby":
4708
+ case "aria-describedby":
4709
+ case "aria-invalid":
4710
+ case "aria-required":
4711
+ case "aria-valuetext":
4712
+ this.#syncInputA11yAttributes();
4713
+ break;
4085
4714
  default:
4086
4715
  this[name] = this.input[name] = newValue;
4087
4716
  this.#syncValue();
@@ -4113,6 +4742,14 @@ class FigInputText extends HTMLElement {
4113
4742
  #boundMouseDown;
4114
4743
  #boundInputChange;
4115
4744
  #boundNativeInput;
4745
+ #boundFocusControl;
4746
+ #a11yAttributes = [
4747
+ "aria-label",
4748
+ "aria-labelledby",
4749
+ "aria-describedby",
4750
+ "aria-invalid",
4751
+ "aria-required",
4752
+ ];
4116
4753
 
4117
4754
  constructor() {
4118
4755
  super();
@@ -4128,6 +4765,7 @@ class FigInputText extends HTMLElement {
4128
4765
  this.#boundNativeInput = () => {
4129
4766
  this.#syncSearchClearVisibility();
4130
4767
  };
4768
+ this.#boundFocusControl = this.focus.bind(this);
4131
4769
  }
4132
4770
 
4133
4771
  connectedCallback() {
@@ -4144,16 +4782,6 @@ class FigInputText extends HTMLElement {
4144
4782
  if (this.getAttribute("step")) {
4145
4783
  this.step = Number(this.getAttribute("step"));
4146
4784
  }
4147
-
4148
- if (this.getAttribute("min")) {
4149
- this.input.setAttribute("min", String(this.min));
4150
- }
4151
- if (this.getAttribute("max")) {
4152
- this.input.setAttribute("max", String(this.max));
4153
- }
4154
- if (this.getAttribute("step")) {
4155
- this.input.setAttribute("step", String(this.step));
4156
- }
4157
4785
  if (this.getAttribute("min")) {
4158
4786
  this.min = Number(this.getAttribute("min"));
4159
4787
  }
@@ -4178,46 +4806,46 @@ class FigInputText extends HTMLElement {
4178
4806
  placeholder="${this.placeholder}">${this.value}</textarea>`;
4179
4807
  }
4180
4808
 
4181
- //child nodes hack
4182
- requestAnimationFrame(() => {
4183
- let append = this.querySelector("[slot=append]");
4184
- let prepend = this.querySelector("[slot=prepend]");
4809
+ let append = this.querySelector("[slot=append]");
4810
+ let prepend = this.querySelector("[slot=prepend]");
4185
4811
 
4186
- this.innerHTML = html;
4812
+ this.innerHTML = html;
4187
4813
 
4188
- if (prepend) {
4189
- prepend.addEventListener("click", this.focus.bind(this));
4190
- this.prepend(prepend);
4191
- }
4192
- if (append) {
4193
- append.addEventListener("click", this.focus.bind(this));
4194
- this.append(append);
4195
- }
4814
+ if (prepend) {
4815
+ prepend.removeEventListener("click", this.#boundFocusControl);
4816
+ prepend.addEventListener("click", this.#boundFocusControl);
4817
+ this.prepend(prepend);
4818
+ }
4819
+ if (append) {
4820
+ append.removeEventListener("click", this.#boundFocusControl);
4821
+ append.addEventListener("click", this.#boundFocusControl);
4822
+ this.append(append);
4823
+ }
4196
4824
 
4197
- this.input = this.querySelector("input,textarea");
4198
- this.input.readOnly = this.readonly;
4199
- this.#syncSearchPrefix();
4200
- this.#syncSearchClear();
4201
- this.#syncSearchClearVisibility();
4202
- this.#syncPasswordToggle();
4825
+ this.input = this.querySelector("input,textarea");
4826
+ this.input.readOnly = this.readonly;
4827
+ this.#syncInputA11yAttributes();
4828
+ this.#syncSearchPrefix();
4829
+ this.#syncSearchClear();
4830
+ this.#syncSearchClearVisibility();
4831
+ this.#syncPasswordToggle();
4203
4832
 
4204
- if (this.type === "number") {
4205
- if (this.getAttribute("min")) {
4206
- this.input.setAttribute("min", this.#transformNumber(this.min));
4207
- }
4208
- if (this.getAttribute("max")) {
4209
- this.input.setAttribute("max", this.#transformNumber(this.max));
4210
- }
4211
- if (this.getAttribute("step")) {
4212
- this.input.setAttribute("step", this.#transformNumber(this.step));
4213
- }
4214
- this.addEventListener("pointerdown", this.#boundMouseDown);
4833
+ if (this.type === "number") {
4834
+ if (this.getAttribute("min")) {
4835
+ this.input.setAttribute("min", this.#transformNumber(this.min));
4215
4836
  }
4216
- this.input.removeEventListener("change", this.#boundInputChange);
4217
- this.input.addEventListener("change", this.#boundInputChange);
4218
- this.input.removeEventListener("input", this.#boundNativeInput);
4219
- this.input.addEventListener("input", this.#boundNativeInput);
4220
- });
4837
+ if (this.getAttribute("max")) {
4838
+ this.input.setAttribute("max", this.#transformNumber(this.max));
4839
+ }
4840
+ if (this.getAttribute("step")) {
4841
+ this.input.setAttribute("step", this.#transformNumber(this.step));
4842
+ }
4843
+ this.addEventListener("pointerdown", this.#boundMouseDown);
4844
+ }
4845
+ this.input.removeEventListener("change", this.#boundInputChange);
4846
+ this.input.addEventListener("change", this.#boundInputChange);
4847
+ this.input.removeEventListener("input", this.#boundNativeInput);
4848
+ this.input.addEventListener("input", this.#boundNativeInput);
4221
4849
  }
4222
4850
 
4223
4851
  disconnectedCallback() {
@@ -4234,6 +4862,17 @@ class FigInputText extends HTMLElement {
4234
4862
  focus() {
4235
4863
  this.input.focus();
4236
4864
  }
4865
+ #syncInputA11yAttributes() {
4866
+ if (!this.input) return;
4867
+ this.#a11yAttributes.forEach((name) => {
4868
+ const value = this.getAttribute(name);
4869
+ if (value === null) {
4870
+ this.input.removeAttribute(name);
4871
+ } else {
4872
+ this.input.setAttribute(name, value);
4873
+ }
4874
+ });
4875
+ }
4237
4876
  #syncSearchPrefix() {
4238
4877
  const generated = this.querySelector(
4239
4878
  '[slot="prepend"][data-generated="search-prefix"]',
@@ -4483,6 +5122,11 @@ class FigInputText extends HTMLElement {
4483
5122
  "max",
4484
5123
  "transform",
4485
5124
  "name",
5125
+ "aria-label",
5126
+ "aria-labelledby",
5127
+ "aria-describedby",
5128
+ "aria-invalid",
5129
+ "aria-required",
4486
5130
  ];
4487
5131
  }
4488
5132
 
@@ -4540,6 +5184,13 @@ class FigInputText extends HTMLElement {
4540
5184
  this.#syncSearchClearVisibility();
4541
5185
  this.#syncPasswordToggle();
4542
5186
  break;
5187
+ case "aria-label":
5188
+ case "aria-labelledby":
5189
+ case "aria-describedby":
5190
+ case "aria-invalid":
5191
+ case "aria-required":
5192
+ this.#syncInputA11yAttributes();
5193
+ break;
4543
5194
  default:
4544
5195
  this[name] = this.input[name] = newValue;
4545
5196
  break;
@@ -4574,6 +5225,7 @@ class FigInputNumber extends HTMLElement {
4574
5225
  #boundFocus;
4575
5226
  #boundBlur;
4576
5227
  #boundKeyDown;
5228
+ #boundFocusControl;
4577
5229
  #units;
4578
5230
  #rawUnits;
4579
5231
  #unitsDisallow;
@@ -4581,6 +5233,13 @@ class FigInputNumber extends HTMLElement {
4581
5233
  #precision;
4582
5234
  #isInteracting = false;
4583
5235
  #stepperEl = null;
5236
+ #a11yAttributes = [
5237
+ "aria-label",
5238
+ "aria-labelledby",
5239
+ "aria-describedby",
5240
+ "aria-invalid",
5241
+ "aria-required",
5242
+ ];
4584
5243
  static #DEFAULT_UNITS_DISALLOW = "px";
4585
5244
 
4586
5245
  #parseUnitsDisallowList(value) {
@@ -4659,6 +5318,7 @@ class FigInputNumber extends HTMLElement {
4659
5318
  this.value = value;
4660
5319
  this.input.value = this.#formatWithUnit(this.value);
4661
5320
  this.#syncStepperState();
5321
+ this.#syncSpinbuttonAria();
4662
5322
  this.dispatchEvent(
4663
5323
  new CustomEvent("input", { detail: this.value, bubbles: true }),
4664
5324
  );
@@ -4691,6 +5351,7 @@ class FigInputNumber extends HTMLElement {
4691
5351
  this.#boundKeyDown = (e) => {
4692
5352
  this.#handleKeyDown(e);
4693
5353
  };
5354
+ this.#boundFocusControl = this.focus.bind(this);
4694
5355
  }
4695
5356
 
4696
5357
  connectedCallback() {
@@ -4734,55 +5395,56 @@ class FigInputNumber extends HTMLElement {
4734
5395
  placeholder="${this.placeholder}"
4735
5396
  value="${this.#formatWithUnit(this.value)}" />`;
4736
5397
 
4737
- //child nodes hack
4738
- requestAnimationFrame(() => {
4739
- let append = this.querySelector("[slot=append]");
4740
- let prepend = this.querySelector("[slot=prepend]");
5398
+ let append = this.querySelector("[slot=append]");
5399
+ let prepend = this.querySelector("[slot=prepend]");
4741
5400
 
4742
- this.innerHTML = html;
5401
+ this.innerHTML = html;
4743
5402
 
4744
- if (prepend) {
4745
- prepend.addEventListener("click", this.focus.bind(this));
4746
- this.prepend(prepend);
4747
- }
4748
- if (append) {
4749
- append.addEventListener("click", this.focus.bind(this));
4750
- this.append(append);
4751
- }
5403
+ if (prepend) {
5404
+ prepend.removeEventListener("click", this.#boundFocusControl);
5405
+ prepend.addEventListener("click", this.#boundFocusControl);
5406
+ this.prepend(prepend);
5407
+ }
5408
+ if (append) {
5409
+ append.removeEventListener("click", this.#boundFocusControl);
5410
+ append.addEventListener("click", this.#boundFocusControl);
5411
+ this.append(append);
5412
+ }
4752
5413
 
4753
- this.input = this.querySelector("input");
5414
+ this.input = this.querySelector("input");
5415
+ this.#syncInputA11yAttributes();
4754
5416
 
4755
- if (this.getAttribute("min")) {
4756
- this.min = Number(this.getAttribute("min"));
4757
- }
4758
- if (this.getAttribute("max")) {
4759
- this.max = Number(this.getAttribute("max"));
4760
- }
4761
- if (this.getAttribute("step")) {
4762
- this.step = Number(this.getAttribute("step"));
4763
- }
5417
+ if (this.getAttribute("min")) {
5418
+ this.min = Number(this.getAttribute("min"));
5419
+ }
5420
+ if (this.getAttribute("max")) {
5421
+ this.max = Number(this.getAttribute("max"));
5422
+ }
5423
+ if (this.getAttribute("step")) {
5424
+ this.step = Number(this.getAttribute("step"));
5425
+ }
4764
5426
 
4765
- this.#syncSteppers(hasSteppers);
5427
+ this.#syncSteppers(hasSteppers);
4766
5428
 
4767
- // Set disabled state if present
4768
- if (this.hasAttribute("disabled")) {
4769
- const disabledAttr = this.getAttribute("disabled");
4770
- this.disabled = this.input.disabled = disabledAttr !== "false";
4771
- }
4772
- this.#syncStepperState();
5429
+ // Set disabled state if present
5430
+ if (this.hasAttribute("disabled")) {
5431
+ const disabledAttr = this.getAttribute("disabled");
5432
+ this.disabled = this.input.disabled = disabledAttr !== "false";
5433
+ }
5434
+ this.#syncStepperState();
5435
+ this.#syncSpinbuttonAria();
4773
5436
 
4774
- this.addEventListener("pointerdown", this.#boundMouseDown);
4775
- this.input.removeEventListener("change", this.#boundInputChange);
4776
- this.input.addEventListener("change", this.#boundInputChange);
4777
- this.input.removeEventListener("input", this.#boundInput);
4778
- this.input.addEventListener("input", this.#boundInput);
4779
- this.input.removeEventListener("focus", this.#boundFocus);
4780
- this.input.addEventListener("focus", this.#boundFocus);
4781
- this.input.removeEventListener("blur", this.#boundBlur);
4782
- this.input.addEventListener("blur", this.#boundBlur);
4783
- this.input.removeEventListener("keydown", this.#boundKeyDown);
4784
- this.input.addEventListener("keydown", this.#boundKeyDown);
4785
- });
5437
+ this.addEventListener("pointerdown", this.#boundMouseDown);
5438
+ this.input.removeEventListener("change", this.#boundInputChange);
5439
+ this.input.addEventListener("change", this.#boundInputChange);
5440
+ this.input.removeEventListener("input", this.#boundInput);
5441
+ this.input.addEventListener("input", this.#boundInput);
5442
+ this.input.removeEventListener("focus", this.#boundFocus);
5443
+ this.input.addEventListener("focus", this.#boundFocus);
5444
+ this.input.removeEventListener("blur", this.#boundBlur);
5445
+ this.input.addEventListener("blur", this.#boundBlur);
5446
+ this.input.removeEventListener("keydown", this.#boundKeyDown);
5447
+ this.input.addEventListener("keydown", this.#boundKeyDown);
4786
5448
  }
4787
5449
 
4788
5450
  disconnectedCallback() {
@@ -4803,6 +5465,41 @@ class FigInputNumber extends HTMLElement {
4803
5465
  this.input.focus();
4804
5466
  }
4805
5467
 
5468
+ #syncInputA11yAttributes() {
5469
+ if (!this.input) return;
5470
+ this.#a11yAttributes.forEach((name) => {
5471
+ const value = this.getAttribute(name);
5472
+ if (value === null) {
5473
+ this.input.removeAttribute(name);
5474
+ } else {
5475
+ this.input.setAttribute(name, value);
5476
+ }
5477
+ });
5478
+ }
5479
+
5480
+ #syncSpinbuttonAria() {
5481
+ if (!this.input) return;
5482
+ this.input.setAttribute("role", "spinbutton");
5483
+ if (typeof this.min === "number") {
5484
+ this.input.setAttribute("aria-valuemin", String(this.min));
5485
+ } else {
5486
+ this.input.removeAttribute("aria-valuemin");
5487
+ }
5488
+ if (typeof this.max === "number") {
5489
+ this.input.setAttribute("aria-valuemax", String(this.max));
5490
+ } else {
5491
+ this.input.removeAttribute("aria-valuemax");
5492
+ }
5493
+ const value = this.value === "" ? null : Number(this.value);
5494
+ if (Number.isFinite(value)) {
5495
+ this.input.setAttribute("aria-valuenow", String(value));
5496
+ this.input.setAttribute("aria-valuetext", this.#formatWithUnit(this.value));
5497
+ } else {
5498
+ this.input.removeAttribute("aria-valuenow");
5499
+ this.input.removeAttribute("aria-valuetext");
5500
+ }
5501
+ }
5502
+
4806
5503
  #getNumericValue(str) {
4807
5504
  if (!str) return "";
4808
5505
  if (!this.#units) {
@@ -4882,6 +5579,7 @@ class FigInputNumber extends HTMLElement {
4882
5579
  e.target.value = "";
4883
5580
  }
4884
5581
  this.#syncStepperState();
5582
+ this.#syncSpinbuttonAria();
4885
5583
  this.dispatchEvent(
4886
5584
  new CustomEvent("change", { detail: this.value, bubbles: true }),
4887
5585
  );
@@ -4908,6 +5606,7 @@ class FigInputNumber extends HTMLElement {
4908
5606
  this.value = value;
4909
5607
  this.input.value = this.#formatWithUnit(this.value);
4910
5608
  this.#syncStepperState();
5609
+ this.#syncSpinbuttonAria();
4911
5610
 
4912
5611
  this.dispatchEvent(
4913
5612
  new CustomEvent("input", { detail: this.value, bubbles: true }),
@@ -4925,6 +5624,7 @@ class FigInputNumber extends HTMLElement {
4925
5624
  this.value = "";
4926
5625
  }
4927
5626
  this.#syncStepperState();
5627
+ this.#syncSpinbuttonAria();
4928
5628
  this.dispatchEvent(
4929
5629
  new CustomEvent("input", { detail: this.value, bubbles: true }),
4930
5630
  );
@@ -4943,6 +5643,7 @@ class FigInputNumber extends HTMLElement {
4943
5643
  e.target.value = "";
4944
5644
  }
4945
5645
  this.#syncStepperState();
5646
+ this.#syncSpinbuttonAria();
4946
5647
  this.dispatchEvent(
4947
5648
  new CustomEvent("input", { detail: this.value, bubbles: true }),
4948
5649
  );
@@ -4964,6 +5665,7 @@ class FigInputNumber extends HTMLElement {
4964
5665
  this.value = value;
4965
5666
  this.input.value = this.#formatWithUnit(this.value);
4966
5667
  this.#syncStepperState();
5668
+ this.#syncSpinbuttonAria();
4967
5669
  this.dispatchEvent(
4968
5670
  new CustomEvent("input", { detail: this.value, bubbles: true }),
4969
5671
  );
@@ -5037,6 +5739,11 @@ class FigInputNumber extends HTMLElement {
5037
5739
  "unit-position",
5038
5740
  "steppers",
5039
5741
  "precision",
5742
+ "aria-label",
5743
+ "aria-labelledby",
5744
+ "aria-describedby",
5745
+ "aria-invalid",
5746
+ "aria-required",
5040
5747
  ];
5041
5748
  }
5042
5749
 
@@ -5052,6 +5759,7 @@ class FigInputNumber extends HTMLElement {
5052
5759
  this.#rawUnits = newValue || "";
5053
5760
  this.#setUnitsFromAttributes();
5054
5761
  this.input.value = this.#formatWithUnit(this.value);
5762
+ this.#syncSpinbuttonAria();
5055
5763
  break;
5056
5764
  case "units-disallow":
5057
5765
  this.#unitsDisallow = this.#parseUnitsDisallowList(
@@ -5061,14 +5769,17 @@ class FigInputNumber extends HTMLElement {
5061
5769
  );
5062
5770
  this.#setUnitsFromAttributes();
5063
5771
  this.input.value = this.#formatWithUnit(this.value);
5772
+ this.#syncSpinbuttonAria();
5064
5773
  break;
5065
5774
  case "unit-position":
5066
5775
  this.#unitPosition = newValue || "suffix";
5067
5776
  this.input.value = this.#formatWithUnit(this.value);
5777
+ this.#syncSpinbuttonAria();
5068
5778
  break;
5069
5779
  case "transform":
5070
5780
  this.transform = Number(newValue) || 1;
5071
5781
  this.input.value = this.#formatWithUnit(this.value);
5782
+ this.#syncSpinbuttonAria();
5072
5783
  break;
5073
5784
  case "value":
5074
5785
  if (this.#isInteracting) break;
@@ -5080,6 +5791,7 @@ class FigInputNumber extends HTMLElement {
5080
5791
  this.value = value;
5081
5792
  this.input.value = this.#formatWithUnit(this.value);
5082
5793
  this.#syncStepperState();
5794
+ this.#syncSpinbuttonAria();
5083
5795
  break;
5084
5796
  case "min":
5085
5797
  case "max":
@@ -5087,10 +5799,12 @@ class FigInputNumber extends HTMLElement {
5087
5799
  if (newValue === null || newValue === "") {
5088
5800
  this[name] = undefined;
5089
5801
  this.#syncStepperState();
5802
+ this.#syncSpinbuttonAria();
5090
5803
  break;
5091
5804
  }
5092
5805
  this[name] = Number(newValue);
5093
5806
  this.#syncStepperState();
5807
+ this.#syncSpinbuttonAria();
5094
5808
  break;
5095
5809
  case "steppers": {
5096
5810
  const hasSteppers = newValue !== null && newValue !== "false";
@@ -5100,6 +5814,7 @@ class FigInputNumber extends HTMLElement {
5100
5814
  case "precision":
5101
5815
  this.#precision = newValue !== null ? Number(newValue) : 2;
5102
5816
  this.input.value = this.#formatWithUnit(this.value);
5817
+ this.#syncSpinbuttonAria();
5103
5818
  break;
5104
5819
  case "name":
5105
5820
  this[name] = this.input[name] = newValue;
@@ -5109,6 +5824,13 @@ class FigInputNumber extends HTMLElement {
5109
5824
  this.placeholder = newValue ?? "";
5110
5825
  this.input.placeholder = this.placeholder;
5111
5826
  break;
5827
+ case "aria-label":
5828
+ case "aria-labelledby":
5829
+ case "aria-describedby":
5830
+ case "aria-invalid":
5831
+ case "aria-required":
5832
+ this.#syncInputA11yAttributes();
5833
+ break;
5112
5834
  default:
5113
5835
  this[name] = this.input[name] = newValue;
5114
5836
  break;
@@ -5129,9 +5851,7 @@ class FigAvatar extends HTMLElement {
5129
5851
  this.initials = this.getInitials(this.name);
5130
5852
  this.setAttribute("initials", this.initials);
5131
5853
  this.setSrc(this.src);
5132
- requestAnimationFrame(() => {
5133
- this.img = this.querySelector("img");
5134
- });
5854
+ this.img = this.querySelector("img");
5135
5855
  }
5136
5856
  setSrc(src) {
5137
5857
  this.src = src;
@@ -5172,9 +5892,15 @@ class FigField extends HTMLElement {
5172
5892
  #toggleable = false;
5173
5893
  #chevron = null;
5174
5894
  #boundToggle = null;
5895
+ #boundFocus = null;
5896
+ #boundLabelEnter = null;
5897
+ #boundLabelLeave = null;
5175
5898
 
5176
5899
  constructor() {
5177
5900
  super();
5901
+ this.#boundFocus = this.focus.bind(this);
5902
+ this.#boundLabelEnter = this.#onLabelEnter.bind(this);
5903
+ this.#boundLabelLeave = this.#onLabelLeave.bind(this);
5178
5904
  }
5179
5905
 
5180
5906
  static get observedAttributes() {
@@ -5182,51 +5908,52 @@ class FigField extends HTMLElement {
5182
5908
  }
5183
5909
 
5184
5910
  connectedCallback() {
5185
- requestAnimationFrame(() => {
5186
- this.label = this.querySelector(":scope>label");
5187
- this.input = Array.from(this.childNodes).find((node) =>
5188
- node.nodeName.toLowerCase().startsWith("fig-"),
5189
- );
5911
+ queueMicrotask(() => {
5912
+ if (this.isConnected) this.#setup();
5913
+ });
5914
+ }
5915
+
5916
+ #setup() {
5917
+ this.label = this.querySelector(":scope>label");
5918
+ this.input = Array.from(this.childNodes).find((node) =>
5919
+ node.nodeName.toLowerCase().startsWith("fig-"),
5920
+ );
5190
5921
 
5191
- this.#toggleable = !!(this.input && "open" in this.input);
5922
+ this.#toggleable = !!(this.input && "open" in this.input);
5192
5923
 
5193
- if (this.#toggleable && this.label) {
5924
+ if (this.#toggleable && this.label) {
5925
+ if (!this.#chevron || !this.#chevron.isConnected) {
5194
5926
  this.#chevron = createFigIcon("chevron", {
5195
5927
  size: "small",
5196
5928
  className: "fig-field-chevron",
5197
5929
  });
5198
5930
  this.insertBefore(this.#chevron, this.label);
5199
-
5200
- this.#boundToggle = (e) => {
5201
- e.preventDefault();
5202
- e.stopPropagation();
5203
- if (this.input && typeof this.input.open !== "undefined") {
5204
- this.input.open = !this.input.open;
5205
- }
5206
- };
5207
- this.#chevron.addEventListener("click", this.#boundToggle);
5208
- this.label.addEventListener("click", this.#boundToggle);
5209
- } else if (this.input && this.label) {
5210
- this.label.addEventListener("click", this.focus.bind(this));
5211
5931
  }
5212
5932
 
5213
- if (this.input && this.label && !this.#toggleable) {
5214
- let inputId = this.input.getAttribute("id") || figUniqueId();
5215
- this.input.setAttribute("id", inputId);
5216
- this.label.setAttribute("for", inputId);
5217
- }
5933
+ this.#boundToggle = (e) => {
5934
+ e.preventDefault();
5935
+ e.stopPropagation();
5936
+ if (this.input && typeof this.input.open !== "undefined") {
5937
+ this.input.open = !this.input.open;
5938
+ }
5939
+ };
5940
+ this.#chevron.addEventListener("click", this.#boundToggle);
5941
+ this.label.addEventListener("click", this.#boundToggle);
5942
+ } else if (this.input && this.label) {
5943
+ this.label.removeEventListener("click", this.#boundFocus);
5944
+ this.label.addEventListener("click", this.#boundFocus);
5945
+ }
5946
+
5947
+ if (this.input && this.label && !this.#toggleable) {
5948
+ this.#syncLabelAssociation();
5949
+ }
5218
5950
 
5219
- if (this.label) {
5220
- this.label.addEventListener(
5221
- "pointerenter",
5222
- this.#onLabelEnter.bind(this),
5223
- );
5224
- this.label.addEventListener(
5225
- "pointerleave",
5226
- this.#onLabelLeave.bind(this),
5227
- );
5228
- }
5229
- });
5951
+ if (this.label) {
5952
+ this.label.removeEventListener("pointerenter", this.#boundLabelEnter);
5953
+ this.label.addEventListener("pointerenter", this.#boundLabelEnter);
5954
+ this.label.removeEventListener("pointerleave", this.#boundLabelLeave);
5955
+ this.label.addEventListener("pointerleave", this.#boundLabelLeave);
5956
+ }
5230
5957
  }
5231
5958
 
5232
5959
  disconnectedCallback() {
@@ -5234,6 +5961,15 @@ class FigField extends HTMLElement {
5234
5961
  if (this.label && this.#boundToggle) {
5235
5962
  this.label.removeEventListener("click", this.#boundToggle);
5236
5963
  }
5964
+ if (this.label && this.#boundFocus) {
5965
+ this.label.removeEventListener("click", this.#boundFocus);
5966
+ }
5967
+ if (this.label && this.#boundLabelEnter) {
5968
+ this.label.removeEventListener("pointerenter", this.#boundLabelEnter);
5969
+ }
5970
+ if (this.label && this.#boundLabelLeave) {
5971
+ this.label.removeEventListener("pointerleave", this.#boundLabelLeave);
5972
+ }
5237
5973
  if (this.#chevron && this.#boundToggle) {
5238
5974
  this.#chevron.removeEventListener("click", this.#boundToggle);
5239
5975
  }
@@ -5248,11 +5984,36 @@ class FigField extends HTMLElement {
5248
5984
  if (this.label) FigTooltip.hide(this.label);
5249
5985
  }
5250
5986
 
5987
+ #syncLabelAssociation() {
5988
+ if (!this.input || !this.label) return;
5989
+ const labelId = this.label.getAttribute("id") || figUniqueId();
5990
+ this.label.setAttribute("id", labelId);
5991
+ const nativeInputs = this.input.querySelectorAll("input, select, textarea");
5992
+ if (nativeInputs.length === 1) {
5993
+ const nativeInput = nativeInputs[0];
5994
+ const inputId = nativeInput.getAttribute("id") || figUniqueId();
5995
+ nativeInput.setAttribute("id", inputId);
5996
+ this.label.setAttribute("for", inputId);
5997
+ if (this.input.getAttribute("aria-labelledby") === labelId) {
5998
+ this.input.removeAttribute("aria-labelledby");
5999
+ }
6000
+ if (!nativeInput.hasAttribute("aria-labelledby")) {
6001
+ nativeInput.setAttribute("aria-labelledby", labelId);
6002
+ }
6003
+ return;
6004
+ }
6005
+ this.label.removeAttribute("for");
6006
+ if (!this.input.hasAttribute("aria-label") && !this.input.hasAttribute("aria-labelledby")) {
6007
+ this.input.setAttribute("aria-labelledby", labelId);
6008
+ }
6009
+ }
6010
+
5251
6011
  attributeChangedCallback(name, oldValue, newValue) {
5252
6012
  switch (name) {
5253
6013
  case "label":
5254
6014
  if (this.label) {
5255
6015
  this.label.textContent = newValue;
6016
+ this.#syncLabelAssociation();
5256
6017
  }
5257
6018
  break;
5258
6019
  }
@@ -5323,15 +6084,9 @@ class FigInputColor extends HTMLElement {
5323
6084
  }
5324
6085
 
5325
6086
  connectedCallback() {
5326
- if (this.#renderRAF) cancelAnimationFrame(this.#renderRAF);
5327
- this.#renderRAF = requestAnimationFrame(() => {
5328
- this.#renderRAF = null;
5329
- this.#buildUI();
5330
- });
6087
+ this.#buildUI();
5331
6088
  }
5332
6089
 
5333
- #renderRAF = null;
5334
-
5335
6090
  #buildUI() {
5336
6091
  this.#setValues(this.getAttribute("value"));
5337
6092
 
@@ -5371,61 +6126,60 @@ class FigInputColor extends HTMLElement {
5371
6126
  }
5372
6127
  this.innerHTML = html;
5373
6128
 
5374
- requestAnimationFrame(() => {
5375
- this.#swatch = this.querySelector("fig-chit");
5376
- this.#fillPicker = this.querySelector("fig-fill-picker");
5377
- this.#textInput = this.querySelector("fig-input-text:not([type=number])");
5378
- this.#alphaInput = this.querySelector("fig-input-number");
6129
+ this.#swatch = this.querySelector("fig-chit");
6130
+ this.#fillPicker = this.querySelector("fig-fill-picker");
6131
+ this.#textInput = this.querySelector("fig-input-text:not([type=number])");
6132
+ this.#alphaInput = this.querySelector("fig-input-number");
6133
+ this.#syncA11yAttributes();
5379
6134
 
5380
- // Setup swatch (native picker)
5381
- if (this.#swatch) {
5382
- this.#swatch.disabled = this.hasAttribute("disabled");
5383
- const swatchInput = this.#swatch.querySelector('input[type="color"]');
5384
- if (this.#textInput || this.hasAttribute("swatch-disabled")) {
5385
- swatchInput?.setAttribute("tabindex", "-1");
5386
- }
5387
- if (this.hasAttribute("swatch-disabled")) {
5388
- swatchInput?.setAttribute("disabled", "");
5389
- if (swatchInput) swatchInput.style.pointerEvents = "none";
5390
- }
5391
- this.#swatch.addEventListener("pointerdown", this.#handleSwatchPointerDown.bind(this), {
5392
- capture: true,
5393
- });
5394
- this.#swatch.addEventListener("click", this.#handleSwatchClick.bind(this), {
5395
- capture: true,
5396
- });
5397
- swatchInput?.addEventListener("keydown", this.#handleSwatchKeyDown.bind(this));
5398
- this.#swatch.addEventListener("input", this.#handleInput.bind(this));
6135
+ // Setup swatch (native picker)
6136
+ if (this.#swatch) {
6137
+ this.#swatch.disabled = this.hasAttribute("disabled");
6138
+ const swatchInput = this.#swatch.querySelector('input[type="color"]');
6139
+ if (this.#textInput || this.hasAttribute("swatch-disabled")) {
6140
+ swatchInput?.setAttribute("tabindex", "-1");
5399
6141
  }
5400
-
5401
- if (this.#textInput) {
5402
- const hex = this.rgbAlphaToHex(this.rgba, 1);
5403
- // Display without # prefix
5404
- this.#textInput.value = hex.slice(1).toUpperCase();
5405
- if (this.#swatch) {
5406
- this.#swatch.background = hex;
5407
- }
5408
- this.#textInput.addEventListener(
5409
- "input",
5410
- this.#handleTextInput.bind(this),
5411
- );
5412
- this.#textInput.addEventListener(
5413
- "change",
5414
- this.#handleChange.bind(this),
5415
- );
6142
+ if (this.hasAttribute("swatch-disabled")) {
6143
+ swatchInput?.setAttribute("disabled", "");
6144
+ if (swatchInput) swatchInput.style.pointerEvents = "none";
5416
6145
  }
6146
+ this.#swatch.addEventListener("pointerdown", this.#handleSwatchPointerDown.bind(this), {
6147
+ capture: true,
6148
+ });
6149
+ this.#swatch.addEventListener("click", this.#handleSwatchClick.bind(this), {
6150
+ capture: true,
6151
+ });
6152
+ swatchInput?.addEventListener("keydown", this.#handleSwatchKeyDown.bind(this));
6153
+ this.#swatch.addEventListener("input", this.#handleInput.bind(this));
6154
+ }
5417
6155
 
5418
- if (this.#alphaInput) {
5419
- this.#alphaInput.addEventListener(
5420
- "input",
5421
- this.#handleAlphaInput.bind(this),
5422
- );
5423
- this.#alphaInput.addEventListener(
5424
- "change",
5425
- this.#handleChange.bind(this),
5426
- );
6156
+ if (this.#textInput) {
6157
+ const hex = this.rgbAlphaToHex(this.rgba, 1);
6158
+ // Display without # prefix
6159
+ this.#textInput.value = hex.slice(1).toUpperCase();
6160
+ if (this.#swatch) {
6161
+ this.#swatch.background = hex;
5427
6162
  }
5428
- });
6163
+ this.#textInput.addEventListener(
6164
+ "input",
6165
+ this.#handleTextInput.bind(this),
6166
+ );
6167
+ this.#textInput.addEventListener(
6168
+ "change",
6169
+ this.#handleChange.bind(this),
6170
+ );
6171
+ }
6172
+
6173
+ if (this.#alphaInput) {
6174
+ this.#alphaInput.addEventListener(
6175
+ "input",
6176
+ this.#handleAlphaInput.bind(this),
6177
+ );
6178
+ this.#alphaInput.addEventListener(
6179
+ "change",
6180
+ this.#handleChange.bind(this),
6181
+ );
6182
+ }
5429
6183
  }
5430
6184
 
5431
6185
  #syncFillPicker() {
@@ -5590,6 +6344,56 @@ class FigInputColor extends HTMLElement {
5590
6344
  this.#swatch?.focus();
5591
6345
  }
5592
6346
 
6347
+ #accessibleName() {
6348
+ return this.getAttribute("aria-label") || "Color";
6349
+ }
6350
+
6351
+ #syncA11yAttributes() {
6352
+ if (!this.hasAttribute("role")) this.setAttribute("role", "group");
6353
+ if (this.#disabled) this.setAttribute("aria-disabled", "true");
6354
+ else this.removeAttribute("aria-disabled");
6355
+
6356
+ const describedBy = this.getAttribute("aria-describedby");
6357
+ const invalid = this.getAttribute("aria-invalid");
6358
+ const required = this.getAttribute("aria-required");
6359
+ const labelledBy = this.getAttribute("aria-labelledby");
6360
+ const name = this.#accessibleName();
6361
+
6362
+ if (this.#textInput) {
6363
+ this.#textInput.setAttribute("aria-label", `${name} hex color`);
6364
+ if (describedBy) this.#textInput.setAttribute("aria-describedby", describedBy);
6365
+ else this.#textInput.removeAttribute("aria-describedby");
6366
+ if (invalid) this.#textInput.setAttribute("aria-invalid", invalid);
6367
+ else this.#textInput.removeAttribute("aria-invalid");
6368
+ if (required) this.#textInput.setAttribute("aria-required", required);
6369
+ else this.#textInput.removeAttribute("aria-required");
6370
+ }
6371
+
6372
+ if (this.#alphaInput) {
6373
+ this.#alphaInput.setAttribute("aria-label", `${name} opacity`);
6374
+ if (describedBy) this.#alphaInput.setAttribute("aria-describedby", describedBy);
6375
+ else this.#alphaInput.removeAttribute("aria-describedby");
6376
+ if (invalid) this.#alphaInput.setAttribute("aria-invalid", invalid);
6377
+ else this.#alphaInput.removeAttribute("aria-invalid");
6378
+ if (required) this.#alphaInput.setAttribute("aria-required", required);
6379
+ else this.#alphaInput.removeAttribute("aria-required");
6380
+ }
6381
+
6382
+ if (!this.#textInput) {
6383
+ const swatchInput = this.#swatch?.querySelector('input[type="color"]');
6384
+ if (!swatchInput) return;
6385
+ if (labelledBy) {
6386
+ swatchInput.setAttribute("aria-labelledby", labelledBy);
6387
+ swatchInput.removeAttribute("aria-label");
6388
+ } else {
6389
+ swatchInput.setAttribute("aria-label", name);
6390
+ swatchInput.removeAttribute("aria-labelledby");
6391
+ }
6392
+ if (describedBy) swatchInput.setAttribute("aria-describedby", describedBy);
6393
+ else swatchInput.removeAttribute("aria-describedby");
6394
+ }
6395
+ }
6396
+
5593
6397
  #handleInput(event) {
5594
6398
  //do not propagate to onInput handler for web component
5595
6399
  event.stopPropagation();
@@ -5662,6 +6466,11 @@ class FigInputColor extends HTMLElement {
5662
6466
  "alpha",
5663
6467
  "text",
5664
6468
  "disabled",
6469
+ "aria-label",
6470
+ "aria-labelledby",
6471
+ "aria-describedby",
6472
+ "aria-invalid",
6473
+ "aria-required",
5665
6474
  ];
5666
6475
  }
5667
6476
 
@@ -5713,6 +6522,13 @@ class FigInputColor extends HTMLElement {
5713
6522
  case "disabled":
5714
6523
  this.#syncDisabled();
5715
6524
  break;
6525
+ case "aria-label":
6526
+ case "aria-labelledby":
6527
+ case "aria-describedby":
6528
+ case "aria-invalid":
6529
+ case "aria-required":
6530
+ this.#syncA11yAttributes();
6531
+ break;
5716
6532
  }
5717
6533
  }
5718
6534
 
@@ -5729,6 +6545,7 @@ class FigInputColor extends HTMLElement {
5729
6545
  if (disabled) child.setAttribute("disabled", "");
5730
6546
  else child.removeAttribute("disabled");
5731
6547
  }
6548
+ this.#syncA11yAttributes();
5732
6549
  if (this.#fillPicker) {
5733
6550
  this.#syncFillPicker();
5734
6551
  }
@@ -5848,18 +6665,15 @@ const GRADIENT_HUE_INTERPOLATIONS = [
5848
6665
  "decreasing",
5849
6666
  ];
5850
6667
 
5851
- const GRADIENT_PICKER_SPACES = ["srgb-linear", "oklab", "oklch"];
6668
+ const GRADIENT_PICKER_SPACES = ["srgb", "srgb-linear", "oklab", "oklch"];
5852
6669
 
5853
6670
  function normalizeGradientConfig(gradient) {
5854
6671
  const next = { ...(gradient ?? {}) };
5855
6672
  let interpolationSpace = String(
5856
- next.interpolationSpace ?? "oklab",
6673
+ next.interpolationSpace ?? "srgb",
5857
6674
  ).toLowerCase();
5858
6675
  if (!GRADIENT_INTERPOLATION_SPACES.includes(interpolationSpace)) {
5859
- interpolationSpace = "oklab";
5860
- }
5861
- if (interpolationSpace === "srgb" || interpolationSpace === "display-p3") {
5862
- interpolationSpace = "oklab";
6676
+ interpolationSpace = "srgb";
5863
6677
  }
5864
6678
  next.interpolationSpace = interpolationSpace;
5865
6679
 
@@ -5888,6 +6702,9 @@ function gradientToValueShape(gradient) {
5888
6702
 
5889
6703
  function gradientInterpolationClause(gradient) {
5890
6704
  const normalized = normalizeGradientConfig(gradient);
6705
+ if (normalized.interpolationSpace === "srgb") {
6706
+ return "";
6707
+ }
5891
6708
  if (normalized.interpolationSpace === "oklch") {
5892
6709
  return `in oklch ${normalized.hueInterpolation} hue`;
5893
6710
  }
@@ -6115,7 +6932,7 @@ class FigInputFill extends HTMLElement {
6115
6932
  #gradient = {
6116
6933
  type: "linear",
6117
6934
  angle: 180,
6118
- interpolationSpace: "oklab",
6935
+ interpolationSpace: "srgb",
6119
6936
  hueInterpolation: "shorter",
6120
6937
  stops: [
6121
6938
  { position: 0, color: "#D9D9D9", opacity: 100 },
@@ -6131,10 +6948,21 @@ class FigInputFill extends HTMLElement {
6131
6948
  }
6132
6949
 
6133
6950
  static get observedAttributes() {
6134
- return ["value", "disabled", "mode", "experimental", "alpha"];
6951
+ return [
6952
+ "value",
6953
+ "disabled",
6954
+ "mode",
6955
+ "experimental",
6956
+ "alpha",
6957
+ "aria-label",
6958
+ "aria-describedby",
6959
+ "aria-invalid",
6960
+ "aria-required",
6961
+ ];
6135
6962
  }
6136
6963
 
6137
6964
  connectedCallback() {
6965
+ if (!this.hasAttribute("role")) this.setAttribute("role", "group");
6138
6966
  this.#parseValue();
6139
6967
  this.#render();
6140
6968
  }
@@ -6257,6 +7085,7 @@ class FigInputFill extends HTMLElement {
6257
7085
 
6258
7086
  #syncDisabled() {
6259
7087
  const disabled = this.hasAttribute("disabled");
7088
+ this.setAttribute("aria-disabled", disabled ? "true" : "false");
6260
7089
  for (const child of [
6261
7090
  this.#fillPicker,
6262
7091
  this.#opacityInput,
@@ -6268,6 +7097,28 @@ class FigInputFill extends HTMLElement {
6268
7097
  }
6269
7098
  }
6270
7099
 
7100
+ #syncA11y() {
7101
+ if (!this.hasAttribute("role")) this.setAttribute("role", "group");
7102
+ this.#syncDisabled();
7103
+ const name = this.getAttribute("aria-label") || "Fill";
7104
+ const describedBy = this.getAttribute("aria-describedby");
7105
+ const invalid = this.getAttribute("aria-invalid");
7106
+ const required = this.getAttribute("aria-required");
7107
+ const syncState = (el, label) => {
7108
+ if (!el) return;
7109
+ el.setAttribute("aria-label", label);
7110
+ if (describedBy) el.setAttribute("aria-describedby", describedBy);
7111
+ else el.removeAttribute("aria-describedby");
7112
+ if (invalid) el.setAttribute("aria-invalid", invalid);
7113
+ else el.removeAttribute("aria-invalid");
7114
+ if (required) el.setAttribute("aria-required", required);
7115
+ else el.removeAttribute("aria-required");
7116
+ };
7117
+ syncState(this.#fillPicker, `${name} picker`);
7118
+ syncState(this.#hexInput, `${name} hex color`);
7119
+ syncState(this.#opacityInput, `${name} opacity`);
7120
+ }
7121
+
6271
7122
  #render() {
6272
7123
  const disabled = this.hasAttribute("disabled");
6273
7124
  const fillPickerValue = JSON.stringify(this.value);
@@ -6347,127 +7198,126 @@ class FigInputFill extends HTMLElement {
6347
7198
  }
6348
7199
 
6349
7200
  #setupEventListeners() {
6350
- requestAnimationFrame(() => {
6351
- this.#fillPicker = this.querySelector("fig-fill-picker");
6352
- this.#opacityInput = this.querySelector(".fig-input-fill-opacity");
6353
- this.#hexInput = this.querySelector(".fig-input-fill-hex");
6354
- const label = this.querySelector(".fig-input-fill-label");
6355
-
6356
- // Label click triggers fill picker
6357
- if (label && this.#fillPicker) {
6358
- label.addEventListener("click", () => {
6359
- const chit = this.#fillPicker.querySelector("fig-chit");
6360
- if (chit) {
6361
- chit.click();
6362
- }
6363
- });
7201
+ this.#fillPicker = this.querySelector("fig-fill-picker");
7202
+ this.#opacityInput = this.querySelector(".fig-input-fill-opacity");
7203
+ this.#hexInput = this.querySelector(".fig-input-fill-hex");
7204
+ const label = this.querySelector(".fig-input-fill-label");
7205
+ this.#syncA11y();
7206
+
7207
+ // Label click triggers fill picker
7208
+ if (label && this.#fillPicker) {
7209
+ label.addEventListener("click", () => {
7210
+ const chit = this.#fillPicker.querySelector("fig-chit");
7211
+ if (chit) {
7212
+ chit.click();
7213
+ }
7214
+ });
7215
+ }
7216
+
7217
+ if (this.#fillPicker) {
7218
+ const anchor = this.getAttribute("picker-anchor");
7219
+ if (!anchor || anchor === "self") {
7220
+ this.#fillPicker.anchorElement = this;
7221
+ } else {
7222
+ const el = document.querySelector(anchor);
7223
+ if (el) this.#fillPicker.anchorElement = el;
6364
7224
  }
6365
7225
 
6366
- if (this.#fillPicker) {
6367
- const anchor = this.getAttribute("picker-anchor");
6368
- if (!anchor || anchor === "self") {
6369
- this.#fillPicker.anchorElement = this;
6370
- } else {
6371
- const el = document.querySelector(anchor);
6372
- if (el) this.#fillPicker.anchorElement = el;
7226
+ this.#fillPicker.addEventListener("input", (e) => {
7227
+ e.stopPropagation();
7228
+ const detail = e.detail;
7229
+ if (!detail) return;
7230
+
7231
+ const newType = detail.type;
7232
+ const typeChanged = newType !== this.#fillType;
7233
+
7234
+ // Update internal state
7235
+ this.#fillType = newType;
7236
+ switch (newType) {
7237
+ case "solid":
7238
+ this.#solid.color = detail.color;
7239
+ this.#solid.alpha = detail.alpha;
7240
+ break;
7241
+ case "gradient":
7242
+ if (detail.gradient) {
7243
+ this.#gradient = normalizeGradientConfig({
7244
+ ...this.#gradient,
7245
+ ...detail.gradient,
7246
+ });
7247
+ }
7248
+ break;
7249
+ case "image":
7250
+ if (detail.image) this.#image = detail.image;
7251
+ break;
7252
+ case "video":
7253
+ if (detail.video) this.#video = detail.video;
7254
+ break;
6373
7255
  }
6374
7256
 
6375
- this.#fillPicker.addEventListener("input", (e) => {
6376
- e.stopPropagation();
6377
- const detail = e.detail;
6378
- if (!detail) return;
6379
-
6380
- const newType = detail.type;
6381
- const typeChanged = newType !== this.#fillType;
6382
-
6383
- // Update internal state
6384
- this.#fillType = newType;
6385
- switch (newType) {
6386
- case "solid":
6387
- this.#solid.color = detail.color;
6388
- this.#solid.alpha = detail.alpha;
6389
- break;
6390
- case "gradient":
6391
- if (detail.gradient) {
6392
- this.#gradient = normalizeGradientConfig({
6393
- ...this.#gradient,
6394
- ...detail.gradient,
6395
- });
6396
- }
6397
- break;
6398
- case "image":
6399
- if (detail.image) this.#image = detail.image;
6400
- break;
6401
- case "video":
6402
- if (detail.video) this.#video = detail.video;
6403
- break;
6404
- }
6405
-
6406
- // Update controls (don't re-render to keep dialog open)
6407
- if (typeChanged) {
6408
- this.#updateControlsForType();
6409
- } else {
6410
- this.#updateControls();
6411
- }
7257
+ // Update controls (don't re-render to keep dialog open)
7258
+ if (typeChanged) {
7259
+ this.#updateControlsForType();
7260
+ } else {
7261
+ this.#updateControls();
7262
+ }
6412
7263
 
6413
- this.#emitInput();
6414
- });
7264
+ this.#emitInput();
7265
+ });
6415
7266
 
6416
- this.#fillPicker.addEventListener("change", (e) => {
6417
- e.stopPropagation();
6418
- this.#emitChange();
6419
- });
6420
- }
7267
+ this.#fillPicker.addEventListener("change", (e) => {
7268
+ e.stopPropagation();
7269
+ this.#emitChange();
7270
+ });
7271
+ }
6421
7272
 
6422
- // Hex input (solid only)
6423
- if (this.#hexInput) {
6424
- this.#hexInput.addEventListener("input", (e) => {
6425
- e.stopPropagation();
6426
- const hex = "#" + e.target.value.replace("#", "");
6427
- this.#solid.color = hex;
6428
- this.#updateFillPicker();
6429
- this.#emitInput();
6430
- });
6431
- this.#hexInput.addEventListener("change", (e) => {
6432
- e.stopPropagation();
6433
- this.#emitChange();
6434
- });
6435
- }
7273
+ // Hex input (solid only)
7274
+ if (this.#hexInput) {
7275
+ this.#hexInput.addEventListener("input", (e) => {
7276
+ e.stopPropagation();
7277
+ const hex = "#" + e.target.value.replace("#", "");
7278
+ this.#solid.color = hex;
7279
+ this.#updateFillPicker();
7280
+ this.#emitInput();
7281
+ });
7282
+ this.#hexInput.addEventListener("change", (e) => {
7283
+ e.stopPropagation();
7284
+ this.#emitChange();
7285
+ });
7286
+ }
6436
7287
 
6437
- // Opacity input (all fill types)
6438
- if (this.#opacityInput) {
6439
- this.#opacityInput.addEventListener("input", (e) => {
6440
- e.stopPropagation();
6441
- const parsed = parseFloat(e.target.value);
6442
- const opacity = isNaN(parsed) ? 100 : parsed;
6443
- const alpha = opacity / 100;
6444
- switch (this.#fillType) {
6445
- case "solid":
6446
- this.#solid.alpha = alpha;
6447
- break;
6448
- case "gradient":
6449
- break;
6450
- case "image":
6451
- this.#image.opacity = alpha;
6452
- break;
6453
- case "video":
6454
- this.#video.opacity = alpha;
6455
- break;
6456
- case "webcam":
6457
- this.#webcam.opacity = alpha;
6458
- break;
6459
- }
6460
- this.#updateFillPicker();
6461
- // Update the chit's alpha
6462
- this.#updateChitAlpha(alpha);
6463
- this.#emitInput();
6464
- });
6465
- this.#opacityInput.addEventListener("change", (e) => {
6466
- e.stopPropagation();
6467
- this.#emitChange();
6468
- });
6469
- }
6470
- });
7288
+ // Opacity input (all fill types)
7289
+ if (this.#opacityInput) {
7290
+ this.#opacityInput.addEventListener("input", (e) => {
7291
+ e.stopPropagation();
7292
+ const parsed = parseFloat(e.target.value);
7293
+ const opacity = isNaN(parsed) ? 100 : parsed;
7294
+ const alpha = opacity / 100;
7295
+ switch (this.#fillType) {
7296
+ case "solid":
7297
+ this.#solid.alpha = alpha;
7298
+ break;
7299
+ case "gradient":
7300
+ break;
7301
+ case "image":
7302
+ this.#image.opacity = alpha;
7303
+ break;
7304
+ case "video":
7305
+ this.#video.opacity = alpha;
7306
+ break;
7307
+ case "webcam":
7308
+ this.#webcam.opacity = alpha;
7309
+ break;
7310
+ }
7311
+ this.#updateFillPicker();
7312
+ // Update the chit's alpha
7313
+ this.#updateChitAlpha(alpha);
7314
+ this.#emitInput();
7315
+ });
7316
+ this.#opacityInput.addEventListener("change", (e) => {
7317
+ e.stopPropagation();
7318
+ this.#emitChange();
7319
+ });
7320
+ }
6471
7321
  }
6472
7322
 
6473
7323
  #updateControls() {
@@ -6634,69 +7484,68 @@ class FigInputFill extends HTMLElement {
6634
7484
  combo.insertAdjacentHTML("beforeend", controlsHtml);
6635
7485
 
6636
7486
  // Re-setup event listeners for the new controls
6637
- requestAnimationFrame(() => {
6638
- this.#opacityInput = this.querySelector(".fig-input-fill-opacity");
6639
- this.#hexInput = this.querySelector(".fig-input-fill-hex");
6640
- const label = this.querySelector(".fig-input-fill-label");
6641
-
6642
- // Label click triggers fill picker
6643
- if (label && this.#fillPicker) {
6644
- label.addEventListener("click", () => {
6645
- const chit = this.#fillPicker.querySelector("fig-chit");
6646
- if (chit) {
6647
- chit.click();
6648
- }
6649
- });
6650
- }
7487
+ this.#opacityInput = this.querySelector(".fig-input-fill-opacity");
7488
+ this.#hexInput = this.querySelector(".fig-input-fill-hex");
7489
+ const label = this.querySelector(".fig-input-fill-label");
7490
+ this.#syncA11y();
7491
+
7492
+ // Label click triggers fill picker
7493
+ if (label && this.#fillPicker) {
7494
+ label.addEventListener("click", () => {
7495
+ const chit = this.#fillPicker.querySelector("fig-chit");
7496
+ if (chit) {
7497
+ chit.click();
7498
+ }
7499
+ });
7500
+ }
6651
7501
 
6652
- // Hex input (solid only)
6653
- if (this.#hexInput) {
6654
- this.#hexInput.addEventListener("input", (e) => {
6655
- e.stopPropagation();
6656
- const hex = "#" + e.target.value.replace("#", "");
6657
- this.#solid.color = hex;
6658
- this.#updateFillPicker();
6659
- this.#emitInput();
6660
- });
6661
- this.#hexInput.addEventListener("change", (e) => {
6662
- e.stopPropagation();
6663
- this.#emitChange();
6664
- });
6665
- }
7502
+ // Hex input (solid only)
7503
+ if (this.#hexInput) {
7504
+ this.#hexInput.addEventListener("input", (e) => {
7505
+ e.stopPropagation();
7506
+ const hex = "#" + e.target.value.replace("#", "");
7507
+ this.#solid.color = hex;
7508
+ this.#updateFillPicker();
7509
+ this.#emitInput();
7510
+ });
7511
+ this.#hexInput.addEventListener("change", (e) => {
7512
+ e.stopPropagation();
7513
+ this.#emitChange();
7514
+ });
7515
+ }
6666
7516
 
6667
- // Opacity input
6668
- if (this.#opacityInput) {
6669
- this.#opacityInput.addEventListener("input", (e) => {
6670
- e.stopPropagation();
6671
- const parsed = parseFloat(e.target.value);
6672
- const opacity = isNaN(parsed) ? 100 : parsed;
6673
- const alpha = opacity / 100;
6674
- switch (this.#fillType) {
6675
- case "solid":
6676
- this.#solid.alpha = alpha;
6677
- break;
6678
- case "gradient":
6679
- break;
6680
- case "image":
6681
- this.#image.opacity = alpha;
6682
- break;
6683
- case "video":
6684
- this.#video.opacity = alpha;
6685
- break;
6686
- case "webcam":
6687
- this.#webcam.opacity = alpha;
6688
- break;
6689
- }
6690
- this.#updateFillPicker();
6691
- this.#updateChitAlpha(alpha);
6692
- this.#emitInput();
6693
- });
6694
- this.#opacityInput.addEventListener("change", (e) => {
6695
- e.stopPropagation();
6696
- this.#emitChange();
6697
- });
6698
- }
6699
- });
7517
+ // Opacity input
7518
+ if (this.#opacityInput) {
7519
+ this.#opacityInput.addEventListener("input", (e) => {
7520
+ e.stopPropagation();
7521
+ const parsed = parseFloat(e.target.value);
7522
+ const opacity = isNaN(parsed) ? 100 : parsed;
7523
+ const alpha = opacity / 100;
7524
+ switch (this.#fillType) {
7525
+ case "solid":
7526
+ this.#solid.alpha = alpha;
7527
+ break;
7528
+ case "gradient":
7529
+ break;
7530
+ case "image":
7531
+ this.#image.opacity = alpha;
7532
+ break;
7533
+ case "video":
7534
+ this.#video.opacity = alpha;
7535
+ break;
7536
+ case "webcam":
7537
+ this.#webcam.opacity = alpha;
7538
+ break;
7539
+ }
7540
+ this.#updateFillPicker();
7541
+ this.#updateChitAlpha(alpha);
7542
+ this.#emitInput();
7543
+ });
7544
+ this.#opacityInput.addEventListener("change", (e) => {
7545
+ e.stopPropagation();
7546
+ this.#emitChange();
7547
+ });
7548
+ }
6700
7549
  }
6701
7550
 
6702
7551
  #updateFillPicker() {
@@ -6804,6 +7653,12 @@ class FigInputFill extends HTMLElement {
6804
7653
  }
6805
7654
  }
6806
7655
  break;
7656
+ case "aria-label":
7657
+ case "aria-describedby":
7658
+ case "aria-invalid":
7659
+ case "aria-required":
7660
+ this.#syncA11y();
7661
+ break;
6807
7662
  }
6808
7663
  }
6809
7664
  }
@@ -6826,6 +7681,7 @@ class FigInputPalette extends HTMLElement {
6826
7681
  #inlinePickers = [];
6827
7682
  #expandedPickers = [];
6828
7683
  #renderRAF = null;
7684
+ #boundHandleKeyDown = this.#handleKeyDown.bind(this);
6829
7685
 
6830
7686
  static get observedAttributes() {
6831
7687
  return ["value", "disabled", "min", "max", "open", "fixed"];
@@ -6870,6 +7726,8 @@ class FigInputPalette extends HTMLElement {
6870
7726
 
6871
7727
  connectedCallback() {
6872
7728
  if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "0");
7729
+ this.removeEventListener("keydown", this.#boundHandleKeyDown);
7730
+ this.addEventListener("keydown", this.#boundHandleKeyDown);
6873
7731
  if (this.#renderRAF) cancelAnimationFrame(this.#renderRAF);
6874
7732
  this.#renderRAF = requestAnimationFrame(() => {
6875
7733
  this.#renderRAF = null;
@@ -6883,10 +7741,21 @@ class FigInputPalette extends HTMLElement {
6883
7741
  cancelAnimationFrame(this.#renderRAF);
6884
7742
  this.#renderRAF = null;
6885
7743
  }
7744
+ this.removeEventListener("keydown", this.#boundHandleKeyDown);
6886
7745
  this.#inlinePickers = [];
6887
7746
  this.#expandedPickers = [];
6888
7747
  }
6889
7748
 
7749
+ #handleKeyDown(event) {
7750
+ if (event.key !== "Enter" && event.key !== " ") return;
7751
+ if (event.target !== this && !event.target?.closest?.(".palette-colors-inline")) return;
7752
+ if (this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") return;
7753
+ event.preventDefault();
7754
+ event.stopPropagation();
7755
+ this.open = true;
7756
+ this.querySelector(".palette-colors-inline")?.setAttribute("aria-expanded", "true");
7757
+ }
7758
+
6890
7759
  attributeChangedCallback(name, oldValue, newValue) {
6891
7760
  if (oldValue === newValue) return;
6892
7761
 
@@ -6999,13 +7868,24 @@ class FigInputPalette extends HTMLElement {
6999
7868
 
7000
7869
  const inlineWrap = document.createElement("div");
7001
7870
  inlineWrap.className = "palette-colors-inline";
7002
- inlineWrap.addEventListener("click", () => {
7871
+ inlineWrap.setAttribute("role", "button");
7872
+ inlineWrap.setAttribute("aria-expanded", String(this.open));
7873
+ inlineWrap.setAttribute("aria-label", "Edit palette colors");
7874
+ const openPalette = () => {
7003
7875
  if (
7004
7876
  this.hasAttribute("disabled") &&
7005
7877
  this.getAttribute("disabled") !== "false"
7006
7878
  )
7007
7879
  return;
7008
7880
  this.open = true;
7881
+ inlineWrap.setAttribute("aria-expanded", "true");
7882
+ };
7883
+ inlineWrap.addEventListener("click", openPalette);
7884
+ inlineWrap.addEventListener("keydown", (event) => {
7885
+ if (event.key !== "Enter" && event.key !== " ") return;
7886
+ event.preventDefault();
7887
+ event.stopPropagation();
7888
+ openPalette();
7009
7889
  });
7010
7890
 
7011
7891
  const wrap = document.createElement("div");
@@ -7258,10 +8138,11 @@ class FigInputGradient extends HTMLElement {
7258
8138
  #handleDragging = false;
7259
8139
  #arrowTooltipTimer = null;
7260
8140
  #colorObserver = null;
8141
+ #repositionRAF = null;
7261
8142
  #gradient = {
7262
8143
  type: "linear",
7263
- angle: 180,
7264
- interpolationSpace: "oklab",
8144
+ angle: 90,
8145
+ interpolationSpace: "srgb",
7265
8146
  hueInterpolation: "shorter",
7266
8147
  stops: [
7267
8148
  { position: 0, color: "#D9D9D9", opacity: 100 },
@@ -7292,18 +8173,49 @@ class FigInputGradient extends HTMLElement {
7292
8173
  return this.getAttribute("mode") === "tip" ? "tip" : "handle";
7293
8174
  }
7294
8175
 
8176
+ #firstStopHandle() {
8177
+ if (!this.#track) return null;
8178
+ return this.#track.querySelector(
8179
+ "fig-handle:not(.fig-input-gradient-ghost):not([disabled])",
8180
+ );
8181
+ }
8182
+
8183
+ #syncFocusTarget() {
8184
+ const disabled = this.hasAttribute("disabled");
8185
+ if (disabled) {
8186
+ this.setAttribute("tabindex", "-1");
8187
+ return;
8188
+ }
8189
+ this.setAttribute("tabindex", this.#isEditable ? "-1" : "0");
8190
+ }
8191
+
8192
+ #normalizeGradient(gradient) {
8193
+ return {
8194
+ ...normalizeGradientConfig(gradient),
8195
+ type: "linear",
8196
+ angle: 90,
8197
+ };
8198
+ }
8199
+
7295
8200
  connectedCallback() {
7296
8201
  this.#parseValue();
7297
8202
  this.#render();
8203
+ this.removeEventListener("keydown", this.#onPickerKeyDown);
8204
+ this.addEventListener("keydown", this.#onPickerKeyDown);
7298
8205
  if (this.#isEditable) document.addEventListener("keydown", this.#onKeyDown);
7299
8206
  }
7300
8207
 
7301
8208
  disconnectedCallback() {
7302
8209
  document.removeEventListener("keydown", this.#onKeyDown);
8210
+ this.removeEventListener("keydown", this.#onPickerKeyDown);
7303
8211
  if (this.#colorObserver) {
7304
8212
  this.#colorObserver.disconnect();
7305
8213
  this.#colorObserver = null;
7306
8214
  }
8215
+ if (this.#repositionRAF !== null) {
8216
+ cancelAnimationFrame(this.#repositionRAF);
8217
+ this.#repositionRAF = null;
8218
+ }
7307
8219
  clearTimeout(this.#arrowTooltipTimer);
7308
8220
  this.removeEventListener("pointerenter", this.#onTrackEnter);
7309
8221
  this.removeEventListener("pointermove", this.#onTrackMove);
@@ -7391,27 +8303,34 @@ class FigInputGradient extends HTMLElement {
7391
8303
  this.#emitChange();
7392
8304
  };
7393
8305
 
8306
+ #onPickerKeyDown = (e) => {
8307
+ if (this.#editMode !== "picker") return;
8308
+ if (e.key !== "Enter" && e.key !== " ") return;
8309
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
8310
+ if (this.hasAttribute("disabled")) return;
8311
+ const picker = this.querySelector("fig-fill-picker");
8312
+ if (!picker || typeof picker.open !== "function") return;
8313
+ e.preventDefault();
8314
+ picker.open();
8315
+ };
8316
+
7394
8317
  #parseValue() {
7395
8318
  const valueAttr = this.getAttribute("value");
7396
8319
  if (!valueAttr) return;
7397
8320
  try {
7398
8321
  const parsed = JSON.parse(valueAttr);
7399
8322
  if (parsed?.type === "gradient" && parsed.gradient) {
7400
- this.#gradient = normalizeGradientConfig({
8323
+ this.#gradient = this.#normalizeGradient({
7401
8324
  ...this.#gradient,
7402
8325
  ...parsed.gradient,
7403
8326
  });
7404
- this.#gradient.type = "linear";
7405
- this.#gradient.angle = 90;
7406
8327
  return;
7407
8328
  }
7408
8329
  if (parsed?.gradient) {
7409
- this.#gradient = normalizeGradientConfig({
8330
+ this.#gradient = this.#normalizeGradient({
7410
8331
  ...this.#gradient,
7411
8332
  ...parsed.gradient,
7412
8333
  });
7413
- this.#gradient.type = "linear";
7414
- this.#gradient.angle = 90;
7415
8334
  }
7416
8335
  } catch (e) {
7417
8336
  // Ignore invalid JSON and keep current/default gradient.
@@ -7419,7 +8338,8 @@ class FigInputGradient extends HTMLElement {
7419
8338
  }
7420
8339
 
7421
8340
  #buildGradientCSS() {
7422
- const sorted = [...this.#gradient.stops].sort(
8341
+ const gradient = this.#normalizeGradient(this.#gradient);
8342
+ const sorted = [...gradient.stops].sort(
7423
8343
  (a, b) => a.position - b.position,
7424
8344
  );
7425
8345
  const stops = sorted
@@ -7430,8 +8350,9 @@ class FigInputGradient extends HTMLElement {
7430
8350
  return `rgba(${r}, ${g}, ${b}, ${alpha}) ${s.position}%`;
7431
8351
  })
7432
8352
  .join(", ");
7433
- const interp = gradientInterpolationClause(this.#gradient);
7434
- return `linear-gradient(${this.#gradient.angle}deg ${interp}, ${stops})`;
8353
+ const interp = gradientInterpolationClause(gradient);
8354
+ const interpolation = interp ? ` ${interp}` : "";
8355
+ return `linear-gradient(${gradient.angle}deg${interpolation}, ${stops})`;
7435
8356
  }
7436
8357
 
7437
8358
  #stopColorCSS(stop) {
@@ -7470,6 +8391,7 @@ class FigInputGradient extends HTMLElement {
7470
8391
  this.#chit = this.querySelector("fig-chit");
7471
8392
  this.#track = null;
7472
8393
  this.#setupPickerEvents();
8394
+ this.#syncFocusTarget();
7473
8395
  return;
7474
8396
  }
7475
8397
 
@@ -7482,8 +8404,8 @@ class FigInputGradient extends HTMLElement {
7482
8404
  if (mode === "true" || mode === "picker") {
7483
8405
  this.#setupGhostHandle();
7484
8406
  this.#setupEventListeners();
7485
- requestAnimationFrame(() => this.#repositionHandles());
7486
8407
  }
8408
+ this.#syncFocusTarget();
7487
8409
  }
7488
8410
 
7489
8411
  #setupPickerEvents() {
@@ -7495,7 +8417,7 @@ class FigInputGradient extends HTMLElement {
7495
8417
  e.stopPropagation();
7496
8418
  const detail = e.detail;
7497
8419
  if (!detail?.gradient) return;
7498
- this.#gradient = normalizeGradientConfig({
8420
+ this.#gradient = this.#normalizeGradient({
7499
8421
  ...this.#gradient,
7500
8422
  ...detail.gradient,
7501
8423
  });
@@ -7658,6 +8580,8 @@ class FigInputGradient extends HTMLElement {
7658
8580
  };
7659
8581
 
7660
8582
  #repositionHandles() {
8583
+ this.#repositionRAF = null;
8584
+ if (!this.isConnected) return;
7661
8585
  if (!this.#track) return;
7662
8586
  const stops = this.#gradient.stops;
7663
8587
  this.#track
@@ -7673,6 +8597,11 @@ class FigInputGradient extends HTMLElement {
7673
8597
  this.#repositionHandles();
7674
8598
  }
7675
8599
 
8600
+ #scheduleRepositionHandles() {
8601
+ if (this.#repositionRAF !== null) return;
8602
+ this.#repositionRAF = requestAnimationFrame(() => this.#repositionHandles());
8603
+ }
8604
+
7676
8605
  #syncHandles() {
7677
8606
  if (!this.#track) return;
7678
8607
  const handles = this.#track.querySelectorAll(
@@ -7686,7 +8615,7 @@ class FigInputGradient extends HTMLElement {
7686
8615
  if (ghost) this.#track.appendChild(ghost);
7687
8616
  this.#syncHandleMode();
7688
8617
  this.#reobserveHandleColors();
7689
- requestAnimationFrame(() => this.#repositionHandles());
8618
+ this.#scheduleRepositionHandles();
7690
8619
  return;
7691
8620
  }
7692
8621
 
@@ -7987,7 +8916,7 @@ class FigInputGradient extends HTMLElement {
7987
8916
  get value() {
7988
8917
  return {
7989
8918
  type: "gradient",
7990
- gradient: gradientToValueShape(this.#gradient),
8919
+ gradient: gradientToValueShape(this.#normalizeGradient(this.#gradient)),
7991
8920
  };
7992
8921
  }
7993
8922
 
@@ -7999,6 +8928,18 @@ class FigInputGradient extends HTMLElement {
7999
8928
  }
8000
8929
  }
8001
8930
 
8931
+ focus(options) {
8932
+ if (this.hasAttribute("disabled")) return;
8933
+ if (this.#isEditable) {
8934
+ const firstHandle = this.#firstStopHandle();
8935
+ if (firstHandle) {
8936
+ firstHandle.focus(options);
8937
+ return;
8938
+ }
8939
+ }
8940
+ HTMLElement.prototype.focus.call(this, options);
8941
+ }
8942
+
8002
8943
  attributeChangedCallback(name, oldValue, newValue) {
8003
8944
  if (oldValue === newValue) return;
8004
8945
  switch (name) {
@@ -8026,6 +8967,7 @@ class FigInputGradient extends HTMLElement {
8026
8967
 
8027
8968
  #syncDisabled() {
8028
8969
  const disabled = this.hasAttribute("disabled");
8970
+ this.#syncFocusTarget();
8029
8971
  if (this.#chit) {
8030
8972
  if (disabled) this.#chit.setAttribute("disabled", "");
8031
8973
  else this.#chit.removeAttribute("disabled");
@@ -8061,7 +9003,6 @@ class FigCheckbox extends HTMLElement {
8061
9003
  this.input.setAttribute("id", figUniqueId());
8062
9004
  this.input.setAttribute("name", this.name);
8063
9005
  this.input.setAttribute("type", "checkbox");
8064
- this.input.setAttribute("role", "checkbox");
8065
9006
  this.#boundHandleInput = this.handleInput.bind(this);
8066
9007
  }
8067
9008
  connectedCallback() {
@@ -8073,18 +9014,9 @@ class FigCheckbox extends HTMLElement {
8073
9014
  this.append(this.input);
8074
9015
  }
8075
9016
 
8076
- this.input.checked =
8077
- this.hasAttribute("checked") && this.getAttribute("checked") !== "false";
8078
9017
  this.input.removeEventListener("change", this.#boundHandleInput);
8079
9018
  this.input.addEventListener("change", this.#boundHandleInput);
8080
-
8081
- if (this.hasAttribute("disabled")) {
8082
- this.input.disabled = true;
8083
- }
8084
- if (this.hasAttribute("indeterminate")) {
8085
- this.input.indeterminate = true;
8086
- this.input.setAttribute("indeterminate", "true");
8087
- }
9019
+ this.#syncInputState();
8088
9020
 
8089
9021
  const existingLabel = this.querySelector(":scope > label");
8090
9022
  if (existingLabel) {
@@ -8119,7 +9051,36 @@ class FigCheckbox extends HTMLElement {
8119
9051
  }
8120
9052
  }
8121
9053
 
8122
- render() {}
9054
+ render() {}
9055
+
9056
+ #isAttrOn(name) {
9057
+ return this.hasAttribute(name) && this.getAttribute(name) !== "false";
9058
+ }
9059
+
9060
+ #syncAriaChecked() {
9061
+ if (this.input.indeterminate) {
9062
+ this.input.setAttribute("aria-checked", "mixed");
9063
+ } else {
9064
+ this.input.setAttribute("aria-checked", this.input.checked ? "true" : "false");
9065
+ }
9066
+ }
9067
+
9068
+ #syncInputState() {
9069
+ const checked = this.#isAttrOn("checked");
9070
+ const indeterminate = this.#isAttrOn("indeterminate") && !checked;
9071
+ const disabled = this.#isAttrOn("disabled");
9072
+ this.input.checked = checked;
9073
+ this.input.indeterminate = indeterminate;
9074
+ this.input.disabled = disabled;
9075
+ this.input.value = this.getAttribute("value") || "";
9076
+ this.input.setAttribute("name", this.getAttribute("name") || this.name);
9077
+ if (indeterminate) {
9078
+ this.input.setAttribute("indeterminate", "true");
9079
+ } else {
9080
+ this.input.removeAttribute("indeterminate");
9081
+ }
9082
+ this.#syncAriaChecked();
9083
+ }
8123
9084
 
8124
9085
  focus() {
8125
9086
  this.input.focus();
@@ -8164,35 +9125,22 @@ class FigCheckbox extends HTMLElement {
8164
9125
  }
8165
9126
  break;
8166
9127
  case "checked":
8167
- this.input.checked =
8168
- this.hasAttribute("checked") &&
8169
- this.getAttribute("checked") !== "false";
8170
- if (this.input.checked && this.hasAttribute("indeterminate")) {
9128
+ if (this.#isAttrOn("checked") && this.hasAttribute("indeterminate")) {
8171
9129
  this.removeAttribute("indeterminate");
8172
9130
  }
8173
- this.input.indeterminate =
8174
- this.hasAttribute("indeterminate") &&
8175
- this.getAttribute("indeterminate") !== "false" &&
8176
- !this.input.checked;
8177
- if (this.input.indeterminate) {
8178
- this.input.setAttribute("indeterminate", "true");
8179
- } else {
8180
- this.input.removeAttribute("indeterminate");
8181
- }
9131
+ this.#syncInputState();
8182
9132
  break;
8183
9133
  case "indeterminate":
8184
- this.input.indeterminate =
8185
- this.hasAttribute("indeterminate") &&
8186
- this.getAttribute("indeterminate") !== "false" &&
8187
- !this.input.checked;
8188
- if (this.input.indeterminate) {
8189
- this.input.setAttribute("indeterminate", "true");
8190
- } else {
8191
- this.input.removeAttribute("indeterminate");
8192
- }
9134
+ this.#syncInputState();
9135
+ break;
9136
+ case "disabled":
9137
+ this.#syncInputState();
9138
+ break;
9139
+ case "name":
9140
+ this.input.setAttribute("name", newValue || this.name);
8193
9141
  break;
8194
9142
  case "value":
8195
- this.input.value = newValue;
9143
+ this.input.value = newValue || "";
8196
9144
  break;
8197
9145
  default:
8198
9146
  this.input[name] = newValue;
@@ -8205,8 +9153,10 @@ class FigCheckbox extends HTMLElement {
8205
9153
  e.stopPropagation();
8206
9154
  this.input.indeterminate = false;
8207
9155
  this.input.removeAttribute("indeterminate");
8208
- // Update ARIA state
8209
- this.input.setAttribute("aria-checked", this.input.checked);
9156
+ if (this.hasAttribute("indeterminate")) {
9157
+ this.removeAttribute("indeterminate");
9158
+ }
9159
+ this.#syncAriaChecked();
8210
9160
  // Sync attribute with input state (without triggering setter loop)
8211
9161
  if (this.input.checked) {
8212
9162
  this.setAttribute("checked", "");
@@ -8267,166 +9217,6 @@ class FigSwitch extends FigCheckbox {
8267
9217
  }
8268
9218
  customElements.define("fig-switch", FigSwitch);
8269
9219
 
8270
- /* Toast */
8271
- /**
8272
- * A toast notification element for non-modal, time-based messages.
8273
- * Always positioned at bottom center of the screen.
8274
- * @attr {number} duration - Auto-dismiss duration in ms (0 = no auto-dismiss, default: 5000)
8275
- * @attr {number} offset - Distance from bottom edge in pixels (default: 16)
8276
- * @attr {string} theme - Visual theme: "dark" (default), "light", "danger", "brand"
8277
- * @attr {boolean} open - Whether the toast is visible
8278
- */
8279
- class FigToast extends HTMLDialogElement {
8280
- constructor() {
8281
- super();
8282
- this._figInit();
8283
- }
8284
-
8285
- _figInit() {
8286
- if (this._figInitialized) return;
8287
- this._figInitialized = true;
8288
- this._defaultOffset = 16;
8289
- this._autoCloseTimer = null;
8290
- this._boundHandleClose = this.handleClose.bind(this);
8291
- }
8292
-
8293
- getOffset() {
8294
- return parseInt(this.getAttribute("offset") ?? this._defaultOffset);
8295
- }
8296
-
8297
- connectedCallback() {
8298
- this._figInit();
8299
-
8300
- // Set default theme if not specified
8301
- if (!this.hasAttribute("theme")) {
8302
- this.setAttribute("theme", "dark");
8303
- }
8304
-
8305
- // Ensure toast is closed by default
8306
- // Remove native open attribute if present and not explicitly "true"
8307
- const shouldOpen =
8308
- this.getAttribute("open") === "true" || this.getAttribute("open") === "";
8309
- if (this.hasAttribute("open") && !shouldOpen) {
8310
- this.removeAttribute("open");
8311
- }
8312
-
8313
- // Close the dialog initially (override native behavior)
8314
- if (!shouldOpen) {
8315
- this.close();
8316
- }
8317
-
8318
- requestAnimationFrame(() => {
8319
- this.addCloseListeners();
8320
- this.applyPosition();
8321
-
8322
- // Auto-show if open attribute is explicitly true
8323
- if (shouldOpen) {
8324
- this.showToast();
8325
- }
8326
- });
8327
- }
8328
-
8329
- disconnectedCallback() {
8330
- this._figInit();
8331
- this.clearAutoClose();
8332
- }
8333
-
8334
- addCloseListeners() {
8335
- this.querySelectorAll("[close-toast]").forEach((button) => {
8336
- button.removeEventListener("click", this._boundHandleClose);
8337
- button.addEventListener("click", this._boundHandleClose);
8338
- });
8339
- }
8340
-
8341
- handleClose() {
8342
- this.hideToast();
8343
- }
8344
-
8345
- applyPosition() {
8346
- // Always bottom center
8347
- this.style.position = "fixed";
8348
- this.style.margin = "0";
8349
- this.style.top = "auto";
8350
- this.style.bottom = `${this.getOffset()}px`;
8351
- this.style.left = "50%";
8352
- this.style.right = "auto";
8353
- this.style.transform = "translateX(-50%)";
8354
- }
8355
-
8356
- startAutoClose() {
8357
- this.clearAutoClose();
8358
-
8359
- const duration = parseInt(this.getAttribute("duration") ?? "5000");
8360
- if (duration > 0) {
8361
- this._autoCloseTimer = setTimeout(() => {
8362
- this.hideToast();
8363
- }, duration);
8364
- }
8365
- }
8366
-
8367
- clearAutoClose() {
8368
- if (this._autoCloseTimer) {
8369
- clearTimeout(this._autoCloseTimer);
8370
- this._autoCloseTimer = null;
8371
- }
8372
- }
8373
-
8374
- _resolveAutoTheme() {
8375
- if (this.getAttribute("theme") !== "auto") return;
8376
- const cs = getComputedStyle(document.documentElement).colorScheme || "";
8377
- const isDark = cs.includes("dark");
8378
- this.style.colorScheme = isDark ? "light" : "dark";
8379
- }
8380
-
8381
- /**
8382
- * Show the toast notification (non-modal)
8383
- */
8384
- showToast() {
8385
- this._resolveAutoTheme();
8386
- this.show(); // Non-modal show
8387
- this.applyPosition();
8388
- this.startAutoClose();
8389
- this.dispatchEvent(new CustomEvent("toast-show", { bubbles: true }));
8390
- }
8391
-
8392
- /**
8393
- * Hide the toast notification
8394
- */
8395
- hideToast() {
8396
- this.clearAutoClose();
8397
- this.close();
8398
- this.dispatchEvent(new CustomEvent("toast-hide", { bubbles: true }));
8399
- }
8400
-
8401
- static get observedAttributes() {
8402
- return ["duration", "offset", "open", "theme"];
8403
- }
8404
-
8405
- attributeChangedCallback(name, oldValue, newValue) {
8406
- this._figInit();
8407
- if (name === "offset") {
8408
- this.applyPosition();
8409
- }
8410
-
8411
- if (name === "open") {
8412
- if (newValue !== null && newValue !== "false") {
8413
- this.showToast();
8414
- } else {
8415
- this.hideToast();
8416
- }
8417
- }
8418
-
8419
- if (name === "theme") {
8420
- if (newValue === "auto") {
8421
- this._resolveAutoTheme();
8422
- } else {
8423
- this.style.removeProperty("color-scheme");
8424
- }
8425
- }
8426
- }
8427
- }
8428
- figDefineCustomizedBuiltIn("fig-toast", FigToast, { extends: "dialog" });
8429
-
8430
9220
  /* Combo Input */
8431
9221
  /**
8432
9222
  * A custom combo input with text and dropdown.
@@ -8443,6 +9233,11 @@ class FigComboInput extends HTMLElement {
8443
9233
  "value",
8444
9234
  "disabled",
8445
9235
  "experimental",
9236
+ "aria-label",
9237
+ "aria-labelledby",
9238
+ "aria-describedby",
9239
+ "aria-invalid",
9240
+ "aria-required",
8446
9241
  ];
8447
9242
 
8448
9243
  #usesCustomDropdown = false;
@@ -8451,6 +9246,13 @@ class FigComboInput extends HTMLElement {
8451
9246
  #button = null;
8452
9247
  #customDropdown = null;
8453
9248
  #internalUpdate = false;
9249
+ #a11yAttributes = [
9250
+ "aria-label",
9251
+ "aria-labelledby",
9252
+ "aria-describedby",
9253
+ "aria-invalid",
9254
+ "aria-required",
9255
+ ];
8454
9256
 
8455
9257
  #boundHandleDropdownInput = this.#handleDropdownInput.bind(this);
8456
9258
  #boundHandleTextInput = this.#handleTextInput.bind(this);
@@ -8492,10 +9294,11 @@ class FigComboInput extends HTMLElement {
8492
9294
  const currentValue = this.value;
8493
9295
  const experimental = this.getAttribute("experimental");
8494
9296
  const expAttr = experimental ? ` experimental="${experimental}"` : "";
9297
+ const dropdownLabel = this.#dropdownLabel();
8495
9298
 
8496
9299
  const dropdownHTML = this.#usesCustomDropdown
8497
9300
  ? ""
8498
- : `<fig-dropdown type="dropdown"${expAttr}>${options.map((o) => `<option>${o.trim()}</option>`).join("")}</fig-dropdown>`;
9301
+ : `<fig-dropdown type="dropdown" label="${dropdownLabel}"${expAttr}>${options.map((o) => `<option>${o.trim()}</option>`).join("")}</fig-dropdown>`;
8499
9302
 
8500
9303
  this.innerHTML = `<div class="input-combo">
8501
9304
  <fig-input-text placeholder="${placeholder}" value="${currentValue}"></fig-input-text>
@@ -8514,6 +9317,9 @@ class FigComboInput extends HTMLElement {
8514
9317
  if (!this.#customDropdown.hasAttribute("type")) {
8515
9318
  this.#customDropdown.setAttribute("type", "dropdown");
8516
9319
  }
9320
+ if (!this.#customDropdown.hasAttribute("label")) {
9321
+ this.#customDropdown.setAttribute("label", dropdownLabel);
9322
+ }
8517
9323
  if (experimental) {
8518
9324
  this.#customDropdown.setAttribute("experimental", experimental);
8519
9325
  }
@@ -8521,6 +9327,7 @@ class FigComboInput extends HTMLElement {
8521
9327
  }
8522
9328
 
8523
9329
  this.#dropdown = this.querySelector("fig-dropdown");
9330
+ this.#syncA11yAttributes();
8524
9331
  }
8525
9332
 
8526
9333
  #setupListeners() {
@@ -8591,6 +9398,31 @@ class FigComboInput extends HTMLElement {
8591
9398
  .filter(Boolean);
8592
9399
  }
8593
9400
 
9401
+ #controlName() {
9402
+ return (
9403
+ this.getAttribute("aria-label") ||
9404
+ this.getAttribute("placeholder") ||
9405
+ "Combo input"
9406
+ ).trim();
9407
+ }
9408
+
9409
+ #dropdownLabel() {
9410
+ return `${this.#controlName()} options`;
9411
+ }
9412
+
9413
+ #syncA11yAttributes() {
9414
+ if (this.#input) {
9415
+ this.#a11yAttributes.forEach((name) => {
9416
+ const value = this.getAttribute(name);
9417
+ if (value === null) this.#input.removeAttribute(name);
9418
+ else this.#input.setAttribute(name, value);
9419
+ });
9420
+ }
9421
+ if (this.#dropdown && !this.#dropdown.hasAttribute("aria-label")) {
9422
+ this.#dropdown.setAttribute("label", this.#dropdownLabel());
9423
+ }
9424
+ }
9425
+
8594
9426
  #applyDisabled(disabled) {
8595
9427
  if (this.#input) {
8596
9428
  if (disabled) this.#input.setAttribute("disabled", "");
@@ -8600,6 +9432,10 @@ class FigComboInput extends HTMLElement {
8600
9432
  if (disabled) this.#button.setAttribute("disabled", "");
8601
9433
  else this.#button.removeAttribute("disabled");
8602
9434
  }
9435
+ if (this.#dropdown) {
9436
+ if (disabled) this.#dropdown.setAttribute("disabled", "");
9437
+ else this.#dropdown.removeAttribute("disabled");
9438
+ }
8603
9439
  }
8604
9440
 
8605
9441
  focus() {
@@ -8619,6 +9455,7 @@ class FigComboInput extends HTMLElement {
8619
9455
  break;
8620
9456
  case "placeholder":
8621
9457
  if (this.#input) this.#input.setAttribute("placeholder", newValue || "");
9458
+ this.#syncA11yAttributes();
8622
9459
  break;
8623
9460
  case "value":
8624
9461
  if (!this.#internalUpdate && this.#input) {
@@ -8635,6 +9472,13 @@ class FigComboInput extends HTMLElement {
8635
9472
  this.#dropdown.removeAttribute("experimental");
8636
9473
  }
8637
9474
  break;
9475
+ case "aria-label":
9476
+ case "aria-labelledby":
9477
+ case "aria-describedby":
9478
+ case "aria-invalid":
9479
+ case "aria-required":
9480
+ this.#syncA11yAttributes();
9481
+ break;
8638
9482
  }
8639
9483
  }
8640
9484
  }
@@ -8660,12 +9504,22 @@ class FigChit extends HTMLElement {
8660
9504
  }
8661
9505
 
8662
9506
  static get observedAttributes() {
8663
- return ["background", "size", "selected", "disabled", "alpha"];
9507
+ return [
9508
+ "background",
9509
+ "size",
9510
+ "selected",
9511
+ "disabled",
9512
+ "alpha",
9513
+ "aria-label",
9514
+ "aria-labelledby",
9515
+ "aria-describedby",
9516
+ ];
8664
9517
  }
8665
9518
 
8666
9519
  connectedCallback() {
8667
9520
  this.#render();
8668
9521
  this.#updateAlpha();
9522
+ this.#syncA11y();
8669
9523
  }
8670
9524
 
8671
9525
  #updateAlpha() {
@@ -8732,9 +9586,11 @@ class FigChit extends HTMLElement {
8732
9586
  if (!isVar) {
8733
9587
  this.input.addEventListener("input", this.#boundHandleInput);
8734
9588
  }
9589
+ this.#syncA11y();
8735
9590
  } else {
8736
9591
  this.innerHTML = "<div></div>";
8737
9592
  this.input = null;
9593
+ this.#syncA11y();
8738
9594
  }
8739
9595
  } else if (this.#type === "color" && this.input) {
8740
9596
  const hex = this.#toHex(bg);
@@ -8753,6 +9609,36 @@ class FigChit extends HTMLElement {
8753
9609
  );
8754
9610
  }
8755
9611
 
9612
+ #syncA11y() {
9613
+ const disabled =
9614
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
9615
+ this.setAttribute("aria-disabled", disabled ? "true" : "false");
9616
+ if (this.input) {
9617
+ this.input.disabled = disabled;
9618
+ const labelledBy = this.getAttribute("aria-labelledby");
9619
+ const label = this.getAttribute("aria-label") || "Color swatch";
9620
+ const describedBy = this.getAttribute("aria-describedby");
9621
+ if (labelledBy) {
9622
+ this.input.setAttribute("aria-labelledby", labelledBy);
9623
+ this.input.removeAttribute("aria-label");
9624
+ } else {
9625
+ this.input.setAttribute("aria-label", label);
9626
+ this.input.removeAttribute("aria-labelledby");
9627
+ }
9628
+ if (describedBy) this.input.setAttribute("aria-describedby", describedBy);
9629
+ else this.input.removeAttribute("aria-describedby");
9630
+ this.removeAttribute("role");
9631
+ this.removeAttribute("aria-hidden");
9632
+ return;
9633
+ }
9634
+ if (!this.hasAttribute("aria-label") && !this.hasAttribute("aria-labelledby")) {
9635
+ this.setAttribute("aria-hidden", "true");
9636
+ } else {
9637
+ this.setAttribute("role", "img");
9638
+ this.removeAttribute("aria-hidden");
9639
+ }
9640
+ }
9641
+
8756
9642
  #handleInput(e) {
8757
9643
  // Update background attribute without triggering full re-render
8758
9644
  this.#internalUpdate = true;
@@ -8790,6 +9676,13 @@ class FigChit extends HTMLElement {
8790
9676
  this.#render();
8791
9677
  } else if (name === "alpha") {
8792
9678
  this.#updateAlpha();
9679
+ } else if (
9680
+ name === "disabled" ||
9681
+ name === "aria-label" ||
9682
+ name === "aria-labelledby" ||
9683
+ name === "aria-describedby"
9684
+ ) {
9685
+ this.#syncA11y();
8793
9686
  }
8794
9687
  }
8795
9688
 
@@ -8825,6 +9718,8 @@ customElements.define("fig-swatch", FigSwatch);
8825
9718
  * @attr {boolean} loop - Video loop
8826
9719
  * @attr {boolean} muted - Video muted
8827
9720
  * @attr {string} poster - Video poster image URL
9721
+ * @attr {string} aria-label - Accessible label forwarded to generated video
9722
+ * @attr {string} aria-labelledby - Accessible label reference forwarded to generated video
8828
9723
  *
8829
9724
  * Sizing model:
8830
9725
  * - Default: host shrinkwraps to its inner <img>/<video> intrinsic size.
@@ -8834,6 +9729,7 @@ customElements.define("fig-swatch", FigSwatch);
8834
9729
  class FigMedia extends HTMLElement {
8835
9730
  #src = null;
8836
9731
  #mediaEl = null;
9732
+ #previewEl = null;
8837
9733
  #fileInput = null;
8838
9734
  #blobUrl = null;
8839
9735
  #previewSrc = null;
@@ -8865,6 +9761,9 @@ class FigMedia extends HTMLElement {
8865
9761
  "loop",
8866
9762
  "muted",
8867
9763
  "poster",
9764
+ "aria-label",
9765
+ "aria-labelledby",
9766
+ "title",
8868
9767
  ];
8869
9768
  }
8870
9769
 
@@ -8939,8 +9838,10 @@ class FigMedia extends HTMLElement {
8939
9838
  }
8940
9839
 
8941
9840
  this.querySelectorAll("fig-chit[data-generated]").forEach((el) => el.remove());
9841
+ this.#ensurePreviewElement();
8942
9842
  this.#ensureMediaElement();
8943
9843
  this.#syncGeneratedMediaElement();
9844
+ this.#syncMediaAccessibility();
8944
9845
 
8945
9846
  const isUpload = this.hasAttribute("upload") && this.getAttribute("upload") !== "false";
8946
9847
  if (isUpload && !this.querySelector("fig-input-file[data-generated]")) {
@@ -8968,6 +9869,19 @@ class FigMedia extends HTMLElement {
8968
9869
  }
8969
9870
  }
8970
9871
 
9872
+ #ensurePreviewElement() {
9873
+ if (this.#previewEl?.isConnected) return;
9874
+ const existing = this.querySelector(":scope > fig-preview");
9875
+ if (existing) {
9876
+ this.#previewEl = existing;
9877
+ return;
9878
+ }
9879
+ const preview = document.createElement("fig-preview");
9880
+ preview.setAttribute("data-generated", "");
9881
+ this.prepend(preview);
9882
+ this.#previewEl = preview;
9883
+ }
9884
+
8971
9885
  #userProvidedMediaEl() {
8972
9886
  const tag = this.mediaKind === "video" ? "video" : "img";
8973
9887
  return this.querySelector(`${tag}:not([data-generated])`);
@@ -8976,6 +9890,7 @@ class FigMedia extends HTMLElement {
8976
9890
  #ensureMediaElement() {
8977
9891
  const userEl = this.#userProvidedMediaEl();
8978
9892
  if (userEl) {
9893
+ this.#ensurePreviewElement();
8979
9894
  if (this.#mediaEl && this.#mediaEl !== userEl) {
8980
9895
  this.#removeMediaElementListeners();
8981
9896
  if (this.#mediaEl.hasAttribute("data-generated")) {
@@ -8983,9 +9898,14 @@ class FigMedia extends HTMLElement {
8983
9898
  }
8984
9899
  }
8985
9900
  this.#mediaEl = userEl;
9901
+ if (this.#previewEl && userEl.parentElement !== this.#previewEl) {
9902
+ this.#previewEl.append(userEl);
9903
+ }
9904
+ this.#syncMediaAccessibility();
8986
9905
  return;
8987
9906
  }
8988
9907
 
9908
+ this.#ensurePreviewElement();
8989
9909
  const expectedTag = this.mediaKind === "video" ? "VIDEO" : "IMG";
8990
9910
  if (this.#mediaEl && this.#mediaEl.tagName !== expectedTag) {
8991
9911
  this.#removeMediaElementListeners();
@@ -9002,7 +9922,7 @@ class FigMedia extends HTMLElement {
9002
9922
  video.className = "fig-media-element";
9003
9923
  video.setAttribute("playsinline", "");
9004
9924
  video.preload = "auto";
9005
- this.prepend(video);
9925
+ this.#previewEl.append(video);
9006
9926
  this.#mediaEl = video;
9007
9927
  this.#mediaEl.addEventListener("play", this.#boundHandleMediaPlay);
9008
9928
  this.#mediaEl.addEventListener("pause", this.#boundHandleMediaPause);
@@ -9021,11 +9941,33 @@ class FigMedia extends HTMLElement {
9021
9941
  img.loading = "lazy";
9022
9942
  img.decoding = "async";
9023
9943
  img.alt = this.getAttribute("alt") || "";
9024
- this.prepend(img);
9944
+ this.#previewEl.append(img);
9025
9945
  this.#mediaEl = img;
9026
9946
  }
9027
9947
  }
9028
9948
 
9949
+ #syncMediaAccessibility() {
9950
+ if (!this.#mediaEl) return;
9951
+ if (this.#mediaEl.tagName === "IMG") {
9952
+ if (
9953
+ this.hasAttribute("alt") ||
9954
+ this.#mediaEl.hasAttribute("data-generated")
9955
+ ) {
9956
+ this.#mediaEl.alt = this.getAttribute("alt") || "";
9957
+ }
9958
+ return;
9959
+ }
9960
+ if (this.#mediaEl.tagName !== "VIDEO") return;
9961
+ ["aria-label", "aria-labelledby", "title"].forEach((name) => {
9962
+ const value = this.getAttribute(name);
9963
+ if (value === null) {
9964
+ this.#mediaEl.removeAttribute(name);
9965
+ } else {
9966
+ this.#mediaEl.setAttribute(name, value);
9967
+ }
9968
+ });
9969
+ }
9970
+
9029
9971
  #isEnabledAttr(name, defaultEnabled = false) {
9030
9972
  if (!this.hasAttribute(name)) return defaultEnabled;
9031
9973
  return this.getAttribute(name) !== "false";
@@ -9044,7 +9986,7 @@ class FigMedia extends HTMLElement {
9044
9986
  }
9045
9987
  }
9046
9988
  if (this.#mediaEl.tagName === "IMG") {
9047
- this.#mediaEl.alt = this.getAttribute("alt") || "";
9989
+ this.#syncMediaAccessibility();
9048
9990
  return;
9049
9991
  }
9050
9992
  const poster = this.getAttribute("poster");
@@ -9059,6 +10001,7 @@ class FigMedia extends HTMLElement {
9059
10001
  this.#mediaEl.loop = this.#isEnabledAttr("loop", false);
9060
10002
  this.#mediaEl.muted = this.#isEnabledAttr("muted", false);
9061
10003
  this.#mediaEl.playsInline = true;
10004
+ this.#syncMediaAccessibility();
9062
10005
  this.#syncControlsVisibility();
9063
10006
  }
9064
10007
 
@@ -9096,7 +10039,6 @@ class FigMedia extends HTMLElement {
9096
10039
  }
9097
10040
  const controls = document.createElement("fig-media-controls");
9098
10041
  controls.setAttribute("data-generated", "");
9099
- controls.setAttribute("overlay", "");
9100
10042
  this.append(controls);
9101
10043
  this.#controlsEl = controls;
9102
10044
  this.#wireControlsToMedia();
@@ -9225,7 +10167,8 @@ class FigMedia extends HTMLElement {
9225
10167
  fi.setAttribute("url", this.#src);
9226
10168
  }
9227
10169
  fi.addEventListener("change", this.#boundHandleFileInput);
9228
- this.append(fi);
10170
+ this.#ensurePreviewElement();
10171
+ this.#previewEl.append(fi);
9229
10172
  this.#fileInput = fi;
9230
10173
  }
9231
10174
 
@@ -9366,8 +10309,8 @@ class FigMedia extends HTMLElement {
9366
10309
  }
9367
10310
  }
9368
10311
 
9369
- if (name === "alt" && this.#mediaEl && this.#mediaEl.tagName === "IMG") {
9370
- this.#mediaEl.alt = newValue || "";
10312
+ if (["alt", "aria-label", "aria-labelledby", "title"].includes(name)) {
10313
+ this.#syncMediaAccessibility();
9371
10314
  }
9372
10315
 
9373
10316
  if (name === "upload") {
@@ -9501,6 +10444,10 @@ class FigMediaControls extends HTMLElement {
9501
10444
  #render() {
9502
10445
  if (this.#rendered) return;
9503
10446
  this.#rendered = true;
10447
+ if (!this.hasAttribute("role")) this.setAttribute("role", "group");
10448
+ if (!this.hasAttribute("aria-label") && !this.hasAttribute("aria-labelledby")) {
10449
+ this.setAttribute("aria-label", "Media controls");
10450
+ }
9504
10451
 
9505
10452
  const tooltip = document.createElement("fig-tooltip");
9506
10453
  tooltip.setAttribute("text", "Play");
@@ -9524,10 +10471,15 @@ class FigMediaControls extends HTMLElement {
9524
10471
  slider.setAttribute("text", "false");
9525
10472
  slider.setAttribute("min", "0");
9526
10473
  slider.setAttribute("max", String(this.duration));
9527
- slider.setAttribute("step", "0.1");
10474
+ slider.setAttribute("step", "1");
9528
10475
  slider.setAttribute("value", String(this.time));
9529
10476
  slider.setAttribute("full", "");
9530
- const timeEl = document.createElement("label");
10477
+ slider.setAttribute("aria-label", "Seek");
10478
+ slider.setAttribute(
10479
+ "aria-valuetext",
10480
+ this.#formatTimeValueText(this.time, this.duration),
10481
+ );
10482
+ const timeEl = document.createElement("span");
9531
10483
  timeEl.className = "fig-media-controls-time";
9532
10484
  timeEl.textContent = this.#formatTime(this.time);
9533
10485
 
@@ -9567,6 +10519,12 @@ class FigMediaControls extends HTMLElement {
9567
10519
  return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
9568
10520
  }
9569
10521
 
10522
+ #formatTimeValueText(time, duration = 0) {
10523
+ const current = this.#formatTime(time);
10524
+ if (!Number.isFinite(duration) || duration <= 0) return current;
10525
+ return `${current} of ${this.#formatTime(duration)}`;
10526
+ }
10527
+
9570
10528
  #syncPlayingUi() {
9571
10529
  if (!this.#playBtn) return;
9572
10530
  const playing = this.playing;
@@ -9588,6 +10546,10 @@ class FigMediaControls extends HTMLElement {
9588
10546
  if (!this.#userSeeking) {
9589
10547
  this.#timeSlider.setAttribute("value", String(t));
9590
10548
  }
10549
+ this.#timeSlider.setAttribute(
10550
+ "aria-valuetext",
10551
+ this.#formatTimeValueText(t, duration),
10552
+ );
9591
10553
  if (this.#timeEl) this.#timeEl.textContent = this.#formatTime(t);
9592
10554
  }
9593
10555
 
@@ -9823,6 +10785,7 @@ class FigInputFile extends HTMLElement {
9823
10785
  this.#clearBtn = document.createElement("fig-button");
9824
10786
  this.#clearBtn.setAttribute("variant", variant === "overlay" ? "overlay" : "ghost");
9825
10787
  this.#clearBtn.setAttribute("icon", "true");
10788
+ this.#clearBtn.setAttribute("aria-label", "Remove");
9826
10789
  this.#clearBtn.className = "fig-input-file-clear";
9827
10790
  if (disabled) this.#clearBtn.setAttribute("disabled", "");
9828
10791
  this.#clearBtn.replaceChildren(createFigIcon("minus"));
@@ -10284,8 +11247,8 @@ class FigEasingCurve extends HTMLElement {
10284
11247
  <line class="fig-easing-curve-target" x1="0" y1="${targetY}" x2="${size}" y2="${targetY}"/>
10285
11248
  <line class="fig-easing-curve-diagonal" x1="0" y1="${startY}" x2="0" y2="${startY}"/>
10286
11249
  <path class="fig-easing-curve-path"/>
10287
- <foreignObject class="fig-easing-curve-handle" data-handle="bounce" width="20" height="20"><fig-handle size="small"></fig-handle></foreignObject>
10288
- <foreignObject class="fig-easing-curve-handle fig-easing-curve-duration-bar" data-handle="duration" width="20" height="20"><fig-handle size="small"></fig-handle></foreignObject>
11250
+ <foreignObject class="fig-easing-curve-handle" data-handle="bounce" width="20" height="20"><fig-handle size="small" drag aria-label="Spring bounce handle"></fig-handle></foreignObject>
11251
+ <foreignObject class="fig-easing-curve-handle fig-easing-curve-duration-bar" data-handle="duration" width="20" height="20"><fig-handle size="small" drag drag-axes="x" aria-label="Spring duration handle"></fig-handle></foreignObject>
10289
11252
  </svg></div>${valueInput}`;
10290
11253
  }
10291
11254
 
@@ -10297,8 +11260,8 @@ class FigEasingCurve extends HTMLElement {
10297
11260
  <path class="fig-easing-curve-path"/>
10298
11261
  <circle class="fig-easing-curve-endpoint" data-endpoint="start" r="${this.#bezierEndpointRadius}"/>
10299
11262
  <circle class="fig-easing-curve-endpoint" data-endpoint="end" r="${this.#bezierEndpointRadius}"/>
10300
- <foreignObject class="fig-easing-curve-handle" data-handle="1" width="20" height="20"><fig-handle size="small"></fig-handle></foreignObject>
10301
- <foreignObject class="fig-easing-curve-handle" data-handle="2" width="20" height="20"><fig-handle size="small"></fig-handle></foreignObject>
11263
+ <foreignObject class="fig-easing-curve-handle" data-handle="1" width="20" height="20"><fig-handle size="small" drag aria-label="First easing control point"></fig-handle></foreignObject>
11264
+ <foreignObject class="fig-easing-curve-handle" data-handle="2" width="20" height="20"><fig-handle size="small" drag aria-label="Second easing control point"></fig-handle></foreignObject>
10302
11265
  </svg></div>${valueInput}`;
10303
11266
  }
10304
11267
 
@@ -10473,6 +11436,18 @@ class FigEasingCurve extends HTMLElement {
10473
11436
  this.#bezierEndpointEnd.setAttribute("cx", p3.x);
10474
11437
  this.#bezierEndpointEnd.setAttribute("cy", p3.y);
10475
11438
  }
11439
+ this.#syncBezierHandleTabOrder();
11440
+ }
11441
+
11442
+ #syncBezierHandleTabOrder() {
11443
+ if (!this.#svg || !this.#handle1 || !this.#handle2) return;
11444
+ const handles =
11445
+ this.#cp1.y >= this.#cp2.y
11446
+ ? [this.#handle1, this.#handle2]
11447
+ : [this.#handle2, this.#handle1];
11448
+ for (const handle of handles) {
11449
+ this.#svg.append(handle);
11450
+ }
10476
11451
  }
10477
11452
 
10478
11453
  #updateSpringPaths() {
@@ -10611,29 +11586,160 @@ class FigEasingCurve extends HTMLElement {
10611
11586
  }
10612
11587
  }
10613
11588
 
10614
- #refreshCustomPresetIcons() {
10615
- if (!this.#dropdown) return;
10616
- if (!this.#isEditEnabled()) return;
10617
- const bezierIcon = FigEasingCurve.curveIcon(
10618
- this.#cp1.x,
10619
- this.#cp1.y,
10620
- this.#cp2.x,
10621
- this.#cp2.y,
10622
- );
10623
- const springIcon = FigEasingCurve.#springIcon(this.#spring);
11589
+ #refreshCustomPresetIcons() {
11590
+ if (!this.#dropdown) return;
11591
+ if (!this.#isEditEnabled()) return;
11592
+ const bezierIcon = FigEasingCurve.curveIcon(
11593
+ this.#cp1.x,
11594
+ this.#cp1.y,
11595
+ this.#cp2.x,
11596
+ this.#cp2.y,
11597
+ );
11598
+ const springIcon = FigEasingCurve.#springIcon(this.#spring);
11599
+
11600
+ // Update both slotted options and the cloned native select options.
11601
+ this.#setOptionIconByValue(this.#dropdown, "Custom bezier", bezierIcon);
11602
+ this.#setOptionIconByValue(this.#dropdown, "Custom spring", springIcon);
11603
+ this.#setOptionIconByValue(
11604
+ this.#dropdown.select,
11605
+ "Custom bezier",
11606
+ bezierIcon,
11607
+ );
11608
+ this.#setOptionIconByValue(
11609
+ this.#dropdown.select,
11610
+ "Custom spring",
11611
+ springIcon,
11612
+ );
11613
+ }
11614
+
11615
+ #syncAfterHandleInput(eventType) {
11616
+ this.#updatePaths();
11617
+ this.#presetName = this.#matchPreset();
11618
+ this.#syncDropdown();
11619
+ this.#syncValueInput();
11620
+ this.#emit(eventType);
11621
+ }
11622
+
11623
+ #handleBezierKeyboard(event, handle) {
11624
+ const step = event.shiftKey ? 0.1 : 0.01;
11625
+ const point = handle === 1 ? this.#cp1 : this.#cp2;
11626
+
11627
+ switch (event.key) {
11628
+ case "ArrowLeft":
11629
+ point.x -= step;
11630
+ break;
11631
+ case "ArrowRight":
11632
+ point.x += step;
11633
+ break;
11634
+ case "ArrowUp":
11635
+ point.y += step;
11636
+ break;
11637
+ case "ArrowDown":
11638
+ point.y -= step;
11639
+ break;
11640
+ case "Home":
11641
+ point.x = 0;
11642
+ point.y = 0;
11643
+ break;
11644
+ case "End":
11645
+ point.x = 1;
11646
+ point.y = 1;
11647
+ break;
11648
+ default:
11649
+ return false;
11650
+ }
11651
+
11652
+ point.x = Math.max(0, Math.min(1, Math.round(point.x * 100) / 100));
11653
+ point.y = Math.round(point.y * 100) / 100;
11654
+ this.#syncAfterHandleInput("input");
11655
+ this.#emit("change");
11656
+ return true;
11657
+ }
11658
+
11659
+ #handleSpringKeyboard(event, handleType) {
11660
+ const unit = event.shiftKey ? 10 : 1;
11661
+
11662
+ if (handleType === "bounce") {
11663
+ switch (event.key) {
11664
+ case "ArrowUp":
11665
+ this.#spring.damping = Math.max(1, Math.round(this.#spring.damping - unit));
11666
+ break;
11667
+ case "ArrowDown":
11668
+ this.#spring.damping = Math.max(1, Math.round(this.#spring.damping + unit));
11669
+ break;
11670
+ case "Home":
11671
+ this.#spring.damping = 1;
11672
+ break;
11673
+ case "End":
11674
+ this.#spring.damping = 50;
11675
+ break;
11676
+ default:
11677
+ return false;
11678
+ }
11679
+ } else {
11680
+ const dx = unit * 2;
11681
+ switch (event.key) {
11682
+ case "ArrowLeft":
11683
+ this.#springDuration = Math.max(0.05, this.#springDuration - dx / 200);
11684
+ this.#spring.stiffness = Math.max(10, Math.round(this.#spring.stiffness + dx * 1.5));
11685
+ break;
11686
+ case "ArrowRight":
11687
+ this.#springDuration = Math.min(0.95, this.#springDuration + dx / 200);
11688
+ this.#spring.stiffness = Math.max(10, Math.round(this.#spring.stiffness - dx * 1.5));
11689
+ break;
11690
+ case "Home":
11691
+ this.#springDuration = 0.05;
11692
+ break;
11693
+ case "End":
11694
+ this.#springDuration = 0.95;
11695
+ break;
11696
+ default:
11697
+ return false;
11698
+ }
11699
+ }
11700
+
11701
+ this.#syncAfterHandleInput("input");
11702
+ this.#emit("change");
11703
+ return true;
11704
+ }
11705
+
11706
+ #setupHandleInteraction(handleContainer, handle) {
11707
+ const handleEl = handleContainer?.querySelector("fig-handle");
11708
+ if (!handleEl) return;
10624
11709
 
10625
- // Update both slotted options and the cloned native select options.
10626
- this.#setOptionIconByValue(this.#dropdown, "Custom bezier", bezierIcon);
10627
- this.#setOptionIconByValue(this.#dropdown, "Custom spring", springIcon);
10628
- this.#setOptionIconByValue(
10629
- this.#dropdown.select,
10630
- "Custom bezier",
10631
- bezierIcon,
11710
+ handleEl.addEventListener(
11711
+ "pointerdown",
11712
+ (event) => {
11713
+ event.preventDefault();
11714
+ event.stopImmediatePropagation();
11715
+ if (this.#mode === "bezier") {
11716
+ this.#startBezierDrag(event, Number(handle));
11717
+ } else {
11718
+ this.#startSpringDrag(event, handle);
11719
+ }
11720
+ },
11721
+ { capture: true },
10632
11722
  );
10633
- this.#setOptionIconByValue(
10634
- this.#dropdown.select,
10635
- "Custom spring",
10636
- springIcon,
11723
+
11724
+ handleEl.addEventListener(
11725
+ "keydown",
11726
+ (event) => {
11727
+ if (
11728
+ !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(
11729
+ event.key,
11730
+ )
11731
+ ) {
11732
+ return;
11733
+ }
11734
+ const handled =
11735
+ this.#mode === "bezier"
11736
+ ? this.#handleBezierKeyboard(event, Number(handle))
11737
+ : this.#handleSpringKeyboard(event, handle);
11738
+ if (!handled) return;
11739
+ event.preventDefault();
11740
+ event.stopImmediatePropagation();
11741
+ },
11742
+ { capture: true },
10637
11743
  );
10638
11744
  }
10639
11745
 
@@ -10655,6 +11761,8 @@ class FigEasingCurve extends HTMLElement {
10655
11761
 
10656
11762
  #setupEvents() {
10657
11763
  if (this.#svg && this.#mode === "bezier") {
11764
+ this.#setupHandleInteraction(this.#handle1, "1");
11765
+ this.#setupHandleInteraction(this.#handle2, "2");
10658
11766
  this.#handle1.addEventListener("pointerdown", (e) =>
10659
11767
  this.#startBezierDrag(e, 1),
10660
11768
  );
@@ -10673,6 +11781,8 @@ class FigEasingCurve extends HTMLElement {
10673
11781
  });
10674
11782
  }
10675
11783
  } else if (this.#svg) {
11784
+ this.#setupHandleInteraction(this.#handle1, "bounce");
11785
+ this.#setupHandleInteraction(this.#handle2, "duration");
10676
11786
  this.#handle1.addEventListener("pointerdown", (e) => {
10677
11787
  e.stopPropagation();
10678
11788
  this.#startSpringDrag(e, "bounce");
@@ -11518,6 +12628,46 @@ class FigOriginGrid extends HTMLElement {
11518
12628
  };
11519
12629
  }
11520
12630
 
12631
+ #moveHandleByKeyboard(event) {
12632
+ if (!this.#dragEnabled) return false;
12633
+ const step = event.shiftKey ? 10 : 1;
12634
+ let nextX = this.#x;
12635
+ let nextY = this.#y;
12636
+
12637
+ switch (event.key) {
12638
+ case "ArrowLeft":
12639
+ nextX -= step;
12640
+ break;
12641
+ case "ArrowRight":
12642
+ nextX += step;
12643
+ break;
12644
+ case "ArrowUp":
12645
+ nextY -= step;
12646
+ break;
12647
+ case "ArrowDown":
12648
+ nextY += step;
12649
+ break;
12650
+ case "Home":
12651
+ nextX = 0;
12652
+ nextY = 0;
12653
+ break;
12654
+ case "End":
12655
+ nextX = 100;
12656
+ nextY = 100;
12657
+ break;
12658
+ default:
12659
+ return false;
12660
+ }
12661
+
12662
+ this.#setFromPercent(
12663
+ this.#clampPercentage(nextX),
12664
+ this.#clampPercentage(nextY),
12665
+ "input",
12666
+ );
12667
+ this.#emit("change");
12668
+ return true;
12669
+ }
12670
+
11521
12671
  #detachHandleDragListeners() {
11522
12672
  if (
11523
12673
  !this.#grid ||
@@ -11621,6 +12771,12 @@ class FigOriginGrid extends HTMLElement {
11621
12771
  this.#clearHoveredCells();
11622
12772
  });
11623
12773
 
12774
+ this.#handle.addEventListener("keydown", (e) => {
12775
+ if (!this.#moveHandleByKeyboard(e)) return;
12776
+ e.preventDefault();
12777
+ e.stopPropagation();
12778
+ });
12779
+
11624
12780
  const bindValueInput = (inputEl, axis) => {
11625
12781
  if (!inputEl) return;
11626
12782
  const handle = (e) => {
@@ -11652,7 +12808,7 @@ customElements.define("fig-origin-grid", FigOriginGrid);
11652
12808
  * @attr {number} transform - A scaling factor for the output.
11653
12809
  * @attr {boolean} fields - Whether to display X and Y inputs.
11654
12810
  * @attr {string} aspect-ratio - Aspect ratio for the joystick plane container.
11655
- * @attr {string} axis-labels - Space-delimited labels. 1 token: top. 2 tokens: x y. 4 tokens: left right top bottom.
12811
+ * @attr {string} axis-labels - Comma- or space-delimited labels. 1 token: top. 2 tokens: x y. 4 tokens: left right top bottom.
11656
12812
  */
11657
12813
  class FigInputJoystick extends HTMLElement {
11658
12814
  #boundPlanePointerDown = null;
@@ -11736,7 +12892,7 @@ class FigInputJoystick extends HTMLElement {
11736
12892
  if (!raw) {
11737
12893
  return { left: "", right: "", top: "", bottom: "", leftNoRotate: false };
11738
12894
  }
11739
- const tokens = raw.split(/\s+/).filter(Boolean);
12895
+ const tokens = raw.split(/[\s,]+/).filter(Boolean);
11740
12896
  if (tokens.length === 1) {
11741
12897
  return {
11742
12898
  left: "",
@@ -11775,7 +12931,7 @@ class FigInputJoystick extends HTMLElement {
11775
12931
  ].join("");
11776
12932
 
11777
12933
  return `
11778
- <div class="fig-input-joystick-plane-container" tabindex="0">
12934
+ <div class="fig-input-joystick-plane-container">
11779
12935
  ${labelsMarkup}
11780
12936
  <div class="fig-input-joystick-plane">
11781
12937
  <div class="fig-input-joystick-guides"></div>
@@ -11977,8 +13133,7 @@ class FigInputJoystick extends HTMLElement {
11977
13133
  }
11978
13134
 
11979
13135
  focus() {
11980
- const container = this.querySelector(".fig-input-joystick-plane-container");
11981
- container?.focus();
13136
+ this.cursor?.focus();
11982
13137
  }
11983
13138
  static get observedAttributes() {
11984
13139
  return [
@@ -12069,10 +13224,11 @@ class FigShimmer extends HTMLElement {
12069
13224
  if (duration) {
12070
13225
  this.style.setProperty(this.durationPropertyName, duration);
12071
13226
  }
13227
+ this.#syncA11y();
12072
13228
  }
12073
13229
 
12074
13230
  static get observedAttributes() {
12075
- return ["duration", "playing"];
13231
+ return ["duration", "playing", "aria-label", "aria-labelledby"];
12076
13232
  }
12077
13233
 
12078
13234
  get playing() {
@@ -12091,138 +13247,44 @@ class FigShimmer extends HTMLElement {
12091
13247
  if (name === "duration") {
12092
13248
  this.style.setProperty(this.durationPropertyName, newValue || "1.5s");
12093
13249
  }
12094
- // playing is handled purely by CSS attribute selectors
12095
- }
12096
- }
12097
- customElements.define("fig-shimmer", FigShimmer);
12098
-
12099
- // FigSkeleton
12100
- class FigSkeleton extends FigShimmer {}
12101
- customElements.define("fig-skeleton", FigSkeleton);
12102
-
12103
- // FigLayer
12104
- class FigLayer extends HTMLElement {
12105
- static get observedAttributes() {
12106
- return ["open", "visible"];
12107
- }
12108
-
12109
- #chevron = null;
12110
- #boundHandleChevronClick = null;
12111
-
12112
- connectedCallback() {
12113
- // Use requestAnimationFrame to ensure child elements have rendered
12114
- requestAnimationFrame(() => {
12115
- this.#injectChevron();
12116
- });
12117
- }
12118
-
12119
- disconnectedCallback() {
12120
- if (this.#chevron && this.#boundHandleChevronClick) {
12121
- this.#chevron.removeEventListener("click", this.#boundHandleChevronClick);
12122
- }
12123
- }
12124
-
12125
- #injectChevron() {
12126
- const row = this.querySelector(":scope > .fig-layer-row");
12127
- if (!row) return;
12128
-
12129
- // Check if chevron already exists
12130
- if (row.querySelector(".fig-layer-chevron")) return;
12131
-
12132
- // Always create chevron element - CSS handles visibility via :has(fig-layer)
12133
- this.#chevron = document.createElement("span");
12134
- this.#chevron.className = "fig-layer-chevron";
12135
- row.prepend(this.#chevron);
12136
-
12137
- // Add click listener to chevron only
12138
- this.#boundHandleChevronClick = this.#handleChevronClick.bind(this);
12139
- this.#chevron.addEventListener("click", this.#boundHandleChevronClick);
12140
- }
12141
-
12142
- #handleChevronClick(e) {
12143
- e.stopPropagation();
12144
- this.open = !this.open;
12145
- }
12146
-
12147
- get open() {
12148
- const attr = this.getAttribute("open");
12149
- return attr !== null && attr !== "false";
12150
- }
12151
-
12152
- set open(value) {
12153
- const oldValue = this.open;
12154
- if (value) {
12155
- this.setAttribute("open", "true");
12156
- } else {
12157
- this.setAttribute("open", "false");
12158
- }
12159
- if (oldValue !== value) {
12160
- this.dispatchEvent(
12161
- new CustomEvent("openchange", {
12162
- detail: { open: value },
12163
- bubbles: true,
12164
- }),
12165
- );
13250
+ if (name === "playing" || name === "aria-label" || name === "aria-labelledby") {
13251
+ this.#syncA11y();
12166
13252
  }
12167
13253
  }
12168
13254
 
12169
- get visible() {
12170
- const attr = this.getAttribute("visible");
12171
- return attr !== "false";
12172
- }
12173
-
12174
- set visible(value) {
12175
- const oldValue = this.visible;
12176
- if (value) {
12177
- this.setAttribute("visible", "true");
13255
+ #syncA11y() {
13256
+ const playing = this.playing;
13257
+ this.setAttribute("aria-busy", playing ? "true" : "false");
13258
+ if (this.hasAttribute("aria-label") || this.hasAttribute("aria-labelledby")) {
13259
+ if (!this.hasAttribute("role")) this.setAttribute("role", "status");
13260
+ this.removeAttribute("aria-hidden");
12178
13261
  } else {
12179
- this.setAttribute("visible", "false");
12180
- }
12181
- if (oldValue !== value) {
12182
- this.dispatchEvent(
12183
- new CustomEvent("visibilitychange", {
12184
- detail: { visible: value },
12185
- bubbles: true,
12186
- }),
12187
- );
13262
+ this.removeAttribute("role");
13263
+ this.setAttribute("aria-hidden", "true");
12188
13264
  }
12189
13265
  }
13266
+ }
13267
+ customElements.define("fig-shimmer", FigShimmer);
12190
13268
 
12191
- attributeChangedCallback(name, oldValue, newValue) {
12192
- if (oldValue === newValue) return;
12193
-
12194
- if (name === "open") {
12195
- const isOpen = newValue !== null && newValue !== "false";
12196
- this.dispatchEvent(
12197
- new CustomEvent("openchange", {
12198
- detail: { open: isOpen },
12199
- bubbles: true,
12200
- }),
12201
- );
12202
- }
12203
-
12204
- if (name === "visible") {
12205
- const isVisible = newValue !== "false";
12206
- this.dispatchEvent(
12207
- new CustomEvent("visibilitychange", {
12208
- detail: { visible: isVisible },
12209
- bubbles: true,
12210
- }),
12211
- );
12212
- }
13269
+ // FigSkeleton
13270
+ class FigSkeleton extends FigShimmer {
13271
+ connectedCallback() {
13272
+ super.connectedCallback();
13273
+ this.inert = true;
13274
+ this.setAttribute("inert", "");
12213
13275
  }
12214
13276
  }
12215
- customElements.define("fig-layer", FigLayer);
13277
+ customElements.define("fig-skeleton", FigSkeleton);
12216
13278
 
12217
13279
  // FigGroup
12218
13280
  class FigGroup extends HTMLElement {
12219
- static observedAttributes = ["name", "collapsible"];
13281
+ static observedAttributes = ["name", "collapsible", "open"];
12220
13282
 
12221
13283
  #header = null;
12222
13284
  #chevron = null;
12223
13285
 
12224
13286
  connectedCallback() {
12225
- requestAnimationFrame(() => this.#render());
13287
+ this.#render();
12226
13288
  }
12227
13289
 
12228
13290
  disconnectedCallback() {
@@ -12231,11 +13293,17 @@ class FigGroup extends HTMLElement {
12231
13293
  }
12232
13294
  if (this.#header) {
12233
13295
  this.#header.removeEventListener("click", this.#handleToggle);
13296
+ this.#header.removeEventListener("keydown", this.#handleHeaderKeyDown);
13297
+ this.#header.querySelector("h3")?.removeEventListener("click", this.#handleToggle);
12234
13298
  }
12235
13299
  }
12236
13300
 
12237
13301
  attributeChangedCallback(name, oldValue, newValue) {
12238
13302
  if (oldValue === newValue) return;
13303
+ if (name === "open") {
13304
+ this.#header?.setAttribute("aria-expanded", String(this.open));
13305
+ return;
13306
+ }
12239
13307
  this.#render();
12240
13308
  }
12241
13309
 
@@ -12251,6 +13319,7 @@ class FigGroup extends HTMLElement {
12251
13319
  } else {
12252
13320
  this.setAttribute("open", "false");
12253
13321
  }
13322
+ this.#header?.setAttribute("aria-expanded", String(!!value));
12254
13323
  if (was !== !!value) {
12255
13324
  this.dispatchEvent(
12256
13325
  new CustomEvent("openchange", {
@@ -12266,6 +13335,13 @@ class FigGroup extends HTMLElement {
12266
13335
  this.open = !this.open;
12267
13336
  };
12268
13337
 
13338
+ #handleHeaderKeyDown = (e) => {
13339
+ if (e.key !== "Enter" && e.key !== " ") return;
13340
+ e.preventDefault();
13341
+ e.stopPropagation();
13342
+ this.open = !this.open;
13343
+ };
13344
+
12269
13345
  #render() {
12270
13346
  const isCollapsible = this.hasAttribute("collapsible");
12271
13347
  const nameAttr = this.getAttribute("name");
@@ -12298,9 +13374,14 @@ class FigGroup extends HTMLElement {
12298
13374
  h3 = document.createElement("h3");
12299
13375
  this.#header.prepend(h3);
12300
13376
  }
13377
+ if (!h3.id) h3.id = figUniqueId();
12301
13378
  if (this.#header.dataset.generated) {
12302
13379
  h3.textContent = label;
12303
13380
  }
13381
+ if (!this.hasAttribute("role")) this.setAttribute("role", "group");
13382
+ if (!this.hasAttribute("aria-label") && !this.hasAttribute("aria-labelledby")) {
13383
+ this.setAttribute("aria-labelledby", h3.id);
13384
+ }
12304
13385
 
12305
13386
  if (isCollapsible) {
12306
13387
  if (!h3.querySelector(".fig-group-chevron")) {
@@ -12311,12 +13392,26 @@ class FigGroup extends HTMLElement {
12311
13392
  h3.prepend(chevron);
12312
13393
  }
12313
13394
  this.#chevron = h3.querySelector(".fig-group-chevron");
12314
- h3.addEventListener("click", this.#handleToggle);
13395
+ h3.removeEventListener("click", this.#handleToggle);
13396
+ this.#header.removeEventListener("click", this.#handleToggle);
13397
+ this.#header.addEventListener("click", this.#handleToggle);
13398
+ this.#header.setAttribute("role", "button");
13399
+ this.#header.setAttribute("tabindex", "0");
13400
+ this.#header.setAttribute("aria-expanded", String(this.open));
13401
+ this.#header.removeEventListener("keydown", this.#handleHeaderKeyDown);
13402
+ this.#header.addEventListener("keydown", this.#handleHeaderKeyDown);
12315
13403
 
12316
13404
  if (!this.hasAttribute("open")) {
12317
13405
  this.setAttribute("open", "false");
13406
+ this.#header.setAttribute("aria-expanded", "false");
12318
13407
  }
12319
13408
  } else {
13409
+ h3.removeEventListener("click", this.#handleToggle);
13410
+ this.#header.removeEventListener("click", this.#handleToggle);
13411
+ this.#header.removeAttribute("role");
13412
+ this.#header.removeAttribute("tabindex");
13413
+ this.#header.removeAttribute("aria-expanded");
13414
+ this.#header.removeEventListener("keydown", this.#handleHeaderKeyDown);
12320
13415
  if (this.#chevron) {
12321
13416
  this.#chevron.remove();
12322
13417
  this.#chevron = null;
@@ -12346,7 +13441,14 @@ class FigFooter extends HTMLElement {}
12346
13441
  customElements.define("fig-footer", FigFooter);
12347
13442
 
12348
13443
  /* Presentational elements (CSS-only, no behavior) */
12349
- class FigSpinner extends HTMLElement {}
13444
+ class FigSpinner extends HTMLElement {
13445
+ connectedCallback() {
13446
+ if (!this.hasAttribute("role")) this.setAttribute("role", "status");
13447
+ if (!this.hasAttribute("aria-label") && !this.hasAttribute("aria-labelledby")) {
13448
+ this.setAttribute("aria-label", "Loading");
13449
+ }
13450
+ }
13451
+ }
12350
13452
  customElements.define("fig-spinner", FigSpinner);
12351
13453
 
12352
13454
  /**
@@ -12486,7 +13588,16 @@ class FigColorTip extends HTMLElement {
12486
13588
  #boundHandleChange = this.#handlePickerChange.bind(this);
12487
13589
 
12488
13590
  static get observedAttributes() {
12489
- return ["value", "selected", "disabled", "alpha", "control"];
13591
+ return [
13592
+ "value",
13593
+ "selected",
13594
+ "disabled",
13595
+ "alpha",
13596
+ "control",
13597
+ "aria-label",
13598
+ "aria-labelledby",
13599
+ "aria-describedby",
13600
+ ];
12490
13601
  }
12491
13602
 
12492
13603
  get #controlMode() {
@@ -12549,10 +13660,12 @@ class FigColorTip extends HTMLElement {
12549
13660
  const mode = this.#controlMode;
12550
13661
  if (mode === "add" || mode === "remove") {
12551
13662
  const iconName = mode === "add" ? "add" : "minus";
12552
- this.innerHTML = `<fig-button icon variant="ghost"><fig-icon name="${iconName}"></fig-icon></fig-button>`;
13663
+ const label = this.getAttribute("aria-label") || (mode === "add" ? "Add color stop" : "Remove color stop");
13664
+ this.innerHTML = `<fig-button icon variant="ghost" aria-label="${label}"><fig-icon name="${iconName}"></fig-icon></fig-button>`;
12553
13665
  this.#fillPicker = null;
12554
13666
  this.#chit = null;
12555
13667
  this.addEventListener("click", this.#handleControlClick);
13668
+ this.#syncA11y();
12556
13669
  return;
12557
13670
  }
12558
13671
  this.removeEventListener("click", this.#handleControlClick);
@@ -12586,6 +13699,7 @@ class FigColorTip extends HTMLElement {
12586
13699
  this.#chit?.addEventListener("change", this.#boundHandleChange);
12587
13700
  }
12588
13701
  this.#observeChitSelected();
13702
+ this.#syncA11y();
12589
13703
  }
12590
13704
 
12591
13705
  #handleControlClick = () => {
@@ -12681,6 +13795,7 @@ class FigColorTip extends HTMLElement {
12681
13795
  }
12682
13796
 
12683
13797
  if (this.#fillPicker) {
13798
+ this.#syncA11y();
12684
13799
  const pickerVal =
12685
13800
  alpha < 1
12686
13801
  ? { type: "solid", color, opacity: Math.round(alpha * 100) }
@@ -12699,6 +13814,7 @@ class FigColorTip extends HTMLElement {
12699
13814
  }
12700
13815
 
12701
13816
  if (this.#chit) {
13817
+ this.#syncA11y();
12702
13818
  this.#chit.setAttribute("background", color);
12703
13819
  if (alpha < 1) {
12704
13820
  this.#chit.setAttribute("alpha", String(alpha));
@@ -12713,6 +13829,39 @@ class FigColorTip extends HTMLElement {
12713
13829
  }
12714
13830
  }
12715
13831
 
13832
+ #syncA11y() {
13833
+ const mode = this.#controlMode;
13834
+ const disabled =
13835
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
13836
+ const selected =
13837
+ this.hasAttribute("selected") && this.getAttribute("selected") !== "false";
13838
+ this.setAttribute("aria-disabled", disabled ? "true" : "false");
13839
+ this.setAttribute("aria-pressed", selected ? "true" : "false");
13840
+ if (mode === "add" || mode === "remove") {
13841
+ const button = this.querySelector("fig-button");
13842
+ const label = this.getAttribute("aria-label") || (mode === "add" ? "Add color stop" : "Remove color stop");
13843
+ button?.setAttribute("aria-label", label);
13844
+ if (disabled) button?.setAttribute("disabled", "");
13845
+ else button?.removeAttribute("disabled");
13846
+ return;
13847
+ }
13848
+
13849
+ const target = this.#fillPicker || this.#chit;
13850
+ if (!target) return;
13851
+ const label = this.getAttribute("aria-label") || "Color stop";
13852
+ const labelledBy = this.getAttribute("aria-labelledby");
13853
+ const describedBy = this.getAttribute("aria-describedby");
13854
+ if (labelledBy) {
13855
+ target.setAttribute("aria-labelledby", labelledBy);
13856
+ target.removeAttribute("aria-label");
13857
+ } else {
13858
+ target.setAttribute("aria-label", label);
13859
+ target.removeAttribute("aria-labelledby");
13860
+ }
13861
+ if (describedBy) target.setAttribute("aria-describedby", describedBy);
13862
+ else target.removeAttribute("aria-describedby");
13863
+ }
13864
+
12716
13865
  #updateColorFromPicker(detail, type) {
12717
13866
  const nextColor = this.#normalizeColor(detail?.color);
12718
13867
  const prevColor = this.#normalizeColor(this.getAttribute("value"));
@@ -12762,7 +13911,11 @@ class FigColorTip extends HTMLElement {
12762
13911
  case "value":
12763
13912
  case "selected":
12764
13913
  case "disabled":
13914
+ case "aria-label":
13915
+ case "aria-labelledby":
13916
+ case "aria-describedby":
12765
13917
  this.#syncFromAttributes();
13918
+ this.#syncA11y();
12766
13919
  break;
12767
13920
  }
12768
13921
  }
@@ -13426,6 +14579,8 @@ class FigHandle extends HTMLElement {
13426
14579
  "tip",
13427
14580
  "hit-area",
13428
14581
  "hit-area-mode",
14582
+ "aria-label",
14583
+ "aria-labelledby",
13429
14584
  ];
13430
14585
 
13431
14586
  #isDragging = false;
@@ -13653,6 +14808,7 @@ class FigHandle extends HTMLElement {
13653
14808
  }
13654
14809
 
13655
14810
  connectedCallback() {
14811
+ this.#syncA11y();
13656
14812
  this.#syncDrag();
13657
14813
  this.#syncHitArea();
13658
14814
  this.addEventListener("click", this.#handleSelect);
@@ -13709,7 +14865,32 @@ class FigHandle extends HTMLElement {
13709
14865
  };
13710
14866
 
13711
14867
  #handleKeyDown = (e) => {
14868
+ if (e.defaultPrevented) return;
14869
+ if (
14870
+ e.target === this &&
14871
+ this.#dragEnabled &&
14872
+ ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)
14873
+ ) {
14874
+ if (this.#moveByKeyboard(e)) {
14875
+ e.preventDefault();
14876
+ if (!this.hasAttribute("selected")) this.select();
14877
+ }
14878
+ return;
14879
+ }
13712
14880
  if (e.key !== "Enter" && e.key !== " ") return;
14881
+ if (e.target === this && !this.hasAttribute("selected")) {
14882
+ e.preventDefault();
14883
+ if (
14884
+ this.getAttribute("type") === "color" &&
14885
+ this.#canOpenColorPicker &&
14886
+ !this.#tipMode
14887
+ ) {
14888
+ this.#openDirectColorPicker();
14889
+ } else {
14890
+ this.select();
14891
+ }
14892
+ return;
14893
+ }
13713
14894
  if (!this.hasAttribute("selected")) return;
13714
14895
  if (this.getAttribute("type") !== "color") return;
13715
14896
  if (!this.#canOpenColorPicker) return;
@@ -13719,6 +14900,64 @@ class FigHandle extends HTMLElement {
13719
14900
  }
13720
14901
  };
13721
14902
 
14903
+ #moveByKeyboard(event) {
14904
+ if (this.hasAttribute("disabled")) return false;
14905
+ const container = this.#getContainer();
14906
+ if (!container) return false;
14907
+ const rect = container.getBoundingClientRect();
14908
+ if (rect.width <= 0 || rect.height <= 0) return false;
14909
+
14910
+ const axes = this.#axes;
14911
+ const current = this.#positionDetail(rect);
14912
+ const pctStep = event.shiftKey ? 0.1 : 0.01;
14913
+ let px = current.px;
14914
+ let py = current.py;
14915
+
14916
+ switch (event.key) {
14917
+ case "ArrowLeft":
14918
+ if (!axes.x) return false;
14919
+ px -= pctStep;
14920
+ break;
14921
+ case "ArrowRight":
14922
+ if (!axes.x) return false;
14923
+ px += pctStep;
14924
+ break;
14925
+ case "ArrowUp":
14926
+ if (!axes.y) return false;
14927
+ py -= pctStep;
14928
+ break;
14929
+ case "ArrowDown":
14930
+ if (!axes.y) return false;
14931
+ py += pctStep;
14932
+ break;
14933
+ case "Home":
14934
+ if (axes.x) px = 0;
14935
+ if (axes.y) py = 0;
14936
+ break;
14937
+ case "End":
14938
+ if (axes.x) px = 1;
14939
+ if (axes.y) py = 1;
14940
+ break;
14941
+ default:
14942
+ return false;
14943
+ }
14944
+
14945
+ px = Math.max(0, Math.min(1, px));
14946
+ py = Math.max(0, Math.min(1, py));
14947
+ this.#syncPositionTranslate(axes);
14948
+ if (axes.x) this.style.left = `${Math.round(px * rect.width)}px`;
14949
+ if (axes.y) this.style.top = `${Math.round(py * rect.height)}px`;
14950
+ this.#syncValueAttribute();
14951
+ const detail = {
14952
+ ...this.#positionDetail(rect),
14953
+ shiftKey: event.shiftKey,
14954
+ keyboard: true,
14955
+ };
14956
+ this.dispatchEvent(new CustomEvent("input", { bubbles: true, detail }));
14957
+ this.dispatchEvent(new CustomEvent("change", { bubbles: true, detail }));
14958
+ return true;
14959
+ }
14960
+
13722
14961
  attributeChangedCallback(name, _old, value) {
13723
14962
  if (name === "color") {
13724
14963
  if (!value || value === "false" || value === "true") {
@@ -13734,6 +14973,16 @@ class FigHandle extends HTMLElement {
13734
14973
  if (name === "drag") this.#syncDrag();
13735
14974
  if (name === "hit-area") this.#syncHitArea();
13736
14975
  if (name === "selected") this.#syncColorTipSelected();
14976
+ if (
14977
+ name === "selected" ||
14978
+ name === "disabled" ||
14979
+ name === "type" ||
14980
+ name === "tip" ||
14981
+ name === "aria-label" ||
14982
+ name === "aria-labelledby"
14983
+ ) {
14984
+ this.#syncA11y();
14985
+ }
13737
14986
  if (name === "value" && !this.#applyingValue && !this.#isDragging) {
13738
14987
  this.#applyValue(value);
13739
14988
  }
@@ -13755,6 +15004,25 @@ class FigHandle extends HTMLElement {
13755
15004
  }
13756
15005
  }
13757
15006
 
15007
+ #syncA11y() {
15008
+ const disabled =
15009
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
15010
+ const selected =
15011
+ this.hasAttribute("selected") && this.getAttribute("selected") !== "false";
15012
+ if (!this.hasAttribute("role")) this.setAttribute("role", "button");
15013
+ if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", disabled ? "-1" : "0");
15014
+ else if (disabled) this.setAttribute("tabindex", "-1");
15015
+ this.setAttribute("aria-disabled", disabled ? "true" : "false");
15016
+ this.setAttribute("aria-pressed", selected ? "true" : "false");
15017
+ if (!this.hasAttribute("aria-label") && !this.hasAttribute("aria-labelledby")) {
15018
+ const mode = this.#tipMode || this.getAttribute("type") || "handle";
15019
+ this.setAttribute(
15020
+ "aria-label",
15021
+ mode === "color" ? "Color handle" : mode === "add" ? "Add handle" : mode === "remove" ? "Remove handle" : "Handle",
15022
+ );
15023
+ }
15024
+ }
15025
+
13758
15026
  #teardownDrag() {
13759
15027
  if (this.#boundPointerDown) {
13760
15028
  this.removeEventListener("pointerdown", this.#boundPointerDown);
@@ -14240,9 +15508,29 @@ class FigMenuItem extends HTMLElement {
14240
15508
  if (!this.hasAttribute("tabindex")) {
14241
15509
  this.setAttribute("tabindex", "-1");
14242
15510
  }
15511
+ this.#syncDisabled();
15512
+ }
15513
+
15514
+ attributeChangedCallback(name, oldValue, newValue) {
15515
+ if (oldValue === newValue) return;
15516
+ if (name === "disabled") {
15517
+ this.#syncDisabled();
15518
+ }
14243
15519
  }
14244
15520
 
14245
- attributeChangedCallback() {}
15521
+ #syncDisabled() {
15522
+ const disabled =
15523
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
15524
+ if (disabled) {
15525
+ this.setAttribute("aria-disabled", "true");
15526
+ this.setAttribute("tabindex", "-1");
15527
+ } else {
15528
+ this.removeAttribute("aria-disabled");
15529
+ if (!this.hasAttribute("tabindex")) {
15530
+ this.setAttribute("tabindex", "-1");
15531
+ }
15532
+ }
15533
+ }
14246
15534
  }
14247
15535
  customElements.define("fig-menu-item", FigMenuItem);
14248
15536
 
@@ -14312,6 +15600,7 @@ class FigMenu extends HTMLElement {
14312
15600
 
14313
15601
  disconnectedCallback() {
14314
15602
  this.#teardownListeners();
15603
+ document.removeEventListener("keydown", this.#boundMenuKeydown, true);
14315
15604
  if (this.#observer) {
14316
15605
  this.#observer.disconnect();
14317
15606
  this.#observer = null;
@@ -14366,6 +15655,7 @@ class FigMenu extends HTMLElement {
14366
15655
  this.#popup.setAttribute("is", "fig-popup");
14367
15656
  this.#popup.setAttribute("theme", "menu");
14368
15657
  this.#popup.setAttribute("role", "menu");
15658
+ this.#popup.setAttribute("id", this.#popup.getAttribute("id") || figUniqueId());
14369
15659
 
14370
15660
  const position = this.getAttribute("position") || "bottom left";
14371
15661
  this.#popup.setAttribute("position", position);
@@ -14399,9 +15689,11 @@ class FigMenu extends HTMLElement {
14399
15689
  this.#trigger.addEventListener("click", this.#boundTriggerClick);
14400
15690
  this.#trigger.setAttribute("aria-haspopup", "menu");
14401
15691
  this.#trigger.setAttribute("aria-expanded", "false");
15692
+ this.#trigger.setAttribute("aria-controls", this.#popup.getAttribute("id"));
14402
15693
  }
14403
15694
  if (this.#popup) {
14404
15695
  this.#popup.addEventListener("click", this.#boundPopupClick);
15696
+ this.#popup.addEventListener("keydown", this.#boundMenuKeydown);
14405
15697
  }
14406
15698
  }
14407
15699
 
@@ -14412,6 +15704,7 @@ class FigMenu extends HTMLElement {
14412
15704
  }
14413
15705
  if (this.#popup) {
14414
15706
  this.#popup.removeEventListener("click", this.#boundPopupClick);
15707
+ this.#popup.removeEventListener("keydown", this.#boundMenuKeydown);
14415
15708
  }
14416
15709
  }
14417
15710
 
@@ -14431,6 +15724,7 @@ class FigMenu extends HTMLElement {
14431
15724
  this.#trigger.addEventListener("click", this.#boundTriggerClick);
14432
15725
  this.#trigger.setAttribute("aria-haspopup", "menu");
14433
15726
  this.#trigger.setAttribute("aria-expanded", "false");
15727
+ this.#trigger.setAttribute("aria-controls", this.#popup.getAttribute("id"));
14434
15728
  this.#popup.anchor = this.#trigger;
14435
15729
  this.#syncDisabled();
14436
15730
  }
@@ -14443,7 +15737,10 @@ class FigMenu extends HTMLElement {
14443
15737
 
14444
15738
  #getItems() {
14445
15739
  if (!this.#popup) return [];
14446
- return Array.from(this.#popup.querySelectorAll("fig-menu-item:not([disabled]):not([disabled='true'])"));
15740
+ return Array.from(this.#popup.querySelectorAll("fig-menu-item")).filter(
15741
+ (item) =>
15742
+ !item.hasAttribute("disabled") || item.getAttribute("disabled") === "false",
15743
+ );
14447
15744
  }
14448
15745
 
14449
15746
  #syncFocusedIndex() {
@@ -14462,9 +15759,9 @@ class FigMenu extends HTMLElement {
14462
15759
  #focusItemAt(index) {
14463
15760
  const items = this.#getItems();
14464
15761
  if (!items.length) return;
14465
- const clamped = Math.max(0, Math.min(index, items.length - 1));
14466
- this.#focusedIndex = clamped;
14467
- items[clamped].focus();
15762
+ const wrapped = (index + items.length) % items.length;
15763
+ this.#focusedIndex = wrapped;
15764
+ items[wrapped].focus();
14468
15765
  }
14469
15766
 
14470
15767
  #syncDisabled() {
@@ -14472,8 +15769,11 @@ class FigMenu extends HTMLElement {
14472
15769
  const disabled = this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
14473
15770
  if (disabled) {
14474
15771
  this.#trigger.setAttribute("disabled", "");
15772
+ this.#trigger.setAttribute("aria-disabled", "true");
15773
+ this.#trigger.setAttribute("aria-expanded", "false");
14475
15774
  } else {
14476
15775
  this.#trigger.removeAttribute("disabled");
15776
+ this.#trigger.removeAttribute("aria-disabled");
14477
15777
  }
14478
15778
  }
14479
15779
 
@@ -14501,7 +15801,19 @@ class FigMenu extends HTMLElement {
14501
15801
  }
14502
15802
 
14503
15803
  #handleMenuKeydown(e) {
14504
- if (!this.open || !this.#popup?.matches?.(":open")) return;
15804
+ if (e.currentTarget === document && e.key !== "Escape") return;
15805
+ if (e.currentTarget === this && this.#popup?.contains(e.target)) return;
15806
+ if (!this.open || !this.#popup?.matches?.(":open")) {
15807
+ if (
15808
+ this.#trigger?.contains(e.target) &&
15809
+ (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ")
15810
+ ) {
15811
+ e.preventDefault();
15812
+ this.open = true;
15813
+ requestAnimationFrame(() => this.#focusItemAt(0));
15814
+ }
15815
+ return;
15816
+ }
14505
15817
 
14506
15818
  const items = this.#getItems();
14507
15819
  if (!items.length) return;
@@ -14529,6 +15841,12 @@ class FigMenu extends HTMLElement {
14529
15841
  this.#focusItemAt(items.length - 1);
14530
15842
  break;
14531
15843
  }
15844
+ case "Escape": {
15845
+ e.preventDefault();
15846
+ this.open = false;
15847
+ this.#trigger?.focus();
15848
+ break;
15849
+ }
14532
15850
  case "Enter":
14533
15851
  case " ": {
14534
15852
  this.#syncFocusedIndex();
@@ -14567,6 +15885,7 @@ class FigMenu extends HTMLElement {
14567
15885
  #openMenu() {
14568
15886
  if (!this.#popup) return;
14569
15887
  this.#popup.open = true;
15888
+ document.addEventListener("keydown", this.#boundMenuKeydown, true);
14570
15889
  if (this.#trigger) {
14571
15890
  this.#trigger.setAttribute("aria-expanded", "true");
14572
15891
  }
@@ -14579,7 +15898,9 @@ class FigMenu extends HTMLElement {
14579
15898
 
14580
15899
  #closeMenu() {
14581
15900
  if (!this.#popup) return;
15901
+ document.removeEventListener("keydown", this.#boundMenuKeydown, true);
14582
15902
  this.#popup.open = false;
15903
+ this.#trigger?.setAttribute("aria-expanded", "false");
14583
15904
  }
14584
15905
  }
14585
15906
  customElements.define("fig-menu", FigMenu);