@rogieking/figui3 6.10.0 → 6.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig.js CHANGED
@@ -35,6 +35,10 @@ function figNextFrame(host, callback) {
35
35
  });
36
36
  }
37
37
 
38
+ function figBooleanAttribute(element, name) {
39
+ return element.hasAttribute(name) && element.getAttribute(name) !== "false";
40
+ }
41
+
38
42
  function figNormalizeTextOnlyInputSlots(host) {
39
43
  host
40
44
  .querySelectorAll(':scope > [slot="prepend"], :scope > [slot="append"]')
@@ -249,6 +253,15 @@ function figSyncCssVar(el, prop, value) {
249
253
  }
250
254
  }
251
255
 
256
+ function figEscapeAttribute(value) {
257
+ return String(value ?? "")
258
+ .replace(/&/g, "&")
259
+ .replace(/"/g, """)
260
+ .replace(/'/g, "'")
261
+ .replace(/</g, "&lt;")
262
+ .replace(/>/g, "&gt;");
263
+ }
264
+
252
265
  let _figSharedCanvas = null;
253
266
  let _figSharedCtx = null;
254
267
  function figGetSharedCanvas(width = 1, height = 1) {
@@ -300,7 +313,10 @@ class FigButton extends HTMLElement {
300
313
  if (!this.button) {
301
314
  const isControlWrapper = this.type === "select" || this.type === "upload";
302
315
  const controlTag = isControlWrapper ? "span" : "button";
303
- const typeAttr = isControlWrapper ? "" : ` type="${this.type}"`;
316
+ // Custom button modes are implemented by the host. Keep the shadow
317
+ // control inert so invalid native types (for example "toggle") cannot
318
+ // normalize to "submit" inside a form.
319
+ const typeAttr = isControlWrapper ? "" : ' type="button"';
304
320
  this.shadowRoot.innerHTML = `
305
321
  <style>
306
322
  button, button:hover, button:active, .fig-button-control {
@@ -343,9 +359,11 @@ class FigButton extends HTMLElement {
343
359
  this.button.addEventListener("click", this.#boundHandleClick);
344
360
  this.button.addEventListener("focus", this.#boundHandleFocus);
345
361
  this.button.addEventListener("blur", this.#boundHandleBlur);
346
- this.addEventListener("keydown", this.#boundHandleControlKeydown);
347
362
  }
348
363
 
364
+ this.removeEventListener("keydown", this.#boundHandleControlKeydown);
365
+ this.addEventListener("keydown", this.#boundHandleControlKeydown);
366
+
349
367
  this.#selected =
350
368
  this.hasAttribute("selected") &&
351
369
  this.getAttribute("selected") !== "false";
@@ -373,12 +391,14 @@ class FigButton extends HTMLElement {
373
391
  #handleClick() {
374
392
  if (this.#isDisabled()) return;
375
393
  if (this.type === "toggle") {
376
- this.toggleAttribute("selected", !this.hasAttribute("selected"));
394
+ if (this.selected) this.removeAttribute("selected");
395
+ else this.setAttribute("selected", "");
377
396
  }
378
397
  if (this.type === "submit") {
379
398
  let form = this.closest("form");
380
399
  if (form) {
381
- form.submit();
400
+ if (typeof form.requestSubmit === "function") form.requestSubmit();
401
+ else form.submit();
382
402
  }
383
403
  }
384
404
  if (this.type === "link") {
@@ -454,8 +474,8 @@ class FigButton extends HTMLElement {
454
474
  this.disabled = disabled;
455
475
  if (this.button instanceof HTMLButtonElement) {
456
476
  this.button.disabled = disabled;
457
- this.button.type = this.type;
458
- this.button.setAttribute("type", this.type);
477
+ this.button.type = "button";
478
+ this.button.setAttribute("type", "button");
459
479
  }
460
480
  this.#syncA11yAttributes();
461
481
  this.#syncPressedState();
@@ -577,7 +597,9 @@ class FigDropdown extends HTMLElement {
577
597
  return;
578
598
  }
579
599
  // Fallback mirror for browsers that don't auto-project selectedcontent reliably.
580
- this.#selectedContentEl.innerHTML = selectedOption.innerHTML;
600
+ this.#selectedContentEl.replaceChildren(
601
+ ...Array.from(selectedOption.childNodes, (node) => node.cloneNode(true)),
602
+ );
581
603
  }
582
604
 
583
605
  #addEventListeners() {
@@ -630,6 +652,16 @@ class FigDropdown extends HTMLElement {
630
652
  }
631
653
 
632
654
  slotChange() {
655
+ const hasDeclaredValue = this.hasAttribute("value");
656
+ const hadNativeSelection =
657
+ this.select.options.length > 0 && this.select.selectedIndex >= 0;
658
+ const selectedValue = hasDeclaredValue
659
+ ? this.getAttribute("value")
660
+ : this.type === "dropdown"
661
+ ? this.#selectedValue
662
+ : hadNativeSelection
663
+ ? this.select.value
664
+ : null;
633
665
  while (this.select.firstChild) {
634
666
  this.select.firstChild.remove();
635
667
  }
@@ -648,7 +680,9 @@ class FigDropdown extends HTMLElement {
648
680
  this.select.appendChild(option.cloneNode(true));
649
681
  }
650
682
  });
651
- this.#syncSelectedValue(this.value);
683
+ if (selectedValue !== null) {
684
+ this.#syncSelectedValue(selectedValue);
685
+ }
652
686
  this.#syncSelectedContent();
653
687
  if (this.type === "dropdown") {
654
688
  this.select.selectedIndex = -1;
@@ -656,6 +690,7 @@ class FigDropdown extends HTMLElement {
656
690
  }
657
691
 
658
692
  #handleSelectInput(e) {
693
+ e.stopPropagation();
659
694
  const selectedOption = e.target.selectedOptions?.[0];
660
695
  if (this.#hasPersistentControl(selectedOption)) {
661
696
  if (this.type === "dropdown") {
@@ -682,6 +717,7 @@ class FigDropdown extends HTMLElement {
682
717
  }
683
718
 
684
719
  #handleSelectChange(e) {
720
+ e.stopPropagation();
685
721
  const selectedOption = e.target.selectedOptions?.[0];
686
722
  if (this.#hasPersistentControl(selectedOption)) {
687
723
  if (this.type === "dropdown") {
@@ -756,13 +792,7 @@ class FigDropdown extends HTMLElement {
756
792
  if (this.type === "dropdown") {
757
793
  return;
758
794
  }
759
- if (this.select) {
760
- this.select.querySelectorAll("option").forEach((o, i) => {
761
- if (o.value === this.getAttribute("value")) {
762
- this.select.selectedIndex = i;
763
- }
764
- });
765
- }
795
+ if (this.select) this.select.value = value ?? "";
766
796
  this.#syncSelectedContent();
767
797
  }
768
798
  attributeChangedCallback(name, oldValue, newValue) {
@@ -770,13 +800,14 @@ class FigDropdown extends HTMLElement {
770
800
  this.#syncSelectedValue(newValue);
771
801
  }
772
802
  if (name === "type") {
773
- this.type = newValue;
803
+ this.type = newValue || "select";
804
+ if (this.isConnected) this.slotChange();
774
805
  }
775
806
  if (name === "experimental") {
776
807
  this.slotChange();
777
808
  }
778
809
  if (name === "label") {
779
- this.#label = newValue;
810
+ this.#label = newValue || "Menu";
780
811
  this.select.setAttribute("aria-label", this.#label);
781
812
  }
782
813
  if (name === "disabled") {
@@ -990,9 +1021,19 @@ class FigTooltip extends HTMLElement {
990
1021
  this.popup.append(content);
991
1022
  content.innerText = this.getAttribute("text") ?? "";
992
1023
 
993
- // Set aria-describedby on the trigger element
1024
+ // Preserve any descriptions supplied by the consumer and append the
1025
+ // transient tooltip id for as long as this popup exists.
994
1026
  if (this.firstElementChild) {
995
- this.firstElementChild.setAttribute("aria-describedby", tooltipId);
1027
+ const describedBy = new Set(
1028
+ (this.firstElementChild.getAttribute("aria-describedby") || "")
1029
+ .split(/\s+/)
1030
+ .filter(Boolean),
1031
+ );
1032
+ describedBy.add(tooltipId);
1033
+ this.firstElementChild.setAttribute(
1034
+ "aria-describedby",
1035
+ [...describedBy].join(" "),
1036
+ );
996
1037
  }
997
1038
 
998
1039
  // Attach to DOM.
@@ -1023,13 +1064,6 @@ class FigTooltip extends HTMLElement {
1023
1064
  this.popup.remove();
1024
1065
  this.popup = null;
1025
1066
  }
1026
- // Remove the click outside listener if it was added
1027
- if (this.action === "click") {
1028
- document.body.removeEventListener(
1029
- "click",
1030
- this.#boundHidePopupOutsideClick,
1031
- );
1032
- }
1033
1067
  }
1034
1068
  #removeDescribedBy(id) {
1035
1069
  const trigger = this.firstElementChild;
@@ -1203,21 +1237,41 @@ class FigTooltip extends HTMLElement {
1203
1237
  // fig-popup observes content size changes and will reposition itself.
1204
1238
  }
1205
1239
  get open() {
1206
- return this.hasAttribute("open") && this.getAttribute("open") === "true";
1240
+ return this.hasAttribute("open") && this.getAttribute("open") !== "false";
1207
1241
  }
1208
1242
  set open(value) {
1209
- this.setAttribute("open", value);
1243
+ if (value === false || value === "false" || value === null) {
1244
+ this.removeAttribute("open");
1245
+ } else {
1246
+ this.setAttribute("open", "true");
1247
+ }
1210
1248
  }
1211
1249
  attributeChangedCallback(name, oldValue, newValue) {
1212
1250
  if (name === "action") {
1213
- this.action = newValue;
1251
+ this.#unbindTriggerListeners();
1252
+ if (oldValue === "click") {
1253
+ document.body?.removeEventListener(
1254
+ "click",
1255
+ this.#boundHidePopupOutsideClick,
1256
+ );
1257
+ }
1258
+ this.action = newValue || "hover";
1259
+ if (this.isConnected) {
1260
+ this.#bindTriggerListeners();
1261
+ if (this.action === "click") {
1262
+ document.body?.addEventListener(
1263
+ "click",
1264
+ this.#boundHidePopupOutsideClick,
1265
+ );
1266
+ }
1267
+ }
1214
1268
  }
1215
1269
  if (name === "delay") {
1216
1270
  let delay = parseInt(newValue);
1217
1271
  this.delay = !isNaN(delay) ? delay : 500;
1218
1272
  }
1219
1273
  if (name === "open") {
1220
- if (newValue === "true") {
1274
+ if (newValue !== null && newValue !== "false") {
1221
1275
  requestAnimationFrame(() => {
1222
1276
  this.showDelayedPopup();
1223
1277
  });
@@ -1878,6 +1932,7 @@ class FigDialog extends HTMLDialogElement {
1878
1932
  this.removeEventListener("pointerdown", this._boundPointerDown);
1879
1933
  document.removeEventListener("pointermove", this._boundPointerMove);
1880
1934
  document.removeEventListener("pointerup", this._boundPointerUp);
1935
+ document.removeEventListener("pointercancel", this._boundPointerUp);
1881
1936
  }
1882
1937
 
1883
1938
  _isInteractiveElement(element) {
@@ -1968,6 +2023,7 @@ class FigDialog extends HTMLDialogElement {
1968
2023
 
1969
2024
  document.addEventListener("pointermove", this._boundPointerMove);
1970
2025
  document.addEventListener("pointerup", this._boundPointerUp);
2026
+ document.addEventListener("pointercancel", this._boundPointerUp);
1971
2027
  }
1972
2028
 
1973
2029
  _handlePointerMove(e) {
@@ -2000,7 +2056,9 @@ class FigDialog extends HTMLDialogElement {
2000
2056
 
2001
2057
  _handlePointerUp(e) {
2002
2058
  if (this._isDragging) {
2003
- this.releasePointerCapture(e.pointerId);
2059
+ if (this.hasPointerCapture?.(e.pointerId)) {
2060
+ this.releasePointerCapture(e.pointerId);
2061
+ }
2004
2062
  this.style.cursor = "";
2005
2063
  }
2006
2064
 
@@ -2009,6 +2067,7 @@ class FigDialog extends HTMLDialogElement {
2009
2067
 
2010
2068
  document.removeEventListener("pointermove", this._boundPointerMove);
2011
2069
  document.removeEventListener("pointerup", this._boundPointerUp);
2070
+ document.removeEventListener("pointercancel", this._boundPointerUp);
2012
2071
 
2013
2072
  e.preventDefault();
2014
2073
  }
@@ -2093,6 +2152,10 @@ class FigToast extends HTMLDialogElement {
2093
2152
  this._figInitialized = true;
2094
2153
  this._defaultOffset = 16;
2095
2154
  this._autoCloseTimer = null;
2155
+ this._toastVisible = false;
2156
+ this._syncingOpen = false;
2157
+ this._managedRole = null;
2158
+ this._managedLive = null;
2096
2159
  this._boundHandleClose = this.handleClose.bind(this);
2097
2160
  }
2098
2161
 
@@ -2104,6 +2167,13 @@ class FigToast extends HTMLDialogElement {
2104
2167
  this.applyPosition();
2105
2168
  if (this.hasAttribute("open") && this.getAttribute("open") !== "false") {
2106
2169
  this.showToast();
2170
+ } else {
2171
+ if (this.getAttribute("open") === "false") {
2172
+ this._syncingOpen = true;
2173
+ this.removeAttribute("open");
2174
+ this._syncingOpen = false;
2175
+ }
2176
+ this._toastVisible = false;
2107
2177
  }
2108
2178
  }
2109
2179
 
@@ -2128,7 +2198,11 @@ class FigToast extends HTMLDialogElement {
2128
2198
  this.style.position = "fixed";
2129
2199
  this.style.margin = "0";
2130
2200
  this.style.top = "auto";
2131
- this.style.bottom = `${parseInt(this.getAttribute("offset") ?? this._defaultOffset)}px`;
2201
+ const configuredOffset = Number.parseFloat(this.getAttribute("offset"));
2202
+ const offset = Number.isFinite(configuredOffset)
2203
+ ? configuredOffset
2204
+ : this._defaultOffset;
2205
+ this.style.bottom = `${offset}px`;
2132
2206
  this.style.left = "50%";
2133
2207
  this.style.right = "auto";
2134
2208
  this.style.transform = "translateX(-50%)";
@@ -2138,9 +2212,22 @@ class FigToast extends HTMLDialogElement {
2138
2212
  const assertive =
2139
2213
  this.getAttribute("live") === "assertive" ||
2140
2214
  this.getAttribute("theme") === "danger";
2141
- if (!this.hasAttribute("role")) this.setAttribute("role", assertive ? "alert" : "status");
2142
- if (!this.hasAttribute("aria-live")) this.setAttribute("aria-live", assertive ? "assertive" : "polite");
2143
- if (!this.hasAttribute("aria-atomic")) this.setAttribute("aria-atomic", "true");
2215
+ const desiredRole = assertive ? "alert" : "status";
2216
+ const desiredLive = assertive ? "assertive" : "polite";
2217
+ if (!this.hasAttribute("role") || this.getAttribute("role") === this._managedRole) {
2218
+ this.setAttribute("role", desiredRole);
2219
+ this._managedRole = desiredRole;
2220
+ }
2221
+ if (
2222
+ !this.hasAttribute("aria-live") ||
2223
+ this.getAttribute("aria-live") === this._managedLive
2224
+ ) {
2225
+ this.setAttribute("aria-live", desiredLive);
2226
+ this._managedLive = desiredLive;
2227
+ }
2228
+ if (!this.hasAttribute("aria-atomic")) {
2229
+ this.setAttribute("aria-atomic", "true");
2230
+ }
2144
2231
  }
2145
2232
 
2146
2233
  startAutoClose() {
@@ -2158,18 +2245,45 @@ class FigToast extends HTMLDialogElement {
2158
2245
  }
2159
2246
  }
2160
2247
 
2248
+ _resolveAutoTheme() {
2249
+ if (this.getAttribute("theme") !== "auto") return;
2250
+ const colorScheme =
2251
+ getComputedStyle(document.documentElement).colorScheme || "";
2252
+ this.style.colorScheme = colorScheme.includes("dark") ? "light" : "dark";
2253
+ }
2254
+
2161
2255
  showToast() {
2256
+ const wasVisible = this._toastVisible;
2162
2257
  this.syncLiveRegion();
2163
- if (!this.open) this.show();
2258
+ this._resolveAutoTheme();
2259
+ this._syncingOpen = true;
2260
+ try {
2261
+ if (!this.open) this.show();
2262
+ } finally {
2263
+ this._syncingOpen = false;
2264
+ }
2265
+ this._toastVisible = true;
2164
2266
  this.applyPosition();
2165
2267
  this.startAutoClose();
2166
- this.dispatchEvent(new CustomEvent("toast-show", { bubbles: true }));
2268
+ if (!wasVisible) {
2269
+ this.dispatchEvent(new CustomEvent("toast-show", { bubbles: true }));
2270
+ }
2167
2271
  }
2168
2272
 
2169
2273
  hideToast() {
2274
+ const wasVisible = this._toastVisible || this.open;
2170
2275
  this.clearAutoClose();
2171
- if (this.open) this.close();
2172
- this.dispatchEvent(new CustomEvent("toast-hide", { bubbles: true }));
2276
+ this._syncingOpen = true;
2277
+ try {
2278
+ if (this.open) this.close();
2279
+ else if (this.hasAttribute("open")) this.removeAttribute("open");
2280
+ } finally {
2281
+ this._syncingOpen = false;
2282
+ }
2283
+ this._toastVisible = false;
2284
+ if (wasVisible) {
2285
+ this.dispatchEvent(new CustomEvent("toast-hide", { bubbles: true }));
2286
+ }
2173
2287
  }
2174
2288
 
2175
2289
  static get observedAttributes() {
@@ -2180,11 +2294,17 @@ class FigToast extends HTMLDialogElement {
2180
2294
  this._figInit();
2181
2295
  if (oldValue === newValue) return;
2182
2296
  if (!this.isConnected) return;
2297
+ if (this._syncingOpen) return;
2183
2298
  if (name === "offset") this.applyPosition();
2184
2299
  if (name === "open") {
2185
2300
  if (newValue !== null && newValue !== "false") this.showToast();
2186
2301
  else this.hideToast();
2187
2302
  }
2303
+ if (name === "duration" && this._toastVisible) this.startAutoClose();
2304
+ if (name === "theme") {
2305
+ if (newValue === "auto") this._resolveAutoTheme();
2306
+ else this.style.removeProperty("color-scheme");
2307
+ }
2188
2308
  if (name === "theme" || name === "live") this.syncLiveRegion();
2189
2309
  }
2190
2310
  }
@@ -2224,6 +2344,7 @@ class FigPopup extends HTMLDialogElement {
2224
2344
  _wasDragged = false;
2225
2345
  _previousFocus = null;
2226
2346
  _boundDocumentKeydown;
2347
+ _boundNativeClose;
2227
2348
 
2228
2349
  constructor() {
2229
2350
  super();
@@ -2243,6 +2364,7 @@ class FigPopup extends HTMLDialogElement {
2243
2364
  this._boundPointerMove = this.handlePointerMove.bind(this);
2244
2365
  this._boundPointerUp = this.handlePointerUp.bind(this);
2245
2366
  this._boundDocumentKeydown = this.handleDocumentKeydown.bind(this);
2367
+ this._boundNativeClose = this.handleNativeClose.bind(this);
2246
2368
  }
2247
2369
 
2248
2370
  ensureInitialized() {
@@ -2299,6 +2421,9 @@ class FigPopup extends HTMLDialogElement {
2299
2421
  if (typeof this._boundDocumentKeydown !== "function") {
2300
2422
  this._boundDocumentKeydown = this.handleDocumentKeydown.bind(this);
2301
2423
  }
2424
+ if (typeof this._boundNativeClose !== "function") {
2425
+ this._boundNativeClose = this.handleNativeClose.bind(this);
2426
+ }
2302
2427
  }
2303
2428
 
2304
2429
  static get observedAttributes() {
@@ -2322,8 +2447,11 @@ class FigPopup extends HTMLDialogElement {
2322
2447
 
2323
2448
  set open(value) {
2324
2449
  if (value === false || value === "false" || value === null) {
2325
- if (!this.open) return;
2326
- this.removeAttribute("open");
2450
+ const isActuallyOpen =
2451
+ this.matches?.(":open") || this.matches?.(":popover-open");
2452
+ if (!isActuallyOpen && !this.hasAttribute("open")) return;
2453
+ this.hidePopup();
2454
+ if (this.hasAttribute("open")) this.removeAttribute("open");
2327
2455
  return;
2328
2456
  }
2329
2457
  if (this.open) return;
@@ -2380,12 +2508,8 @@ class FigPopup extends HTMLDialogElement {
2380
2508
  this.drag =
2381
2509
  this.hasAttribute("drag") && this.getAttribute("drag") !== "false";
2382
2510
 
2383
- this.addEventListener("close", () => {
2384
- this.teardownObservers();
2385
- if (this.hasAttribute("open")) {
2386
- this.removeAttribute("open");
2387
- }
2388
- });
2511
+ this.removeEventListener("close", this._boundNativeClose);
2512
+ this.addEventListener("close", this._boundNativeClose);
2389
2513
 
2390
2514
  requestAnimationFrame(() => {
2391
2515
  this.setupDragListeners();
@@ -2408,6 +2532,7 @@ class FigPopup extends HTMLDialogElement {
2408
2532
  true,
2409
2533
  );
2410
2534
  document.removeEventListener("keydown", this._boundDocumentKeydown, true);
2535
+ this.removeEventListener("close", this._boundNativeClose);
2411
2536
  if (this._rafId !== null) {
2412
2537
  cancelAnimationFrame(this._rafId);
2413
2538
  this._rafId = null;
@@ -2419,7 +2544,11 @@ class FigPopup extends HTMLDialogElement {
2419
2544
  if (oldValue === newValue) return;
2420
2545
 
2421
2546
  if (name === "open") {
2422
- if (newValue === null || newValue === "false") {
2547
+ if (newValue === "false") {
2548
+ this.open = false;
2549
+ return;
2550
+ }
2551
+ if (newValue === null) {
2423
2552
  this.hidePopup();
2424
2553
  return;
2425
2554
  }
@@ -2458,7 +2587,7 @@ class FigPopup extends HTMLDialogElement {
2458
2587
  // When the popup opts into the native popover API, prefer showPopover()
2459
2588
  // so the element is promoted into the browser's top layer (above any
2460
2589
  // modal dialogs) without needing showModal().
2461
- const usePopover =
2590
+ let usePopover =
2462
2591
  this.hasAttribute("popover") &&
2463
2592
  typeof this.showPopover === "function" &&
2464
2593
  !this.matches?.(":popover-open");
@@ -2470,7 +2599,7 @@ class FigPopup extends HTMLDialogElement {
2470
2599
  try {
2471
2600
  this.showPopover();
2472
2601
  } catch (e) {
2473
- // Fall back to non-modal dialog show below.
2602
+ usePopover = false;
2474
2603
  }
2475
2604
  }
2476
2605
  if (!usePopover && !super.open) {
@@ -2580,13 +2709,20 @@ class FigPopup extends HTMLDialogElement {
2580
2709
  return val === null || val !== "false";
2581
2710
  }
2582
2711
  set autoresize(value) {
2583
- if (value || value === "") {
2712
+ if (value === false || value === "false" || value === null) {
2713
+ this.setAttribute("autoresize", "false");
2714
+ } else if (value || value === "") {
2584
2715
  this.setAttribute("autoresize", value === true ? "" : value);
2585
2716
  } else {
2586
- this.removeAttribute("autoresize");
2717
+ this.setAttribute("autoresize", "false");
2587
2718
  }
2588
2719
  }
2589
2720
 
2721
+ handleNativeClose() {
2722
+ this.teardownObservers();
2723
+ if (this.hasAttribute("open")) this.removeAttribute("open");
2724
+ }
2725
+
2590
2726
  setupObservers() {
2591
2727
  this.teardownObservers();
2592
2728
 
@@ -2700,7 +2836,11 @@ class FigPopup extends HTMLDialogElement {
2700
2836
  }
2701
2837
 
2702
2838
  handleOutsidePointerDown(event) {
2703
- if (!this.open || !super.open) return;
2839
+ if (
2840
+ !this.open ||
2841
+ !(this.matches?.(":open") || this.matches?.(":popover-open"))
2842
+ )
2843
+ return;
2704
2844
  const closedby = this.getAttribute("closedby");
2705
2845
  if (closedby === "none" || closedby === "closerequest") return;
2706
2846
  const target = event.target;
@@ -2764,6 +2904,7 @@ class FigPopup extends HTMLDialogElement {
2764
2904
  this.removeEventListener("pointerdown", this._boundPointerDown);
2765
2905
  document.removeEventListener("pointermove", this._boundPointerMove);
2766
2906
  document.removeEventListener("pointerup", this._boundPointerUp);
2907
+ document.removeEventListener("pointercancel", this._boundPointerUp);
2767
2908
  }
2768
2909
 
2769
2910
  isInteractiveElement(element) {
@@ -2831,6 +2972,7 @@ class FigPopup extends HTMLDialogElement {
2831
2972
 
2832
2973
  document.addEventListener("pointermove", this._boundPointerMove);
2833
2974
  document.addEventListener("pointerup", this._boundPointerUp);
2975
+ document.addEventListener("pointercancel", this._boundPointerUp);
2834
2976
  }
2835
2977
 
2836
2978
  handlePointerMove(e) {
@@ -2863,7 +3005,9 @@ class FigPopup extends HTMLDialogElement {
2863
3005
 
2864
3006
  handlePointerUp(e) {
2865
3007
  if (this._isDragging) {
2866
- this.releasePointerCapture(e.pointerId);
3008
+ if (this.hasPointerCapture?.(e.pointerId)) {
3009
+ this.releasePointerCapture(e.pointerId);
3010
+ }
2867
3011
  this.style.cursor = "";
2868
3012
  }
2869
3013
 
@@ -2872,6 +3016,7 @@ class FigPopup extends HTMLDialogElement {
2872
3016
 
2873
3017
  document.removeEventListener("pointermove", this._boundPointerMove);
2874
3018
  document.removeEventListener("pointerup", this._boundPointerUp);
3019
+ document.removeEventListener("pointercancel", this._boundPointerUp);
2875
3020
  e.preventDefault();
2876
3021
  }
2877
3022
 
@@ -2885,13 +3030,17 @@ class FigPopup extends HTMLDialogElement {
2885
3030
 
2886
3031
  // Local-first: nearest parent subtree.
2887
3032
  const localScope = this.parentElement;
2888
- if (localScope?.querySelector) {
2889
- const localMatch = localScope.querySelector(selector);
2890
- if (localMatch && !this.contains(localMatch)) return localMatch;
2891
- }
3033
+ try {
3034
+ if (localScope?.querySelector) {
3035
+ const localMatch = localScope.querySelector(selector);
3036
+ if (localMatch && !this.contains(localMatch)) return localMatch;
3037
+ }
2892
3038
 
2893
- // Fallback: global document query.
2894
- return document.querySelector(selector);
3039
+ // Fallback: global document query.
3040
+ return document.querySelector(selector);
3041
+ } catch {
3042
+ return null;
3043
+ }
2895
3044
  }
2896
3045
 
2897
3046
  parsePosition() {
@@ -3610,7 +3759,7 @@ class FigTab extends HTMLElement {
3610
3759
  this.removeEventListener("click", this.#boundHandleClick);
3611
3760
  }
3612
3761
  handleClick() {
3613
- if (this.hasAttribute("disabled")) return;
3762
+ if (figBooleanAttribute(this, "disabled")) return;
3614
3763
  this.selected = true;
3615
3764
  if (this.content) {
3616
3765
  this.content.style.display = "block";
@@ -3656,6 +3805,7 @@ class FigTabs extends HTMLElement {
3656
3805
  #navStart = null;
3657
3806
  #navEnd = null;
3658
3807
  #isUnwrapping = false;
3808
+ #parentDisabledTabs = new WeakSet();
3659
3809
 
3660
3810
  constructor() {
3661
3811
  super();
@@ -3683,7 +3833,7 @@ class FigTabs extends HTMLElement {
3683
3833
  if (value) {
3684
3834
  this.#selectByValue(value);
3685
3835
  }
3686
- if (this.hasAttribute("disabled")) {
3836
+ if (figBooleanAttribute(this, "disabled")) {
3687
3837
  this.#applyDisabled(true);
3688
3838
  }
3689
3839
  this.#syncTabIndexes();
@@ -3696,10 +3846,16 @@ class FigTabs extends HTMLElement {
3696
3846
  const tabs = this.querySelectorAll("fig-tab");
3697
3847
  tabs.forEach((tab) => {
3698
3848
  if (disabled) {
3699
- tab.setAttribute("disabled", "");
3849
+ const alreadyDisabled =
3850
+ tab.hasAttribute("disabled") && tab.getAttribute("disabled") !== "false";
3851
+ if (!alreadyDisabled) {
3852
+ this.#parentDisabledTabs.add(tab);
3853
+ tab.setAttribute("disabled", "");
3854
+ }
3700
3855
  tab.setAttribute("aria-disabled", "true");
3701
3856
  tab.setAttribute("tabindex", "-1");
3702
- } else {
3857
+ } else if (this.#parentDisabledTabs.has(tab)) {
3858
+ this.#parentDisabledTabs.delete(tab);
3703
3859
  tab.removeAttribute("disabled");
3704
3860
  tab.removeAttribute("aria-disabled");
3705
3861
  }
@@ -3715,7 +3871,11 @@ class FigTabs extends HTMLElement {
3715
3871
 
3716
3872
  #syncTabIndexes() {
3717
3873
  const tabs = Array.from(this.querySelectorAll("fig-tab"));
3718
- const selected = tabs.find((tab) => tab.hasAttribute("selected")) || this.#availableTabs()[0];
3874
+ const selected =
3875
+ tabs.find(
3876
+ (tab) =>
3877
+ tab.hasAttribute("selected") && tab.getAttribute("selected") !== "false",
3878
+ ) || this.#availableTabs()[0];
3719
3879
  tabs.forEach((tab) => {
3720
3880
  const disabled = tab.hasAttribute("disabled") && tab.getAttribute("disabled") !== "false";
3721
3881
  tab.setAttribute("tabindex", !disabled && tab === selected ? "0" : "-1");
@@ -3832,7 +3992,9 @@ class FigTabs extends HTMLElement {
3832
3992
  #handleKeyDown(event) {
3833
3993
  const tabs = this.#availableTabs();
3834
3994
  if (!tabs.length) return;
3835
- const currentIndex = tabs.findIndex((tab) => tab.hasAttribute("selected"));
3995
+ const currentIndex = tabs.findIndex((tab) =>
3996
+ figBooleanAttribute(tab, "selected"),
3997
+ );
3836
3998
  let newIndex = currentIndex >= 0 ? currentIndex : 0;
3837
3999
 
3838
4000
  switch (event.key) {
@@ -3925,9 +4087,18 @@ class FigTabs extends HTMLElement {
3925
4087
  }
3926
4088
 
3927
4089
  handleClick(event) {
3928
- if (this.hasAttribute("disabled")) return;
4090
+ if (
4091
+ this.hasAttribute("disabled") &&
4092
+ this.getAttribute("disabled") !== "false"
4093
+ )
4094
+ return;
3929
4095
  const target = event.target.closest("fig-tab");
3930
4096
  if (!target || !this.contains(target)) return;
4097
+ if (
4098
+ target.hasAttribute("disabled") &&
4099
+ target.getAttribute("disabled") !== "false"
4100
+ )
4101
+ return;
3931
4102
  const previousTab = this.selectedTab;
3932
4103
  const previousValue = this.value;
3933
4104
  const tabs = this.querySelectorAll("fig-tab");
@@ -3975,6 +4146,12 @@ class FigSegment extends HTMLElement {
3975
4146
  this.removeEventListener("click", this.#boundHandleClick);
3976
4147
  }
3977
4148
  handleClick() {
4149
+ if (
4150
+ this.hasAttribute("disabled") &&
4151
+ this.getAttribute("disabled") !== "false"
4152
+ ) {
4153
+ return;
4154
+ }
3978
4155
  const parentControl = this.closest("fig-segmented-control");
3979
4156
  if (
3980
4157
  parentControl &&
@@ -4049,6 +4226,7 @@ class FigSegmentedControl extends HTMLElement {
4049
4226
  #indicatorFrame = 0;
4050
4227
  #indicatorSyncInstant = false;
4051
4228
  #hasRenderedIndicator = false;
4229
+ #parentDisabledSegments = new WeakSet();
4052
4230
 
4053
4231
  constructor() {
4054
4232
  super();
@@ -4101,7 +4279,7 @@ class FigSegmentedControl extends HTMLElement {
4101
4279
  const segments = this.querySelectorAll("fig-segment");
4102
4280
  for (const seg of segments) {
4103
4281
  const shouldBeSelected = seg === segment;
4104
- const isSelected = seg.hasAttribute("selected");
4282
+ const isSelected = figBooleanAttribute(seg, "selected");
4105
4283
  if (shouldBeSelected && !isSelected) {
4106
4284
  seg.setAttribute("selected", "true");
4107
4285
  } else if (!shouldBeSelected && isSelected) {
@@ -4156,7 +4334,11 @@ class FigSegmentedControl extends HTMLElement {
4156
4334
  #getFirstSelectedSegment() {
4157
4335
  const segments = this.querySelectorAll("fig-segment");
4158
4336
  for (const segment of segments) {
4159
- if (segment.hasAttribute("selected")) return segment;
4337
+ if (
4338
+ segment.hasAttribute("selected") &&
4339
+ segment.getAttribute("selected") !== "false"
4340
+ )
4341
+ return segment;
4160
4342
  }
4161
4343
  return null;
4162
4344
  }
@@ -4171,7 +4353,11 @@ class FigSegmentedControl extends HTMLElement {
4171
4353
 
4172
4354
  #syncSegmentA11y() {
4173
4355
  const segments = Array.from(this.querySelectorAll("fig-segment"));
4174
- const selected = segments.find((segment) => segment.hasAttribute("selected"));
4356
+ const selected = segments.find(
4357
+ (segment) =>
4358
+ segment.hasAttribute("selected") &&
4359
+ segment.getAttribute("selected") !== "false",
4360
+ );
4175
4361
  segments.forEach((segment) => {
4176
4362
  const disabled =
4177
4363
  segment.hasAttribute("disabled") &&
@@ -4211,7 +4397,7 @@ class FigSegmentedControl extends HTMLElement {
4211
4397
  const segments = this.#availableSegments();
4212
4398
  if (!segments.length) return;
4213
4399
  const currentIndex = segments.findIndex((segment) =>
4214
- segment.hasAttribute("selected"),
4400
+ figBooleanAttribute(segment, "selected"),
4215
4401
  );
4216
4402
  let nextIndex = currentIndex >= 0 ? currentIndex : 0;
4217
4403
 
@@ -4388,7 +4574,7 @@ class FigSegmentedControl extends HTMLElement {
4388
4574
  }
4389
4575
 
4390
4576
  if (enforceFallback) {
4391
- this.selectedSegment = segments[0];
4577
+ this.selectedSegment = this.#availableSegments()[0] || null;
4392
4578
  }
4393
4579
  }
4394
4580
 
@@ -4449,6 +4635,11 @@ class FigSegmentedControl extends HTMLElement {
4449
4635
  }
4450
4636
  const segment = event.target.closest("fig-segment");
4451
4637
  if (!segment || !this.contains(segment)) return;
4638
+ if (
4639
+ segment.hasAttribute("disabled") &&
4640
+ segment.getAttribute("disabled") !== "false"
4641
+ )
4642
+ return;
4452
4643
 
4453
4644
  this.#selectSegment(segment);
4454
4645
  }
@@ -4457,9 +4648,16 @@ class FigSegmentedControl extends HTMLElement {
4457
4648
  this.setAttribute("aria-disabled", disabled ? "true" : "false");
4458
4649
  this.querySelectorAll("fig-segment").forEach((segment) => {
4459
4650
  if (disabled) {
4460
- segment.setAttribute("disabled", "");
4651
+ const alreadyDisabled =
4652
+ segment.hasAttribute("disabled") &&
4653
+ segment.getAttribute("disabled") !== "false";
4654
+ if (!alreadyDisabled) {
4655
+ this.#parentDisabledSegments.add(segment);
4656
+ segment.setAttribute("disabled", "");
4657
+ }
4461
4658
  segment.setAttribute("aria-disabled", "true");
4462
- } else {
4659
+ } else if (this.#parentDisabledSegments.has(segment)) {
4660
+ this.#parentDisabledSegments.delete(segment);
4463
4661
  segment.removeAttribute("disabled");
4464
4662
  segment.removeAttribute("aria-disabled");
4465
4663
  }
@@ -4652,8 +4850,8 @@ class FigOptions extends HTMLElement {
4652
4850
  const sc = document.createElement("fig-segmented-control");
4653
4851
  sc.setAttribute("sizing", this.getAttribute("sizing") || "equal");
4654
4852
 
4655
- if (this.hasAttribute("disabled")) sc.setAttribute("disabled", "");
4656
- if (this.hasAttribute("full")) sc.setAttribute("full", "");
4853
+ if (figBooleanAttribute(this, "disabled")) sc.setAttribute("disabled", "");
4854
+ if (figBooleanAttribute(this, "full")) sc.setAttribute("full", "");
4657
4855
 
4658
4856
  const currentValue = this.getAttribute("value");
4659
4857
 
@@ -4709,7 +4907,7 @@ class FigOptions extends HTMLElement {
4709
4907
  if (this.#parsedOptions.length === 0) return;
4710
4908
 
4711
4909
  const dd = document.createElement("fig-dropdown");
4712
- if (this.hasAttribute("disabled")) dd.setAttribute("disabled", "");
4910
+ if (figBooleanAttribute(this, "disabled")) dd.setAttribute("disabled", "");
4713
4911
 
4714
4912
  const currentValue = this.getAttribute("value");
4715
4913
 
@@ -4892,7 +5090,8 @@ class FigSlider extends HTMLElement {
4892
5090
 
4893
5091
  constructor() {
4894
5092
  super();
4895
- this.initialInnerHTML = this.innerHTML;
5093
+ // Parser-created custom elements do not have their children yet here.
5094
+ this.initialInnerHTML = null;
4896
5095
 
4897
5096
  // Bind the event handlers
4898
5097
  this.#boundHandleInput = (e) => {
@@ -4932,7 +5131,8 @@ class FigSlider extends HTMLElement {
4932
5131
  this.text = this.getAttribute("text") !== "false";
4933
5132
  this.units = this.getAttribute("units") || "";
4934
5133
  this.transform = Number(this.getAttribute("transform") || 1);
4935
- this.disabled = this.getAttribute("disabled") ? true : false;
5134
+ this.disabled =
5135
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
4936
5136
  this.precision = this.hasAttribute("precision")
4937
5137
  ? Number(this.getAttribute("precision"))
4938
5138
  : null;
@@ -4941,10 +5141,16 @@ class FigSlider extends HTMLElement {
4941
5141
  ? this.getAttribute("placeholder")
4942
5142
  : "##";
4943
5143
 
4944
- const defaults = this.#typeDefaults[this.type];
4945
- this.min = Number(this.getAttribute("min") || defaults.min);
4946
- this.max = Number(this.getAttribute("max") || defaults.max);
4947
- this.step = Number(this.getAttribute("step") || defaults.step);
5144
+ const defaults = this.#typeDefaults[this.type] || this.#typeDefaults.range;
5145
+ const readNumber = (name, fallback) => {
5146
+ const raw = this.getAttribute(name);
5147
+ const parsed = raw === null || raw.trim() === "" ? fallback : Number(raw);
5148
+ return Number.isFinite(parsed) ? parsed : fallback;
5149
+ };
5150
+ this.min = readNumber("min", defaults.min);
5151
+ this.max = readNumber("max", defaults.max);
5152
+ this.step = readNumber("step", defaults.step);
5153
+ if (!(this.step > 0)) this.step = defaults.step > 0 ? defaults.step : 1;
4948
5154
  this.color = this.getAttribute("color") || defaults?.color;
4949
5155
  this.default = this.hasAttribute("default")
4950
5156
  ? this.getAttribute("default")
@@ -5022,6 +5228,8 @@ class FigSlider extends HTMLElement {
5022
5228
  this.input.addEventListener("pointerdown", this.#boundRangePointerDown);
5023
5229
  this.input.removeEventListener("pointerup", this.#boundRangePointerUp);
5024
5230
  this.input.addEventListener("pointerup", this.#boundRangePointerUp);
5231
+ this.input.removeEventListener("pointercancel", this.#boundRangePointerUp);
5232
+ this.input.addEventListener("pointercancel", this.#boundRangePointerUp);
5025
5233
 
5026
5234
  if (this.default) {
5027
5235
  this.style.setProperty(
@@ -5077,13 +5285,13 @@ class FigSlider extends HTMLElement {
5077
5285
  if (this.text) {
5078
5286
  html = `${slider}
5079
5287
  <fig-input-number
5080
- placeholder="${this.placeholder}"
5288
+ placeholder="${figEscapeAttribute(this.placeholder)}"
5081
5289
  min="${this.min}"
5082
5290
  max="${this.max}"
5083
5291
  transform="${this.transform}"
5084
5292
  step="${this.step}"
5085
5293
  value="${this.#showEmptyTextValue ? "" : this.value}"
5086
- ${this.units ? `units="${this.units}"` : ""}
5294
+ ${this.units ? `units="${figEscapeAttribute(this.units)}"` : ""}
5087
5295
  ${this.precision !== null ? `precision="${this.precision}"` : ""}>
5088
5296
  </fig-input-number>`;
5089
5297
  } else {
@@ -5107,7 +5315,10 @@ class FigSlider extends HTMLElement {
5107
5315
  } else if (this.type === "stepper") {
5108
5316
  this.datalist = document.createElement("datalist");
5109
5317
  this.datalist.setAttribute("id", figUniqueId());
5110
- let steps = (this.max - this.min) / this.step + 1;
5318
+ const steps = Math.min(
5319
+ 1001,
5320
+ Math.max(0, Math.floor((this.max - this.min) / this.step) + 1),
5321
+ );
5111
5322
  for (let i = 0; i < steps; i++) {
5112
5323
  let option = document.createElement("option");
5113
5324
  option.setAttribute("value", this.min + i * this.step);
@@ -5136,6 +5347,7 @@ class FigSlider extends HTMLElement {
5136
5347
  }
5137
5348
 
5138
5349
  connectedCallback() {
5350
+ if (this.initialInnerHTML === null) this.initialInnerHTML = this.innerHTML;
5139
5351
  if (this.#canReuseRenderedMarkup()) {
5140
5352
  this.#updateRenderedMarkup();
5141
5353
  this.#bindControlListeners();
@@ -5184,6 +5396,7 @@ class FigSlider extends HTMLElement {
5184
5396
  this.input.removeEventListener("keydown", this.#boundHandleKeyDown);
5185
5397
  this.input.removeEventListener("pointerdown", this.#boundRangePointerDown);
5186
5398
  this.input.removeEventListener("pointerup", this.#boundRangePointerUp);
5399
+ this.input.removeEventListener("pointercancel", this.#boundRangePointerUp);
5187
5400
  }
5188
5401
  if (this.figInputNumber) {
5189
5402
  this.figInputNumber.removeEventListener(
@@ -5536,6 +5749,8 @@ class FigInputText extends HTMLElement {
5536
5749
  #boundAdornmentClick;
5537
5750
  #mutationObserver = null;
5538
5751
  #syncRaf = 0;
5752
+ #previousUserSelect = "";
5753
+ #previousBodyCursor = "";
5539
5754
  #a11yAttributes = [
5540
5755
  "aria-label",
5541
5756
  "aria-labelledby",
@@ -5555,15 +5770,25 @@ class FigInputText extends HTMLElement {
5555
5770
  e.stopPropagation();
5556
5771
  this.#handleInputChange(e);
5557
5772
  };
5558
- this.#boundNativeInput = () => {
5773
+ this.#boundNativeInput = (e) => {
5774
+ e.stopPropagation();
5775
+ let value = e.target.value;
5776
+ if (this.type === "number") {
5777
+ value = Number(value) / (this.transform || 1);
5778
+ }
5779
+ this.value = value;
5559
5780
  this.#syncSearchClearVisibility();
5781
+ this.dispatchEvent(
5782
+ new CustomEvent("input", { detail: this.value, bubbles: true }),
5783
+ );
5560
5784
  };
5561
5785
  this.#boundFocusControl = this.focus.bind(this);
5562
5786
  this.#boundAdornmentClick = this.#handleAdornmentClick.bind(this);
5563
5787
  }
5564
5788
 
5565
5789
  connectedCallback() {
5566
- this.multiline = this.hasAttribute("multiline") || false;
5790
+ this.multiline =
5791
+ this.hasAttribute("multiline") && this.getAttribute("multiline") !== "false";
5567
5792
  this.value = this.getAttribute("value") || "";
5568
5793
  this.type = this.getAttribute("type") || "text";
5569
5794
  this.placeholder = this.getAttribute("placeholder") || "";
@@ -5590,6 +5815,9 @@ class FigInputText extends HTMLElement {
5590
5815
 
5591
5816
  this.input = this.#ensureInputControl();
5592
5817
  this.input.readOnly = this.readonly;
5818
+ this.disabled =
5819
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
5820
+ this.input.disabled = this.disabled;
5593
5821
  this.#syncInputA11yAttributes();
5594
5822
  this.#syncSearchPrefix();
5595
5823
  this.#syncSearchClear();
@@ -5878,9 +6106,6 @@ class FigInputText extends HTMLElement {
5878
6106
  this.value = value;
5879
6107
  this.input.value = valueTransformed;
5880
6108
  this.#syncSearchClearVisibility();
5881
- this.dispatchEvent(
5882
- new CustomEvent("input", { detail: this.value, bubbles: true }),
5883
- );
5884
6109
  this.dispatchEvent(
5885
6110
  new CustomEvent("change", { detail: this.value, bubbles: true }),
5886
6111
  );
@@ -5903,14 +6128,13 @@ class FigInputText extends HTMLElement {
5903
6128
  this.dispatchEvent(
5904
6129
  new CustomEvent("input", { detail: this.value, bubbles: true }),
5905
6130
  );
5906
- this.dispatchEvent(
5907
- new CustomEvent("change", { detail: this.value, bubbles: true }),
5908
- );
5909
6131
  }
5910
6132
  #handleMouseDown(e) {
5911
6133
  if (this.type !== "number") return;
5912
6134
  if (e.altKey || e.target.closest("[slot]")) {
5913
6135
  this.#isInteracting = true;
6136
+ this.#previousUserSelect = this.style.userSelect;
6137
+ this.#previousBodyCursor = document.body.style.cursor;
5914
6138
  this.input.style.cursor =
5915
6139
  this.style.cursor =
5916
6140
  document.body.style.cursor =
@@ -5918,20 +6142,27 @@ class FigInputText extends HTMLElement {
5918
6142
  this.style.userSelect = "none";
5919
6143
  window.addEventListener("pointermove", this.#boundMouseMove);
5920
6144
  window.addEventListener("pointerup", this.#boundMouseUp);
6145
+ window.addEventListener("pointercancel", this.#boundMouseUp);
5921
6146
  window.addEventListener("blur", this.#boundWindowBlur);
5922
6147
  }
5923
6148
  }
5924
6149
  #handleMouseUp(e) {
5925
6150
  if (this.type !== "number") return;
6151
+ const wasInteracting = this.#isInteracting;
5926
6152
  this.#isInteracting = false;
5927
- this.input.style.cursor =
5928
- this.style.cursor =
5929
- document.body.style.cursor =
5930
- "";
5931
- this.style.userSelect = "all";
6153
+ this.input.style.cursor = "";
6154
+ this.style.cursor = "";
6155
+ document.body.style.cursor = this.#previousBodyCursor;
6156
+ this.style.userSelect = this.#previousUserSelect;
5932
6157
  window.removeEventListener("pointermove", this.#boundMouseMove);
5933
6158
  window.removeEventListener("pointerup", this.#boundMouseUp);
6159
+ window.removeEventListener("pointercancel", this.#boundMouseUp);
5934
6160
  window.removeEventListener("blur", this.#boundWindowBlur);
6161
+ if (wasInteracting) {
6162
+ this.dispatchEvent(
6163
+ new CustomEvent("change", { detail: this.value, bubbles: true }),
6164
+ );
6165
+ }
5935
6166
  }
5936
6167
  #sanitizeInput(value, transform = true) {
5937
6168
  let sanitized = value;
@@ -5978,6 +6209,7 @@ class FigInputText extends HTMLElement {
5978
6209
  "disabled",
5979
6210
  "readonly",
5980
6211
  "type",
6212
+ "multiline",
5981
6213
  "step",
5982
6214
  "min",
5983
6215
  "max",
@@ -6024,14 +6256,18 @@ class FigInputText extends HTMLElement {
6024
6256
  case "min":
6025
6257
  case "max":
6026
6258
  case "step":
6027
- this[name] = this.input[name] = Number(newValue);
6028
- if (this.input) {
6259
+ if (newValue === null || newValue === "") {
6260
+ this[name] = undefined;
6261
+ this.input.removeAttribute(name);
6262
+ } else {
6263
+ this[name] = this.input[name] = Number(newValue);
6029
6264
  this.input.setAttribute(name, newValue);
6030
6265
  }
6031
6266
  break;
6032
6267
  case "name":
6033
- this[name] = this.input[name] = newValue;
6034
- this.input.setAttribute("name", newValue);
6268
+ this[name] = this.input[name] = newValue || "";
6269
+ if (newValue) this.input.setAttribute("name", newValue);
6270
+ else this.input.removeAttribute("name");
6035
6271
  break;
6036
6272
  case "placeholder":
6037
6273
  this.placeholder = newValue ?? "";
@@ -6040,11 +6276,42 @@ class FigInputText extends HTMLElement {
6040
6276
  case "type":
6041
6277
  this.type = newValue || "text";
6042
6278
  this.input.type = this.type;
6279
+ this.removeEventListener("pointerdown", this.#boundMouseDown);
6280
+ if (this.type === "number") {
6281
+ this.step = this.hasAttribute("step")
6282
+ ? Number(this.getAttribute("step"))
6283
+ : undefined;
6284
+ this.min = this.hasAttribute("min")
6285
+ ? Number(this.getAttribute("min"))
6286
+ : undefined;
6287
+ this.max = this.hasAttribute("max")
6288
+ ? Number(this.getAttribute("max"))
6289
+ : undefined;
6290
+ this.transform = Number(this.getAttribute("transform") || 1);
6291
+ this.addEventListener("pointerdown", this.#boundMouseDown);
6292
+ }
6043
6293
  this.#syncSearchPrefix();
6044
6294
  this.#syncSearchClear();
6045
6295
  this.#syncSearchClearVisibility();
6046
6296
  this.#syncPasswordToggle();
6047
6297
  break;
6298
+ case "multiline": {
6299
+ const next = newValue !== null && newValue !== "false";
6300
+ if (next !== this.multiline) {
6301
+ this.multiline = next;
6302
+ const oldInput = this.input;
6303
+ oldInput.removeEventListener("change", this.#boundInputChange);
6304
+ oldInput.removeEventListener("input", this.#boundNativeInput);
6305
+ oldInput.remove();
6306
+ this.input = this.#ensureInputControl();
6307
+ this.input.readOnly = this.readonly;
6308
+ this.input.disabled = Boolean(this.disabled);
6309
+ this.input.addEventListener("change", this.#boundInputChange);
6310
+ this.input.addEventListener("input", this.#boundNativeInput);
6311
+ this.#syncInputA11yAttributes();
6312
+ }
6313
+ break;
6314
+ }
6048
6315
  case "aria-label":
6049
6316
  case "aria-labelledby":
6050
6317
  case "aria-describedby":
@@ -6097,6 +6364,8 @@ class FigInputNumber extends HTMLElement {
6097
6364
  #stepperEl = null;
6098
6365
  #mutationObserver = null;
6099
6366
  #syncRaf = 0;
6367
+ #previousUserSelect = "";
6368
+ #previousBodyCursor = "";
6100
6369
  #a11yAttributes = [
6101
6370
  "aria-label",
6102
6371
  "aria-labelledby",
@@ -6234,9 +6503,9 @@ class FigInputNumber extends HTMLElement {
6234
6503
  );
6235
6504
  this.#setUnitsFromAttributes();
6236
6505
  this.#unitPosition = this.getAttribute("unit-position") || "suffix";
6237
- this.#precision = this.hasAttribute("precision")
6238
- ? Number(this.getAttribute("precision"))
6239
- : 2;
6506
+ this.#precision = this.#normalizePrecision(
6507
+ this.hasAttribute("precision") ? this.getAttribute("precision") : 2,
6508
+ );
6240
6509
 
6241
6510
  if (this.getAttribute("step")) {
6242
6511
  this.step = Number(this.getAttribute("step"));
@@ -6495,9 +6764,6 @@ class FigInputNumber extends HTMLElement {
6495
6764
  }
6496
6765
  this.#syncStepperState();
6497
6766
  this.#syncSpinbuttonAria();
6498
- this.dispatchEvent(
6499
- new CustomEvent("change", { detail: this.value, bubbles: true }),
6500
- );
6501
6767
  }
6502
6768
 
6503
6769
  #handleKeyDown(e) {
@@ -6559,9 +6825,6 @@ class FigInputNumber extends HTMLElement {
6559
6825
  }
6560
6826
  this.#syncStepperState();
6561
6827
  this.#syncSpinbuttonAria();
6562
- this.dispatchEvent(
6563
- new CustomEvent("input", { detail: this.value, bubbles: true }),
6564
- );
6565
6828
  this.dispatchEvent(
6566
6829
  new CustomEvent("change", { detail: this.value, bubbles: true }),
6567
6830
  );
@@ -6584,15 +6847,14 @@ class FigInputNumber extends HTMLElement {
6584
6847
  this.dispatchEvent(
6585
6848
  new CustomEvent("input", { detail: this.value, bubbles: true }),
6586
6849
  );
6587
- this.dispatchEvent(
6588
- new CustomEvent("change", { detail: this.value, bubbles: true }),
6589
- );
6590
6850
  }
6591
6851
 
6592
6852
  #handleMouseDown(e) {
6593
6853
  if (this.disabled) return;
6594
6854
  if (e.altKey || e.target.closest("[slot]")) {
6595
6855
  this.#isInteracting = true;
6856
+ this.#previousUserSelect = this.style.userSelect;
6857
+ this.#previousBodyCursor = document.body.style.cursor;
6596
6858
  this.input.style.cursor =
6597
6859
  this.style.cursor =
6598
6860
  document.body.style.cursor =
@@ -6600,20 +6862,27 @@ class FigInputNumber extends HTMLElement {
6600
6862
  this.style.userSelect = "none";
6601
6863
  window.addEventListener("pointermove", this.#boundMouseMove);
6602
6864
  window.addEventListener("pointerup", this.#boundMouseUp);
6865
+ window.addEventListener("pointercancel", this.#boundMouseUp);
6603
6866
  window.addEventListener("blur", this.#boundWindowBlur);
6604
6867
  }
6605
6868
  }
6606
6869
 
6607
6870
  #handleMouseUp(e) {
6871
+ const wasInteracting = this.#isInteracting;
6608
6872
  this.#isInteracting = false;
6609
- this.input.style.cursor =
6610
- this.style.cursor =
6611
- document.body.style.cursor =
6612
- "";
6613
- this.style.userSelect = "all";
6873
+ this.input.style.cursor = "";
6874
+ this.style.cursor = "";
6875
+ document.body.style.cursor = this.#previousBodyCursor;
6876
+ this.style.userSelect = this.#previousUserSelect;
6614
6877
  window.removeEventListener("pointermove", this.#boundMouseMove);
6615
6878
  window.removeEventListener("pointerup", this.#boundMouseUp);
6879
+ window.removeEventListener("pointercancel", this.#boundMouseUp);
6616
6880
  window.removeEventListener("blur", this.#boundWindowBlur);
6881
+ if (wasInteracting) {
6882
+ this.dispatchEvent(
6883
+ new CustomEvent("change", { detail: this.value, bubbles: true }),
6884
+ );
6885
+ }
6617
6886
  }
6618
6887
 
6619
6888
  #sanitizeInput(value, transform = true) {
@@ -6639,6 +6908,12 @@ class FigInputNumber extends HTMLElement {
6639
6908
  : parseFloat(rounded.toFixed(precision));
6640
6909
  }
6641
6910
 
6911
+ #normalizePrecision(value) {
6912
+ const parsed = Number(value);
6913
+ if (!Number.isFinite(parsed)) return 2;
6914
+ return Math.max(0, Math.min(100, Math.trunc(parsed)));
6915
+ }
6916
+
6642
6917
  static get observedAttributes() {
6643
6918
  return [
6644
6919
  "value",
@@ -6727,13 +7002,16 @@ class FigInputNumber extends HTMLElement {
6727
7002
  break;
6728
7003
  }
6729
7004
  case "precision":
6730
- this.#precision = newValue !== null ? Number(newValue) : 2;
7005
+ this.#precision = this.#normalizePrecision(
7006
+ newValue !== null ? newValue : 2,
7007
+ );
6731
7008
  this.input.value = this.#formatWithUnit(this.value);
6732
7009
  this.#syncSpinbuttonAria();
6733
7010
  break;
6734
7011
  case "name":
6735
- this[name] = this.input[name] = newValue;
6736
- this.input.setAttribute("name", newValue);
7012
+ this[name] = this.input[name] = newValue || "";
7013
+ if (newValue) this.input.setAttribute("name", newValue);
7014
+ else this.input.removeAttribute("name");
6737
7015
  break;
6738
7016
  case "placeholder":
6739
7017
  this.placeholder = newValue ?? "";
@@ -6770,11 +7048,16 @@ class FigAvatar extends HTMLElement {
6770
7048
  }
6771
7049
  setSrc(src) {
6772
7050
  this.src = src;
6773
- if (src) {
6774
- this.innerHTML = `<img src="${this.src}" ${
6775
- this.name ? `alt="${this.name}"` : ""
6776
- } />`;
7051
+ if (!src) {
7052
+ this.querySelector(":scope > img")?.remove();
7053
+ this.img = null;
7054
+ return;
6777
7055
  }
7056
+ const img = this.querySelector(":scope > img") || document.createElement("img");
7057
+ img.src = src;
7058
+ img.alt = this.name || "";
7059
+ if (!img.isConnected) this.replaceChildren(img);
7060
+ this.img = img;
6778
7061
  }
6779
7062
  getInitials(name) {
6780
7063
  return name
@@ -6790,7 +7073,7 @@ class FigAvatar extends HTMLElement {
6790
7073
  attributeChangedCallback(name, oldValue, newValue) {
6791
7074
  this[name] = newValue;
6792
7075
  if (name === "name") {
6793
- this.img?.setAttribute("alt", newValue);
7076
+ if (this.img) this.img.alt = newValue || "";
6794
7077
  this.name = newValue;
6795
7078
  this.initials = this.getInitials(this.name);
6796
7079
  this.setAttribute("initials", this.initials);
@@ -6870,7 +7153,12 @@ class FigField extends HTMLElement {
6870
7153
  this.#chevron.addEventListener("click", this.#boundToggle);
6871
7154
  this.label.addEventListener("click", this.#boundToggle);
6872
7155
  } else if (this.input && this.label) {
6873
- this.label.addEventListener("click", this.#boundFocus);
7156
+ const nativeInputs = this.input.querySelectorAll("input, select, textarea");
7157
+ // A label with a `for` target already focuses and activates its control.
7158
+ // Only use the composite fallback when there is no single native target.
7159
+ if (nativeInputs.length !== 1) {
7160
+ this.label.addEventListener("click", this.#boundFocus);
7161
+ }
6874
7162
  }
6875
7163
 
6876
7164
  if (this.input && this.label && !this.#toggleable) {
@@ -6964,7 +7252,6 @@ class FigField extends HTMLElement {
6964
7252
  const nativeInputs = this.input.querySelectorAll("input, select, textarea");
6965
7253
  if (nativeInputs.length === 1) {
6966
7254
  nativeInputs[0].focus();
6967
- nativeInputs[0].click();
6968
7255
  } else {
6969
7256
  this.input.focus();
6970
7257
  if (nativeInputs.length === 0) {
@@ -7096,7 +7383,7 @@ class FigInputColor extends HTMLElement {
7096
7383
 
7097
7384
  #bindControlListeners() {
7098
7385
  if (this.#swatch) {
7099
- this.#swatch.disabled = this.hasAttribute("disabled");
7386
+ this.#swatch.disabled = figBooleanAttribute(this, "disabled");
7100
7387
  const swatchInput = this.#swatch.querySelector('input[type="color"]');
7101
7388
  if (this.#textInput || this.hasAttribute("swatch-disabled")) {
7102
7389
  swatchInput?.setAttribute("tabindex", "-1");
@@ -7197,7 +7484,7 @@ class FigInputColor extends HTMLElement {
7197
7484
  } else {
7198
7485
  this.#fillPicker.setAttribute("alpha", "false");
7199
7486
  }
7200
- if (this.hasAttribute("disabled")) {
7487
+ if (figBooleanAttribute(this, "disabled")) {
7201
7488
  this.#fillPicker.setAttribute("disabled", "");
7202
7489
  } else {
7203
7490
  this.#fillPicker.removeAttribute("disabled");
@@ -7231,7 +7518,11 @@ class FigInputColor extends HTMLElement {
7231
7518
  }
7232
7519
 
7233
7520
  #openFillPicker() {
7234
- if (this.hasAttribute("disabled") || this.hasAttribute("swatch-disabled")) return false;
7521
+ if (
7522
+ figBooleanAttribute(this, "disabled") ||
7523
+ figBooleanAttribute(this, "swatch-disabled")
7524
+ )
7525
+ return false;
7235
7526
  const picker = this.#ensureFillPicker();
7236
7527
  if (!picker) return false;
7237
7528
  requestAnimationFrame(() => picker.open?.());
@@ -7246,7 +7537,11 @@ class FigInputColor extends HTMLElement {
7246
7537
 
7247
7538
  #handleSwatchPointerDown(event) {
7248
7539
  if (!hasFigFillPicker()) return;
7249
- if (this.hasAttribute("disabled") || this.hasAttribute("swatch-disabled")) return;
7540
+ if (
7541
+ figBooleanAttribute(this, "disabled") ||
7542
+ figBooleanAttribute(this, "swatch-disabled")
7543
+ )
7544
+ return;
7250
7545
  this.#pendingFillPickerPointerOpen = true;
7251
7546
  this.#suppressNativeColorClick = true;
7252
7547
  if (this.#nativeColorClickTimer) clearTimeout(this.#nativeColorClickTimer);
@@ -7300,8 +7595,14 @@ class FigInputColor extends HTMLElement {
7300
7595
  //do not propagate to onInput handler for web component
7301
7596
  event.stopPropagation();
7302
7597
  // Add # prefix if not present for internal processing
7303
- let inputValue = event.target.value.replace("#", "");
7598
+ let inputValue = event.target.value.replace(/#/g, "");
7304
7599
  this.#setValues("#" + inputValue);
7600
+ if (this.#textInput) {
7601
+ this.#textInput.setAttribute(
7602
+ "value",
7603
+ this.hexOpaque.slice(1).toUpperCase(),
7604
+ );
7605
+ }
7305
7606
  if (this.#alphaInput) {
7306
7607
  this.#alphaInput.setAttribute("value", this.#alphaPercent);
7307
7608
  }
@@ -7493,7 +7794,10 @@ class FigInputColor extends HTMLElement {
7493
7794
  case "value":
7494
7795
  this.#setValues(newValue);
7495
7796
  if (this.#textInput) {
7496
- this.#textInput.setAttribute("value", this.value);
7797
+ this.#textInput.setAttribute(
7798
+ "value",
7799
+ this.hexOpaque.slice(1).toUpperCase(),
7800
+ );
7497
7801
  }
7498
7802
  if (this.#swatch) {
7499
7803
  this.#swatch.setAttribute("background", this.hexOpaque);
@@ -8043,7 +8347,7 @@ class FigInputFill extends HTMLElement {
8043
8347
  }
8044
8348
  if (!attrs["dialog-position"]) attrs["dialog-position"] = "left";
8045
8349
  return Object.entries(attrs)
8046
- .map(([k, v]) => `${k}="${v}"`)
8350
+ .map(([k, v]) => `${k}="${figEscapeAttribute(v)}"`)
8047
8351
  .join(" ");
8048
8352
  }
8049
8353
 
@@ -8063,7 +8367,8 @@ class FigInputFill extends HTMLElement {
8063
8367
  return `rgba(${r}, ${g}, ${b}, ${alpha}) ${stop.position}%`;
8064
8368
  })
8065
8369
  .join(", ");
8066
- return `linear-gradient(${this.#gradient.angle}deg ${gradientInterpolationClause(this.#gradient)}, ${stops})`;
8370
+ const interpolation = gradientInterpolationClause(this.#gradient);
8371
+ return `linear-gradient(${this.#gradient.angle}deg${interpolation ? ` ${interpolation}` : ""}, ${stops})`;
8067
8372
  }
8068
8373
  case "image":
8069
8374
  return this.#image.url ? `url(${this.#image.url})` : "#D9D9D9";
@@ -8088,7 +8393,8 @@ class FigInputFill extends HTMLElement {
8088
8393
  }
8089
8394
 
8090
8395
  #syncDisabled() {
8091
- const disabled = this.hasAttribute("disabled");
8396
+ const disabled =
8397
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
8092
8398
  this.setAttribute("aria-disabled", disabled ? "true" : "false");
8093
8399
  for (const child of [
8094
8400
  this.#fillPicker,
@@ -8124,7 +8430,8 @@ class FigInputFill extends HTMLElement {
8124
8430
  }
8125
8431
 
8126
8432
  #render() {
8127
- const disabled = this.hasAttribute("disabled");
8433
+ const disabled =
8434
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
8128
8435
  const fillPickerValue = JSON.stringify(this.value);
8129
8436
  const showAlpha = this.getAttribute("alpha") !== "false";
8130
8437
 
@@ -8152,7 +8459,7 @@ class FigInputFill extends HTMLElement {
8152
8459
  type="text"
8153
8460
  class="fig-input-fill-hex"
8154
8461
  placeholder="000000"
8155
- value="${this.#solid.color.slice(1).toUpperCase()}"
8462
+ value="${figEscapeAttribute(this.#solid.color.slice(1).toUpperCase())}"
8156
8463
  ${disabled ? "disabled" : ""}>
8157
8464
  </fig-input-text>
8158
8465
  ${opacityHtml(Math.round(this.#solid.alpha * 100))}`;
@@ -8163,8 +8470,7 @@ class FigInputFill extends HTMLElement {
8163
8470
  this.#gradient.type.charAt(0).toUpperCase() +
8164
8471
  this.#gradient.type.slice(1);
8165
8472
  controlsHtml = `
8166
- <label class="fig-input-fill-label">${gradientLabel}</label>
8167
- ${opacityHtml(100)}`;
8473
+ <label class="fig-input-fill-label">${figEscapeAttribute(gradientLabel)}</label>`;
8168
8474
  break;
8169
8475
  }
8170
8476
 
@@ -8190,10 +8496,10 @@ class FigInputFill extends HTMLElement {
8190
8496
  const fpAttrs = this.#buildFillPickerAttrs();
8191
8497
  this.innerHTML = `
8192
8498
  <div class="input-combo">
8193
- <fig-fill-picker ${fpAttrs} value='${fillPickerValue}' ${
8499
+ <fig-fill-picker ${fpAttrs} value='${figEscapeAttribute(fillPickerValue)}' ${
8194
8500
  disabled ? "disabled" : ""
8195
8501
  }>
8196
- <fig-swatch background="${this.#fillPickerSwatchBackground()}" alpha="${this.#fillPickerSwatchAlpha()}"${disabled ? " disabled" : ""}></fig-swatch>
8502
+ <fig-swatch background="${figEscapeAttribute(this.#fillPickerSwatchBackground())}" alpha="${this.#fillPickerSwatchAlpha()}"${disabled ? " disabled" : ""}></fig-swatch>
8197
8503
  </fig-fill-picker>
8198
8504
  ${controlsHtml}
8199
8505
  </div>`;
@@ -8223,7 +8529,10 @@ class FigInputFill extends HTMLElement {
8223
8529
  if (!anchor || anchor === "self") {
8224
8530
  this.#fillPicker.anchorElement = this;
8225
8531
  } else {
8226
- const el = document.querySelector(anchor);
8532
+ let el = null;
8533
+ try {
8534
+ el = document.querySelector(anchor);
8535
+ } catch {}
8227
8536
  if (el) this.#fillPicker.anchorElement = el;
8228
8537
  }
8229
8538
 
@@ -8380,7 +8689,9 @@ class FigInputFill extends HTMLElement {
8380
8689
 
8381
8690
  #updateControlsForType() {
8382
8691
  // Update only the controls (not the fill picker) when type changes
8383
- const disabled = this.hasAttribute("disabled");
8692
+ const disabled =
8693
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
8694
+ const showAlpha = this.getAttribute("alpha") !== "false";
8384
8695
  const combo = this.querySelector(".input-combo");
8385
8696
  if (!combo) return;
8386
8697
 
@@ -8403,10 +8714,10 @@ class FigInputFill extends HTMLElement {
8403
8714
  type="text"
8404
8715
  class="fig-input-fill-hex"
8405
8716
  placeholder="000000"
8406
- value="${this.#solid.color.slice(1).toUpperCase()}"
8717
+ value="${figEscapeAttribute(this.#solid.color.slice(1).toUpperCase())}"
8407
8718
  ${disabled ? "disabled" : ""}>
8408
8719
  </fig-input-text>
8409
- <fig-tooltip text="Opacity">
8720
+ ${showAlpha ? `<fig-tooltip text="Opacity">
8410
8721
  <fig-input-number
8411
8722
  class="fig-input-fill-opacity"
8412
8723
  placeholder="##"
@@ -8416,31 +8727,20 @@ class FigInputFill extends HTMLElement {
8416
8727
  units="%"
8417
8728
  ${disabled ? "disabled" : ""}>
8418
8729
  </fig-input-number>
8419
- </fig-tooltip>`;
8730
+ </fig-tooltip>` : ""}`;
8420
8731
  break;
8421
8732
  case "gradient": {
8422
8733
  const gradientLabel =
8423
8734
  this.#gradient.type.charAt(0).toUpperCase() +
8424
8735
  this.#gradient.type.slice(1);
8425
8736
  controlsHtml = `
8426
- <label class="fig-input-fill-label">${gradientLabel}</label>
8427
- <fig-tooltip text="Opacity">
8428
- <fig-input-number
8429
- class="fig-input-fill-opacity"
8430
- placeholder="##"
8431
- min="0"
8432
- max="100"
8433
- value="100"
8434
- units="%"
8435
- ${disabled ? "disabled" : ""}>
8436
- </fig-input-number>
8437
- </fig-tooltip>`;
8737
+ <label class="fig-input-fill-label">${figEscapeAttribute(gradientLabel)}</label>`;
8438
8738
  break;
8439
8739
  }
8440
8740
  case "image":
8441
8741
  controlsHtml = `
8442
8742
  <label class="fig-input-fill-label">Image</label>
8443
- <fig-tooltip text="Opacity">
8743
+ ${showAlpha ? `<fig-tooltip text="Opacity">
8444
8744
  <fig-input-number
8445
8745
  class="fig-input-fill-opacity"
8446
8746
  placeholder="##"
@@ -8450,12 +8750,12 @@ class FigInputFill extends HTMLElement {
8450
8750
  units="%"
8451
8751
  ${disabled ? "disabled" : ""}>
8452
8752
  </fig-input-number>
8453
- </fig-tooltip>`;
8753
+ </fig-tooltip>` : ""}`;
8454
8754
  break;
8455
8755
  case "video":
8456
8756
  controlsHtml = `
8457
8757
  <label class="fig-input-fill-label">Video</label>
8458
- <fig-tooltip text="Opacity">
8758
+ ${showAlpha ? `<fig-tooltip text="Opacity">
8459
8759
  <fig-input-number
8460
8760
  class="fig-input-fill-opacity"
8461
8761
  placeholder="##"
@@ -8465,12 +8765,12 @@ class FigInputFill extends HTMLElement {
8465
8765
  units="%"
8466
8766
  ${disabled ? "disabled" : ""}>
8467
8767
  </fig-input-number>
8468
- </fig-tooltip>`;
8768
+ </fig-tooltip>` : ""}`;
8469
8769
  break;
8470
8770
  case "webcam":
8471
8771
  controlsHtml = `
8472
8772
  <label class="fig-input-fill-label">Webcam</label>
8473
- <fig-tooltip text="Opacity">
8773
+ ${showAlpha ? `<fig-tooltip text="Opacity">
8474
8774
  <fig-input-number
8475
8775
  class="fig-input-fill-opacity"
8476
8776
  placeholder="##"
@@ -8480,7 +8780,7 @@ class FigInputFill extends HTMLElement {
8480
8780
  units="%"
8481
8781
  ${disabled ? "disabled" : ""}>
8482
8782
  </fig-input-number>
8483
- </fig-tooltip>`;
8783
+ </fig-tooltip>` : ""}`;
8484
8784
  break;
8485
8785
  }
8486
8786
 
@@ -8657,6 +8957,9 @@ class FigInputFill extends HTMLElement {
8657
8957
  }
8658
8958
  }
8659
8959
  break;
8960
+ case "alpha":
8961
+ if (this.isConnected) this.#render();
8962
+ break;
8660
8963
  case "aria-label":
8661
8964
  case "aria-describedby":
8662
8965
  case "aria-invalid":
@@ -9132,6 +9435,7 @@ customElements.define("fig-input-palette", FigInputPalette);
9132
9435
  /**
9133
9436
  * A gradient-only fill input built on top of fig-fill-picker.
9134
9437
  * @attr {string} value - JSON string with gradient fill data
9438
+ * @attr {string} size - Passed through to the internal fig-swatch (e.g. "large")
9135
9439
  * @attr {boolean} disabled - Whether the input is disabled
9136
9440
  * @fires input - When the gradient value changes
9137
9441
  * @fires change - When the gradient value is committed
@@ -9160,7 +9464,7 @@ class FigInputGradient extends HTMLElement {
9160
9464
  }
9161
9465
 
9162
9466
  static get observedAttributes() {
9163
- return ["value", "disabled", "edit", "mode"];
9467
+ return ["value", "disabled", "edit", "mode", "size"];
9164
9468
  }
9165
9469
 
9166
9470
  get #editMode() {
@@ -9186,7 +9490,8 @@ class FigInputGradient extends HTMLElement {
9186
9490
  }
9187
9491
 
9188
9492
  #syncFocusTarget() {
9189
- const disabled = this.hasAttribute("disabled");
9493
+ const disabled =
9494
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
9190
9495
  if (disabled) {
9191
9496
  this.setAttribute("tabindex", "-1");
9192
9497
  return;
@@ -9195,10 +9500,21 @@ class FigInputGradient extends HTMLElement {
9195
9500
  }
9196
9501
 
9197
9502
  #normalizeGradient(gradient) {
9503
+ const normalized = normalizeGradientConfig(gradient);
9198
9504
  return {
9199
- ...normalizeGradientConfig(gradient),
9200
- type: "linear",
9201
- angle: 90,
9505
+ ...normalized,
9506
+ type: ["linear", "radial", "angular"].includes(normalized.type)
9507
+ ? normalized.type
9508
+ : "linear",
9509
+ angle: Number.isFinite(Number(normalized.angle))
9510
+ ? Number(normalized.angle)
9511
+ : 90,
9512
+ centerX: Number.isFinite(Number(normalized.centerX))
9513
+ ? Number(normalized.centerX)
9514
+ : 50,
9515
+ centerY: Number.isFinite(Number(normalized.centerY))
9516
+ ? Number(normalized.centerY)
9517
+ : 50,
9202
9518
  };
9203
9519
  }
9204
9520
 
@@ -9231,6 +9547,7 @@ class FigInputGradient extends HTMLElement {
9231
9547
 
9232
9548
  #onKeyDown = (e) => {
9233
9549
  const active = document.activeElement;
9550
+ if (!(active instanceof Node) || !this.contains(active)) return;
9234
9551
  const isTyping =
9235
9552
  active &&
9236
9553
  (active.tagName === "INPUT" ||
@@ -9312,7 +9629,7 @@ class FigInputGradient extends HTMLElement {
9312
9629
  if (this.#editMode !== "picker") return;
9313
9630
  if (e.key !== "Enter" && e.key !== " ") return;
9314
9631
  if (e.altKey || e.ctrlKey || e.metaKey) return;
9315
- if (this.hasAttribute("disabled")) return;
9632
+ if (figBooleanAttribute(this, "disabled")) return;
9316
9633
  const picker = this.querySelector("fig-fill-picker");
9317
9634
  if (!picker || typeof picker.open !== "function") return;
9318
9635
  e.preventDefault();
@@ -9357,6 +9674,12 @@ class FigInputGradient extends HTMLElement {
9357
9674
  .join(", ");
9358
9675
  const interp = gradientInterpolationClause(gradient);
9359
9676
  const interpolation = interp ? ` ${interp}` : "";
9677
+ if (gradient.type === "radial") {
9678
+ return `radial-gradient(circle at ${gradient.centerX}% ${gradient.centerY}%${interpolation}, ${stops})`;
9679
+ }
9680
+ if (gradient.type === "angular") {
9681
+ return `conic-gradient(from ${gradient.angle}deg${interpolation}, ${stops})`;
9682
+ }
9360
9683
  return `linear-gradient(${gradient.angle}deg${interpolation}, ${stops})`;
9361
9684
  }
9362
9685
 
@@ -9367,8 +9690,21 @@ class FigInputGradient extends HTMLElement {
9367
9690
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
9368
9691
  }
9369
9692
 
9693
+ #swatchSizeAttr() {
9694
+ const size = this.getAttribute("size");
9695
+ return size ? ` size="${size}"` : "";
9696
+ }
9697
+
9698
+ #syncSwatchSize() {
9699
+ if (!this.#swatch) return;
9700
+ const size = this.getAttribute("size");
9701
+ if (size) this.#swatch.setAttribute("size", size);
9702
+ else this.#swatch.removeAttribute("size");
9703
+ }
9704
+
9370
9705
  #buildStopHandles() {
9371
- const disabled = this.hasAttribute("disabled");
9706
+ const disabled =
9707
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
9372
9708
  const tipAttr = this.#stopHandleMode === "tip" ? ' tip="color"' : "";
9373
9709
  return this.#gradient.stops
9374
9710
  .map(
@@ -9382,7 +9718,9 @@ class FigInputGradient extends HTMLElement {
9382
9718
  #addedOnPointerDown = false;
9383
9719
 
9384
9720
  #render() {
9385
- const disabled = this.hasAttribute("disabled");
9721
+ this.#colorObserver?.disconnect();
9722
+ this.#colorObserver = null;
9723
+ const disabled = figBooleanAttribute(this, "disabled");
9386
9724
  const mode = this.#editMode;
9387
9725
 
9388
9726
  if (mode === "picker" && hasFigFillPicker()) {
@@ -9391,7 +9729,7 @@ class FigInputGradient extends HTMLElement {
9391
9729
  const gradientValue = JSON.stringify(this.value);
9392
9730
  this.innerHTML = `
9393
9731
  <fig-fill-picker mode="gradient"${expAttr} value='${gradientValue}'${disabled ? " disabled" : ""}>
9394
- <fig-swatch background="${this.#buildGradientCSS()}"${disabled ? " disabled" : ""}></fig-swatch>
9732
+ <fig-swatch background="${this.#buildGradientCSS()}"${this.#swatchSizeAttr()}${disabled ? " disabled" : ""}></fig-swatch>
9395
9733
  </fig-fill-picker>`;
9396
9734
  this.#swatch = this.querySelector("fig-swatch");
9397
9735
  this.#track = null;
@@ -9401,7 +9739,7 @@ class FigInputGradient extends HTMLElement {
9401
9739
  }
9402
9740
 
9403
9741
  this.innerHTML = `
9404
- <fig-swatch background="${this.#buildGradientCSS()}"${disabled ? " disabled" : ""}></fig-swatch>
9742
+ <fig-swatch background="${this.#buildGradientCSS()}"${this.#swatchSizeAttr()}${disabled ? " disabled" : ""}></fig-swatch>
9405
9743
  ${mode === "true" || mode === "picker" ? `<div class="fig-input-gradient-track">${this.#buildStopHandles()}</div>` : ""}`;
9406
9744
  this.#swatch = this.querySelector("fig-swatch");
9407
9745
  this.#track = this.querySelector(".fig-input-gradient-track");
@@ -9450,7 +9788,11 @@ class FigInputGradient extends HTMLElement {
9450
9788
  }
9451
9789
 
9452
9790
  #setupGhostHandle() {
9453
- if (!this.#track || this.hasAttribute("disabled")) return;
9791
+ if (
9792
+ !this.#track ||
9793
+ (this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false")
9794
+ )
9795
+ return;
9454
9796
 
9455
9797
  const ghost = document.createElement("fig-handle");
9456
9798
  ghost.classList.add("fig-input-gradient-ghost");
@@ -9711,13 +10053,14 @@ class FigInputGradient extends HTMLElement {
9711
10053
  #syncSwatch() {
9712
10054
  if (!this.#swatch) return;
9713
10055
  this.#swatch.setAttribute("background", this.#buildGradientCSS());
10056
+ this.#syncSwatchSize();
9714
10057
  }
9715
10058
 
9716
10059
  #setupEventListeners() {
9717
10060
  if (!this.#track) return;
9718
10061
 
9719
10062
  this.#track.addEventListener("pointerdown", (e) => {
9720
- if (this.hasAttribute("disabled")) return;
10063
+ if (figBooleanAttribute(this, "disabled")) return;
9721
10064
  if (e.target.closest("fig-handle:not(.fig-input-gradient-ghost)")) return;
9722
10065
  if (e.button !== 0) return;
9723
10066
  e.preventDefault();
@@ -9934,7 +10277,11 @@ class FigInputGradient extends HTMLElement {
9934
10277
  }
9935
10278
 
9936
10279
  focus(options) {
9937
- if (this.hasAttribute("disabled")) return;
10280
+ if (
10281
+ this.hasAttribute("disabled") &&
10282
+ this.getAttribute("disabled") !== "false"
10283
+ )
10284
+ return;
9938
10285
  if (this.#isEditable) {
9939
10286
  const firstHandle = this.#firstStopHandle();
9940
10287
  if (firstHandle) {
@@ -9967,11 +10314,15 @@ class FigInputGradient extends HTMLElement {
9967
10314
  case "mode":
9968
10315
  this.#syncHandleMode();
9969
10316
  break;
10317
+ case "size":
10318
+ this.#syncSwatchSize();
10319
+ break;
9970
10320
  }
9971
10321
  }
9972
10322
 
9973
10323
  #syncDisabled() {
9974
- const disabled = this.hasAttribute("disabled");
10324
+ const disabled =
10325
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
9975
10326
  this.#syncFocusTarget();
9976
10327
  if (this.#swatch) {
9977
10328
  if (disabled) this.#swatch.setAttribute("disabled", "");
@@ -9999,16 +10350,24 @@ customElements.define("fig-input-gradient", FigInputGradient);
9999
10350
  class FigCheckbox extends HTMLElement {
10000
10351
  #labelElement = null;
10001
10352
  #boundHandleInput;
10353
+ #boundHandleChange;
10354
+ #boundHandleHostClick;
10355
+ #labelIsGenerated = false;
10002
10356
 
10003
10357
  constructor() {
10004
10358
  super();
10005
10359
  this.input = document.createElement("input");
10006
- this.name = this.getAttribute("name") || "checkbox";
10360
+ this.name = this.getAttribute("name") || "";
10007
10361
  this.input.value = this.getAttribute("value") || "";
10008
10362
  this.input.setAttribute("id", figUniqueId());
10009
- this.input.setAttribute("name", this.name);
10363
+ if (this.name) this.input.setAttribute("name", this.name);
10010
10364
  this.input.setAttribute("type", "checkbox");
10011
10365
  this.#boundHandleInput = this.handleInput.bind(this);
10366
+ this.#boundHandleChange = this.handleChange.bind(this);
10367
+ this.#boundHandleHostClick = (event) => {
10368
+ if (event.target !== this || this.input.disabled) return;
10369
+ this.input.click();
10370
+ };
10012
10371
  }
10013
10372
  connectedCallback() {
10014
10373
  // Reuse cloned internals when this element is duplicated via option.cloneNode(true).
@@ -10019,13 +10378,18 @@ class FigCheckbox extends HTMLElement {
10019
10378
  this.append(this.input);
10020
10379
  }
10021
10380
 
10022
- this.input.removeEventListener("change", this.#boundHandleInput);
10023
- this.input.addEventListener("change", this.#boundHandleInput);
10381
+ this.input.removeEventListener("input", this.#boundHandleInput);
10382
+ this.input.addEventListener("input", this.#boundHandleInput);
10383
+ this.input.removeEventListener("change", this.#boundHandleChange);
10384
+ this.input.addEventListener("change", this.#boundHandleChange);
10385
+ this.removeEventListener("click", this.#boundHandleHostClick);
10386
+ this.addEventListener("click", this.#boundHandleHostClick);
10024
10387
  this.#syncInputState();
10025
10388
 
10026
10389
  const existingLabel = this.querySelector(":scope > label");
10027
10390
  if (existingLabel) {
10028
10391
  this.#labelElement = existingLabel;
10392
+ this.#labelIsGenerated = false;
10029
10393
  this.#labelElement.setAttribute("for", this.input.id);
10030
10394
  }
10031
10395
 
@@ -10035,6 +10399,21 @@ class FigCheckbox extends HTMLElement {
10035
10399
  this.#labelElement.innerText = this.getAttribute("label");
10036
10400
  }
10037
10401
 
10402
+ if (!this.hasAttribute("label")) {
10403
+ const text = Array.from(this.childNodes)
10404
+ .filter((node) => node.nodeType === Node.TEXT_NODE)
10405
+ .map((node) => node.textContent || "")
10406
+ .join(" ")
10407
+ .trim();
10408
+ if (
10409
+ text &&
10410
+ !this.input.hasAttribute("aria-label") &&
10411
+ !this.input.hasAttribute("aria-labelledby")
10412
+ ) {
10413
+ this.input.setAttribute("aria-label", text);
10414
+ }
10415
+ }
10416
+
10038
10417
  this.render();
10039
10418
  }
10040
10419
  static get observedAttributes() {
@@ -10044,6 +10423,7 @@ class FigCheckbox extends HTMLElement {
10044
10423
  #createLabel() {
10045
10424
  if (!this.#labelElement) {
10046
10425
  this.#labelElement = document.createElement("label");
10426
+ this.#labelIsGenerated = true;
10047
10427
  this.#labelElement.setAttribute("for", this.input.id);
10048
10428
  }
10049
10429
  // Add to DOM if not already there and input is in the DOM
@@ -10078,7 +10458,9 @@ class FigCheckbox extends HTMLElement {
10078
10458
  this.input.indeterminate = indeterminate;
10079
10459
  this.input.disabled = disabled;
10080
10460
  this.input.value = this.getAttribute("value") || "";
10081
- this.input.setAttribute("name", this.getAttribute("name") || this.name);
10461
+ const name = this.getAttribute("name") || this.name;
10462
+ if (name) this.input.setAttribute("name", name);
10463
+ else this.input.removeAttribute("name");
10082
10464
  if (indeterminate) {
10083
10465
  this.input.setAttribute("indeterminate", "true");
10084
10466
  } else {
@@ -10114,8 +10496,9 @@ class FigCheckbox extends HTMLElement {
10114
10496
  }
10115
10497
 
10116
10498
  disconnectedCallback() {
10117
- this.input.removeEventListener("change", this.#boundHandleInput);
10118
- this.input.remove();
10499
+ this.input.removeEventListener("input", this.#boundHandleInput);
10500
+ this.input.removeEventListener("change", this.#boundHandleChange);
10501
+ this.removeEventListener("click", this.#boundHandleHostClick);
10119
10502
  }
10120
10503
 
10121
10504
  attributeChangedCallback(name, oldValue, newValue) {
@@ -10124,9 +10507,10 @@ class FigCheckbox extends HTMLElement {
10124
10507
  if (newValue) {
10125
10508
  this.#createLabel();
10126
10509
  this.#labelElement.innerText = newValue;
10127
- } else if (this.#labelElement) {
10510
+ } else if (this.#labelElement && this.#labelIsGenerated) {
10128
10511
  this.#labelElement.remove();
10129
10512
  this.#labelElement = null;
10513
+ this.#labelIsGenerated = false;
10130
10514
  }
10131
10515
  break;
10132
10516
  case "checked":
@@ -10142,7 +10526,8 @@ class FigCheckbox extends HTMLElement {
10142
10526
  this.#syncInputState();
10143
10527
  break;
10144
10528
  case "name":
10145
- this.input.setAttribute("name", newValue || this.name);
10529
+ if (newValue) this.input.setAttribute("name", newValue);
10530
+ else this.input.removeAttribute("name");
10146
10531
  break;
10147
10532
  case "value":
10148
10533
  this.input.value = newValue || "";
@@ -10168,7 +10553,6 @@ class FigCheckbox extends HTMLElement {
10168
10553
  } else {
10169
10554
  this.removeAttribute("checked");
10170
10555
  }
10171
- // Emit both input and change events
10172
10556
  this.dispatchEvent(
10173
10557
  new CustomEvent("input", {
10174
10558
  bubbles: true,
@@ -10176,6 +10560,11 @@ class FigCheckbox extends HTMLElement {
10176
10560
  detail: { checked: this.input.checked, value: this.input.value },
10177
10561
  }),
10178
10562
  );
10563
+ }
10564
+
10565
+ handleChange(e) {
10566
+ e.stopPropagation();
10567
+ this.#syncAriaChecked();
10179
10568
  this.dispatchEvent(
10180
10569
  new CustomEvent("change", {
10181
10570
  bubbles: true,
@@ -10200,7 +10589,9 @@ class FigRadio extends FigCheckbox {
10200
10589
  constructor() {
10201
10590
  super();
10202
10591
  this.input.setAttribute("type", "radio");
10203
- this.input.setAttribute("name", this.getAttribute("name") || "radio");
10592
+ const name = this.getAttribute("name");
10593
+ if (name) this.input.setAttribute("name", name);
10594
+ else this.input.removeAttribute("name");
10204
10595
  }
10205
10596
  }
10206
10597
  customElements.define("fig-radio", FigRadio);
@@ -10307,9 +10698,7 @@ class FigComboInput extends HTMLElement {
10307
10698
  this.#setupListeners();
10308
10699
  }
10309
10700
 
10310
- if (this.hasAttribute("disabled")) {
10311
- this.#applyDisabled(true);
10312
- }
10701
+ this.#applyDisabled(figBooleanAttribute(this, "disabled"));
10313
10702
  }
10314
10703
 
10315
10704
  disconnectedCallback() {
@@ -10321,15 +10710,17 @@ class FigComboInput extends HTMLElement {
10321
10710
  const placeholder = this.getAttribute("placeholder") || "";
10322
10711
  const currentValue = this.value;
10323
10712
  const experimental = this.getAttribute("experimental");
10324
- const expAttr = experimental ? ` experimental="${experimental}"` : "";
10713
+ const expAttr = experimental
10714
+ ? ` experimental="${figEscapeAttribute(experimental)}"`
10715
+ : "";
10325
10716
  const dropdownLabel = this.#dropdownLabel();
10326
10717
 
10327
10718
  const dropdownHTML = this.#usesCustomDropdown
10328
10719
  ? ""
10329
- : `<fig-dropdown type="dropdown" label="${dropdownLabel}"${expAttr}>${options.map((o) => `<option>${o.trim()}</option>`).join("")}</fig-dropdown>`;
10720
+ : `<fig-dropdown type="dropdown" label="${figEscapeAttribute(dropdownLabel)}"${expAttr}>${options.map((o) => `<option>${figEscapeAttribute(o.trim())}</option>`).join("")}</fig-dropdown>`;
10330
10721
 
10331
10722
  this.innerHTML = `<div class="input-combo">
10332
- <fig-input-text placeholder="${placeholder}" value="${currentValue}"></fig-input-text>
10723
+ <fig-input-text placeholder="${figEscapeAttribute(placeholder)}" value="${figEscapeAttribute(currentValue)}"></fig-input-text>
10333
10724
  <fig-button type="select" variant="input" icon>
10334
10725
  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
10335
10726
  <path d="M5.87868 7.12132L8 9.24264L10.1213 7.12132" stroke="currentColor" stroke-opacity="0.9" stroke-linecap="round"/>
@@ -10477,9 +10868,13 @@ class FigComboInput extends HTMLElement {
10477
10868
  case "options":
10478
10869
  if (this.#dropdown && !this.#usesCustomDropdown) {
10479
10870
  const options = this.#getOptions();
10480
- this.#dropdown.innerHTML = options
10481
- .map((o) => `<option>${o}</option>`)
10482
- .join("");
10871
+ this.#dropdown.replaceChildren(
10872
+ ...options.map((value) => {
10873
+ const option = document.createElement("option");
10874
+ option.textContent = value;
10875
+ return option;
10876
+ }),
10877
+ );
10483
10878
  }
10484
10879
  break;
10485
10880
  case "placeholder":
@@ -10869,6 +11264,7 @@ class FigMedia extends HTMLElement {
10869
11264
  this.querySelectorAll("fig-swatch[data-generated]").forEach((el) => el.remove());
10870
11265
  this.#ensurePreviewElement();
10871
11266
  this.#ensureMediaElement();
11267
+ this.#addMediaElementListeners();
10872
11268
  this.#syncGeneratedMediaElement();
10873
11269
  this.#syncMediaAccessibility();
10874
11270
  this.#syncCaption();
@@ -10877,6 +11273,9 @@ class FigMedia extends HTMLElement {
10877
11273
  if (isUpload && !this.querySelector("fig-input-file[data-generated]")) {
10878
11274
  this.#createFileInput();
10879
11275
  }
11276
+ this.#fileInput = this.querySelector("fig-input-file[data-generated]");
11277
+ this.#fileInput?.removeEventListener("change", this.#boundHandleFileInput);
11278
+ this.#fileInput?.addEventListener("change", this.#boundHandleFileInput);
10880
11279
  }
10881
11280
 
10882
11281
  disconnectedCallback() {
@@ -10899,6 +11298,14 @@ class FigMedia extends HTMLElement {
10899
11298
  }
10900
11299
  }
10901
11300
 
11301
+ #addMediaElementListeners() {
11302
+ this.#removeMediaElementListeners();
11303
+ if (this.#mediaEl?.tagName !== "VIDEO") return;
11304
+ this.#mediaEl.addEventListener("play", this.#boundHandleMediaPlay);
11305
+ this.#mediaEl.addEventListener("pause", this.#boundHandleMediaPause);
11306
+ this.#mediaEl.addEventListener("ended", this.#boundHandleMediaEnded);
11307
+ }
11308
+
10902
11309
  #ensurePreviewElement() {
10903
11310
  if (this.#previewEl?.isConnected) return;
10904
11311
  const existing = this.querySelector(":scope > fig-preview");
@@ -10954,9 +11361,7 @@ class FigMedia extends HTMLElement {
10954
11361
  video.preload = "auto";
10955
11362
  this.#previewEl.append(video);
10956
11363
  this.#mediaEl = video;
10957
- this.#mediaEl.addEventListener("play", this.#boundHandleMediaPlay);
10958
- this.#mediaEl.addEventListener("pause", this.#boundHandleMediaPause);
10959
- this.#mediaEl.addEventListener("ended", this.#boundHandleMediaEnded);
11364
+ this.#addMediaElementListeners();
10960
11365
  const seekToFirstFrame = () => {
10961
11366
  if (this.#mediaEl?.autoplay) return;
10962
11367
  try {
@@ -11748,6 +12153,11 @@ class FigInputFile extends HTMLElement {
11748
12153
  };
11749
12154
 
11750
12155
  #onDragOver = (e) => {
12156
+ if (
12157
+ this.hasAttribute("disabled") &&
12158
+ this.getAttribute("disabled") !== "false"
12159
+ )
12160
+ return;
11751
12161
  e.preventDefault();
11752
12162
  if (!this.hasAttribute("dragover")) {
11753
12163
  this.setAttribute("dragover", "");
@@ -11794,7 +12204,10 @@ class FigInputFile extends HTMLElement {
11794
12204
  );
11795
12205
  });
11796
12206
  }
11797
- if (!this.hasAttribute("multiple")) {
12207
+ if (
12208
+ !this.hasAttribute("multiple") ||
12209
+ this.getAttribute("multiple") === "false"
12210
+ ) {
11798
12211
  dropped = dropped.slice(0, 1);
11799
12212
  }
11800
12213
  if (dropped.length === 0) return;
@@ -11816,7 +12229,8 @@ class FigInputFile extends HTMLElement {
11816
12229
  const disabled =
11817
12230
  this.hasAttribute("disabled") &&
11818
12231
  this.getAttribute("disabled") !== "false";
11819
- const multiple = this.hasAttribute("multiple");
12232
+ const multiple =
12233
+ this.hasAttribute("multiple") && this.getAttribute("multiple") !== "false";
11820
12234
  const variant = this.getAttribute("variant") || "input";
11821
12235
  const hasFile =
11822
12236
  (this.#files && this.#files.length > 0) ||
@@ -11850,6 +12264,7 @@ class FigInputFile extends HTMLElement {
11850
12264
  this.#fileInput = document.createElement("input");
11851
12265
  this.#fileInput.type = "file";
11852
12266
  this.#fileInput.title = "";
12267
+ this.#fileInput.disabled = disabled;
11853
12268
  if (accepts) this.#fileInput.setAttribute("accept", accepts);
11854
12269
  if (multiple) this.#fileInput.setAttribute("multiple", "");
11855
12270
  this.#fileInput.addEventListener("change", this.#onFileChange);
@@ -11898,6 +12313,7 @@ class FigInputFile extends HTMLElement {
11898
12313
  this.#fileInput = document.createElement("input");
11899
12314
  this.#fileInput.type = "file";
11900
12315
  this.#fileInput.title = "";
12316
+ this.#fileInput.disabled = disabled;
11901
12317
  if (accepts) this.#fileInput.setAttribute("accept", accepts);
11902
12318
  if (multiple) this.#fileInput.setAttribute("multiple", "");
11903
12319
  this.#fileInput.addEventListener("change", this.#onFileChange);
@@ -14849,7 +15265,7 @@ class FigColorTip extends HTMLElement {
14849
15265
  if (mode === "add" || mode === "remove") {
14850
15266
  const iconName = mode === "add" ? "add" : "minus";
14851
15267
  const label = this.getAttribute("aria-label") || (mode === "add" ? "Add color stop" : "Remove color stop");
14852
- this.innerHTML = `<fig-button icon variant="ghost" aria-label="${label}"><fig-icon name="${iconName}"></fig-icon></fig-button>`;
15268
+ this.innerHTML = `<fig-button icon variant="ghost" aria-label="${figEscapeAttribute(label)}"><fig-icon name="${iconName}"></fig-icon></fig-button>`;
14853
15269
  this.#fillPicker = null;
14854
15270
  this.#swatch = null;
14855
15271
  this.addEventListener("click", this.#handleControlClick);
@@ -14994,7 +15410,7 @@ class FigColorTip extends HTMLElement {
14994
15410
  } else {
14995
15411
  this.#fillPicker.setAttribute("alpha", "false");
14996
15412
  }
14997
- if (this.hasAttribute("disabled")) {
15413
+ if (figBooleanAttribute(this, "disabled")) {
14998
15414
  this.#fillPicker.setAttribute("disabled", "");
14999
15415
  } else {
15000
15416
  this.#fillPicker.removeAttribute("disabled");
@@ -15009,7 +15425,7 @@ class FigColorTip extends HTMLElement {
15009
15425
  } else {
15010
15426
  this.#swatch.removeAttribute("alpha");
15011
15427
  }
15012
- if (this.hasAttribute("disabled")) {
15428
+ if (figBooleanAttribute(this, "disabled")) {
15013
15429
  this.#swatch.setAttribute("disabled", "");
15014
15430
  } else {
15015
15431
  this.#swatch.removeAttribute("disabled");
@@ -15120,14 +15536,14 @@ class FigColorTip extends HTMLElement {
15120
15536
  }
15121
15537
 
15122
15538
  get selected() {
15123
- return this.hasAttribute("selected");
15539
+ return figBooleanAttribute(this, "selected");
15124
15540
  }
15125
15541
  set selected(value) {
15126
15542
  this.toggleAttribute("selected", Boolean(value));
15127
15543
  }
15128
15544
 
15129
15545
  get disabled() {
15130
- return this.hasAttribute("disabled");
15546
+ return figBooleanAttribute(this, "disabled");
15131
15547
  }
15132
15548
  set disabled(value) {
15133
15549
  this.toggleAttribute("disabled", Boolean(value));
@@ -15154,9 +15570,9 @@ class FigChoice extends HTMLElement {
15154
15570
  }
15155
15571
  this.setAttribute(
15156
15572
  "aria-selected",
15157
- this.hasAttribute("selected") ? "true" : "false",
15573
+ figBooleanAttribute(this, "selected") ? "true" : "false",
15158
15574
  );
15159
- if (this.hasAttribute("disabled")) {
15575
+ if (figBooleanAttribute(this, "disabled")) {
15160
15576
  this.setAttribute("aria-disabled", "true");
15161
15577
  }
15162
15578
  }
@@ -15165,7 +15581,7 @@ class FigChoice extends HTMLElement {
15165
15581
  if (name === "selected") {
15166
15582
  this.setAttribute(
15167
15583
  "aria-selected",
15168
- this.hasAttribute("selected") ? "true" : "false",
15584
+ figBooleanAttribute(this, "selected") ? "true" : "false",
15169
15585
  );
15170
15586
  }
15171
15587
  if (name === "disabled") {
@@ -15260,7 +15676,7 @@ class FigChooser extends HTMLElement {
15260
15676
  const choices = this.choices;
15261
15677
  for (const choice of choices) {
15262
15678
  const shouldSelect = choice === element;
15263
- const isSelected = choice.hasAttribute("selected");
15679
+ const isSelected = figBooleanAttribute(choice, "selected");
15264
15680
  if (shouldSelect && !isSelected) {
15265
15681
  choice.setAttribute("selected", "");
15266
15682
  } else if (!shouldSelect && isSelected) {
@@ -15402,7 +15818,9 @@ class FigChooser extends HTMLElement {
15402
15818
  const valueAttr = this.getAttribute("value");
15403
15819
  if (valueAttr && this.#selectByValue(valueAttr)) return;
15404
15820
 
15405
- const alreadySelected = choices.find((c) => c.hasAttribute("selected"));
15821
+ const alreadySelected = choices.find((choice) =>
15822
+ figBooleanAttribute(choice, "selected"),
15823
+ );
15406
15824
  if (alreadySelected) {
15407
15825
  this.selectedChoice = alreadySelected;
15408
15826
  return;
@@ -16014,7 +16432,7 @@ class FigHandle extends HTMLElement {
16014
16432
  }
16015
16433
 
16016
16434
  #onHitAreaPointerDown(e) {
16017
- if (this.hasAttribute("disabled")) return;
16435
+ if (figBooleanAttribute(this, "disabled")) return;
16018
16436
  if (e.target !== this.#hitAreaEl) return;
16019
16437
  if (this.#hitAreaMode === "delegate") {
16020
16438
  e.preventDefault();
@@ -16057,7 +16475,7 @@ class FigHandle extends HTMLElement {
16057
16475
  }
16058
16476
 
16059
16477
  select() {
16060
- if (this.hasAttribute("disabled")) return;
16478
+ if (figBooleanAttribute(this, "disabled")) return;
16061
16479
  this.setAttribute("selected", "");
16062
16480
  }
16063
16481
 
@@ -16096,12 +16514,12 @@ class FigHandle extends HTMLElement {
16096
16514
  ) {
16097
16515
  if (this.#moveByKeyboard(e)) {
16098
16516
  e.preventDefault();
16099
- if (!this.hasAttribute("selected")) this.select();
16517
+ if (!figBooleanAttribute(this, "selected")) this.select();
16100
16518
  }
16101
16519
  return;
16102
16520
  }
16103
16521
  if (e.key !== "Enter" && e.key !== " ") return;
16104
- if (e.target === this && !this.hasAttribute("selected")) {
16522
+ if (e.target === this && !figBooleanAttribute(this, "selected")) {
16105
16523
  e.preventDefault();
16106
16524
  if (
16107
16525
  this.getAttribute("type") === "color" &&
@@ -16114,7 +16532,7 @@ class FigHandle extends HTMLElement {
16114
16532
  }
16115
16533
  return;
16116
16534
  }
16117
- if (!this.hasAttribute("selected")) return;
16535
+ if (!figBooleanAttribute(this, "selected")) return;
16118
16536
  if (this.getAttribute("type") !== "color") return;
16119
16537
  if (!this.#canOpenColorPicker) return;
16120
16538
  e.preventDefault();
@@ -16124,7 +16542,7 @@ class FigHandle extends HTMLElement {
16124
16542
  };
16125
16543
 
16126
16544
  #moveByKeyboard(event) {
16127
- if (this.hasAttribute("disabled")) return false;
16545
+ if (figBooleanAttribute(this, "disabled")) return false;
16128
16546
  const container = this.#getContainer();
16129
16547
  if (!container) return false;
16130
16548
  const rect = container.getBoundingClientRect();
@@ -16255,7 +16673,7 @@ class FigHandle extends HTMLElement {
16255
16673
  }
16256
16674
 
16257
16675
  #onPointerDown(e) {
16258
- if (!this.#dragEnabled || this.hasAttribute("disabled")) return;
16676
+ if (!this.#dragEnabled || figBooleanAttribute(this, "disabled")) return;
16259
16677
  e.preventDefault();
16260
16678
  const container = this.#getContainer();
16261
16679
  if (!container) return;
@@ -16321,7 +16739,7 @@ class FigHandle extends HTMLElement {
16321
16739
  this.#closeColorPickerForDrag();
16322
16740
  this.classList.add("dragging");
16323
16741
  this.style.cursor = "grabbing";
16324
- if (!this.hasAttribute("selected")) this.select();
16742
+ if (!figBooleanAttribute(this, "selected")) this.select();
16325
16743
  }
16326
16744
  this.#didDrag = true;
16327
16745
  clampAndApply(e.clientX, e.clientY, e.shiftKey);
@@ -16478,7 +16896,7 @@ class FigHandle extends HTMLElement {
16478
16896
  }
16479
16897
 
16480
16898
  #openDirectColorPicker() {
16481
- if (this.hasAttribute("disabled")) return;
16899
+ if (figBooleanAttribute(this, "disabled")) return;
16482
16900
  const picker = this.#ensureDirectColorPicker();
16483
16901
  if (!picker) {
16484
16902
  this.#openNativeColorPicker();
@@ -16833,6 +17251,12 @@ class FigMenu extends HTMLElement {
16833
17251
  }
16834
17252
  if (this.#popup) {
16835
17253
  this.#popup.removeEventListener("close", this.#boundPopupClose);
17254
+ const items = Array.from(
17255
+ this.#popup.querySelectorAll(
17256
+ ":scope > fig-menu-item, :scope > fig-menu-separator",
17257
+ ),
17258
+ );
17259
+ for (const item of items) this.insertBefore(item, this.#popup);
16836
17260
  this.#popup.remove();
16837
17261
  this.#popup = null;
16838
17262
  }
@@ -17041,16 +17465,24 @@ class FigMenu extends HTMLElement {
17041
17465
  #showAtAfterPointerRelease(x, y) {
17042
17466
  let opened = false;
17043
17467
  let fallbackTimer = 0;
17468
+ const cleanup = () => {
17469
+ window.clearTimeout(fallbackTimer);
17470
+ window.removeEventListener("pointerup", openMenu, true);
17471
+ window.removeEventListener("pointercancel", cancelMenu, true);
17472
+ };
17044
17473
  const openMenu = () => {
17045
17474
  if (opened) return;
17046
17475
  opened = true;
17047
- window.clearTimeout(fallbackTimer);
17048
- window.removeEventListener("pointerup", openMenu, true);
17049
- window.removeEventListener("pointercancel", openMenu, true);
17476
+ cleanup();
17050
17477
  requestAnimationFrame(() => this.showAt(x, y));
17051
17478
  };
17479
+ const cancelMenu = () => {
17480
+ if (opened) return;
17481
+ opened = true;
17482
+ cleanup();
17483
+ };
17052
17484
  window.addEventListener("pointerup", openMenu, { once: true, capture: true });
17053
- window.addEventListener("pointercancel", openMenu, {
17485
+ window.addEventListener("pointercancel", cancelMenu, {
17054
17486
  once: true,
17055
17487
  capture: true,
17056
17488
  });