@rogieking/figui3 6.21.1 → 6.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig-lab.js CHANGED
@@ -18,6 +18,13 @@ function figLabBooleanAttribute(element, name) {
18
18
  return element.hasAttribute(name) && element.getAttribute(name) !== "false";
19
19
  }
20
20
 
21
+ /* Unique IDs for lab components such as propskit-group. */
22
+ let figLabUniqueIdCounter = 0;
23
+ function figLabUniqueId(prefix = "fig-lab") {
24
+ figLabUniqueIdCounter += 1;
25
+ return `${prefix}-${figLabUniqueIdCounter}`;
26
+ }
27
+
21
28
  /* Field + Switch wrapper */
22
29
  class PropskitSwitch extends HTMLElement {
23
30
  #field = null;
@@ -218,7 +225,9 @@ class PropskitSwitch extends HTMLElement {
218
225
  }
219
226
 
220
227
  set checked(nextChecked) {
221
- this.toggleAttribute("checked", Boolean(nextChecked));
228
+ const checked = Boolean(nextChecked);
229
+ this.toggleAttribute("checked", checked);
230
+ if (this.#switch) this.#switch.value = checked ? "on" : "off";
222
231
  }
223
232
 
224
233
  get value() {
@@ -978,8 +987,11 @@ class PropskitText extends HTMLElement {
978
987
  set value(nextValue) {
979
988
  if (nextValue === null || nextValue === undefined) {
980
989
  this.removeAttribute("value");
990
+ if (this.#input) this.#input.value = "";
981
991
  } else {
982
- this.setAttribute("value", String(nextValue));
992
+ const next = String(nextValue);
993
+ this.setAttribute("value", next);
994
+ if (this.#input) this.#input.value = next;
983
995
  }
984
996
  }
985
997
 
@@ -1178,8 +1190,11 @@ class PropskitNumber extends HTMLElement {
1178
1190
  set value(nextValue) {
1179
1191
  if (nextValue === null || nextValue === undefined || nextValue === "") {
1180
1192
  this.removeAttribute("value");
1193
+ if (this.#input) this.#input.value = "";
1181
1194
  } else {
1182
- this.setAttribute("value", String(nextValue));
1195
+ const next = String(nextValue);
1196
+ this.setAttribute("value", next);
1197
+ if (this.#input) this.#input.value = next;
1183
1198
  }
1184
1199
  }
1185
1200
 
@@ -1203,6 +1218,7 @@ class PropskitGroup extends HTMLElement {
1203
1218
  ].join(",");
1204
1219
 
1205
1220
  #header = null;
1221
+ #disclosure = null;
1206
1222
  #chevron = null;
1207
1223
  #resetTooltip = null;
1208
1224
  #defaults = new WeakMap();
@@ -1225,10 +1241,7 @@ class PropskitGroup extends HTMLElement {
1225
1241
  cancelAnimationFrame(this.#dirtyFrame);
1226
1242
  this.#dirtyFrame = 0;
1227
1243
  }
1228
- if (this.#header) {
1229
- this.#header.removeEventListener("click", this.#handleToggle);
1230
- this.#header.removeEventListener("keydown", this.#handleHeaderKeyDown);
1231
- }
1244
+ this.#disclosure?.removeEventListener("click", this.#handleToggle);
1232
1245
  const resetBtn = this.#resetTooltip?.querySelector("fig-button");
1233
1246
  resetBtn?.removeEventListener("click", this.#handleReset);
1234
1247
  }
@@ -1236,7 +1249,7 @@ class PropskitGroup extends HTMLElement {
1236
1249
  attributeChangedCallback(name, oldValue, newValue) {
1237
1250
  if (oldValue === newValue) return;
1238
1251
  if (name === "open") {
1239
- this.#header?.setAttribute("aria-expanded", String(this.open));
1252
+ this.#disclosure?.setAttribute("aria-expanded", String(this.open));
1240
1253
  return;
1241
1254
  }
1242
1255
  if (name === "show-reset") {
@@ -1259,7 +1272,7 @@ class PropskitGroup extends HTMLElement {
1259
1272
  } else {
1260
1273
  this.setAttribute("open", "false");
1261
1274
  }
1262
- this.#header?.setAttribute("aria-expanded", String(!!value));
1275
+ this.#disclosure?.setAttribute("aria-expanded", String(!!value));
1263
1276
  if (was !== !!value) {
1264
1277
  this.dispatchEvent(
1265
1278
  new CustomEvent("openchange", {
@@ -1339,14 +1352,6 @@ class PropskitGroup extends HTMLElement {
1339
1352
  this.open = !this.open;
1340
1353
  };
1341
1354
 
1342
- #handleHeaderKeyDown = (e) => {
1343
- if (this.#isResetTarget(e.target)) return;
1344
- if (e.key !== "Enter" && e.key !== " ") return;
1345
- e.preventDefault();
1346
- e.stopPropagation();
1347
- this.open = !this.open;
1348
- };
1349
-
1350
1355
  #handleReset = (e) => {
1351
1356
  e.preventDefault();
1352
1357
  e.stopPropagation();
@@ -1528,17 +1533,34 @@ class PropskitGroup extends HTMLElement {
1528
1533
 
1529
1534
  if (userHeader) {
1530
1535
  this.#header = userHeader;
1531
- } else if (!this.#header || !this.#header.dataset.generated) {
1536
+ } else if (
1537
+ !this.#header ||
1538
+ !this.#header.dataset.generated ||
1539
+ this.#header.parentElement !== this
1540
+ ) {
1532
1541
  this.#header = document.createElement("fig-header");
1533
1542
  this.#header.setAttribute("borderless", "");
1534
1543
  this.#header.dataset.generated = "true";
1535
1544
  this.prepend(this.#header);
1536
1545
  }
1537
1546
 
1538
- let h3 = this.#header.querySelector("h3");
1547
+ let disclosure = this.#header.querySelector(
1548
+ ":scope > .propskit-group-disclosure",
1549
+ );
1550
+ if (!disclosure) {
1551
+ disclosure = document.createElement("fig-button");
1552
+ disclosure.className = "propskit-group-disclosure";
1553
+ disclosure.setAttribute("variant", "ghost");
1554
+ const existingHeading = this.#header.querySelector(":scope > h3");
1555
+ if (existingHeading) disclosure.appendChild(existingHeading);
1556
+ this.#header.prepend(disclosure);
1557
+ }
1558
+ this.#disclosure = disclosure;
1559
+
1560
+ let h3 = disclosure.querySelector("h3");
1539
1561
  if (!h3) {
1540
1562
  h3 = document.createElement("h3");
1541
- this.#header.prepend(h3);
1563
+ disclosure.appendChild(h3);
1542
1564
  }
1543
1565
  if (!h3.id) h3.id = figLabUniqueId("propskit-group");
1544
1566
  if (this.#header.dataset.generated) {
@@ -1564,17 +1586,17 @@ class PropskitGroup extends HTMLElement {
1564
1586
  }
1565
1587
  this.#chevron = h3.querySelector(".propskit-group-chevron");
1566
1588
  this.#syncResetButton();
1567
- this.#header.removeEventListener("click", this.#handleToggle);
1568
- this.#header.addEventListener("click", this.#handleToggle);
1569
- this.#header.setAttribute("role", "button");
1570
- this.#header.setAttribute("tabindex", "0");
1571
- this.#header.setAttribute("aria-expanded", String(this.open));
1572
- this.#header.removeEventListener("keydown", this.#handleHeaderKeyDown);
1573
- this.#header.addEventListener("keydown", this.#handleHeaderKeyDown);
1589
+ this.#header.removeAttribute("role");
1590
+ this.#header.removeAttribute("tabindex");
1591
+ this.#header.removeAttribute("aria-expanded");
1592
+ this.#disclosure.setAttribute("aria-labelledby", h3.id);
1593
+ this.#disclosure.setAttribute("aria-expanded", String(this.open));
1594
+ this.#disclosure.removeEventListener("click", this.#handleToggle);
1595
+ this.#disclosure.addEventListener("click", this.#handleToggle);
1574
1596
 
1575
1597
  if (!this.hasAttribute("open")) {
1576
1598
  this.setAttribute("open", "false");
1577
- this.#header.setAttribute("aria-expanded", "false");
1599
+ this.#disclosure.setAttribute("aria-expanded", "false");
1578
1600
  }
1579
1601
  }
1580
1602
  }
@@ -1607,6 +1629,7 @@ class PropskitSlider extends HTMLElement {
1607
1629
  #boundHandleRangeDoubleClick = this.#handleRangeDoubleClick.bind(this);
1608
1630
  #boundHandleContextMenu = this.#handleContextMenu.bind(this);
1609
1631
  #boundHandleContextMenuChange = this.#handleContextMenuChange.bind(this);
1632
+ #boundHandleClick = this.#handleClick.bind(this);
1610
1633
  #ignoredSliderAttrs = new Set([
1611
1634
  "variant",
1612
1635
  "color",
@@ -1644,6 +1667,16 @@ class PropskitSlider extends HTMLElement {
1644
1667
  });
1645
1668
  this.removeEventListener("contextmenu", this.#boundHandleContextMenu);
1646
1669
  this.addEventListener("contextmenu", this.#boundHandleContextMenu);
1670
+ this.removeEventListener("click", this.#boundHandleClick, true);
1671
+ this.addEventListener("click", this.#boundHandleClick, true);
1672
+ this.#contextMenu?.removeEventListener(
1673
+ "change",
1674
+ this.#boundHandleContextMenuChange,
1675
+ );
1676
+ this.#contextMenu?.addEventListener(
1677
+ "change",
1678
+ this.#boundHandleContextMenuChange,
1679
+ );
1647
1680
 
1648
1681
  if (!this.#observer) {
1649
1682
  this.#observer = new MutationObserver((mutations) => {
@@ -1699,6 +1732,7 @@ class PropskitSlider extends HTMLElement {
1699
1732
  capture: true,
1700
1733
  });
1701
1734
  this.removeEventListener("contextmenu", this.#boundHandleContextMenu);
1735
+ this.removeEventListener("click", this.#boundHandleClick, true);
1702
1736
  this.#contextMenu?.removeEventListener("change", this.#boundHandleContextMenuChange);
1703
1737
  }
1704
1738
 
@@ -1798,8 +1832,10 @@ class PropskitSlider extends HTMLElement {
1798
1832
 
1799
1833
  for (const attrName of hostAttrs) {
1800
1834
  if (attrName === "text") continue;
1801
- const value = this.getAttribute(attrName);
1802
- this.#slider.setAttribute(attrName, value ?? "");
1835
+ const value = this.getAttribute(attrName) ?? "";
1836
+ if (this.#slider.getAttribute(attrName) !== value) {
1837
+ this.#slider.setAttribute(attrName, value);
1838
+ }
1803
1839
  }
1804
1840
 
1805
1841
  this.#slider.removeAttribute("variant");
@@ -1875,8 +1911,45 @@ class PropskitSlider extends HTMLElement {
1875
1911
  if (rangeInput !== this.#rangeInput) {
1876
1912
  this.#bindRangeInput(rangeInput);
1877
1913
  }
1878
- rangeInput?.removeAttribute("tabindex");
1879
- numberInput?.setAttribute("tabindex", "-1");
1914
+ if (rangeInput) {
1915
+ rangeInput.removeAttribute("tabindex");
1916
+ rangeInput.removeAttribute("aria-hidden");
1917
+ const label =
1918
+ this.getAttribute("aria-label") ||
1919
+ this.#label?.textContent?.trim() ||
1920
+ "Slider";
1921
+ if (
1922
+ !rangeInput.hasAttribute("aria-label") &&
1923
+ !rangeInput.hasAttribute("aria-labelledby")
1924
+ ) {
1925
+ rangeInput.setAttribute("aria-label", label);
1926
+ }
1927
+ }
1928
+ if (numberInput) {
1929
+ numberInput.setAttribute("tabindex", "-1");
1930
+ numberInput.setAttribute("aria-hidden", "true");
1931
+ }
1932
+ }
1933
+
1934
+ #handleClick(event) {
1935
+ if (figLabBooleanAttribute(this, "disabled")) return;
1936
+ if (
1937
+ event.target instanceof Element &&
1938
+ event.target.closest("fig-input-number, fig-menu")
1939
+ ) {
1940
+ return;
1941
+ }
1942
+ this.#queueRangeFocus();
1943
+ }
1944
+
1945
+ #queueRangeFocus() {
1946
+ requestAnimationFrame(() => {
1947
+ requestAnimationFrame(() => {
1948
+ if (this.isConnected && !figLabBooleanAttribute(this, "disabled")) {
1949
+ this.focus();
1950
+ }
1951
+ });
1952
+ });
1880
1953
  }
1881
1954
 
1882
1955
  #bindRangeInput(rangeInput) {
@@ -1904,6 +1977,9 @@ class PropskitSlider extends HTMLElement {
1904
1977
 
1905
1978
  #handleElasticPointerDown(event) {
1906
1979
  if (event.button !== 0 || figLabBooleanAttribute(this, "disabled")) return;
1980
+ if (!event.target?.closest?.("fig-input-number, fig-menu")) {
1981
+ this.#queueRangeFocus();
1982
+ }
1907
1983
  if (this.getAttribute("elastic") === "false") return;
1908
1984
  if (event.target?.closest?.("fig-input-number")) return;
1909
1985
  const rangeInput =
@@ -2116,6 +2192,7 @@ class PropskitSlider extends HTMLElement {
2116
2192
  #setSliderValue(value, eventType) {
2117
2193
  if (!this.#slider || value === null || value === undefined) return;
2118
2194
  this.#slider.value = value;
2195
+ this.setAttribute("value", String(this.#slider.value));
2119
2196
  this.dispatchEvent(
2120
2197
  new CustomEvent(eventType, {
2121
2198
  detail: this.#slider.value,
@@ -2180,6 +2257,9 @@ class PropskitSlider extends HTMLElement {
2180
2257
  event instanceof CustomEvent && event.detail !== undefined
2181
2258
  ? event.detail
2182
2259
  : this.#slider?.value;
2260
+ if (this.#slider?.value !== undefined) {
2261
+ this.setAttribute("value", String(this.#slider.value));
2262
+ }
2183
2263
  this.dispatchEvent(
2184
2264
  new CustomEvent(type, {
2185
2265
  detail,
@@ -2191,7 +2271,25 @@ class PropskitSlider extends HTMLElement {
2191
2271
  }
2192
2272
 
2193
2273
  focus(options) {
2194
- this.#slider?.querySelector('input[type="range"]')?.focus(options);
2274
+ this.#syncFocusDelegation();
2275
+ const range = this.#slider?.querySelector('input[type="range"]');
2276
+ range?.setAttribute("data-propskit-focus-called", "");
2277
+ range?.focus(options);
2278
+ }
2279
+
2280
+ get value() {
2281
+ return this.getAttribute("value") ?? this.#slider?.value ?? "";
2282
+ }
2283
+
2284
+ set value(nextValue) {
2285
+ if (nextValue === null || nextValue === undefined || nextValue === "") {
2286
+ this.removeAttribute("value");
2287
+ if (this.#slider) this.#slider.value = "";
2288
+ return;
2289
+ }
2290
+ const next = String(nextValue);
2291
+ this.setAttribute("value", next);
2292
+ if (this.#slider) this.#slider.value = next;
2195
2293
  }
2196
2294
 
2197
2295
  resetToDefault() {
@@ -2242,6 +2340,8 @@ class FigCanvasControl extends HTMLElement {
2242
2340
  #rotateCursorPrevBodyCursor = "";
2243
2341
  #rotateCursorPrevBodyCursorPriority = "";
2244
2342
  #boundRotateCursorEnd = null;
2343
+ #activeGestureController = null;
2344
+ #activeGestureFinish = null;
2245
2345
 
2246
2346
  get #type() {
2247
2347
  return this.getAttribute("type") || "point";
@@ -2340,6 +2440,7 @@ class FigCanvasControl extends HTMLElement {
2340
2440
  }
2341
2441
 
2342
2442
  disconnectedCallback() {
2443
+ this.#cancelActiveGesture();
2343
2444
  this.#teardownRadiusDrag();
2344
2445
  this.#deactivateMoveCursor();
2345
2446
  this.#deactivateRotateCursor();
@@ -2361,6 +2462,7 @@ class FigCanvasControl extends HTMLElement {
2361
2462
  else this.#render();
2362
2463
  }
2363
2464
  if (name === "type") {
2465
+ this.#cancelActiveGesture();
2364
2466
  this.#parseValue();
2365
2467
  this.#render();
2366
2468
  }
@@ -2369,6 +2471,7 @@ class FigCanvasControl extends HTMLElement {
2369
2471
  else this.#pointHandle.removeAttribute("color");
2370
2472
  }
2371
2473
  if (name === "disabled") {
2474
+ this.#cancelActiveGesture();
2372
2475
  this.#render();
2373
2476
  }
2374
2477
  if (name === "tooltips") {
@@ -2440,6 +2543,7 @@ class FigCanvasControl extends HTMLElement {
2440
2543
  }
2441
2544
 
2442
2545
  #render() {
2546
+ this.#cancelActiveGesture();
2443
2547
  this.innerHTML = "";
2444
2548
  this.#pointHandle = null;
2445
2549
  this.#secondHandle = null;
@@ -2862,7 +2966,6 @@ class FigCanvasControl extends HTMLElement {
2862
2966
  e.stopPropagation();
2863
2967
  const container = this.#container;
2864
2968
  if (!container) return;
2865
- const rect0 = container.getBoundingClientRect();
2866
2969
  const startX = e.clientX;
2867
2970
  const startY = e.clientY;
2868
2971
  const x0 = this.#x;
@@ -2894,21 +2997,24 @@ class FigCanvasControl extends HTMLElement {
2894
2997
  this.#emitInput();
2895
2998
  };
2896
2999
 
2897
- const onUp = () => {
3000
+ const gesture = this.#beginActiveGesture((commit) => {
2898
3001
  document.body.classList.remove("fig-lab-move-active");
2899
3002
  hitLine.style.pointerEvents = "stroke";
2900
- this.#syncValueAttribute();
2901
- this.#emitChange();
2902
- window.removeEventListener("pointermove", onMove);
2903
- window.removeEventListener("pointerup", onUp);
2904
- requestAnimationFrame(() => {
2905
- this.#isDragging = false;
2906
- this.#isSecondDragging = false;
2907
- });
2908
- };
3003
+ this.#isDragging = false;
3004
+ this.#isSecondDragging = false;
3005
+ if (commit) {
3006
+ this.#syncValueAttribute();
3007
+ this.#emitChange();
3008
+ }
3009
+ });
2909
3010
 
2910
- window.addEventListener("pointermove", onMove);
2911
- window.addEventListener("pointerup", onUp);
3011
+ window.addEventListener("pointermove", onMove, { signal: gesture.signal });
3012
+ window.addEventListener("pointerup", () => gesture.finish(true), {
3013
+ signal: gesture.signal,
3014
+ });
3015
+ window.addEventListener("pointercancel", () => gesture.finish(false), {
3016
+ signal: gesture.signal,
3017
+ });
2912
3018
  });
2913
3019
  }
2914
3020
 
@@ -3066,7 +3172,7 @@ class FigCanvasControl extends HTMLElement {
3066
3172
  svg.style.top = "0";
3067
3173
  svg.setAttribute("viewBox", `0 0 ${rect.width} ${rect.height}`);
3068
3174
  const lines = svg.querySelectorAll(
3069
- ".fig-canvas-control-angle-line, .fig-canvas-control-angle-line-halo",
3175
+ ".fig-canvas-control-angle-line, .fig-canvas-control-angle-line-halo, .fig-canvas-control-angle-line-hit",
3070
3176
  );
3071
3177
  for (const line of lines) {
3072
3178
  line.setAttribute("x1", String(cx));
@@ -3254,19 +3360,24 @@ class FigCanvasControl extends HTMLElement {
3254
3360
  this.#emitInput();
3255
3361
  };
3256
3362
 
3257
- const onUp = () => {
3363
+ const gesture = this.#beginActiveGesture((commit) => {
3258
3364
  this.#isAngleDragging = false;
3259
3365
  this.classList.remove("fig-canvas-control-ring-active");
3260
3366
  this.#angleHandle.removeAttribute("selected");
3261
3367
  if (this.#angleTooltip) this.#angleTooltip.removeAttribute("show");
3262
- this.#syncValueAttribute();
3263
- this.#emitChange();
3264
- window.removeEventListener("pointermove", onMove);
3265
- window.removeEventListener("pointerup", onUp);
3266
- };
3368
+ if (commit) {
3369
+ this.#syncValueAttribute();
3370
+ this.#emitChange();
3371
+ }
3372
+ });
3267
3373
 
3268
- window.addEventListener("pointermove", onMove);
3269
- window.addEventListener("pointerup", onUp);
3374
+ window.addEventListener("pointermove", onMove, { signal: gesture.signal });
3375
+ window.addEventListener("pointerup", () => gesture.finish(true), {
3376
+ signal: gesture.signal,
3377
+ });
3378
+ window.addEventListener("pointercancel", () => gesture.finish(false), {
3379
+ signal: gesture.signal,
3380
+ });
3270
3381
  });
3271
3382
  }
3272
3383
 
@@ -3356,17 +3467,22 @@ class FigCanvasControl extends HTMLElement {
3356
3467
  this.#emitInput();
3357
3468
  };
3358
3469
 
3359
- const onUp = () => {
3470
+ const gesture = this.#beginActiveGesture((commit) => {
3360
3471
  this.#isDragging = false;
3361
3472
  if (tooltip) tooltip.removeAttribute("show");
3362
- this.#syncValueAttribute();
3363
- this.#emitChange();
3364
- window.removeEventListener("pointermove", onMove);
3365
- window.removeEventListener("pointerup", onUp);
3366
- };
3473
+ if (commit) {
3474
+ this.#syncValueAttribute();
3475
+ this.#emitChange();
3476
+ }
3477
+ });
3367
3478
 
3368
- window.addEventListener("pointermove", onMove);
3369
- window.addEventListener("pointerup", onUp);
3479
+ window.addEventListener("pointermove", onMove, { signal: gesture.signal });
3480
+ window.addEventListener("pointerup", () => gesture.finish(true), {
3481
+ signal: gesture.signal,
3482
+ });
3483
+ window.addEventListener("pointercancel", () => gesture.finish(false), {
3484
+ signal: gesture.signal,
3485
+ });
3370
3486
  });
3371
3487
  }
3372
3488
 
@@ -3462,7 +3578,7 @@ class FigCanvasControl extends HTMLElement {
3462
3578
  this.#emitInput();
3463
3579
  };
3464
3580
 
3465
- const onUp = () => {
3581
+ const gesture = this.#beginActiveGesture((commit) => {
3466
3582
  this.#isRadiusDragging = false;
3467
3583
  this.classList.remove("fig-canvas-control-ring-active");
3468
3584
  circle.style.pointerEvents = "";
@@ -3474,14 +3590,19 @@ class FigCanvasControl extends HTMLElement {
3474
3590
  }
3475
3591
  document.body.style.cursor = prevBodyCursor;
3476
3592
  if (this.#radiusTooltip) this.#radiusTooltip.removeAttribute("show");
3477
- this.#syncValueAttribute();
3478
- this.#emitChange();
3479
- window.removeEventListener("pointermove", onMove);
3480
- window.removeEventListener("pointerup", onUp);
3481
- };
3593
+ if (commit) {
3594
+ this.#syncValueAttribute();
3595
+ this.#emitChange();
3596
+ }
3597
+ });
3482
3598
 
3483
- window.addEventListener("pointermove", onMove);
3484
- window.addEventListener("pointerup", onUp);
3599
+ window.addEventListener("pointermove", onMove, { signal: gesture.signal });
3600
+ window.addEventListener("pointerup", () => gesture.finish(true), {
3601
+ signal: gesture.signal,
3602
+ });
3603
+ window.addEventListener("pointercancel", () => gesture.finish(false), {
3604
+ signal: gesture.signal,
3605
+ });
3485
3606
  };
3486
3607
  circle.addEventListener("pointerdown", onDown);
3487
3608
  this._radiusDragCleanup = () =>
@@ -3494,6 +3615,32 @@ class FigCanvasControl extends HTMLElement {
3494
3615
  this._radiusDragCleanup = null;
3495
3616
  }
3496
3617
  }
3618
+
3619
+ #beginActiveGesture(onFinish) {
3620
+ this.#cancelActiveGesture();
3621
+ const controller = new AbortController();
3622
+ let finished = false;
3623
+ const finish = (commit = false) => {
3624
+ if (finished) return;
3625
+ finished = true;
3626
+ controller.abort();
3627
+ if (this.#activeGestureController === controller) {
3628
+ this.#activeGestureController = null;
3629
+ this.#activeGestureFinish = null;
3630
+ }
3631
+ onFinish(commit);
3632
+ };
3633
+ this.#activeGestureController = controller;
3634
+ this.#activeGestureFinish = finish;
3635
+ return { signal: controller.signal, finish };
3636
+ }
3637
+
3638
+ #cancelActiveGesture() {
3639
+ this.#activeGestureFinish?.(false);
3640
+ this.#activeGestureController?.abort();
3641
+ this.#activeGestureController = null;
3642
+ this.#activeGestureFinish = null;
3643
+ }
3497
3644
  }
3498
3645
  figLabDefineElement("fig-canvas-control", FigCanvasControl);
3499
3646
 
@@ -3528,6 +3675,8 @@ class PropskitOscillator extends HTMLElement {
3528
3675
  #expandedWaveIndices = new Set();
3529
3676
  #resizeObserver = null;
3530
3677
  #activeFieldInput = null;
3678
+ #dragController = null;
3679
+ #dragCleanup = null;
3531
3680
 
3532
3681
  static TYPES = [
3533
3682
  { name: "Wave", value: "sine" },
@@ -3559,7 +3708,7 @@ class PropskitOscillator extends HTMLElement {
3559
3708
  }
3560
3709
 
3561
3710
  disconnectedCallback() {
3562
- this.#isDragging = null;
3711
+ this.#cancelDrag();
3563
3712
  this.#stopPlayhead();
3564
3713
  if (this.#resizeObserver) {
3565
3714
  this.#resizeObserver.disconnect();
@@ -3764,6 +3913,7 @@ class PropskitOscillator extends HTMLElement {
3764
3913
  }
3765
3914
 
3766
3915
  #render() {
3916
+ this.#cancelDrag();
3767
3917
  this.#stopPlayhead();
3768
3918
  this.innerHTML = this.#getInnerHTML();
3769
3919
  this.#cacheRefs();
@@ -3774,7 +3924,8 @@ class PropskitOscillator extends HTMLElement {
3774
3924
  }
3775
3925
 
3776
3926
  #getInnerHTML() {
3777
- const disabled = this.#isDisabled() ? " disabled" : "";
3927
+ const interactive = this.#isEditEnabled() && !this.#isDisabled();
3928
+ const disabled = interactive ? "" : " disabled";
3778
3929
 
3779
3930
  return `<div class="propskit-oscillator-svg-container">
3780
3931
  <svg viewBox="0 0 ${this.#drawWidth} ${this.#drawHeight}" class="propskit-oscillator-svg">
@@ -3782,8 +3933,8 @@ class PropskitOscillator extends HTMLElement {
3782
3933
  <line class="propskit-oscillator-baseline"></line>
3783
3934
  <path class="propskit-oscillator-path"></path>
3784
3935
  <circle class="propskit-oscillator-playhead"></circle>
3785
- <foreignObject class="propskit-oscillator-handle propskit-oscillator-amplitude-handle" data-handle="amplitude" width="20" height="20"><div class="propskit-oscillator-handle-inner"><fig-tooltip text="Amplitude"><fig-handle size="small" aria-label="Oscillator amplitude handle"${disabled}></fig-handle></fig-tooltip></div></foreignObject>
3786
- <foreignObject class="propskit-oscillator-handle propskit-oscillator-frequency-handle" data-handle="frequency" width="20" height="20"><div class="propskit-oscillator-handle-inner"><fig-tooltip text="Frequency"><fig-handle size="small" aria-label="Oscillator frequency handle"${disabled}></fig-handle></fig-tooltip></div></foreignObject>
3936
+ ${interactive ? `<foreignObject class="propskit-oscillator-handle propskit-oscillator-amplitude-handle" data-handle="amplitude" width="20" height="20"><div class="propskit-oscillator-handle-inner"><fig-tooltip text="Amplitude"><fig-handle size="small" aria-label="Oscillator amplitude handle"></fig-handle></fig-tooltip></div></foreignObject>
3937
+ <foreignObject class="propskit-oscillator-handle propskit-oscillator-frequency-handle" data-handle="frequency" width="20" height="20"><div class="propskit-oscillator-handle-inner"><fig-tooltip text="Frequency"><fig-handle size="small" aria-label="Oscillator frequency handle"></fig-handle></fig-tooltip></div></foreignObject>` : ""}
3787
3938
  </svg>
3788
3939
  </div>
3789
3940
  ${this.#isEditEnabled() ? this.#getWaveControlsHTML(disabled) : ""}`;
@@ -3807,10 +3958,7 @@ class PropskitOscillator extends HTMLElement {
3807
3958
  <fig-button class="propskit-oscillator-remove-button" variant="ghost" icon data-wave-index="${index}" aria-label="Remove form"${removeDisabled}><fig-icon name="minus"></fig-icon></fig-button>
3808
3959
  </fig-tooltip>
3809
3960
  <fig-tooltip text="Add form">
3810
- <fig-button class="propskit-oscillator-add-type-button" type="select" variant="ghost" icon data-wave-index="${index}" aria-label="Add form"${disabled}>
3811
- <fig-icon name="add"></fig-icon>
3812
- ${this.#getWaveTypeDropdownHTML("propskit-oscillator-add-type", "sine", disabled, index)}
3813
- </fig-button>
3961
+ ${this.#getWaveTypeSelectHTML("propskit-oscillator-add-type propskit-oscillator-add-type-button", "sine", disabled, index)}
3814
3962
  </fig-tooltip>
3815
3963
  </fig-header>
3816
3964
  <div class="propskit-oscillator-fields" data-wave-index="${index}"${active}>
@@ -3822,17 +3970,17 @@ class PropskitOscillator extends HTMLElement {
3822
3970
  </fig-group>`;
3823
3971
  }
3824
3972
 
3825
- #getWaveTypeDropdownHTML(className, value, disabled, index = null) {
3973
+ #getWaveTypeSelectHTML(className, value, disabled, index = null) {
3826
3974
  const options = PropskitOscillator.TYPES.map((type) => {
3827
3975
  const selected = type.value === value ? " selected" : "";
3828
- return `<option value="${type.value}"${selected}>
3829
- ${PropskitOscillator.waveIcon(type.value, 24)}
3830
- <label>${type.name}</label>
3831
- </option>`;
3976
+ return `<fig-select-option value="${type.value}" label="${type.name}"${selected}>
3977
+ <span slot="prepend">${PropskitOscillator.waveIcon(type.value, 24)}</span>
3978
+ <span>${type.name}</span>
3979
+ </fig-select-option>`;
3832
3980
  }).join("");
3833
3981
  const indexAttr =
3834
3982
  index === null ? "" : ` data-wave-index="${PropskitOscillator.#escapeAttribute(String(index))}"`;
3835
- return `<fig-dropdown class="${className}" value="${value}" experimental="modern" type="dropdown" label="Add form"${indexAttr}${disabled}>${options}</fig-dropdown>`;
3983
+ return `<fig-select class="${className}" value="${value}" label="Add form"${indexAttr}${disabled}><fig-select-options>${options}</fig-select-options></fig-select>`;
3836
3984
  }
3837
3985
 
3838
3986
  #getNumberFieldHTML(index, name, label, min, max, step, units) {
@@ -4039,6 +4187,7 @@ class PropskitOscillator extends HTMLElement {
4039
4187
  }
4040
4188
 
4041
4189
  #setupEvents() {
4190
+ if (!this.#isEditEnabled() || this.#isDisabled()) return;
4042
4191
  for (const typeControl of this.#typeControls) {
4043
4192
  typeControl.addEventListener("change", (event) => {
4044
4193
  if (this.#isDisabled()) return;
@@ -4273,6 +4422,7 @@ class PropskitOscillator extends HTMLElement {
4273
4422
  }
4274
4423
 
4275
4424
  #startDrag(event, type) {
4425
+ this.#cancelDrag();
4276
4426
  this.#isDragging = type;
4277
4427
  this.#svg?.classList.add("dragging");
4278
4428
  const dragCursor =
@@ -4313,19 +4463,42 @@ class PropskitOscillator extends HTMLElement {
4313
4463
  this.#emit("input");
4314
4464
  };
4315
4465
 
4316
- const onUp = () => {
4466
+ const controller = new AbortController();
4467
+ const finish = (commit = false) => {
4317
4468
  this.#isDragging = null;
4318
4469
  this.#svg?.classList.remove("dragging");
4319
4470
  if (dragCursor) {
4320
4471
  document.body.style.cursor = prevBodyCursor;
4321
4472
  }
4322
- document.removeEventListener("pointermove", onMove);
4323
- document.removeEventListener("pointerup", onUp);
4324
- this.#emit("change");
4473
+ controller.abort();
4474
+ if (this.#dragController === controller) {
4475
+ this.#dragController = null;
4476
+ this.#dragCleanup = null;
4477
+ }
4478
+ if (commit) this.#emit("change");
4325
4479
  };
4480
+ this.#dragController = controller;
4481
+ this.#dragCleanup = finish;
4482
+
4483
+ document.addEventListener("pointermove", onMove, { signal: controller.signal });
4484
+ document.addEventListener("pointerup", () => finish(true), {
4485
+ signal: controller.signal,
4486
+ });
4487
+ document.addEventListener("pointercancel", () => finish(false), {
4488
+ signal: controller.signal,
4489
+ });
4490
+ window.addEventListener("blur", () => finish(false), {
4491
+ signal: controller.signal,
4492
+ });
4493
+ }
4326
4494
 
4327
- document.addEventListener("pointermove", onMove);
4328
- document.addEventListener("pointerup", onUp);
4495
+ #cancelDrag() {
4496
+ this.#dragCleanup?.(false);
4497
+ this.#dragController?.abort();
4498
+ this.#dragController = null;
4499
+ this.#dragCleanup = null;
4500
+ this.#isDragging = null;
4501
+ this.#svg?.classList.remove("dragging");
4329
4502
  }
4330
4503
 
4331
4504
  #sampleAtWithoutWave(excludedIndex, t) {
@@ -4421,6 +4594,9 @@ class FigInputAngle extends HTMLElement {
4421
4594
  #boundHandleKeyDown;
4422
4595
  #boundHandleKeyUp;
4423
4596
  #boundHandleAngleInput;
4597
+ #boundHandleDialKeyDown;
4598
+ #gestureController = null;
4599
+ #gestureCleanup = null;
4424
4600
 
4425
4601
  constructor() {
4426
4602
  super();
@@ -4446,10 +4622,12 @@ class FigInputAngle extends HTMLElement {
4446
4622
  this.#boundHandleKeyDown = this.#handleKeyDown.bind(this);
4447
4623
  this.#boundHandleKeyUp = this.#handleKeyUp.bind(this);
4448
4624
  this.#boundHandleAngleInput = this.#handleAngleInput.bind(this);
4625
+ this.#boundHandleDialKeyDown = this.#handleDialKeyDown.bind(this);
4449
4626
  }
4450
4627
 
4451
4628
  connectedCallback() {
4452
4629
  requestAnimationFrame(() => {
4630
+ if (!this.isConnected) return;
4453
4631
  this.precision = this.getAttribute("precision") || 1;
4454
4632
  this.precision = parseInt(this.precision);
4455
4633
  this.text = this.getAttribute("text") === "true";
@@ -4481,10 +4659,13 @@ class FigInputAngle extends HTMLElement {
4481
4659
  }
4482
4660
 
4483
4661
  disconnectedCallback() {
4662
+ this.#cancelGesture();
4484
4663
  this.#cleanupListeners();
4485
4664
  }
4486
4665
 
4487
4666
  #render() {
4667
+ this.#cancelGesture();
4668
+ this.#cleanupListeners();
4488
4669
  this.innerHTML = this.#getInnerHTML();
4489
4670
  }
4490
4671
 
@@ -4511,10 +4692,23 @@ class FigInputAngle extends HTMLElement {
4511
4692
  const step = this.#getStepForUnit();
4512
4693
  const minAttr = this.min !== null ? `min="${this.min}"` : "";
4513
4694
  const maxAttr = this.max !== null ? `max="${this.max}"` : "";
4695
+ const disabled = this.#isDisabled();
4696
+ const name =
4697
+ this.getAttribute("aria-label") || this.getAttribute("name") || "Angle";
4698
+ const ariaMin = this.min ?? this.#fromDegrees(0);
4699
+ const ariaMax = this.max ?? this.#fromDegrees(360);
4514
4700
  return `
4515
4701
  ${
4516
4702
  this.dial
4517
- ? `<div class="fig-input-angle-plane" tabindex="0">
4703
+ ? `<div class="fig-input-angle-plane"
4704
+ role="slider"
4705
+ tabindex="${disabled ? -1 : 0}"
4706
+ aria-label="${FigInputAngle.#escapeAttribute(name)}"
4707
+ aria-valuemin="${ariaMin}"
4708
+ aria-valuemax="${ariaMax}"
4709
+ aria-valuenow="${this.angle}"
4710
+ aria-valuetext="${FigInputAngle.#escapeAttribute(`${this.angle.toFixed(this.precision)}${this.units}`)}"
4711
+ ${disabled ? 'aria-disabled="true"' : ""}>
4518
4712
  <div class="fig-input-angle-handle"></div>
4519
4713
  </div>`
4520
4714
  : ""
@@ -4527,7 +4721,9 @@ class FigInputAngle extends HTMLElement {
4527
4721
  value="${this.angle}"
4528
4722
  ${minAttr}
4529
4723
  ${maxAttr}
4530
- units="${this.units}">
4724
+ units="${this.units}"
4725
+ aria-label="${FigInputAngle.#escapeAttribute(name)}"
4726
+ ${disabled ? "disabled" : ""}>
4531
4727
  ${this.showRotations ? `<span slot="append" class="fig-input-angle-rotations"></span>` : ""}
4532
4728
  </fig-input-number>`
4533
4729
  : ""
@@ -4540,6 +4736,57 @@ class FigInputAngle extends HTMLElement {
4540
4736
  return Math.floor(degrees / 360);
4541
4737
  }
4542
4738
 
4739
+ static #escapeAttribute(value) {
4740
+ return String(value)
4741
+ .replace(/&/g, "&amp;")
4742
+ .replace(/"/g, "&quot;")
4743
+ .replace(/</g, "&lt;")
4744
+ .replace(/>/g, "&gt;");
4745
+ }
4746
+
4747
+ #isDisabled() {
4748
+ return figLabBooleanAttribute(this, "disabled");
4749
+ }
4750
+
4751
+ #clampValue(value) {
4752
+ let next = Number(value);
4753
+ if (!Number.isFinite(next)) return this.angle;
4754
+ if (this.min !== null && Number.isFinite(this.min)) {
4755
+ next = Math.max(this.min, next);
4756
+ }
4757
+ if (this.max !== null && Number.isFinite(this.max)) {
4758
+ next = Math.min(this.max, next);
4759
+ }
4760
+ return next;
4761
+ }
4762
+
4763
+ #setValue(value, { reflect = true } = {}) {
4764
+ const next = this.#clampValue(value);
4765
+ this.angle = next;
4766
+ this.#calculateAdjacentAndOpposite();
4767
+ if (reflect) {
4768
+ const serialized = String(next);
4769
+ if (this.getAttribute("value") !== serialized) {
4770
+ this.setAttribute("value", serialized);
4771
+ }
4772
+ }
4773
+ this.#syncHandlePosition();
4774
+ if (this.angleInput) {
4775
+ this.angleInput.value = next.toFixed(this.precision);
4776
+ }
4777
+ this.#syncDialState();
4778
+ this.#updateRotationDisplay();
4779
+ }
4780
+
4781
+ #syncDialState() {
4782
+ if (!this.plane) return;
4783
+ this.plane.setAttribute("aria-valuenow", String(this.angle));
4784
+ this.plane.setAttribute(
4785
+ "aria-valuetext",
4786
+ `${this.angle.toFixed(this.precision)}${this.units}`,
4787
+ );
4788
+ }
4789
+
4543
4790
  #updateRotationDisplay() {
4544
4791
  if (!this.rotationSpan) return;
4545
4792
  const rotations = this.#getRotationCount();
@@ -4615,10 +4862,12 @@ class FigInputAngle extends HTMLElement {
4615
4862
  this.#updateRotationDisplay();
4616
4863
  this.plane?.addEventListener("mousedown", this.#boundHandleMouseDown);
4617
4864
  this.plane?.addEventListener("touchstart", this.#boundHandleTouchStart);
4865
+ this.plane?.addEventListener("keydown", this.#boundHandleDialKeyDown);
4618
4866
  window.addEventListener("keydown", this.#boundHandleKeyDown);
4619
4867
  window.addEventListener("keyup", this.#boundHandleKeyUp);
4620
4868
  if (this.text && this.angleInput) {
4621
4869
  this.angleInput.addEventListener("input", this.#boundHandleAngleInput);
4870
+ this.angleInput.addEventListener("change", this.#boundHandleAngleInput);
4622
4871
  }
4623
4872
  this.addEventListener("change", this.#boundHandleRawChange, true);
4624
4873
  }
@@ -4626,10 +4875,12 @@ class FigInputAngle extends HTMLElement {
4626
4875
  #cleanupListeners() {
4627
4876
  this.plane?.removeEventListener("mousedown", this.#boundHandleMouseDown);
4628
4877
  this.plane?.removeEventListener("touchstart", this.#boundHandleTouchStart);
4878
+ this.plane?.removeEventListener("keydown", this.#boundHandleDialKeyDown);
4629
4879
  window.removeEventListener("keydown", this.#boundHandleKeyDown);
4630
4880
  window.removeEventListener("keyup", this.#boundHandleKeyUp);
4631
4881
  if (this.text && this.angleInput) {
4632
4882
  this.angleInput.removeEventListener("input", this.#boundHandleAngleInput);
4883
+ this.angleInput.removeEventListener("change", this.#boundHandleAngleInput);
4633
4884
  }
4634
4885
  this.removeEventListener("change", this.#boundHandleRawChange, true);
4635
4886
  }
@@ -4651,12 +4902,10 @@ class FigInputAngle extends HTMLElement {
4651
4902
 
4652
4903
  #handleAngleInput(e) {
4653
4904
  e.stopPropagation();
4654
- this.angle = Number(e.target.value);
4655
- this.#calculateAdjacentAndOpposite();
4656
- this.#syncHandlePosition();
4657
- this.#updateRotationDisplay();
4658
- this.#emitInputEvent();
4659
- this.#emitChangeEvent();
4905
+ if (this.#isDisabled()) return;
4906
+ this.#setValue(Number(e.target.value));
4907
+ if (e.type === "change") this.#emitChangeEvent();
4908
+ else this.#emitInputEvent();
4660
4909
  }
4661
4910
 
4662
4911
  #calculateAdjacentAndOpposite() {
@@ -4689,7 +4938,7 @@ class FigInputAngle extends HTMLElement {
4689
4938
  const isBounded = this.min !== null || this.max !== null;
4690
4939
 
4691
4940
  if (isBounded) {
4692
- this.angle = this.#fromDegrees(normalizedAngle);
4941
+ this.angle = this.#clampValue(this.#fromDegrees(normalizedAngle));
4693
4942
  } else {
4694
4943
  if (this.#prevRawAngle === null) {
4695
4944
  this.#prevRawAngle = normalizedAngle;
@@ -4698,23 +4947,25 @@ class FigInputAngle extends HTMLElement {
4698
4947
  let delta = normalizedAngle - currentMod;
4699
4948
  if (delta > 180) delta -= 360;
4700
4949
  if (delta < -180) delta += 360;
4701
- this.angle += this.#fromDegrees(delta);
4950
+ this.angle = this.#clampValue(this.angle + this.#fromDegrees(delta));
4702
4951
  } else {
4703
4952
  let delta = normalizedAngle - this.#prevRawAngle;
4704
4953
  if (delta > 180) delta -= 360;
4705
4954
  if (delta < -180) delta += 360;
4706
- this.angle += this.#fromDegrees(delta);
4955
+ this.angle = this.#clampValue(this.angle + this.#fromDegrees(delta));
4707
4956
  this.#prevRawAngle = normalizedAngle;
4708
4957
  }
4709
4958
  }
4710
4959
 
4711
4960
  this.#calculateAdjacentAndOpposite();
4961
+ this.setAttribute("value", String(this.angle));
4712
4962
 
4713
4963
  this.#syncHandlePosition();
4714
4964
  if (this.text && this.angleInput) {
4715
4965
  this.angleInput.setAttribute("value", this.angle.toFixed(this.precision));
4716
4966
  }
4717
4967
  this.#updateRotationDisplay();
4968
+ this.#syncDialState();
4718
4969
 
4719
4970
  this.#emitInputEvent();
4720
4971
  }
@@ -4751,6 +5002,8 @@ class FigInputAngle extends HTMLElement {
4751
5002
  }
4752
5003
 
4753
5004
  #handleMouseDown(e) {
5005
+ if (this.#isDisabled() || e.button !== 0) return;
5006
+ this.#cancelGesture();
4754
5007
  this.isDragging = true;
4755
5008
  this.#prevRawAngle = null;
4756
5009
  this.#updateAngle(e);
@@ -4760,21 +5013,36 @@ class FigInputAngle extends HTMLElement {
4760
5013
  if (this.isDragging) this.#updateAngle(e);
4761
5014
  };
4762
5015
 
4763
- const handleMouseUp = () => {
5016
+ const controller = new AbortController();
5017
+ const finish = (commit = false) => {
4764
5018
  this.isDragging = false;
4765
5019
  this.#prevRawAngle = null;
4766
5020
  this.plane.classList.remove("dragging");
4767
- window.removeEventListener("mousemove", handleMouseMove);
4768
- window.removeEventListener("mouseup", handleMouseUp);
4769
- this.#emitChangeEvent();
5021
+ controller.abort();
5022
+ if (this.#gestureController === controller) {
5023
+ this.#gestureController = null;
5024
+ this.#gestureCleanup = null;
5025
+ }
5026
+ if (commit) this.#emitChangeEvent();
4770
5027
  };
5028
+ this.#gestureController = controller;
5029
+ this.#gestureCleanup = finish;
4771
5030
 
4772
- window.addEventListener("mousemove", handleMouseMove);
4773
- window.addEventListener("mouseup", handleMouseUp);
5031
+ window.addEventListener("mousemove", handleMouseMove, {
5032
+ signal: controller.signal,
5033
+ });
5034
+ window.addEventListener("mouseup", () => finish(true), {
5035
+ signal: controller.signal,
5036
+ });
5037
+ window.addEventListener("blur", () => finish(false), {
5038
+ signal: controller.signal,
5039
+ });
4774
5040
  }
4775
5041
 
4776
5042
  #handleTouchStart(e) {
5043
+ if (this.#isDisabled()) return;
4777
5044
  e.preventDefault();
5045
+ this.#cancelGesture();
4778
5046
  this.isDragging = true;
4779
5047
  this.#prevRawAngle = null;
4780
5048
  this.#updateAngle(e.touches[0]);
@@ -4784,17 +5052,56 @@ class FigInputAngle extends HTMLElement {
4784
5052
  if (this.isDragging) this.#updateAngle(e.touches[0]);
4785
5053
  };
4786
5054
 
4787
- const handleTouchEnd = () => {
5055
+ const controller = new AbortController();
5056
+ const finish = (commit = false) => {
4788
5057
  this.isDragging = false;
4789
5058
  this.#prevRawAngle = null;
4790
5059
  this.plane.classList.remove("dragging");
4791
- window.removeEventListener("touchmove", handleTouchMove);
4792
- window.removeEventListener("touchend", handleTouchEnd);
4793
- this.#emitChangeEvent();
5060
+ controller.abort();
5061
+ if (this.#gestureController === controller) {
5062
+ this.#gestureController = null;
5063
+ this.#gestureCleanup = null;
5064
+ }
5065
+ if (commit) this.#emitChangeEvent();
4794
5066
  };
5067
+ this.#gestureController = controller;
5068
+ this.#gestureCleanup = finish;
5069
+
5070
+ window.addEventListener("touchmove", handleTouchMove, {
5071
+ signal: controller.signal,
5072
+ });
5073
+ window.addEventListener("touchend", () => finish(true), {
5074
+ signal: controller.signal,
5075
+ });
5076
+ window.addEventListener("touchcancel", () => finish(false), {
5077
+ signal: controller.signal,
5078
+ });
5079
+ }
4795
5080
 
4796
- window.addEventListener("touchmove", handleTouchMove);
4797
- window.addEventListener("touchend", handleTouchEnd);
5081
+ #cancelGesture() {
5082
+ this.#gestureCleanup?.(false);
5083
+ this.#gestureController?.abort();
5084
+ this.#gestureController = null;
5085
+ this.#gestureCleanup = null;
5086
+ this.isDragging = false;
5087
+ this.#prevRawAngle = null;
5088
+ this.plane?.classList.remove("dragging");
5089
+ }
5090
+
5091
+ #handleDialKeyDown(e) {
5092
+ if (this.#isDisabled()) return;
5093
+ const step = this.#getStepForUnit() * (e.shiftKey ? 10 : 1);
5094
+ let next = this.angle;
5095
+ if (e.key === "ArrowLeft" || e.key === "ArrowDown") next -= step;
5096
+ else if (e.key === "ArrowRight" || e.key === "ArrowUp") next += step;
5097
+ else if (e.key === "Home") next = this.min ?? this.#fromDegrees(0);
5098
+ else if (e.key === "End") next = this.max ?? this.#fromDegrees(360);
5099
+ else return;
5100
+ e.preventDefault();
5101
+ e.stopPropagation();
5102
+ this.#setValue(next);
5103
+ this.#emitInputEvent();
5104
+ this.#emitChangeEvent();
4798
5105
  }
4799
5106
 
4800
5107
  #handleKeyDown(e) {
@@ -4820,6 +5127,9 @@ class FigInputAngle extends HTMLElement {
4820
5127
  "dial",
4821
5128
  "rotations",
4822
5129
  "show-rotations",
5130
+ "disabled",
5131
+ "aria-label",
5132
+ "name",
4823
5133
  ];
4824
5134
  }
4825
5135
 
@@ -4840,20 +5150,14 @@ class FigInputAngle extends HTMLElement {
4840
5150
  console.error("Invalid value: must be a number.");
4841
5151
  return;
4842
5152
  }
4843
- this.angle = value;
4844
- this.#calculateAdjacentAndOpposite();
4845
- this.#syncHandlePosition();
4846
- if (this.angleInput) {
4847
- this.angleInput.setAttribute("value", this.angle.toFixed(this.precision));
4848
- }
4849
- this.#updateRotationDisplay();
5153
+ this.#setValue(Number(value));
4850
5154
  }
4851
5155
 
4852
5156
  attributeChangedCallback(name, oldValue, newValue) {
4853
5157
  switch (name) {
4854
5158
  case "value":
4855
5159
  if (this.isDragging) break;
4856
- this.value = Number(newValue);
5160
+ if (newValue !== null) this.#setValue(Number(newValue), { reflect: false });
4857
5161
  break;
4858
5162
  case "precision":
4859
5163
  this.precision = parseInt(newValue);
@@ -4889,6 +5193,7 @@ class FigInputAngle extends HTMLElement {
4889
5193
  }
4890
5194
  case "min":
4891
5195
  this.min = newValue !== null ? Number(newValue) : null;
5196
+ this.#setValue(this.angle);
4892
5197
  if (this.isConnected) {
4893
5198
  this.#render();
4894
5199
  this.#setupListeners();
@@ -4897,6 +5202,23 @@ class FigInputAngle extends HTMLElement {
4897
5202
  break;
4898
5203
  case "max":
4899
5204
  this.max = newValue !== null ? Number(newValue) : null;
5205
+ this.#setValue(this.angle);
5206
+ if (this.isConnected) {
5207
+ this.#render();
5208
+ this.#setupListeners();
5209
+ this.#syncHandlePosition();
5210
+ }
5211
+ break;
5212
+ case "disabled":
5213
+ this.#cancelGesture();
5214
+ if (this.isConnected) {
5215
+ this.#render();
5216
+ this.#setupListeners();
5217
+ this.#syncHandlePosition();
5218
+ }
5219
+ break;
5220
+ case "aria-label":
5221
+ case "name":
4900
5222
  if (this.isConnected) {
4901
5223
  this.#render();
4902
5224
  this.#setupListeners();
@@ -4955,9 +5277,15 @@ class FigReorder extends HTMLElement {
4955
5277
  #bindings = new Map();
4956
5278
  #drag = null;
4957
5279
  #indicator = null;
5280
+ #liveRegion = null;
4958
5281
 
4959
5282
  connectedCallback() {
4960
5283
  this.style.display = "contents";
5284
+ if (!this.hasAttribute("role")) this.setAttribute("role", "list");
5285
+ if (!this.hasAttribute("aria-label")) {
5286
+ this.setAttribute("aria-label", "Reorderable list");
5287
+ }
5288
+ this.#ensureLiveRegion();
4961
5289
  this.#syncChildren();
4962
5290
  this.#childObserver = new MutationObserver(() => this.#syncChildren());
4963
5291
  this.#childObserver.observe(this, { childList: true });
@@ -4969,10 +5297,15 @@ class FigReorder extends HTMLElement {
4969
5297
  this.#unbindAll();
4970
5298
  this.#cancelDrag();
4971
5299
  this.#removeIndicator();
5300
+ this.#liveRegion?.remove();
5301
+ this.#liveRegion = null;
4972
5302
  }
4973
5303
 
4974
5304
  attributeChangedCallback() {
4975
- if (this.isConnected) this.#syncChildren();
5305
+ if (this.isConnected) {
5306
+ if (this.#disabled) this.#cancelDrag();
5307
+ this.#syncChildren();
5308
+ }
4976
5309
  }
4977
5310
 
4978
5311
  get #disabled() {
@@ -4991,7 +5324,11 @@ class FigReorder extends HTMLElement {
4991
5324
  }
4992
5325
 
4993
5326
  #getElementChildren() {
4994
- return [...this.children].filter((node) => node.nodeType === Node.ELEMENT_NODE);
5327
+ return [...this.children].filter(
5328
+ (node) =>
5329
+ node.nodeType === Node.ELEMENT_NODE &&
5330
+ !node.hasAttribute("data-reorder-live"),
5331
+ );
4995
5332
  }
4996
5333
 
4997
5334
  #syncChildren() {
@@ -5005,6 +5342,8 @@ class FigReorder extends HTMLElement {
5005
5342
  binding.onPointerDown,
5006
5343
  true,
5007
5344
  );
5345
+ binding.target.removeEventListener("keydown", binding.onKeyDown);
5346
+ this.#restoreKeyboardAttrs(binding);
5008
5347
  this.#bindings.delete(child);
5009
5348
  }
5010
5349
  }
@@ -5040,12 +5379,20 @@ class FigReorder extends HTMLElement {
5040
5379
  #clearReorderItemMarks(children) {
5041
5380
  for (const child of children) {
5042
5381
  child.removeAttribute("data-reorder-item");
5382
+ if (child.hasAttribute("data-reorder-generated-role")) {
5383
+ child.removeAttribute("role");
5384
+ child.removeAttribute("data-reorder-generated-role");
5385
+ }
5043
5386
  }
5044
5387
  }
5045
5388
 
5046
5389
  #markReorderItems(children) {
5047
5390
  for (const child of children) {
5048
5391
  child.setAttribute("data-reorder-item", "");
5392
+ if (!child.hasAttribute("role")) {
5393
+ child.setAttribute("role", "listitem");
5394
+ child.setAttribute("data-reorder-generated-role", "");
5395
+ }
5049
5396
  }
5050
5397
  }
5051
5398
 
@@ -5053,7 +5400,10 @@ class FigReorder extends HTMLElement {
5053
5400
  for (const child of children) {
5054
5401
  child
5055
5402
  .querySelectorAll("[data-reorder-handle]")
5056
- .forEach((node) => node.removeAttribute("data-reorder-handle"));
5403
+ .forEach((node) => {
5404
+ node.removeAttribute("data-reorder-handle");
5405
+ node.removeAttribute("aria-roledescription");
5406
+ });
5057
5407
  }
5058
5408
  }
5059
5409
 
@@ -5061,7 +5411,10 @@ class FigReorder extends HTMLElement {
5061
5411
  if (!this.#handleSelector) return;
5062
5412
  for (const child of children) {
5063
5413
  const handle = child.querySelector(this.#handleSelector);
5064
- if (handle) handle.setAttribute("data-reorder-handle", "");
5414
+ if (handle) {
5415
+ handle.setAttribute("data-reorder-handle", "");
5416
+ handle.setAttribute("aria-roledescription", "reorder handle");
5417
+ }
5065
5418
  }
5066
5419
  }
5067
5420
 
@@ -5084,9 +5437,107 @@ class FigReorder extends HTMLElement {
5084
5437
  }
5085
5438
  this.#startPendingDrag(event, child, target);
5086
5439
  };
5440
+ const originalTabIndex = target.getAttribute("tabindex");
5441
+ const originalAriaLabel = target.getAttribute("aria-label");
5442
+ const originalRole = target.getAttribute("role");
5443
+ const itemName =
5444
+ child.getAttribute("aria-label") ||
5445
+ child.textContent?.trim().replace(/\s+/g, " ").slice(0, 80) ||
5446
+ "item";
5447
+ target.setAttribute("tabindex", "0");
5448
+ target.setAttribute("aria-label", `Move ${itemName}`);
5449
+ if (target !== child && !target.hasAttribute("role")) {
5450
+ target.setAttribute("role", "button");
5451
+ }
5452
+ const onKeyDown = (event) => {
5453
+ if (this.#disabled) return;
5454
+ const horizontal = this.#axis === "horizontal";
5455
+ const previousKey = horizontal ? "ArrowLeft" : "ArrowUp";
5456
+ const nextKey = horizontal ? "ArrowRight" : "ArrowDown";
5457
+ if (
5458
+ event.key !== previousKey &&
5459
+ event.key !== nextKey &&
5460
+ event.key !== "Home" &&
5461
+ event.key !== "End"
5462
+ ) {
5463
+ return;
5464
+ }
5465
+ event.preventDefault();
5466
+ event.stopPropagation();
5467
+ const items = this.#getElementChildren();
5468
+ const oldIndex = items.indexOf(child);
5469
+ let newIndex = oldIndex;
5470
+ if (event.key === previousKey) newIndex = Math.max(0, oldIndex - 1);
5471
+ else if (event.key === nextKey) {
5472
+ newIndex = Math.min(items.length - 1, oldIndex + 1);
5473
+ } else if (event.key === "Home") newIndex = 0;
5474
+ else if (event.key === "End") newIndex = items.length - 1;
5475
+ if (newIndex === oldIndex) {
5476
+ this.#announce(`${itemName}, position ${oldIndex + 1} of ${items.length}`);
5477
+ return;
5478
+ }
5479
+ this.#moveItemToFinalIndex(child, newIndex);
5480
+ this.#syncChildren();
5481
+ this.#getDragTarget(child)?.focus();
5482
+ this.dispatchEvent(
5483
+ new CustomEvent("reorder", {
5484
+ bubbles: true,
5485
+ detail: { oldIndex, newIndex, item: child },
5486
+ }),
5487
+ );
5488
+ this.#announce(`${itemName}, position ${newIndex + 1} of ${items.length}`);
5489
+ };
5087
5490
 
5088
5491
  target.addEventListener("pointerdown", onPointerDown, true);
5089
- this.#bindings.set(child, { target, onPointerDown });
5492
+ target.addEventListener("keydown", onKeyDown);
5493
+ this.#bindings.set(child, {
5494
+ target,
5495
+ onPointerDown,
5496
+ onKeyDown,
5497
+ originalTabIndex,
5498
+ originalAriaLabel,
5499
+ originalRole,
5500
+ });
5501
+ }
5502
+
5503
+ #restoreKeyboardAttrs(binding) {
5504
+ const { target, originalTabIndex, originalAriaLabel, originalRole } = binding;
5505
+ if (originalTabIndex === null) target.removeAttribute("tabindex");
5506
+ else target.setAttribute("tabindex", originalTabIndex);
5507
+ if (originalAriaLabel === null) target.removeAttribute("aria-label");
5508
+ else target.setAttribute("aria-label", originalAriaLabel);
5509
+ if (originalRole === null) target.removeAttribute("role");
5510
+ else target.setAttribute("role", originalRole);
5511
+ }
5512
+
5513
+ #ensureLiveRegion() {
5514
+ if (this.#liveRegion?.isConnected) return;
5515
+ const region = document.createElement("span");
5516
+ region.setAttribute("data-reorder-live", "");
5517
+ region.setAttribute("role", "status");
5518
+ region.setAttribute("aria-live", "polite");
5519
+ region.setAttribute("aria-atomic", "true");
5520
+ Object.assign(region.style, {
5521
+ position: "absolute",
5522
+ width: "1px",
5523
+ height: "1px",
5524
+ padding: "0",
5525
+ margin: "-1px",
5526
+ overflow: "hidden",
5527
+ clip: "rect(0, 0, 0, 0)",
5528
+ whiteSpace: "nowrap",
5529
+ border: "0",
5530
+ });
5531
+ this.#liveRegion = region;
5532
+ document.body.appendChild(region);
5533
+ }
5534
+
5535
+ #announce(message) {
5536
+ if (!this.#liveRegion) return;
5537
+ this.#liveRegion.textContent = "";
5538
+ requestAnimationFrame(() => {
5539
+ if (this.#liveRegion) this.#liveRegion.textContent = message;
5540
+ });
5090
5541
  }
5091
5542
 
5092
5543
  #isInteractiveTarget(target, child) {
@@ -5304,6 +5755,13 @@ class FigReorder extends HTMLElement {
5304
5755
  if (ref !== item) this.insertBefore(item, ref);
5305
5756
  }
5306
5757
 
5758
+ #moveItemToFinalIndex(item, newIndex) {
5759
+ const items = this.#getElementChildren().filter((candidate) => candidate !== item);
5760
+ const ref = items[newIndex] ?? null;
5761
+ if (ref) this.insertBefore(item, ref);
5762
+ else this.appendChild(item);
5763
+ }
5764
+
5307
5765
  #finishDrag(state, revert) {
5308
5766
  const { item, oldIndex, active, onMove, onUp, onKeyDown } = state;
5309
5767
 
@@ -5323,6 +5781,13 @@ class FigReorder extends HTMLElement {
5323
5781
  detail: { oldIndex, newIndex, item },
5324
5782
  }),
5325
5783
  );
5784
+ const name =
5785
+ item.getAttribute("aria-label") ||
5786
+ item.textContent?.trim().replace(/\s+/g, " ").slice(0, 80) ||
5787
+ "item";
5788
+ this.#announce(
5789
+ `${name}, position ${newIndex + 1} of ${this.#getElementChildren().length}`,
5790
+ );
5326
5791
  }
5327
5792
  }
5328
5793
  }
@@ -5340,6 +5805,8 @@ class FigReorder extends HTMLElement {
5340
5805
  binding.onPointerDown,
5341
5806
  true,
5342
5807
  );
5808
+ binding.target.removeEventListener("keydown", binding.onKeyDown);
5809
+ this.#restoreKeyboardAttrs(binding);
5343
5810
  }
5344
5811
  this.#bindings.clear();
5345
5812
  }
@@ -5352,1185 +5819,167 @@ class FigReorder extends HTMLElement {
5352
5819
 
5353
5820
  figLabDefineElement("fig-reorder", FigReorder);
5354
5821
 
5355
- /* Select — dropdown-styled trigger + fig-popup listbox */
5356
- let figLabSelectId = 0;
5357
- function figLabUniqueId(prefix = "fig-select") {
5358
- figLabSelectId += 1;
5359
- return `${prefix}-${figLabSelectId}`;
5360
- }
5361
-
5362
- /** Parse options attr — same formats as fig-options / propskit-select. */
5363
- function figLabParseOptionsAttribute(raw) {
5364
- const text = raw || "";
5365
- if (text.startsWith("[")) {
5366
- try {
5367
- const parsed = JSON.parse(text);
5368
- return Array.isArray(parsed) ? parsed : [];
5369
- } catch {
5370
- /* fall through */
5371
- }
5372
- }
5373
- const delimiter = text.includes("\n") ? "\n" : ",";
5374
- return text
5375
- .split(delimiter)
5376
- .map((s) => s.trim())
5377
- .filter(Boolean);
5378
- }
5822
+ /*
5823
+ * fig-select currently lives in fig.js. Keep the lab consumers resilient to
5824
+ * reconnects and selected-option mutations until those core fixes can move
5825
+ * with the component.
5826
+ */
5827
+ const figLabEnhancedSelects = new WeakSet();
5828
+ const figLabDisconnectedSelects = new WeakSet();
5829
+ const figLabReconnectFallbacks = new WeakSet();
5379
5830
 
5380
- function figLabOptionEntryValue(opt) {
5381
- if (opt && typeof opt === "object") {
5382
- return String(opt.value ?? opt.label ?? "");
5383
- }
5384
- return String(opt ?? "");
5831
+ function figLabSelectOptions(select) {
5832
+ return Array.from(select.querySelectorAll("fig-select-option"));
5385
5833
  }
5386
5834
 
5387
- function figLabOptionEntryLabel(opt) {
5388
- if (opt && typeof opt === "object") {
5389
- return String(opt.label ?? opt.value ?? "");
5835
+ function figLabSyncSelectedOption(select, requestedOption, emit = true) {
5836
+ const options = figLabSelectOptions(select);
5837
+ if (!options.length) return;
5838
+ let option =
5839
+ requestedOption && options.includes(requestedOption)
5840
+ ? requestedOption
5841
+ : options.find((candidate) => figLabBooleanAttribute(candidate, "selected"));
5842
+ if (!option || figLabBooleanAttribute(option, "disabled")) {
5843
+ option =
5844
+ options.find((candidate) => !figLabBooleanAttribute(candidate, "disabled")) ||
5845
+ options[0];
5390
5846
  }
5391
- return String(opt ?? "");
5392
- }
5847
+ if (!option) return;
5393
5848
 
5394
- /**
5395
- * A selectable option for fig-select.
5396
- * Supports light-DOM slots: `slot="prepend"` (leading) and `slot="append"` (trailing).
5397
- * Use the `label` attribute for the closed-trigger label when option content is rich.
5398
- *
5399
- * @attr {string} value - Option value
5400
- * @attr {string} label - Optional display label for the select trigger
5401
- * @attr {boolean} disabled - Whether the option is disabled
5402
- * @attr {boolean} selected - Whether the option is selected
5403
- */
5404
- class FigSelectOption extends HTMLElement {
5405
- static get observedAttributes() {
5406
- return ["value", "disabled", "selected", "label"];
5849
+ const value = option.value ?? option.getAttribute("value") ?? "";
5850
+ const previousValue = select.getAttribute("value") ?? "";
5851
+ select.setAttribute("value", String(value));
5852
+ for (const candidate of options) {
5853
+ const selected = candidate === option;
5854
+ candidate.toggleAttribute("selected", selected);
5855
+ candidate.setAttribute("aria-selected", String(selected));
5407
5856
  }
5408
-
5409
- get value() {
5410
- const attr = this.getAttribute("value");
5411
- if (attr !== null) return attr;
5412
- return (this.textContent || "").trim();
5857
+ if (emit && previousValue !== String(value)) {
5858
+ select.dispatchEvent(
5859
+ new CustomEvent("input", {
5860
+ detail: String(value),
5861
+ bubbles: true,
5862
+ composed: true,
5863
+ }),
5864
+ );
5865
+ select.dispatchEvent(
5866
+ new CustomEvent("change", {
5867
+ detail: String(value),
5868
+ bubbles: true,
5869
+ composed: true,
5870
+ }),
5871
+ );
5413
5872
  }
5873
+ }
5414
5874
 
5415
- set value(val) {
5416
- if (val === null || val === undefined) {
5417
- this.removeAttribute("value");
5418
- } else {
5419
- this.setAttribute("value", String(val));
5420
- }
5875
+ function figLabEnhanceSelect(select) {
5876
+ if (figLabEnhancedSelects.has(select)) return;
5877
+ const trigger = select.shadowRoot?.querySelector(".fig-select-trigger");
5878
+ if (!trigger) {
5879
+ requestAnimationFrame(() => {
5880
+ if (select.isConnected) figLabEnhanceSelect(select);
5881
+ });
5882
+ return;
5421
5883
  }
5884
+ figLabEnhancedSelects.add(select);
5422
5885
 
5423
- get disabled() {
5424
- return figLabBooleanAttribute(this, "disabled");
5425
- }
5426
-
5427
- set disabled(val) {
5428
- if (val) this.setAttribute("disabled", "");
5429
- else this.removeAttribute("disabled");
5430
- }
5431
-
5432
- get selected() {
5433
- return figLabBooleanAttribute(this, "selected");
5434
- }
5435
-
5436
- set selected(val) {
5437
- if (val) this.setAttribute("selected", "");
5438
- else this.removeAttribute("selected");
5439
- }
5440
-
5441
- connectedCallback() {
5442
- if (!this.hasAttribute("role")) this.setAttribute("role", "option");
5443
- if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
5444
- this.#syncDisabled();
5445
- }
5446
-
5447
- attributeChangedCallback(name, oldValue, newValue) {
5448
- if (oldValue === newValue) return;
5449
- if (name === "disabled") this.#syncDisabled();
5450
- }
5451
-
5452
- #syncDisabled() {
5453
- const disabled = this.disabled;
5454
- if (disabled) {
5455
- this.setAttribute("aria-disabled", "true");
5456
- this.setAttribute("tabindex", "-1");
5457
- } else {
5458
- this.removeAttribute("aria-disabled");
5459
- if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
5460
- }
5461
- }
5462
- }
5463
- figLabDefineElement("fig-select-option", FigSelectOption);
5464
-
5465
- function figLabSyncOverflowState(host, scrollEl, threshold = 2) {
5466
- if (!host || !scrollEl) return false;
5467
- const scrollable = scrollEl.scrollHeight - scrollEl.clientHeight > threshold;
5468
- const atStart = !scrollable || scrollEl.scrollTop <= threshold;
5469
- const atEnd =
5470
- !scrollable ||
5471
- scrollEl.scrollTop + scrollEl.clientHeight >=
5472
- scrollEl.scrollHeight - threshold;
5473
- host.classList.toggle("overflow-start", !atStart);
5474
- host.classList.toggle("overflow-end", !atEnd);
5475
- return scrollable;
5476
- }
5477
-
5478
- function figLabScrollOverflowPage(scrollEl, direction = 1) {
5479
- if (!scrollEl) return;
5480
- scrollEl.scrollBy({
5481
- top: scrollEl.clientHeight * 0.8 * direction,
5482
- behavior: "smooth",
5483
- });
5484
- }
5485
-
5486
- function figLabCreateOverflowButtons({ onStart, onEnd } = {}) {
5487
- const makeButton = (direction, onClick) => {
5488
- const button = document.createElement("button");
5489
- button.type = "button";
5490
- button.className = `fig-overflow fig-overflow-${direction}`;
5491
- button.dataset.figOverflow = direction;
5492
- button.setAttribute("data-fig-select-nav", direction);
5493
- button.setAttribute("tabindex", "-1");
5494
- button.setAttribute(
5495
- "aria-label",
5496
- direction === "start" ? "Scroll up" : "Scroll down",
5497
- );
5498
- const icon = document.createElement("fig-icon");
5499
- icon.setAttribute("name", "chevron");
5500
- icon.setAttribute("size", "small");
5501
- icon.className = "fig-overflow-chevron";
5502
- button.appendChild(icon);
5503
- button.addEventListener("click", (event) => {
5504
- event.preventDefault();
5505
- event.stopPropagation();
5506
- onClick?.(event);
5507
- });
5508
- return button;
5509
- };
5510
- return {
5511
- start: makeButton("start", onStart),
5512
- end: makeButton("end", onEnd),
5513
- };
5514
- }
5515
-
5516
- /** Light-DOM panel wrapper projected into fig-select's popup; owns overflow buttons. */
5517
- class FigSelectOptions extends HTMLElement {
5518
- #navStart = null;
5519
- #navEnd = null;
5520
- #resizeObserver = null;
5521
- #boundSyncOverflow = this.syncOverflow.bind(this);
5522
-
5523
- connectedCallback() {
5524
- if (!this.hasAttribute("slot")) this.setAttribute("slot", "panel");
5525
- this.#unwrapLegacyChooser();
5526
- this.#ensureNavButtons();
5527
- this.addEventListener("scroll", this.#boundSyncOverflow, { passive: true });
5528
- this.#resizeObserver?.disconnect();
5529
- this.#resizeObserver = new ResizeObserver(() => this.syncOverflow());
5530
- this.#resizeObserver.observe(this);
5531
- requestAnimationFrame(() => this.syncOverflow());
5532
- }
5533
-
5534
- disconnectedCallback() {
5535
- this.removeEventListener("scroll", this.#boundSyncOverflow);
5536
- this.#resizeObserver?.disconnect();
5537
- this.#resizeObserver = null;
5538
- this.#removeNavButtons();
5539
- }
5540
-
5541
- syncOverflow() {
5542
- return figLabSyncOverflowState(this, this);
5543
- }
5544
-
5545
- scrollToOption(option, behavior = "auto") {
5546
- if (!option || !this.contains(option)) return;
5547
- requestAnimationFrame(() => {
5548
- if (!option.isConnected) return;
5549
- if (this.scrollHeight <= this.clientHeight + 1) {
5550
- this.syncOverflow();
5551
- return;
5552
- }
5553
- const optionRect = option.getBoundingClientRect();
5554
- const hostRect = this.getBoundingClientRect();
5555
- const optionTop = optionRect.top - hostRect.top + this.scrollTop;
5556
- const maxScroll = this.scrollHeight - this.clientHeight;
5557
- const top = Math.max(
5558
- 0,
5559
- Math.min(
5560
- optionTop + optionRect.height / 2 - this.clientHeight / 2,
5561
- maxScroll,
5562
- ),
5563
- );
5564
- this.scrollTo({ top, behavior });
5565
- this.syncOverflow();
5566
- });
5567
- }
5568
-
5569
- #unwrapLegacyChooser() {
5570
- const chooser = this.querySelector(":scope > fig-chooser");
5571
- if (!chooser) return;
5572
- while (chooser.firstChild) {
5573
- this.insertBefore(chooser.firstChild, chooser);
5574
- }
5575
- chooser.remove();
5576
- }
5577
-
5578
- #ensureNavButtons() {
5579
- if (
5580
- this.#navStart &&
5581
- this.#navEnd &&
5582
- this.contains(this.#navStart) &&
5583
- this.contains(this.#navEnd)
5584
- ) {
5585
- return;
5586
- }
5587
- this.#removeNavButtons();
5588
- const buttons = figLabCreateOverflowButtons({
5589
- onStart: () => figLabScrollOverflowPage(this, -1),
5590
- onEnd: () => figLabScrollOverflowPage(this, 1),
5591
- });
5592
- this.#navStart = buttons.start;
5593
- this.#navEnd = buttons.end;
5594
- this.prepend(this.#navStart);
5595
- this.append(this.#navEnd);
5596
- }
5597
-
5598
- #removeNavButtons() {
5599
- this.#navStart?.remove();
5600
- this.#navEnd?.remove();
5601
- this.#navStart = null;
5602
- this.#navEnd = null;
5603
- this.classList.remove("overflow-start", "overflow-end");
5604
- }
5605
- }
5606
- figLabDefineElement("fig-select-options", FigSelectOptions);
5607
-
5608
- class FigSelect extends HTMLElement {
5609
- #button = null;
5610
- #popup = null;
5611
- #labelEl = null;
5612
- #panelSlot = null;
5613
- #observer = null;
5614
- #initialized = false;
5615
- #focusedIndex = -1;
5616
- #syncingValue = false;
5617
- #popupPositionPatched = false;
5618
- #originalPositionPopup = null;
5619
- /**
5620
- * After open align, ignore content/scroll-driven positionPopup passes so
5621
- * overflow paging isn't yanked back. Still realign when the trigger moves
5622
- * or the viewport size changes (window resize, layout shift, page scroll).
5623
- */
5624
- #freezeMenuPosition = false;
5625
- #frozenLabelRect = null;
5626
- #frozenViewport = null;
5627
- #syncingOptions = false;
5628
- #boundTriggerClick = this.#handleTriggerClick.bind(this);
5629
- #boundOptionClick = this.#handleOptionClick.bind(this);
5630
- #boundKeydown = this.#handleKeydown.bind(this);
5631
- #boundPopupClose = this.#handlePopupClose.bind(this);
5632
- #boundSlotChange = this.#handleSlotChange.bind(this);
5633
-
5634
- static get observedAttributes() {
5635
- return [
5636
- "value",
5637
- "disabled",
5638
- "label",
5639
- "options",
5640
- "position",
5641
- "offset",
5642
- "closedby",
5643
- "open",
5644
- ];
5645
- }
5646
-
5647
- get value() {
5648
- return this.getAttribute("value") ?? "";
5649
- }
5650
-
5651
- set value(val) {
5652
- if (val === null || val === undefined) this.removeAttribute("value");
5653
- else this.setAttribute("value", String(val));
5654
- }
5655
-
5656
- get open() {
5657
- return figLabBooleanAttribute(this, "open");
5658
- }
5659
-
5660
- set open(val) {
5661
- if (val) this.setAttribute("open", "");
5662
- else this.removeAttribute("open");
5663
- }
5664
-
5665
- connectedCallback() {
5666
- if (!this.#initialized) this.#initialize();
5667
- this.#ensurePanelSlotAttrs();
5668
- this.#syncOptionsFromAttribute();
5669
- this.#syncDisabled();
5670
- this.#syncPopupAttrs();
5671
- this.#syncValue();
5672
- this.#setupObserver();
5673
- if (this.open) this.#openList();
5674
- }
5675
-
5676
- disconnectedCallback() {
5677
- this.#teardownListeners();
5678
- document.removeEventListener("keydown", this.#boundKeydown, true);
5679
- this.#observer?.disconnect();
5680
- this.#observer = null;
5681
- }
5682
-
5683
- attributeChangedCallback(name, oldValue, newValue) {
5684
- if (oldValue === newValue || !this.#initialized) return;
5685
- if (name === "options") {
5686
- this.#syncOptionsFromAttribute();
5687
- this.#syncValue();
5688
- return;
5689
- }
5690
- if (name === "value" || name === "label") {
5691
- this.#syncValue();
5692
- return;
5693
- }
5694
- if (name === "disabled") {
5695
- this.#syncDisabled();
5696
- return;
5697
- }
5698
- if (name === "open") {
5699
- if (newValue === null || newValue === "false") this.#closeList();
5700
- else this.#openList();
5701
- return;
5702
- }
5703
- if (name === "position" || name === "offset" || name === "closedby") {
5704
- this.#syncPopupAttrs();
5705
- }
5706
- }
5707
-
5708
- focus(options) {
5709
- this.#button?.focus(options);
5710
- }
5711
-
5712
- blur() {
5713
- this.#button?.blur();
5714
- }
5715
-
5716
- #isMenuChild(node) {
5717
- return (
5718
- node?.nodeType === 1 &&
5719
- (node.tagName === "FIG-SELECT-OPTION" ||
5720
- node.tagName === "FIG-MENU-SEPARATOR" ||
5721
- node.tagName === "FIG-SELECT-OPTIONS")
5722
- );
5723
- }
5724
-
5725
- #ensurePanelSlotAttrs() {
5726
- for (const panel of this.querySelectorAll(":scope > fig-select-options")) {
5727
- if (!panel.hasAttribute("slot")) panel.setAttribute("slot", "panel");
5728
- }
5729
- }
5730
-
5731
- #getPanel() {
5732
- const assigned = this.#panelSlot?.assignedElements({ flatten: true }) ?? [];
5733
- const fromSlot = assigned.find(
5734
- (el) => el.tagName === "FIG-SELECT-OPTIONS",
5735
- );
5736
- if (fromSlot) return fromSlot;
5737
- return this.querySelector(":scope > fig-select-options");
5738
- }
5739
-
5740
- #hasAuthoredOptions() {
5741
- return Boolean(
5742
- this.querySelector(
5743
- ":scope > fig-select-option:not([data-fig-generated]), :scope > fig-select-options > fig-select-option:not([data-fig-generated])",
5744
- ),
5745
- );
5746
- }
5747
-
5748
- #ensureOptionsPanel() {
5749
- let panel = this.#getPanel();
5750
- if (panel) {
5751
- if (!panel.hasAttribute("slot")) panel.setAttribute("slot", "panel");
5752
- return panel;
5753
- }
5754
- panel = document.createElement("fig-select-options");
5755
- panel.setAttribute("slot", "panel");
5756
- panel.setAttribute("data-fig-generated", "");
5757
- this.appendChild(panel);
5758
- return panel;
5759
- }
5760
-
5761
- /**
5762
- * When no authored fig-select-option exists, build panel/options from the
5763
- * options attribute (comma / newline / JSON — same as fig-options).
5764
- */
5765
- #syncOptionsFromAttribute() {
5766
- if (this.#hasAuthoredOptions()) return;
5767
-
5768
- const hasOptionsAttr = this.hasAttribute("options");
5769
- const panel = hasOptionsAttr
5770
- ? this.#ensureOptionsPanel()
5771
- : this.#getPanel();
5772
- if (!panel) return;
5773
-
5774
- this.#syncingOptions = true;
5775
- try {
5776
- for (const opt of panel.querySelectorAll(
5777
- ":scope > fig-select-option[data-fig-generated]",
5778
- )) {
5779
- opt.remove();
5886
+ const observer = new MutationObserver((mutations) => {
5887
+ let selectedOption = null;
5888
+ let selectedWasRemoved = false;
5889
+ for (const mutation of mutations) {
5890
+ if (
5891
+ mutation.type !== "attributes" ||
5892
+ mutation.attributeName !== "selected" ||
5893
+ mutation.target.tagName !== "FIG-SELECT-OPTION"
5894
+ ) {
5895
+ continue;
5780
5896
  }
5781
-
5782
- if (!hasOptionsAttr) return;
5783
-
5784
- const parsed = figLabParseOptionsAttribute(this.getAttribute("options"));
5785
- const endBtn = panel.querySelector(":scope > .fig-overflow-end");
5786
- for (const entry of parsed) {
5787
- const el = document.createElement("fig-select-option");
5788
- el.setAttribute("data-fig-generated", "");
5789
- el.setAttribute("value", figLabOptionEntryValue(entry));
5790
- el.textContent = figLabOptionEntryLabel(entry);
5791
- if (endBtn) panel.insertBefore(el, endBtn);
5792
- else panel.appendChild(el);
5897
+ if (figLabBooleanAttribute(mutation.target, "selected")) {
5898
+ selectedOption = mutation.target;
5899
+ } else if (
5900
+ String(mutation.target.value ?? "") ===
5901
+ (select.getAttribute("value") ?? "")
5902
+ ) {
5903
+ selectedWasRemoved = true;
5793
5904
  }
5794
- } finally {
5795
- this.#syncingOptions = false;
5796
- }
5797
- }
5798
-
5799
- #initialize() {
5800
- this.#initialized = true;
5801
- const shadow = this.attachShadow({ mode: "open" });
5802
- shadow.innerHTML = `
5803
- <style>
5804
- :host {
5805
- display: inline-flex;
5806
- position: relative;
5807
- align-items: center;
5808
- min-width: 0;
5809
- }
5810
- :host([full]:not([full="false"])) {
5811
- display: flex;
5812
- width: 100%;
5813
- }
5814
- .fig-select-trigger {
5815
- display: flex;
5816
- align-items: center;
5817
- justify-content: flex-start;
5818
- flex: 1;
5819
- min-width: 0;
5820
- width: var(--fig-select-trigger-width, 100%);
5821
- height: 100%;
5822
- margin: 0;
5823
- padding: 0 var(--spacer-4, 1rem) 0 var(--spacer-2, 0.5rem);
5824
- border: 0;
5825
- border-radius: inherit;
5826
- background: transparent;
5827
- box-shadow: none;
5828
- color: inherit;
5829
- font: inherit;
5830
- font-weight: inherit;
5831
- text-align: left;
5832
- white-space: nowrap;
5833
- overflow: hidden;
5834
- text-overflow: ellipsis;
5835
- cursor: default;
5836
- }
5837
- .fig-select-trigger:hover,
5838
- .fig-select-trigger:active,
5839
- .fig-select-trigger:active:hover {
5840
- background: transparent;
5841
- box-shadow: none;
5842
- color: inherit;
5843
- }
5844
- .fig-select-trigger:focus-visible,
5845
- .fig-select-trigger[data-focus-visible] {
5846
- outline: var(--figma-focus-outline);
5847
- outline-offset: var(--figma-focus-outline-offset);
5848
- }
5849
- :host([disabled]:not([disabled="false"])) .fig-select-trigger,
5850
- :host([disabled]:not([disabled="false"])) .fig-select-label {
5851
- color: var(--figma-color-text-tertiary);
5852
- }
5853
- .fig-select-label {
5854
- display: block;
5855
- width: 100%;
5856
- min-width: 0;
5857
- overflow: hidden;
5858
- text-overflow: ellipsis;
5859
- white-space: nowrap;
5860
- text-align: left;
5861
- }
5862
- /* Listbox chrome from document fig-select::part(listbox).
5863
- Overflow UI lives on slotted fig-select-options.
5864
- Never set display except when open — closed <dialog> must stay display:none. */
5865
- dialog[is="fig-popup"] {
5866
- flex-direction: column;
5867
- overflow: hidden;
5868
- }
5869
- dialog[is="fig-popup"][open] {
5870
- display: flex;
5871
- }
5872
- ::slotted(fig-select-options) {
5873
- flex: 1 1 auto;
5874
- min-height: 0;
5875
- max-height: inherit;
5876
- }
5877
- </style>
5878
- `;
5879
-
5880
- const button = document.createElement("fig-button");
5881
- button.className = "fig-select-trigger";
5882
- button.setAttribute("part", "trigger");
5883
- button.setAttribute("variant", "ghost");
5884
- button.setAttribute("aria-haspopup", "listbox");
5885
- button.setAttribute("aria-expanded", "false");
5886
-
5887
- const labelEl = document.createElement("span");
5888
- labelEl.className = "fig-select-label";
5889
- labelEl.setAttribute("part", "label");
5890
- button.appendChild(labelEl);
5891
-
5892
- const popup = document.createElement("dialog", { is: "fig-popup" });
5893
- popup.setAttribute("is", "fig-popup");
5894
- popup.setAttribute("part", "listbox");
5895
- popup.setAttribute("theme", "menu");
5896
- popup.setAttribute("role", "listbox");
5897
- // Top-layer via popover so the menu escapes ancestor contain/overflow
5898
- // (e.g. fig-fill-picker-dialog). Stays in shadow so option slots still work —
5899
- // unlike tooltips, we cannot portal this popup to the overlay root.
5900
- if ("popover" in HTMLElement.prototype) {
5901
- popup.setAttribute("popover", "manual");
5902
- }
5903
- popup.id = figLabUniqueId("fig-select-list");
5904
- button.setAttribute("aria-controls", popup.id);
5905
-
5906
- const panelSlot = document.createElement("slot");
5907
- panelSlot.setAttribute("name", "panel");
5908
- popup.appendChild(panelSlot);
5909
-
5910
- shadow.append(button, popup);
5911
-
5912
- this.#button = button;
5913
- this.#labelEl = labelEl;
5914
- this.#popup = popup;
5915
- this.#panelSlot = panelSlot;
5916
- popup.anchor = button;
5917
-
5918
- this.#ensurePanelSlotAttrs();
5919
- this.#setupListeners();
5920
- this.#installPopupPositioning();
5921
-
5922
- if (!this.hasAttribute("value")) {
5923
- const selected = this.#getOptions().find((opt) =>
5924
- figLabBooleanAttribute(opt, "selected"),
5925
- );
5926
- if (selected) this.setAttribute("value", selected.value);
5927
5905
  }
5928
- }
5929
-
5930
- #installPopupPositioning() {
5931
- if (!this.#popup || this.#popupPositionPatched) return;
5932
- if (typeof this.#popup.positionPopup !== "function") return;
5933
- this.#originalPositionPopup = this.#popup.positionPopup.bind(this.#popup);
5934
- this.#popup.positionPopup = () => {
5935
- if (!this.open) {
5936
- this.#originalPositionPopup?.();
5937
- return;
5906
+ if (selectedOption) {
5907
+ const optionValue = String(selectedOption.value ?? "");
5908
+ if ((select.getAttribute("value") ?? "") !== optionValue) {
5909
+ figLabSyncSelectedOption(select, selectedOption);
5938
5910
  }
5939
- this.#positionPopupOverSelected();
5940
- };
5941
- this.#popupPositionPatched = true;
5942
- }
5943
-
5944
- #getOptionTextRect(option) {
5945
- if (!option) return null;
5946
- const range = document.createRange();
5947
- range.selectNodeContents(option);
5948
- const rects = [...range.getClientRects()].filter(
5949
- (rect) => rect.width > 0 && rect.height > 0,
5950
- );
5951
- if (rects.length) return rects[0];
5952
- return option.getBoundingClientRect();
5953
- }
5954
-
5955
- #getViewportMargins() {
5956
- if (typeof this.#popup?.parseViewportMargins === "function") {
5957
- return this.#popup.parseViewportMargins();
5958
- }
5959
- return { top: 8, right: 8, bottom: 8, left: 8 };
5960
- }
5961
-
5962
- #readLabelRectSnapshot() {
5963
- const rect = this.#labelEl?.getBoundingClientRect();
5964
- if (!rect) return null;
5965
- return {
5966
- x: rect.x,
5967
- y: rect.y,
5968
- width: rect.width,
5969
- height: rect.height,
5970
- };
5971
- }
5972
-
5973
- #readViewportSnapshot() {
5974
- const vv = window.visualViewport;
5975
- return {
5976
- width: vv?.width ?? window.innerWidth,
5977
- height: vv?.height ?? window.innerHeight,
5978
- offsetLeft: vv?.offsetLeft ?? 0,
5979
- offsetTop: vv?.offsetTop ?? 0,
5980
- };
5981
- }
5982
-
5983
- #rectSnapshotChanged(prev, next, epsilon = 0.25) {
5984
- if (!prev && !next) return false;
5985
- if (!prev || !next) return true;
5986
- return (
5987
- Math.abs(prev.x - next.x) > epsilon ||
5988
- Math.abs(prev.y - next.y) > epsilon ||
5989
- Math.abs(prev.width - next.width) > epsilon ||
5990
- Math.abs(prev.height - next.height) > epsilon
5991
- );
5992
- }
5993
-
5994
- #viewportSnapshotChanged(prev, next, epsilon = 0.25) {
5995
- if (!prev && !next) return false;
5996
- if (!prev || !next) return true;
5997
- return (
5998
- Math.abs(prev.width - next.width) > epsilon ||
5999
- Math.abs(prev.height - next.height) > epsilon ||
6000
- Math.abs(prev.offsetLeft - next.offsetLeft) > epsilon ||
6001
- Math.abs(prev.offsetTop - next.offsetTop) > epsilon
6002
- );
6003
- }
6004
-
6005
- #shouldSkipFrozenPositionPass() {
6006
- if (!this.#freezeMenuPosition) return false;
6007
- const labelMoved = this.#rectSnapshotChanged(
6008
- this.#frozenLabelRect,
6009
- this.#readLabelRectSnapshot(),
6010
- );
6011
- const viewportChanged = this.#viewportSnapshotChanged(
6012
- this.#frozenViewport,
6013
- this.#readViewportSnapshot(),
6014
- );
6015
- // Skip only when neither the trigger nor the viewport moved — typical of
6016
- // overflow scroll / content sync fighting the open-time alignment.
6017
- return !labelMoved && !viewportChanged;
6018
- }
6019
-
6020
- #rememberFrozenGeometry() {
6021
- this.#frozenLabelRect = this.#readLabelRectSnapshot();
6022
- this.#frozenViewport = this.#readViewportSnapshot();
6023
- }
6024
-
6025
- #positionPopupOverSelected() {
6026
- // Content ResizeObserver / overflow scroll re-enter here; keep the
6027
- // open-time alignment unless the trigger or viewport actually changed.
6028
- if (this.#shouldSkipFrozenPositionPass()) return;
6029
-
6030
- const popup = this.#popup;
6031
- const label = this.#labelEl;
6032
- if (!popup || !label) {
6033
- this.#originalPositionPopup?.();
6034
- return;
6035
- }
6036
-
6037
- const options = this.#getOptions();
6038
- const selected =
6039
- options.find((opt) => this.#optionValue(opt) === this.value) ||
6040
- options[0];
6041
- if (!selected) {
6042
- this.#originalPositionPopup?.();
6043
- return;
5911
+ } else if (selectedWasRemoved) {
5912
+ figLabSyncSelectedOption(select, null);
6044
5913
  }
5914
+ });
5915
+ observer.observe(select, {
5916
+ attributes: true,
5917
+ subtree: true,
5918
+ attributeFilter: ["selected"],
5919
+ });
5920
+ }
6045
5921
 
6046
- // Lay out with the default positioning first so option metrics are valid.
6047
- this.#originalPositionPopup?.();
5922
+ function figLabInstallSelectReconnectFallback(select) {
5923
+ if (figLabReconnectFallbacks.has(select)) return;
5924
+ const trigger = select.shadowRoot?.querySelector(".fig-select-trigger");
5925
+ if (!trigger) return;
5926
+ figLabReconnectFallbacks.add(select);
6048
5927
 
6049
- const popupRect = popup.getBoundingClientRect();
6050
- const labelRect = label.getBoundingClientRect();
6051
- const optionTextRect = this.#getOptionTextRect(selected);
5928
+ trigger.addEventListener("click", () => {
5929
+ if (figLabBooleanAttribute(select, "disabled")) return;
5930
+ select.open = !select.open;
5931
+ });
5932
+ select.addEventListener("click", (event) => {
5933
+ const option = event
5934
+ .composedPath()
5935
+ .find((node) => node?.tagName === "FIG-SELECT-OPTION");
6052
5936
  if (
6053
- !popupRect.width ||
6054
- !popupRect.height ||
6055
- !labelRect.width ||
6056
- !optionTextRect
5937
+ !option ||
5938
+ !select.contains(option) ||
5939
+ figLabBooleanAttribute(option, "disabled")
6057
5940
  ) {
6058
5941
  return;
6059
5942
  }
5943
+ figLabSyncSelectedOption(select, option);
5944
+ select.open = false;
5945
+ });
5946
+ }
6060
5947
 
6061
- const selectedOffsetX = optionTextRect.left - popupRect.left;
6062
- const selectedOffsetY = optionTextRect.top - popupRect.top;
6063
- const full = figLabBooleanAttribute(this, "full");
6064
- // [full]: pin menu to host width/edges. Otherwise overlay selected
6065
- // option text on the trigger label (blend-mode style).
6066
- let left = full
6067
- ? this.getBoundingClientRect().left
6068
- : labelRect.left - selectedOffsetX;
6069
- let top = labelRect.top - selectedOffsetY;
6070
-
6071
- // Keep the whole menu in-view when aligning over the selected option
6072
- // would otherwise push it past a viewport edge (corners / far sides).
6073
- const margins = this.#getViewportMargins();
6074
- if (typeof popup.clampToViewport === "function") {
6075
- ({ left, top } = popup.clampToViewport({ left, top }, popupRect, margins));
6076
- } else {
6077
- const minLeft = margins.left;
6078
- const minTop = margins.top;
6079
- const maxLeft = window.innerWidth - popupRect.width - margins.right;
6080
- const maxTop = window.innerHeight - popupRect.height - margins.bottom;
6081
- left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft));
6082
- top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop));
6083
- }
6084
-
6085
- // !important: fig-select::part(listbox) and dialog UA rules can otherwise
6086
- // keep the menu at its static/anchor position past the viewport edge.
6087
- popup.style.setProperty("right", "auto", "important");
6088
- popup.style.setProperty("bottom", "auto", "important");
6089
- popup.style.setProperty("left", `${Math.round(left)}px`, "important");
6090
- popup.style.setProperty("top", `${Math.round(top)}px`, "important");
6091
-
6092
- // Nudge the panel scroller so the selected label stays over the trigger.
6093
- const panel = this.#getPanel();
6094
- const alignedTextRect = this.#getOptionTextRect(selected);
6095
- if (
6096
- alignedTextRect &&
6097
- panel &&
6098
- panel.scrollHeight > panel.clientHeight + 1
6099
- ) {
6100
- const deltaY = alignedTextRect.top - labelRect.top;
6101
- if (Math.abs(deltaY) > 0.5) {
6102
- panel.scrollTop += deltaY;
6103
- }
6104
- panel.syncOverflow?.();
6105
- }
6106
-
6107
- if (this.#freezeMenuPosition || this.open) {
6108
- this.#rememberFrozenGeometry();
6109
- }
6110
- }
6111
-
6112
- #setupListeners() {
6113
- this.#button?.addEventListener("click", this.#boundTriggerClick);
6114
- this.#button?.addEventListener("keydown", this.#boundKeydown);
6115
- // Host click: slotted options stay in light DOM (not dialog.contains).
6116
- this.addEventListener("click", this.#boundOptionClick);
6117
- this.#popup?.addEventListener("keydown", this.#boundKeydown);
6118
- this.#popup?.addEventListener("close", this.#boundPopupClose);
6119
- this.#panelSlot?.addEventListener("slotchange", this.#boundSlotChange);
6120
- }
6121
-
6122
- #teardownListeners() {
6123
- this.#button?.removeEventListener("click", this.#boundTriggerClick);
6124
- this.#button?.removeEventListener("keydown", this.#boundKeydown);
6125
- this.removeEventListener("click", this.#boundOptionClick);
6126
- this.#popup?.removeEventListener("keydown", this.#boundKeydown);
6127
- this.#popup?.removeEventListener("close", this.#boundPopupClose);
6128
- this.#panelSlot?.removeEventListener("slotchange", this.#boundSlotChange);
6129
- }
6130
-
6131
- #handleSlotChange() {
6132
- this.#ensurePanelSlotAttrs();
6133
- this.#syncValue();
6134
- }
6135
-
6136
- #setupObserver() {
6137
- if (this.#observer) return;
6138
- this.#observer = new MutationObserver((mutations) => {
6139
- if (this.#syncingValue || this.#syncingOptions) return;
6140
- let needsSync = false;
6141
- for (const mutation of mutations) {
6142
- if (mutation.type === "childList") {
6143
- if (
6144
- [...mutation.addedNodes].some((node) => this.#isMenuChild(node)) ||
6145
- [...mutation.removedNodes].some((node) => this.#isMenuChild(node))
6146
- ) {
6147
- needsSync = true;
6148
- }
6149
- }
6150
- if (
6151
- mutation.type === "attributes" &&
6152
- mutation.target?.tagName === "FIG-SELECT-OPTION" &&
6153
- (mutation.attributeName === "value" ||
6154
- mutation.attributeName === "disabled" ||
6155
- mutation.attributeName === "label")
6156
- ) {
6157
- needsSync = true;
6158
- }
6159
- if (
6160
- mutation.type === "characterData" &&
6161
- mutation.target?.parentElement?.tagName === "FIG-SELECT-OPTION"
6162
- ) {
6163
- needsSync = true;
6164
- }
6165
- }
6166
- if (needsSync) this.#syncValue();
6167
- });
6168
- this.#observer.observe(this, {
6169
- childList: true,
6170
- subtree: true,
6171
- characterData: true,
6172
- attributes: true,
6173
- attributeFilter: ["value", "disabled", "selected", "label"],
6174
- });
6175
- }
6176
-
6177
- #getOptions({ enabledOnly = false } = {}) {
6178
- const panel = this.#getPanel();
6179
- const options = panel
6180
- ? Array.from(panel.querySelectorAll(":scope > fig-select-option"))
6181
- : [];
6182
- if (!enabledOnly) return options;
6183
- return options.filter((opt) => !figLabBooleanAttribute(opt, "disabled"));
6184
- }
6185
-
6186
- #optionValue(option) {
6187
- if (!option) return "";
6188
- if (typeof option.value === "string") return option.value;
6189
- const attr = option.getAttribute?.("value");
6190
- if (attr != null) return attr;
6191
- return (option.textContent || "").trim();
6192
- }
6193
-
6194
- #optionLabel(option) {
6195
- if (!option) return "";
6196
- const labelAttr = option.getAttribute?.("label");
6197
- if (labelAttr != null && labelAttr !== "") return labelAttr.trim();
6198
-
6199
- // Ignore prepend/append slot content when deriving a label from children.
6200
- const parts = [];
6201
- for (const node of option.childNodes) {
6202
- if (node.nodeType === Node.TEXT_NODE) {
6203
- const text = node.textContent?.trim();
6204
- if (text) parts.push(text);
6205
- continue;
6206
- }
6207
- if (!(node instanceof Element)) continue;
6208
- const slot = node.getAttribute("slot");
6209
- if (slot === "prepend" || slot === "append") continue;
6210
- const text = node.textContent?.trim();
6211
- if (text) parts.push(text);
6212
- }
6213
- if (parts.length) return parts.join(" ").trim();
6214
- return (option.textContent || "").trim();
6215
- }
6216
-
6217
- #syncPopupAttrs() {
6218
- if (!this.#popup) return;
6219
- this.#popup.setAttribute(
6220
- "position",
6221
- this.getAttribute("position") || "bottom left",
6222
- );
6223
- const offset = this.getAttribute("offset");
6224
- if (offset) this.#popup.setAttribute("offset", offset);
6225
- else this.#popup.removeAttribute("offset");
6226
- const closedby = this.getAttribute("closedby");
6227
- if (closedby) this.#popup.setAttribute("closedby", closedby);
6228
- else this.#popup.removeAttribute("closedby");
5948
+ function figLabMarkDisconnectedSelectTree(root) {
5949
+ if (root instanceof Element && root.matches("fig-select")) {
5950
+ figLabDisconnectedSelects.add(root);
6229
5951
  }
5952
+ root
5953
+ .querySelectorAll?.("fig-select")
5954
+ .forEach((select) => figLabDisconnectedSelects.add(select));
5955
+ }
6230
5956
 
6231
- #syncDisabled() {
6232
- const disabled = figLabBooleanAttribute(this, "disabled");
6233
- if (this.#button) {
6234
- if (disabled) this.#button.setAttribute("disabled", "");
6235
- else this.#button.removeAttribute("disabled");
6236
- }
6237
- if (disabled && this.open) this.open = false;
5957
+ function figLabEnhanceSelectTree(root) {
5958
+ if (root instanceof Element && root.matches("fig-select")) {
5959
+ figLabEnhanceSelect(root);
6238
5960
  }
5961
+ root
5962
+ .querySelectorAll?.("fig-select")
5963
+ .forEach((select) => figLabEnhanceSelect(select));
5964
+ }
6239
5965
 
6240
- #pickFallbackOption(options) {
6241
- if (!options.length) return null;
6242
- const selected = options.find((opt) =>
6243
- figLabBooleanAttribute(opt, "selected"),
6244
- );
6245
- if (selected && !figLabBooleanAttribute(selected, "disabled")) {
6246
- return selected;
5966
+ figLabEnhanceSelectTree(document);
5967
+ new MutationObserver((mutations) => {
5968
+ for (const mutation of mutations) {
5969
+ for (const node of mutation.removedNodes) {
5970
+ if (node instanceof Element) figLabMarkDisconnectedSelectTree(node);
6247
5971
  }
6248
- return (
6249
- options.find((opt) => !figLabBooleanAttribute(opt, "disabled")) ||
6250
- options[0] ||
6251
- null
6252
- );
6253
- }
6254
-
6255
- #emitValueEvents(value) {
6256
- this.dispatchEvent(
6257
- new CustomEvent("input", {
6258
- detail: value,
6259
- bubbles: true,
6260
- composed: true,
6261
- }),
6262
- );
6263
- this.dispatchEvent(
6264
- new CustomEvent("change", {
6265
- detail: value,
6266
- bubbles: true,
6267
- composed: true,
6268
- }),
6269
- );
6270
- }
6271
-
6272
- #syncValue() {
6273
- if (this.#syncingValue) return;
6274
- this.#syncingValue = true;
6275
- try {
6276
- const options = this.#getOptions();
6277
- const hasValueAttr = this.hasAttribute("value");
6278
- const previousValue = hasValueAttr ? this.getAttribute("value") : null;
6279
- let match = hasValueAttr
6280
- ? options.find((opt) => this.#optionValue(opt) === previousValue)
6281
- : null;
6282
- let valueCorrected = false;
6283
-
6284
- if (!match) {
6285
- if (hasValueAttr) {
6286
- // Options may not be built yet (options attr sync). Keep value until then.
6287
- if (!options.length) {
6288
- if (this.#labelEl) {
6289
- this.#labelEl.textContent =
6290
- previousValue || this.getAttribute("label") || "";
6291
- }
6292
- return;
6293
- }
6294
- // Value orphaned (option removed / value attr changed) — clamp or clear.
6295
- match = this.#pickFallbackOption(options);
6296
- if (match) {
6297
- const nextValue = this.#optionValue(match);
6298
- if (previousValue !== nextValue) {
6299
- this.setAttribute("value", nextValue);
6300
- valueCorrected = true;
6301
- }
6302
- } else {
6303
- this.removeAttribute("value");
6304
- valueCorrected = true;
6305
- }
6306
- } else {
6307
- // No host value yet — honor a selected option if present.
6308
- match = options.find((opt) =>
6309
- figLabBooleanAttribute(opt, "selected"),
6310
- );
6311
- if (match) {
6312
- this.setAttribute("value", this.#optionValue(match));
6313
- valueCorrected = true;
6314
- }
5972
+ for (const node of mutation.addedNodes) {
5973
+ if (!(node instanceof Element)) continue;
5974
+ figLabEnhanceSelectTree(node);
5975
+ const selects = node.matches("fig-select")
5976
+ ? [node]
5977
+ : [...node.querySelectorAll("fig-select")];
5978
+ for (const select of selects) {
5979
+ if (figLabDisconnectedSelects.has(select)) {
5980
+ figLabInstallSelectReconnectFallback(select);
6315
5981
  }
6316
5982
  }
6317
-
6318
- for (const opt of options) {
6319
- const selected = opt === match;
6320
- opt.setAttribute("aria-selected", selected ? "true" : "false");
6321
- if (selected) opt.setAttribute("selected", "");
6322
- else opt.removeAttribute("selected");
6323
- }
6324
-
6325
- const label =
6326
- (match && this.#optionLabel(match)) || this.getAttribute("label") || "";
6327
- if (this.#labelEl) this.#labelEl.textContent = label;
6328
-
6329
- const ariaLabel = this.getAttribute("label") || "Select";
6330
- this.#button?.setAttribute("aria-label", ariaLabel);
6331
-
6332
- // Don't scrollToOption while open — reposition/sync would fight overflow paging.
6333
- this.#getPanel()?.syncOverflow?.();
6334
-
6335
- if (valueCorrected) {
6336
- this.#emitValueEvents(this.getAttribute("value") ?? "");
6337
- }
6338
- } finally {
6339
- this.#syncingValue = false;
6340
5983
  }
6341
5984
  }
6342
-
6343
- #handleTriggerClick(e) {
6344
- if (figLabBooleanAttribute(this, "disabled")) return;
6345
- e.preventDefault();
6346
- e.stopPropagation();
6347
- const nextOpen = !this.open;
6348
- if (nextOpen && this.#popup && this.#button) {
6349
- this.#popup.anchor = this.#button;
6350
- }
6351
- this.open = nextOpen;
6352
- }
6353
-
6354
- #handleOptionClick(e) {
6355
- const path = typeof e.composedPath === "function" ? e.composedPath() : [];
6356
- const option = path.find(
6357
- (node) => node?.tagName === "FIG-SELECT-OPTION",
6358
- );
6359
- if (!option || !this.contains(option)) return;
6360
- if (figLabBooleanAttribute(option, "disabled")) return;
6361
- // Do not stopPropagation — React light-DOM onClick must still fire.
6362
- this.#selectOption(option);
6363
- }
6364
-
6365
- #handleKeydown(e) {
6366
- if (e.currentTarget === document && e.key !== "Escape") return;
6367
-
6368
- const listOpen = this.open && (this.#popup?.matches?.(":open") ?? false);
6369
- if (!listOpen) {
6370
- if (
6371
- this.#button?.contains(e.target) &&
6372
- (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ")
6373
- ) {
6374
- e.preventDefault();
6375
- if (this.#popup && this.#button) this.#popup.anchor = this.#button;
6376
- this.open = true;
6377
- requestAnimationFrame(() => {
6378
- const options = this.#getOptions({ enabledOnly: true });
6379
- const selectedIndex = options.findIndex(
6380
- (opt) => this.#optionValue(opt) === this.value,
6381
- );
6382
- this.#focusOptionAt(selectedIndex >= 0 ? selectedIndex : 0);
6383
- });
6384
- }
6385
- return;
6386
- }
6387
-
6388
- const options = this.#getOptions({ enabledOnly: true });
6389
- if (!options.length) return;
6390
-
6391
- switch (e.key) {
6392
- case "ArrowDown":
6393
- e.preventDefault();
6394
- this.#syncFocusedIndex();
6395
- this.#focusOptionAt(this.#focusedIndex + 1);
6396
- break;
6397
- case "ArrowUp":
6398
- e.preventDefault();
6399
- this.#syncFocusedIndex();
6400
- this.#focusOptionAt(this.#focusedIndex - 1);
6401
- break;
6402
- case "Home":
6403
- e.preventDefault();
6404
- this.#focusOptionAt(0);
6405
- break;
6406
- case "End":
6407
- e.preventDefault();
6408
- this.#focusOptionAt(options.length - 1);
6409
- break;
6410
- case "Escape":
6411
- e.preventDefault();
6412
- this.open = false;
6413
- this.#button?.focus();
6414
- break;
6415
- case "Enter":
6416
- case " ": {
6417
- this.#syncFocusedIndex();
6418
- const focused = options[this.#focusedIndex];
6419
- if (!focused) return;
6420
- e.preventDefault();
6421
- this.#selectOption(focused);
6422
- break;
6423
- }
6424
- }
6425
- }
6426
-
6427
- #handlePopupClose() {
6428
- if (this.hasAttribute("open")) this.removeAttribute("open");
6429
- this.#button?.setAttribute("aria-expanded", "false");
6430
- this.#button?.focus();
6431
- this.#focusedIndex = -1;
6432
- }
6433
-
6434
- #selectOption(option) {
6435
- const value = this.#optionValue(option);
6436
- this.setAttribute("value", value);
6437
- this.#syncValue();
6438
- this.#emitValueEvents(value);
6439
- this.open = false;
6440
- }
6441
-
6442
- #getEnabledOptions() {
6443
- return this.#getOptions({ enabledOnly: true });
6444
- }
6445
-
6446
- #syncFocusedIndex() {
6447
- const options = this.#getEnabledOptions();
6448
- if (!options.length) {
6449
- this.#focusedIndex = -1;
6450
- return;
6451
- }
6452
- const active = options.find((opt) => opt === document.activeElement);
6453
- const index = active ? options.indexOf(active) : -1;
6454
- this.#focusedIndex = index >= 0 ? index : this.#focusedIndex;
6455
- }
6456
-
6457
- #focusOptionAt(index) {
6458
- const options = this.#getEnabledOptions();
6459
- if (!options.length) return;
6460
- const next = ((index % options.length) + options.length) % options.length;
6461
- this.#focusedIndex = next;
6462
- options[next]?.focus();
6463
- }
6464
-
6465
- #syncPopupWidth() {
6466
- if (!this.#popup || !this.#button) return;
6467
- const hostWidth = Math.ceil(this.getBoundingClientRect().width);
6468
- const triggerWidth = Math.ceil(this.#button.getBoundingClientRect().width);
6469
- const anchorWidth = Math.max(hostWidth, triggerWidth, 96);
6470
- const full = figLabBooleanAttribute(this, "full");
6471
-
6472
- // Use !important — fig-select::part(listbox) width rules beat element.style.
6473
- // [full]: lock to host. Otherwise content-sized with host as min and 20rem max.
6474
- if (full) {
6475
- this.#popup.style.setProperty("width", `${anchorWidth}px`, "important");
6476
- this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
6477
- this.#popup.style.setProperty("max-width", `${anchorWidth}px`, "important");
6478
- } else {
6479
- this.#popup.style.setProperty("width", "max-content", "important");
6480
- this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
6481
- this.#popup.style.setProperty(
6482
- "max-width",
6483
- "min(20rem, calc(100vw - 1rem))",
6484
- "important",
6485
- );
6486
- }
6487
- }
6488
-
6489
- #openList() {
6490
- if (!this.#popup || figLabBooleanAttribute(this, "disabled")) return;
6491
- if (this.#button) this.#popup.anchor = this.#button;
6492
- this.#installPopupPositioning();
6493
- this.#freezeMenuPosition = false;
6494
- this.#frozenLabelRect = null;
6495
- this.#frozenViewport = null;
6496
- this.#syncValue();
6497
- this.#syncPopupWidth();
6498
- this.#popup.open = true;
6499
- document.addEventListener("keydown", this.#boundKeydown, true);
6500
- this.#button?.setAttribute("aria-expanded", "true");
6501
- this.#focusedIndex = -1;
6502
- requestAnimationFrame(() => {
6503
- this.#syncPopupWidth();
6504
- this.#positionPopupOverSelected();
6505
- const panel = this.#getPanel();
6506
- const options = this.#getEnabledOptions();
6507
- const selectedIndex = options.findIndex(
6508
- (opt) => this.#optionValue(opt) === this.value,
6509
- );
6510
- if (selectedIndex >= 0) {
6511
- this.#focusOptionAt(selectedIndex);
6512
- } else if (
6513
- this.#button?.hasAttribute("data-focus-visible") ||
6514
- this.#button?.matches?.(":focus-visible")
6515
- ) {
6516
- this.#focusOptionAt(0);
6517
- }
6518
- panel?.syncOverflow?.();
6519
- // Freeze after open align so later positionPopup passes don't undo scroll.
6520
- // Window resize / trigger movement still realigns via geometry checks.
6521
- this.#freezeMenuPosition = true;
6522
- this.#rememberFrozenGeometry();
6523
- });
6524
- }
6525
-
6526
- #closeList() {
6527
- if (!this.#popup) return;
6528
- this.#freezeMenuPosition = false;
6529
- this.#frozenLabelRect = null;
6530
- this.#frozenViewport = null;
6531
- document.removeEventListener("keydown", this.#boundKeydown, true);
6532
- this.#popup.open = false;
6533
- this.#button?.setAttribute("aria-expanded", "false");
6534
- }
6535
- }
6536
- figLabDefineElement("fig-select", FigSelect);
5985
+ }).observe(document.documentElement, { childList: true, subtree: true });