@rogieking/figui3 6.11.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":
@@ -9187,7 +9490,8 @@ class FigInputGradient extends HTMLElement {
9187
9490
  }
9188
9491
 
9189
9492
  #syncFocusTarget() {
9190
- const disabled = this.hasAttribute("disabled");
9493
+ const disabled =
9494
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
9191
9495
  if (disabled) {
9192
9496
  this.setAttribute("tabindex", "-1");
9193
9497
  return;
@@ -9196,10 +9500,21 @@ class FigInputGradient extends HTMLElement {
9196
9500
  }
9197
9501
 
9198
9502
  #normalizeGradient(gradient) {
9503
+ const normalized = normalizeGradientConfig(gradient);
9199
9504
  return {
9200
- ...normalizeGradientConfig(gradient),
9201
- type: "linear",
9202
- 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,
9203
9518
  };
9204
9519
  }
9205
9520
 
@@ -9232,6 +9547,7 @@ class FigInputGradient extends HTMLElement {
9232
9547
 
9233
9548
  #onKeyDown = (e) => {
9234
9549
  const active = document.activeElement;
9550
+ if (!(active instanceof Node) || !this.contains(active)) return;
9235
9551
  const isTyping =
9236
9552
  active &&
9237
9553
  (active.tagName === "INPUT" ||
@@ -9313,7 +9629,7 @@ class FigInputGradient extends HTMLElement {
9313
9629
  if (this.#editMode !== "picker") return;
9314
9630
  if (e.key !== "Enter" && e.key !== " ") return;
9315
9631
  if (e.altKey || e.ctrlKey || e.metaKey) return;
9316
- if (this.hasAttribute("disabled")) return;
9632
+ if (figBooleanAttribute(this, "disabled")) return;
9317
9633
  const picker = this.querySelector("fig-fill-picker");
9318
9634
  if (!picker || typeof picker.open !== "function") return;
9319
9635
  e.preventDefault();
@@ -9358,6 +9674,12 @@ class FigInputGradient extends HTMLElement {
9358
9674
  .join(", ");
9359
9675
  const interp = gradientInterpolationClause(gradient);
9360
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
+ }
9361
9683
  return `linear-gradient(${gradient.angle}deg${interpolation}, ${stops})`;
9362
9684
  }
9363
9685
 
@@ -9381,7 +9703,8 @@ class FigInputGradient extends HTMLElement {
9381
9703
  }
9382
9704
 
9383
9705
  #buildStopHandles() {
9384
- const disabled = this.hasAttribute("disabled");
9706
+ const disabled =
9707
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
9385
9708
  const tipAttr = this.#stopHandleMode === "tip" ? ' tip="color"' : "";
9386
9709
  return this.#gradient.stops
9387
9710
  .map(
@@ -9395,7 +9718,9 @@ class FigInputGradient extends HTMLElement {
9395
9718
  #addedOnPointerDown = false;
9396
9719
 
9397
9720
  #render() {
9398
- const disabled = this.hasAttribute("disabled");
9721
+ this.#colorObserver?.disconnect();
9722
+ this.#colorObserver = null;
9723
+ const disabled = figBooleanAttribute(this, "disabled");
9399
9724
  const mode = this.#editMode;
9400
9725
 
9401
9726
  if (mode === "picker" && hasFigFillPicker()) {
@@ -9463,7 +9788,11 @@ class FigInputGradient extends HTMLElement {
9463
9788
  }
9464
9789
 
9465
9790
  #setupGhostHandle() {
9466
- if (!this.#track || this.hasAttribute("disabled")) return;
9791
+ if (
9792
+ !this.#track ||
9793
+ (this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false")
9794
+ )
9795
+ return;
9467
9796
 
9468
9797
  const ghost = document.createElement("fig-handle");
9469
9798
  ghost.classList.add("fig-input-gradient-ghost");
@@ -9731,7 +10060,7 @@ class FigInputGradient extends HTMLElement {
9731
10060
  if (!this.#track) return;
9732
10061
 
9733
10062
  this.#track.addEventListener("pointerdown", (e) => {
9734
- if (this.hasAttribute("disabled")) return;
10063
+ if (figBooleanAttribute(this, "disabled")) return;
9735
10064
  if (e.target.closest("fig-handle:not(.fig-input-gradient-ghost)")) return;
9736
10065
  if (e.button !== 0) return;
9737
10066
  e.preventDefault();
@@ -9948,7 +10277,11 @@ class FigInputGradient extends HTMLElement {
9948
10277
  }
9949
10278
 
9950
10279
  focus(options) {
9951
- if (this.hasAttribute("disabled")) return;
10280
+ if (
10281
+ this.hasAttribute("disabled") &&
10282
+ this.getAttribute("disabled") !== "false"
10283
+ )
10284
+ return;
9952
10285
  if (this.#isEditable) {
9953
10286
  const firstHandle = this.#firstStopHandle();
9954
10287
  if (firstHandle) {
@@ -9988,7 +10321,8 @@ class FigInputGradient extends HTMLElement {
9988
10321
  }
9989
10322
 
9990
10323
  #syncDisabled() {
9991
- const disabled = this.hasAttribute("disabled");
10324
+ const disabled =
10325
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
9992
10326
  this.#syncFocusTarget();
9993
10327
  if (this.#swatch) {
9994
10328
  if (disabled) this.#swatch.setAttribute("disabled", "");
@@ -10016,16 +10350,24 @@ customElements.define("fig-input-gradient", FigInputGradient);
10016
10350
  class FigCheckbox extends HTMLElement {
10017
10351
  #labelElement = null;
10018
10352
  #boundHandleInput;
10353
+ #boundHandleChange;
10354
+ #boundHandleHostClick;
10355
+ #labelIsGenerated = false;
10019
10356
 
10020
10357
  constructor() {
10021
10358
  super();
10022
10359
  this.input = document.createElement("input");
10023
- this.name = this.getAttribute("name") || "checkbox";
10360
+ this.name = this.getAttribute("name") || "";
10024
10361
  this.input.value = this.getAttribute("value") || "";
10025
10362
  this.input.setAttribute("id", figUniqueId());
10026
- this.input.setAttribute("name", this.name);
10363
+ if (this.name) this.input.setAttribute("name", this.name);
10027
10364
  this.input.setAttribute("type", "checkbox");
10028
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
+ };
10029
10371
  }
10030
10372
  connectedCallback() {
10031
10373
  // Reuse cloned internals when this element is duplicated via option.cloneNode(true).
@@ -10036,13 +10378,18 @@ class FigCheckbox extends HTMLElement {
10036
10378
  this.append(this.input);
10037
10379
  }
10038
10380
 
10039
- this.input.removeEventListener("change", this.#boundHandleInput);
10040
- 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);
10041
10387
  this.#syncInputState();
10042
10388
 
10043
10389
  const existingLabel = this.querySelector(":scope > label");
10044
10390
  if (existingLabel) {
10045
10391
  this.#labelElement = existingLabel;
10392
+ this.#labelIsGenerated = false;
10046
10393
  this.#labelElement.setAttribute("for", this.input.id);
10047
10394
  }
10048
10395
 
@@ -10052,6 +10399,21 @@ class FigCheckbox extends HTMLElement {
10052
10399
  this.#labelElement.innerText = this.getAttribute("label");
10053
10400
  }
10054
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
+
10055
10417
  this.render();
10056
10418
  }
10057
10419
  static get observedAttributes() {
@@ -10061,6 +10423,7 @@ class FigCheckbox extends HTMLElement {
10061
10423
  #createLabel() {
10062
10424
  if (!this.#labelElement) {
10063
10425
  this.#labelElement = document.createElement("label");
10426
+ this.#labelIsGenerated = true;
10064
10427
  this.#labelElement.setAttribute("for", this.input.id);
10065
10428
  }
10066
10429
  // Add to DOM if not already there and input is in the DOM
@@ -10095,7 +10458,9 @@ class FigCheckbox extends HTMLElement {
10095
10458
  this.input.indeterminate = indeterminate;
10096
10459
  this.input.disabled = disabled;
10097
10460
  this.input.value = this.getAttribute("value") || "";
10098
- 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");
10099
10464
  if (indeterminate) {
10100
10465
  this.input.setAttribute("indeterminate", "true");
10101
10466
  } else {
@@ -10131,8 +10496,9 @@ class FigCheckbox extends HTMLElement {
10131
10496
  }
10132
10497
 
10133
10498
  disconnectedCallback() {
10134
- this.input.removeEventListener("change", this.#boundHandleInput);
10135
- this.input.remove();
10499
+ this.input.removeEventListener("input", this.#boundHandleInput);
10500
+ this.input.removeEventListener("change", this.#boundHandleChange);
10501
+ this.removeEventListener("click", this.#boundHandleHostClick);
10136
10502
  }
10137
10503
 
10138
10504
  attributeChangedCallback(name, oldValue, newValue) {
@@ -10141,9 +10507,10 @@ class FigCheckbox extends HTMLElement {
10141
10507
  if (newValue) {
10142
10508
  this.#createLabel();
10143
10509
  this.#labelElement.innerText = newValue;
10144
- } else if (this.#labelElement) {
10510
+ } else if (this.#labelElement && this.#labelIsGenerated) {
10145
10511
  this.#labelElement.remove();
10146
10512
  this.#labelElement = null;
10513
+ this.#labelIsGenerated = false;
10147
10514
  }
10148
10515
  break;
10149
10516
  case "checked":
@@ -10159,7 +10526,8 @@ class FigCheckbox extends HTMLElement {
10159
10526
  this.#syncInputState();
10160
10527
  break;
10161
10528
  case "name":
10162
- this.input.setAttribute("name", newValue || this.name);
10529
+ if (newValue) this.input.setAttribute("name", newValue);
10530
+ else this.input.removeAttribute("name");
10163
10531
  break;
10164
10532
  case "value":
10165
10533
  this.input.value = newValue || "";
@@ -10185,7 +10553,6 @@ class FigCheckbox extends HTMLElement {
10185
10553
  } else {
10186
10554
  this.removeAttribute("checked");
10187
10555
  }
10188
- // Emit both input and change events
10189
10556
  this.dispatchEvent(
10190
10557
  new CustomEvent("input", {
10191
10558
  bubbles: true,
@@ -10193,6 +10560,11 @@ class FigCheckbox extends HTMLElement {
10193
10560
  detail: { checked: this.input.checked, value: this.input.value },
10194
10561
  }),
10195
10562
  );
10563
+ }
10564
+
10565
+ handleChange(e) {
10566
+ e.stopPropagation();
10567
+ this.#syncAriaChecked();
10196
10568
  this.dispatchEvent(
10197
10569
  new CustomEvent("change", {
10198
10570
  bubbles: true,
@@ -10217,7 +10589,9 @@ class FigRadio extends FigCheckbox {
10217
10589
  constructor() {
10218
10590
  super();
10219
10591
  this.input.setAttribute("type", "radio");
10220
- 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");
10221
10595
  }
10222
10596
  }
10223
10597
  customElements.define("fig-radio", FigRadio);
@@ -10324,9 +10698,7 @@ class FigComboInput extends HTMLElement {
10324
10698
  this.#setupListeners();
10325
10699
  }
10326
10700
 
10327
- if (this.hasAttribute("disabled")) {
10328
- this.#applyDisabled(true);
10329
- }
10701
+ this.#applyDisabled(figBooleanAttribute(this, "disabled"));
10330
10702
  }
10331
10703
 
10332
10704
  disconnectedCallback() {
@@ -10338,15 +10710,17 @@ class FigComboInput extends HTMLElement {
10338
10710
  const placeholder = this.getAttribute("placeholder") || "";
10339
10711
  const currentValue = this.value;
10340
10712
  const experimental = this.getAttribute("experimental");
10341
- const expAttr = experimental ? ` experimental="${experimental}"` : "";
10713
+ const expAttr = experimental
10714
+ ? ` experimental="${figEscapeAttribute(experimental)}"`
10715
+ : "";
10342
10716
  const dropdownLabel = this.#dropdownLabel();
10343
10717
 
10344
10718
  const dropdownHTML = this.#usesCustomDropdown
10345
10719
  ? ""
10346
- : `<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>`;
10347
10721
 
10348
10722
  this.innerHTML = `<div class="input-combo">
10349
- <fig-input-text placeholder="${placeholder}" value="${currentValue}"></fig-input-text>
10723
+ <fig-input-text placeholder="${figEscapeAttribute(placeholder)}" value="${figEscapeAttribute(currentValue)}"></fig-input-text>
10350
10724
  <fig-button type="select" variant="input" icon>
10351
10725
  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
10352
10726
  <path d="M5.87868 7.12132L8 9.24264L10.1213 7.12132" stroke="currentColor" stroke-opacity="0.9" stroke-linecap="round"/>
@@ -10494,9 +10868,13 @@ class FigComboInput extends HTMLElement {
10494
10868
  case "options":
10495
10869
  if (this.#dropdown && !this.#usesCustomDropdown) {
10496
10870
  const options = this.#getOptions();
10497
- this.#dropdown.innerHTML = options
10498
- .map((o) => `<option>${o}</option>`)
10499
- .join("");
10871
+ this.#dropdown.replaceChildren(
10872
+ ...options.map((value) => {
10873
+ const option = document.createElement("option");
10874
+ option.textContent = value;
10875
+ return option;
10876
+ }),
10877
+ );
10500
10878
  }
10501
10879
  break;
10502
10880
  case "placeholder":
@@ -10886,6 +11264,7 @@ class FigMedia extends HTMLElement {
10886
11264
  this.querySelectorAll("fig-swatch[data-generated]").forEach((el) => el.remove());
10887
11265
  this.#ensurePreviewElement();
10888
11266
  this.#ensureMediaElement();
11267
+ this.#addMediaElementListeners();
10889
11268
  this.#syncGeneratedMediaElement();
10890
11269
  this.#syncMediaAccessibility();
10891
11270
  this.#syncCaption();
@@ -10894,6 +11273,9 @@ class FigMedia extends HTMLElement {
10894
11273
  if (isUpload && !this.querySelector("fig-input-file[data-generated]")) {
10895
11274
  this.#createFileInput();
10896
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);
10897
11279
  }
10898
11280
 
10899
11281
  disconnectedCallback() {
@@ -10916,6 +11298,14 @@ class FigMedia extends HTMLElement {
10916
11298
  }
10917
11299
  }
10918
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
+
10919
11309
  #ensurePreviewElement() {
10920
11310
  if (this.#previewEl?.isConnected) return;
10921
11311
  const existing = this.querySelector(":scope > fig-preview");
@@ -10971,9 +11361,7 @@ class FigMedia extends HTMLElement {
10971
11361
  video.preload = "auto";
10972
11362
  this.#previewEl.append(video);
10973
11363
  this.#mediaEl = video;
10974
- this.#mediaEl.addEventListener("play", this.#boundHandleMediaPlay);
10975
- this.#mediaEl.addEventListener("pause", this.#boundHandleMediaPause);
10976
- this.#mediaEl.addEventListener("ended", this.#boundHandleMediaEnded);
11364
+ this.#addMediaElementListeners();
10977
11365
  const seekToFirstFrame = () => {
10978
11366
  if (this.#mediaEl?.autoplay) return;
10979
11367
  try {
@@ -11765,6 +12153,11 @@ class FigInputFile extends HTMLElement {
11765
12153
  };
11766
12154
 
11767
12155
  #onDragOver = (e) => {
12156
+ if (
12157
+ this.hasAttribute("disabled") &&
12158
+ this.getAttribute("disabled") !== "false"
12159
+ )
12160
+ return;
11768
12161
  e.preventDefault();
11769
12162
  if (!this.hasAttribute("dragover")) {
11770
12163
  this.setAttribute("dragover", "");
@@ -11811,7 +12204,10 @@ class FigInputFile extends HTMLElement {
11811
12204
  );
11812
12205
  });
11813
12206
  }
11814
- if (!this.hasAttribute("multiple")) {
12207
+ if (
12208
+ !this.hasAttribute("multiple") ||
12209
+ this.getAttribute("multiple") === "false"
12210
+ ) {
11815
12211
  dropped = dropped.slice(0, 1);
11816
12212
  }
11817
12213
  if (dropped.length === 0) return;
@@ -11833,7 +12229,8 @@ class FigInputFile extends HTMLElement {
11833
12229
  const disabled =
11834
12230
  this.hasAttribute("disabled") &&
11835
12231
  this.getAttribute("disabled") !== "false";
11836
- const multiple = this.hasAttribute("multiple");
12232
+ const multiple =
12233
+ this.hasAttribute("multiple") && this.getAttribute("multiple") !== "false";
11837
12234
  const variant = this.getAttribute("variant") || "input";
11838
12235
  const hasFile =
11839
12236
  (this.#files && this.#files.length > 0) ||
@@ -11867,6 +12264,7 @@ class FigInputFile extends HTMLElement {
11867
12264
  this.#fileInput = document.createElement("input");
11868
12265
  this.#fileInput.type = "file";
11869
12266
  this.#fileInput.title = "";
12267
+ this.#fileInput.disabled = disabled;
11870
12268
  if (accepts) this.#fileInput.setAttribute("accept", accepts);
11871
12269
  if (multiple) this.#fileInput.setAttribute("multiple", "");
11872
12270
  this.#fileInput.addEventListener("change", this.#onFileChange);
@@ -11915,6 +12313,7 @@ class FigInputFile extends HTMLElement {
11915
12313
  this.#fileInput = document.createElement("input");
11916
12314
  this.#fileInput.type = "file";
11917
12315
  this.#fileInput.title = "";
12316
+ this.#fileInput.disabled = disabled;
11918
12317
  if (accepts) this.#fileInput.setAttribute("accept", accepts);
11919
12318
  if (multiple) this.#fileInput.setAttribute("multiple", "");
11920
12319
  this.#fileInput.addEventListener("change", this.#onFileChange);
@@ -14866,7 +15265,7 @@ class FigColorTip extends HTMLElement {
14866
15265
  if (mode === "add" || mode === "remove") {
14867
15266
  const iconName = mode === "add" ? "add" : "minus";
14868
15267
  const label = this.getAttribute("aria-label") || (mode === "add" ? "Add color stop" : "Remove color stop");
14869
- 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>`;
14870
15269
  this.#fillPicker = null;
14871
15270
  this.#swatch = null;
14872
15271
  this.addEventListener("click", this.#handleControlClick);
@@ -15011,7 +15410,7 @@ class FigColorTip extends HTMLElement {
15011
15410
  } else {
15012
15411
  this.#fillPicker.setAttribute("alpha", "false");
15013
15412
  }
15014
- if (this.hasAttribute("disabled")) {
15413
+ if (figBooleanAttribute(this, "disabled")) {
15015
15414
  this.#fillPicker.setAttribute("disabled", "");
15016
15415
  } else {
15017
15416
  this.#fillPicker.removeAttribute("disabled");
@@ -15026,7 +15425,7 @@ class FigColorTip extends HTMLElement {
15026
15425
  } else {
15027
15426
  this.#swatch.removeAttribute("alpha");
15028
15427
  }
15029
- if (this.hasAttribute("disabled")) {
15428
+ if (figBooleanAttribute(this, "disabled")) {
15030
15429
  this.#swatch.setAttribute("disabled", "");
15031
15430
  } else {
15032
15431
  this.#swatch.removeAttribute("disabled");
@@ -15137,14 +15536,14 @@ class FigColorTip extends HTMLElement {
15137
15536
  }
15138
15537
 
15139
15538
  get selected() {
15140
- return this.hasAttribute("selected");
15539
+ return figBooleanAttribute(this, "selected");
15141
15540
  }
15142
15541
  set selected(value) {
15143
15542
  this.toggleAttribute("selected", Boolean(value));
15144
15543
  }
15145
15544
 
15146
15545
  get disabled() {
15147
- return this.hasAttribute("disabled");
15546
+ return figBooleanAttribute(this, "disabled");
15148
15547
  }
15149
15548
  set disabled(value) {
15150
15549
  this.toggleAttribute("disabled", Boolean(value));
@@ -15171,9 +15570,9 @@ class FigChoice extends HTMLElement {
15171
15570
  }
15172
15571
  this.setAttribute(
15173
15572
  "aria-selected",
15174
- this.hasAttribute("selected") ? "true" : "false",
15573
+ figBooleanAttribute(this, "selected") ? "true" : "false",
15175
15574
  );
15176
- if (this.hasAttribute("disabled")) {
15575
+ if (figBooleanAttribute(this, "disabled")) {
15177
15576
  this.setAttribute("aria-disabled", "true");
15178
15577
  }
15179
15578
  }
@@ -15182,7 +15581,7 @@ class FigChoice extends HTMLElement {
15182
15581
  if (name === "selected") {
15183
15582
  this.setAttribute(
15184
15583
  "aria-selected",
15185
- this.hasAttribute("selected") ? "true" : "false",
15584
+ figBooleanAttribute(this, "selected") ? "true" : "false",
15186
15585
  );
15187
15586
  }
15188
15587
  if (name === "disabled") {
@@ -15277,7 +15676,7 @@ class FigChooser extends HTMLElement {
15277
15676
  const choices = this.choices;
15278
15677
  for (const choice of choices) {
15279
15678
  const shouldSelect = choice === element;
15280
- const isSelected = choice.hasAttribute("selected");
15679
+ const isSelected = figBooleanAttribute(choice, "selected");
15281
15680
  if (shouldSelect && !isSelected) {
15282
15681
  choice.setAttribute("selected", "");
15283
15682
  } else if (!shouldSelect && isSelected) {
@@ -15419,7 +15818,9 @@ class FigChooser extends HTMLElement {
15419
15818
  const valueAttr = this.getAttribute("value");
15420
15819
  if (valueAttr && this.#selectByValue(valueAttr)) return;
15421
15820
 
15422
- const alreadySelected = choices.find((c) => c.hasAttribute("selected"));
15821
+ const alreadySelected = choices.find((choice) =>
15822
+ figBooleanAttribute(choice, "selected"),
15823
+ );
15423
15824
  if (alreadySelected) {
15424
15825
  this.selectedChoice = alreadySelected;
15425
15826
  return;
@@ -16031,7 +16432,7 @@ class FigHandle extends HTMLElement {
16031
16432
  }
16032
16433
 
16033
16434
  #onHitAreaPointerDown(e) {
16034
- if (this.hasAttribute("disabled")) return;
16435
+ if (figBooleanAttribute(this, "disabled")) return;
16035
16436
  if (e.target !== this.#hitAreaEl) return;
16036
16437
  if (this.#hitAreaMode === "delegate") {
16037
16438
  e.preventDefault();
@@ -16074,7 +16475,7 @@ class FigHandle extends HTMLElement {
16074
16475
  }
16075
16476
 
16076
16477
  select() {
16077
- if (this.hasAttribute("disabled")) return;
16478
+ if (figBooleanAttribute(this, "disabled")) return;
16078
16479
  this.setAttribute("selected", "");
16079
16480
  }
16080
16481
 
@@ -16113,12 +16514,12 @@ class FigHandle extends HTMLElement {
16113
16514
  ) {
16114
16515
  if (this.#moveByKeyboard(e)) {
16115
16516
  e.preventDefault();
16116
- if (!this.hasAttribute("selected")) this.select();
16517
+ if (!figBooleanAttribute(this, "selected")) this.select();
16117
16518
  }
16118
16519
  return;
16119
16520
  }
16120
16521
  if (e.key !== "Enter" && e.key !== " ") return;
16121
- if (e.target === this && !this.hasAttribute("selected")) {
16522
+ if (e.target === this && !figBooleanAttribute(this, "selected")) {
16122
16523
  e.preventDefault();
16123
16524
  if (
16124
16525
  this.getAttribute("type") === "color" &&
@@ -16131,7 +16532,7 @@ class FigHandle extends HTMLElement {
16131
16532
  }
16132
16533
  return;
16133
16534
  }
16134
- if (!this.hasAttribute("selected")) return;
16535
+ if (!figBooleanAttribute(this, "selected")) return;
16135
16536
  if (this.getAttribute("type") !== "color") return;
16136
16537
  if (!this.#canOpenColorPicker) return;
16137
16538
  e.preventDefault();
@@ -16141,7 +16542,7 @@ class FigHandle extends HTMLElement {
16141
16542
  };
16142
16543
 
16143
16544
  #moveByKeyboard(event) {
16144
- if (this.hasAttribute("disabled")) return false;
16545
+ if (figBooleanAttribute(this, "disabled")) return false;
16145
16546
  const container = this.#getContainer();
16146
16547
  if (!container) return false;
16147
16548
  const rect = container.getBoundingClientRect();
@@ -16272,7 +16673,7 @@ class FigHandle extends HTMLElement {
16272
16673
  }
16273
16674
 
16274
16675
  #onPointerDown(e) {
16275
- if (!this.#dragEnabled || this.hasAttribute("disabled")) return;
16676
+ if (!this.#dragEnabled || figBooleanAttribute(this, "disabled")) return;
16276
16677
  e.preventDefault();
16277
16678
  const container = this.#getContainer();
16278
16679
  if (!container) return;
@@ -16338,7 +16739,7 @@ class FigHandle extends HTMLElement {
16338
16739
  this.#closeColorPickerForDrag();
16339
16740
  this.classList.add("dragging");
16340
16741
  this.style.cursor = "grabbing";
16341
- if (!this.hasAttribute("selected")) this.select();
16742
+ if (!figBooleanAttribute(this, "selected")) this.select();
16342
16743
  }
16343
16744
  this.#didDrag = true;
16344
16745
  clampAndApply(e.clientX, e.clientY, e.shiftKey);
@@ -16495,7 +16896,7 @@ class FigHandle extends HTMLElement {
16495
16896
  }
16496
16897
 
16497
16898
  #openDirectColorPicker() {
16498
- if (this.hasAttribute("disabled")) return;
16899
+ if (figBooleanAttribute(this, "disabled")) return;
16499
16900
  const picker = this.#ensureDirectColorPicker();
16500
16901
  if (!picker) {
16501
16902
  this.#openNativeColorPicker();
@@ -16850,6 +17251,12 @@ class FigMenu extends HTMLElement {
16850
17251
  }
16851
17252
  if (this.#popup) {
16852
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);
16853
17260
  this.#popup.remove();
16854
17261
  this.#popup = null;
16855
17262
  }
@@ -17058,16 +17465,24 @@ class FigMenu extends HTMLElement {
17058
17465
  #showAtAfterPointerRelease(x, y) {
17059
17466
  let opened = false;
17060
17467
  let fallbackTimer = 0;
17468
+ const cleanup = () => {
17469
+ window.clearTimeout(fallbackTimer);
17470
+ window.removeEventListener("pointerup", openMenu, true);
17471
+ window.removeEventListener("pointercancel", cancelMenu, true);
17472
+ };
17061
17473
  const openMenu = () => {
17062
17474
  if (opened) return;
17063
17475
  opened = true;
17064
- window.clearTimeout(fallbackTimer);
17065
- window.removeEventListener("pointerup", openMenu, true);
17066
- window.removeEventListener("pointercancel", openMenu, true);
17476
+ cleanup();
17067
17477
  requestAnimationFrame(() => this.showAt(x, y));
17068
17478
  };
17479
+ const cancelMenu = () => {
17480
+ if (opened) return;
17481
+ opened = true;
17482
+ cleanup();
17483
+ };
17069
17484
  window.addEventListener("pointerup", openMenu, { once: true, capture: true });
17070
- window.addEventListener("pointercancel", openMenu, {
17485
+ window.addEventListener("pointercancel", cancelMenu, {
17071
17486
  once: true,
17072
17487
  capture: true,
17073
17488
  });