@rogieking/figui3 6.21.0 → 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
@@ -8,10 +8,23 @@
8
8
  * <script src="fig-lab.js"></script>
9
9
  */
10
10
 
11
+ function figLabDefineElement(name, constructor) {
12
+ if (!customElements.get(name)) {
13
+ customElements.define(name, constructor);
14
+ }
15
+ }
16
+
11
17
  function figLabBooleanAttribute(element, name) {
12
18
  return element.hasAttribute(name) && element.getAttribute(name) !== "false";
13
19
  }
14
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
+
15
28
  /* Field + Switch wrapper */
16
29
  class PropskitSwitch extends HTMLElement {
17
30
  #field = null;
@@ -212,7 +225,9 @@ class PropskitSwitch extends HTMLElement {
212
225
  }
213
226
 
214
227
  set checked(nextChecked) {
215
- 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";
216
231
  }
217
232
 
218
233
  get value() {
@@ -230,7 +245,7 @@ class PropskitSwitch extends HTMLElement {
230
245
  selected?.focus(options);
231
246
  }
232
247
  }
233
- customElements.define("propskit-switch", PropskitSwitch);
248
+ figLabDefineElement("propskit-switch", PropskitSwitch);
234
249
 
235
250
  /* Field + Color wrapper */
236
251
  class PropskitColor extends HTMLElement {
@@ -516,7 +531,7 @@ class PropskitColor extends HTMLElement {
516
531
  this.#input?.querySelector("input:not([tabindex='-1'])")?.focus(options);
517
532
  }
518
533
  }
519
- customElements.define("propskit-color", PropskitColor);
534
+ figLabDefineElement("propskit-color", PropskitColor);
520
535
 
521
536
  /* Field + Select wrapper */
522
537
  class PropskitSelect extends HTMLElement {
@@ -771,7 +786,7 @@ class PropskitSelect extends HTMLElement {
771
786
  this.#select?.focus(options);
772
787
  }
773
788
  }
774
- customElements.define("propskit-select", PropskitSelect);
789
+ figLabDefineElement("propskit-select", PropskitSelect);
775
790
 
776
791
  /* Field + Text wrapper */
777
792
  class PropskitText extends HTMLElement {
@@ -972,8 +987,11 @@ class PropskitText extends HTMLElement {
972
987
  set value(nextValue) {
973
988
  if (nextValue === null || nextValue === undefined) {
974
989
  this.removeAttribute("value");
990
+ if (this.#input) this.#input.value = "";
975
991
  } else {
976
- this.setAttribute("value", String(nextValue));
992
+ const next = String(nextValue);
993
+ this.setAttribute("value", next);
994
+ if (this.#input) this.#input.value = next;
977
995
  }
978
996
  }
979
997
 
@@ -981,7 +999,7 @@ class PropskitText extends HTMLElement {
981
999
  this.#input?.focus(options);
982
1000
  }
983
1001
  }
984
- customElements.define("propskit-text", PropskitText);
1002
+ figLabDefineElement("propskit-text", PropskitText);
985
1003
 
986
1004
  /* Field + Number wrapper */
987
1005
  class PropskitNumber extends HTMLElement {
@@ -1172,8 +1190,11 @@ class PropskitNumber extends HTMLElement {
1172
1190
  set value(nextValue) {
1173
1191
  if (nextValue === null || nextValue === undefined || nextValue === "") {
1174
1192
  this.removeAttribute("value");
1193
+ if (this.#input) this.#input.value = "";
1175
1194
  } else {
1176
- this.setAttribute("value", String(nextValue));
1195
+ const next = String(nextValue);
1196
+ this.setAttribute("value", next);
1197
+ if (this.#input) this.#input.value = next;
1177
1198
  }
1178
1199
  }
1179
1200
 
@@ -1181,7 +1202,7 @@ class PropskitNumber extends HTMLElement {
1181
1202
  this.#input?.focus(options);
1182
1203
  }
1183
1204
  }
1184
- customElements.define("propskit-number", PropskitNumber);
1205
+ figLabDefineElement("propskit-number", PropskitNumber);
1185
1206
 
1186
1207
  /* Collapsible property group — always collapsible (no collapsible attr). */
1187
1208
  class PropskitGroup extends HTMLElement {
@@ -1197,6 +1218,7 @@ class PropskitGroup extends HTMLElement {
1197
1218
  ].join(",");
1198
1219
 
1199
1220
  #header = null;
1221
+ #disclosure = null;
1200
1222
  #chevron = null;
1201
1223
  #resetTooltip = null;
1202
1224
  #defaults = new WeakMap();
@@ -1219,10 +1241,7 @@ class PropskitGroup extends HTMLElement {
1219
1241
  cancelAnimationFrame(this.#dirtyFrame);
1220
1242
  this.#dirtyFrame = 0;
1221
1243
  }
1222
- if (this.#header) {
1223
- this.#header.removeEventListener("click", this.#handleToggle);
1224
- this.#header.removeEventListener("keydown", this.#handleHeaderKeyDown);
1225
- }
1244
+ this.#disclosure?.removeEventListener("click", this.#handleToggle);
1226
1245
  const resetBtn = this.#resetTooltip?.querySelector("fig-button");
1227
1246
  resetBtn?.removeEventListener("click", this.#handleReset);
1228
1247
  }
@@ -1230,7 +1249,7 @@ class PropskitGroup extends HTMLElement {
1230
1249
  attributeChangedCallback(name, oldValue, newValue) {
1231
1250
  if (oldValue === newValue) return;
1232
1251
  if (name === "open") {
1233
- this.#header?.setAttribute("aria-expanded", String(this.open));
1252
+ this.#disclosure?.setAttribute("aria-expanded", String(this.open));
1234
1253
  return;
1235
1254
  }
1236
1255
  if (name === "show-reset") {
@@ -1253,7 +1272,7 @@ class PropskitGroup extends HTMLElement {
1253
1272
  } else {
1254
1273
  this.setAttribute("open", "false");
1255
1274
  }
1256
- this.#header?.setAttribute("aria-expanded", String(!!value));
1275
+ this.#disclosure?.setAttribute("aria-expanded", String(!!value));
1257
1276
  if (was !== !!value) {
1258
1277
  this.dispatchEvent(
1259
1278
  new CustomEvent("openchange", {
@@ -1333,14 +1352,6 @@ class PropskitGroup extends HTMLElement {
1333
1352
  this.open = !this.open;
1334
1353
  };
1335
1354
 
1336
- #handleHeaderKeyDown = (e) => {
1337
- if (this.#isResetTarget(e.target)) return;
1338
- if (e.key !== "Enter" && e.key !== " ") return;
1339
- e.preventDefault();
1340
- e.stopPropagation();
1341
- this.open = !this.open;
1342
- };
1343
-
1344
1355
  #handleReset = (e) => {
1345
1356
  e.preventDefault();
1346
1357
  e.stopPropagation();
@@ -1522,17 +1533,34 @@ class PropskitGroup extends HTMLElement {
1522
1533
 
1523
1534
  if (userHeader) {
1524
1535
  this.#header = userHeader;
1525
- } 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
+ ) {
1526
1541
  this.#header = document.createElement("fig-header");
1527
1542
  this.#header.setAttribute("borderless", "");
1528
1543
  this.#header.dataset.generated = "true";
1529
1544
  this.prepend(this.#header);
1530
1545
  }
1531
1546
 
1532
- 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");
1533
1561
  if (!h3) {
1534
1562
  h3 = document.createElement("h3");
1535
- this.#header.prepend(h3);
1563
+ disclosure.appendChild(h3);
1536
1564
  }
1537
1565
  if (!h3.id) h3.id = figLabUniqueId("propskit-group");
1538
1566
  if (this.#header.dataset.generated) {
@@ -1558,21 +1586,21 @@ class PropskitGroup extends HTMLElement {
1558
1586
  }
1559
1587
  this.#chevron = h3.querySelector(".propskit-group-chevron");
1560
1588
  this.#syncResetButton();
1561
- this.#header.removeEventListener("click", this.#handleToggle);
1562
- this.#header.addEventListener("click", this.#handleToggle);
1563
- this.#header.setAttribute("role", "button");
1564
- this.#header.setAttribute("tabindex", "0");
1565
- this.#header.setAttribute("aria-expanded", String(this.open));
1566
- this.#header.removeEventListener("keydown", this.#handleHeaderKeyDown);
1567
- 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);
1568
1596
 
1569
1597
  if (!this.hasAttribute("open")) {
1570
1598
  this.setAttribute("open", "false");
1571
- this.#header.setAttribute("aria-expanded", "false");
1599
+ this.#disclosure.setAttribute("aria-expanded", "false");
1572
1600
  }
1573
1601
  }
1574
1602
  }
1575
- customElements.define("propskit-group", PropskitGroup);
1603
+ figLabDefineElement("propskit-group", PropskitGroup);
1576
1604
 
1577
1605
  /* Field + Slider wrapper */
1578
1606
  class PropskitSlider extends HTMLElement {
@@ -1601,6 +1629,7 @@ class PropskitSlider extends HTMLElement {
1601
1629
  #boundHandleRangeDoubleClick = this.#handleRangeDoubleClick.bind(this);
1602
1630
  #boundHandleContextMenu = this.#handleContextMenu.bind(this);
1603
1631
  #boundHandleContextMenuChange = this.#handleContextMenuChange.bind(this);
1632
+ #boundHandleClick = this.#handleClick.bind(this);
1604
1633
  #ignoredSliderAttrs = new Set([
1605
1634
  "variant",
1606
1635
  "color",
@@ -1638,6 +1667,16 @@ class PropskitSlider extends HTMLElement {
1638
1667
  });
1639
1668
  this.removeEventListener("contextmenu", this.#boundHandleContextMenu);
1640
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
+ );
1641
1680
 
1642
1681
  if (!this.#observer) {
1643
1682
  this.#observer = new MutationObserver((mutations) => {
@@ -1693,6 +1732,7 @@ class PropskitSlider extends HTMLElement {
1693
1732
  capture: true,
1694
1733
  });
1695
1734
  this.removeEventListener("contextmenu", this.#boundHandleContextMenu);
1735
+ this.removeEventListener("click", this.#boundHandleClick, true);
1696
1736
  this.#contextMenu?.removeEventListener("change", this.#boundHandleContextMenuChange);
1697
1737
  }
1698
1738
 
@@ -1792,8 +1832,10 @@ class PropskitSlider extends HTMLElement {
1792
1832
 
1793
1833
  for (const attrName of hostAttrs) {
1794
1834
  if (attrName === "text") continue;
1795
- const value = this.getAttribute(attrName);
1796
- 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
+ }
1797
1839
  }
1798
1840
 
1799
1841
  this.#slider.removeAttribute("variant");
@@ -1869,8 +1911,45 @@ class PropskitSlider extends HTMLElement {
1869
1911
  if (rangeInput !== this.#rangeInput) {
1870
1912
  this.#bindRangeInput(rangeInput);
1871
1913
  }
1872
- rangeInput?.removeAttribute("tabindex");
1873
- 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
+ });
1874
1953
  }
1875
1954
 
1876
1955
  #bindRangeInput(rangeInput) {
@@ -1898,6 +1977,9 @@ class PropskitSlider extends HTMLElement {
1898
1977
 
1899
1978
  #handleElasticPointerDown(event) {
1900
1979
  if (event.button !== 0 || figLabBooleanAttribute(this, "disabled")) return;
1980
+ if (!event.target?.closest?.("fig-input-number, fig-menu")) {
1981
+ this.#queueRangeFocus();
1982
+ }
1901
1983
  if (this.getAttribute("elastic") === "false") return;
1902
1984
  if (event.target?.closest?.("fig-input-number")) return;
1903
1985
  const rangeInput =
@@ -2110,6 +2192,7 @@ class PropskitSlider extends HTMLElement {
2110
2192
  #setSliderValue(value, eventType) {
2111
2193
  if (!this.#slider || value === null || value === undefined) return;
2112
2194
  this.#slider.value = value;
2195
+ this.setAttribute("value", String(this.#slider.value));
2113
2196
  this.dispatchEvent(
2114
2197
  new CustomEvent(eventType, {
2115
2198
  detail: this.#slider.value,
@@ -2174,6 +2257,9 @@ class PropskitSlider extends HTMLElement {
2174
2257
  event instanceof CustomEvent && event.detail !== undefined
2175
2258
  ? event.detail
2176
2259
  : this.#slider?.value;
2260
+ if (this.#slider?.value !== undefined) {
2261
+ this.setAttribute("value", String(this.#slider.value));
2262
+ }
2177
2263
  this.dispatchEvent(
2178
2264
  new CustomEvent(type, {
2179
2265
  detail,
@@ -2185,14 +2271,32 @@ class PropskitSlider extends HTMLElement {
2185
2271
  }
2186
2272
 
2187
2273
  focus(options) {
2188
- 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;
2189
2293
  }
2190
2294
 
2191
2295
  resetToDefault() {
2192
2296
  this.#resetToDefault();
2193
2297
  }
2194
2298
  }
2195
- customElements.define("propskit-slider", PropskitSlider);
2299
+ figLabDefineElement("propskit-slider", PropskitSlider);
2196
2300
 
2197
2301
  /* Canvas Control */
2198
2302
  class FigCanvasControl extends HTMLElement {
@@ -2236,6 +2340,8 @@ class FigCanvasControl extends HTMLElement {
2236
2340
  #rotateCursorPrevBodyCursor = "";
2237
2341
  #rotateCursorPrevBodyCursorPriority = "";
2238
2342
  #boundRotateCursorEnd = null;
2343
+ #activeGestureController = null;
2344
+ #activeGestureFinish = null;
2239
2345
 
2240
2346
  get #type() {
2241
2347
  return this.getAttribute("type") || "point";
@@ -2334,6 +2440,7 @@ class FigCanvasControl extends HTMLElement {
2334
2440
  }
2335
2441
 
2336
2442
  disconnectedCallback() {
2443
+ this.#cancelActiveGesture();
2337
2444
  this.#teardownRadiusDrag();
2338
2445
  this.#deactivateMoveCursor();
2339
2446
  this.#deactivateRotateCursor();
@@ -2355,6 +2462,7 @@ class FigCanvasControl extends HTMLElement {
2355
2462
  else this.#render();
2356
2463
  }
2357
2464
  if (name === "type") {
2465
+ this.#cancelActiveGesture();
2358
2466
  this.#parseValue();
2359
2467
  this.#render();
2360
2468
  }
@@ -2363,6 +2471,7 @@ class FigCanvasControl extends HTMLElement {
2363
2471
  else this.#pointHandle.removeAttribute("color");
2364
2472
  }
2365
2473
  if (name === "disabled") {
2474
+ this.#cancelActiveGesture();
2366
2475
  this.#render();
2367
2476
  }
2368
2477
  if (name === "tooltips") {
@@ -2434,6 +2543,7 @@ class FigCanvasControl extends HTMLElement {
2434
2543
  }
2435
2544
 
2436
2545
  #render() {
2546
+ this.#cancelActiveGesture();
2437
2547
  this.innerHTML = "";
2438
2548
  this.#pointHandle = null;
2439
2549
  this.#secondHandle = null;
@@ -2856,7 +2966,6 @@ class FigCanvasControl extends HTMLElement {
2856
2966
  e.stopPropagation();
2857
2967
  const container = this.#container;
2858
2968
  if (!container) return;
2859
- const rect0 = container.getBoundingClientRect();
2860
2969
  const startX = e.clientX;
2861
2970
  const startY = e.clientY;
2862
2971
  const x0 = this.#x;
@@ -2888,21 +2997,24 @@ class FigCanvasControl extends HTMLElement {
2888
2997
  this.#emitInput();
2889
2998
  };
2890
2999
 
2891
- const onUp = () => {
3000
+ const gesture = this.#beginActiveGesture((commit) => {
2892
3001
  document.body.classList.remove("fig-lab-move-active");
2893
3002
  hitLine.style.pointerEvents = "stroke";
2894
- this.#syncValueAttribute();
2895
- this.#emitChange();
2896
- window.removeEventListener("pointermove", onMove);
2897
- window.removeEventListener("pointerup", onUp);
2898
- requestAnimationFrame(() => {
2899
- this.#isDragging = false;
2900
- this.#isSecondDragging = false;
2901
- });
2902
- };
3003
+ this.#isDragging = false;
3004
+ this.#isSecondDragging = false;
3005
+ if (commit) {
3006
+ this.#syncValueAttribute();
3007
+ this.#emitChange();
3008
+ }
3009
+ });
2903
3010
 
2904
- window.addEventListener("pointermove", onMove);
2905
- 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
+ });
2906
3018
  });
2907
3019
  }
2908
3020
 
@@ -3060,7 +3172,7 @@ class FigCanvasControl extends HTMLElement {
3060
3172
  svg.style.top = "0";
3061
3173
  svg.setAttribute("viewBox", `0 0 ${rect.width} ${rect.height}`);
3062
3174
  const lines = svg.querySelectorAll(
3063
- ".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",
3064
3176
  );
3065
3177
  for (const line of lines) {
3066
3178
  line.setAttribute("x1", String(cx));
@@ -3248,19 +3360,24 @@ class FigCanvasControl extends HTMLElement {
3248
3360
  this.#emitInput();
3249
3361
  };
3250
3362
 
3251
- const onUp = () => {
3363
+ const gesture = this.#beginActiveGesture((commit) => {
3252
3364
  this.#isAngleDragging = false;
3253
3365
  this.classList.remove("fig-canvas-control-ring-active");
3254
3366
  this.#angleHandle.removeAttribute("selected");
3255
3367
  if (this.#angleTooltip) this.#angleTooltip.removeAttribute("show");
3256
- this.#syncValueAttribute();
3257
- this.#emitChange();
3258
- window.removeEventListener("pointermove", onMove);
3259
- window.removeEventListener("pointerup", onUp);
3260
- };
3368
+ if (commit) {
3369
+ this.#syncValueAttribute();
3370
+ this.#emitChange();
3371
+ }
3372
+ });
3261
3373
 
3262
- window.addEventListener("pointermove", onMove);
3263
- 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
+ });
3264
3381
  });
3265
3382
  }
3266
3383
 
@@ -3350,17 +3467,22 @@ class FigCanvasControl extends HTMLElement {
3350
3467
  this.#emitInput();
3351
3468
  };
3352
3469
 
3353
- const onUp = () => {
3470
+ const gesture = this.#beginActiveGesture((commit) => {
3354
3471
  this.#isDragging = false;
3355
3472
  if (tooltip) tooltip.removeAttribute("show");
3356
- this.#syncValueAttribute();
3357
- this.#emitChange();
3358
- window.removeEventListener("pointermove", onMove);
3359
- window.removeEventListener("pointerup", onUp);
3360
- };
3473
+ if (commit) {
3474
+ this.#syncValueAttribute();
3475
+ this.#emitChange();
3476
+ }
3477
+ });
3361
3478
 
3362
- window.addEventListener("pointermove", onMove);
3363
- 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
+ });
3364
3486
  });
3365
3487
  }
3366
3488
 
@@ -3456,7 +3578,7 @@ class FigCanvasControl extends HTMLElement {
3456
3578
  this.#emitInput();
3457
3579
  };
3458
3580
 
3459
- const onUp = () => {
3581
+ const gesture = this.#beginActiveGesture((commit) => {
3460
3582
  this.#isRadiusDragging = false;
3461
3583
  this.classList.remove("fig-canvas-control-ring-active");
3462
3584
  circle.style.pointerEvents = "";
@@ -3468,14 +3590,19 @@ class FigCanvasControl extends HTMLElement {
3468
3590
  }
3469
3591
  document.body.style.cursor = prevBodyCursor;
3470
3592
  if (this.#radiusTooltip) this.#radiusTooltip.removeAttribute("show");
3471
- this.#syncValueAttribute();
3472
- this.#emitChange();
3473
- window.removeEventListener("pointermove", onMove);
3474
- window.removeEventListener("pointerup", onUp);
3475
- };
3593
+ if (commit) {
3594
+ this.#syncValueAttribute();
3595
+ this.#emitChange();
3596
+ }
3597
+ });
3476
3598
 
3477
- window.addEventListener("pointermove", onMove);
3478
- 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
+ });
3479
3606
  };
3480
3607
  circle.addEventListener("pointerdown", onDown);
3481
3608
  this._radiusDragCleanup = () =>
@@ -3488,8 +3615,34 @@ class FigCanvasControl extends HTMLElement {
3488
3615
  this._radiusDragCleanup = null;
3489
3616
  }
3490
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
+ }
3491
3644
  }
3492
- customElements.define("fig-canvas-control", FigCanvasControl);
3645
+ figLabDefineElement("fig-canvas-control", FigCanvasControl);
3493
3646
 
3494
3647
  /* Oscillator Input */
3495
3648
  /**
@@ -3522,6 +3675,8 @@ class PropskitOscillator extends HTMLElement {
3522
3675
  #expandedWaveIndices = new Set();
3523
3676
  #resizeObserver = null;
3524
3677
  #activeFieldInput = null;
3678
+ #dragController = null;
3679
+ #dragCleanup = null;
3525
3680
 
3526
3681
  static TYPES = [
3527
3682
  { name: "Wave", value: "sine" },
@@ -3553,7 +3708,7 @@ class PropskitOscillator extends HTMLElement {
3553
3708
  }
3554
3709
 
3555
3710
  disconnectedCallback() {
3556
- this.#isDragging = null;
3711
+ this.#cancelDrag();
3557
3712
  this.#stopPlayhead();
3558
3713
  if (this.#resizeObserver) {
3559
3714
  this.#resizeObserver.disconnect();
@@ -3758,6 +3913,7 @@ class PropskitOscillator extends HTMLElement {
3758
3913
  }
3759
3914
 
3760
3915
  #render() {
3916
+ this.#cancelDrag();
3761
3917
  this.#stopPlayhead();
3762
3918
  this.innerHTML = this.#getInnerHTML();
3763
3919
  this.#cacheRefs();
@@ -3768,7 +3924,8 @@ class PropskitOscillator extends HTMLElement {
3768
3924
  }
3769
3925
 
3770
3926
  #getInnerHTML() {
3771
- const disabled = this.#isDisabled() ? " disabled" : "";
3927
+ const interactive = this.#isEditEnabled() && !this.#isDisabled();
3928
+ const disabled = interactive ? "" : " disabled";
3772
3929
 
3773
3930
  return `<div class="propskit-oscillator-svg-container">
3774
3931
  <svg viewBox="0 0 ${this.#drawWidth} ${this.#drawHeight}" class="propskit-oscillator-svg">
@@ -3776,8 +3933,8 @@ class PropskitOscillator extends HTMLElement {
3776
3933
  <line class="propskit-oscillator-baseline"></line>
3777
3934
  <path class="propskit-oscillator-path"></path>
3778
3935
  <circle class="propskit-oscillator-playhead"></circle>
3779
- <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>
3780
- <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>` : ""}
3781
3938
  </svg>
3782
3939
  </div>
3783
3940
  ${this.#isEditEnabled() ? this.#getWaveControlsHTML(disabled) : ""}`;
@@ -3801,10 +3958,7 @@ class PropskitOscillator extends HTMLElement {
3801
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>
3802
3959
  </fig-tooltip>
3803
3960
  <fig-tooltip text="Add form">
3804
- <fig-button class="propskit-oscillator-add-type-button" type="select" variant="ghost" icon data-wave-index="${index}" aria-label="Add form"${disabled}>
3805
- <fig-icon name="add"></fig-icon>
3806
- ${this.#getWaveTypeDropdownHTML("propskit-oscillator-add-type", "sine", disabled, index)}
3807
- </fig-button>
3961
+ ${this.#getWaveTypeSelectHTML("propskit-oscillator-add-type propskit-oscillator-add-type-button", "sine", disabled, index)}
3808
3962
  </fig-tooltip>
3809
3963
  </fig-header>
3810
3964
  <div class="propskit-oscillator-fields" data-wave-index="${index}"${active}>
@@ -3816,17 +3970,17 @@ class PropskitOscillator extends HTMLElement {
3816
3970
  </fig-group>`;
3817
3971
  }
3818
3972
 
3819
- #getWaveTypeDropdownHTML(className, value, disabled, index = null) {
3973
+ #getWaveTypeSelectHTML(className, value, disabled, index = null) {
3820
3974
  const options = PropskitOscillator.TYPES.map((type) => {
3821
3975
  const selected = type.value === value ? " selected" : "";
3822
- return `<option value="${type.value}"${selected}>
3823
- ${PropskitOscillator.waveIcon(type.value, 24)}
3824
- <label>${type.name}</label>
3825
- </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>`;
3826
3980
  }).join("");
3827
3981
  const indexAttr =
3828
3982
  index === null ? "" : ` data-wave-index="${PropskitOscillator.#escapeAttribute(String(index))}"`;
3829
- 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>`;
3830
3984
  }
3831
3985
 
3832
3986
  #getNumberFieldHTML(index, name, label, min, max, step, units) {
@@ -4033,6 +4187,7 @@ class PropskitOscillator extends HTMLElement {
4033
4187
  }
4034
4188
 
4035
4189
  #setupEvents() {
4190
+ if (!this.#isEditEnabled() || this.#isDisabled()) return;
4036
4191
  for (const typeControl of this.#typeControls) {
4037
4192
  typeControl.addEventListener("change", (event) => {
4038
4193
  if (this.#isDisabled()) return;
@@ -4267,6 +4422,7 @@ class PropskitOscillator extends HTMLElement {
4267
4422
  }
4268
4423
 
4269
4424
  #startDrag(event, type) {
4425
+ this.#cancelDrag();
4270
4426
  this.#isDragging = type;
4271
4427
  this.#svg?.classList.add("dragging");
4272
4428
  const dragCursor =
@@ -4307,19 +4463,42 @@ class PropskitOscillator extends HTMLElement {
4307
4463
  this.#emit("input");
4308
4464
  };
4309
4465
 
4310
- const onUp = () => {
4466
+ const controller = new AbortController();
4467
+ const finish = (commit = false) => {
4311
4468
  this.#isDragging = null;
4312
4469
  this.#svg?.classList.remove("dragging");
4313
4470
  if (dragCursor) {
4314
4471
  document.body.style.cursor = prevBodyCursor;
4315
4472
  }
4316
- document.removeEventListener("pointermove", onMove);
4317
- document.removeEventListener("pointerup", onUp);
4318
- 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");
4319
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
+ }
4320
4494
 
4321
- document.addEventListener("pointermove", onMove);
4322
- 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");
4323
4502
  }
4324
4503
 
4325
4504
  #sampleAtWithoutWave(excludedIndex, t) {
@@ -4391,7 +4570,7 @@ class PropskitOscillator extends HTMLElement {
4391
4570
  );
4392
4571
  }
4393
4572
  }
4394
- customElements.define("propskit-oscillator", PropskitOscillator);
4573
+ figLabDefineElement("propskit-oscillator", PropskitOscillator);
4395
4574
 
4396
4575
  /* Angle Input */
4397
4576
  /**
@@ -4415,6 +4594,9 @@ class FigInputAngle extends HTMLElement {
4415
4594
  #boundHandleKeyDown;
4416
4595
  #boundHandleKeyUp;
4417
4596
  #boundHandleAngleInput;
4597
+ #boundHandleDialKeyDown;
4598
+ #gestureController = null;
4599
+ #gestureCleanup = null;
4418
4600
 
4419
4601
  constructor() {
4420
4602
  super();
@@ -4440,10 +4622,12 @@ class FigInputAngle extends HTMLElement {
4440
4622
  this.#boundHandleKeyDown = this.#handleKeyDown.bind(this);
4441
4623
  this.#boundHandleKeyUp = this.#handleKeyUp.bind(this);
4442
4624
  this.#boundHandleAngleInput = this.#handleAngleInput.bind(this);
4625
+ this.#boundHandleDialKeyDown = this.#handleDialKeyDown.bind(this);
4443
4626
  }
4444
4627
 
4445
4628
  connectedCallback() {
4446
4629
  requestAnimationFrame(() => {
4630
+ if (!this.isConnected) return;
4447
4631
  this.precision = this.getAttribute("precision") || 1;
4448
4632
  this.precision = parseInt(this.precision);
4449
4633
  this.text = this.getAttribute("text") === "true";
@@ -4475,10 +4659,13 @@ class FigInputAngle extends HTMLElement {
4475
4659
  }
4476
4660
 
4477
4661
  disconnectedCallback() {
4662
+ this.#cancelGesture();
4478
4663
  this.#cleanupListeners();
4479
4664
  }
4480
4665
 
4481
4666
  #render() {
4667
+ this.#cancelGesture();
4668
+ this.#cleanupListeners();
4482
4669
  this.innerHTML = this.#getInnerHTML();
4483
4670
  }
4484
4671
 
@@ -4505,10 +4692,23 @@ class FigInputAngle extends HTMLElement {
4505
4692
  const step = this.#getStepForUnit();
4506
4693
  const minAttr = this.min !== null ? `min="${this.min}"` : "";
4507
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);
4508
4700
  return `
4509
4701
  ${
4510
4702
  this.dial
4511
- ? `<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"' : ""}>
4512
4712
  <div class="fig-input-angle-handle"></div>
4513
4713
  </div>`
4514
4714
  : ""
@@ -4521,7 +4721,9 @@ class FigInputAngle extends HTMLElement {
4521
4721
  value="${this.angle}"
4522
4722
  ${minAttr}
4523
4723
  ${maxAttr}
4524
- units="${this.units}">
4724
+ units="${this.units}"
4725
+ aria-label="${FigInputAngle.#escapeAttribute(name)}"
4726
+ ${disabled ? "disabled" : ""}>
4525
4727
  ${this.showRotations ? `<span slot="append" class="fig-input-angle-rotations"></span>` : ""}
4526
4728
  </fig-input-number>`
4527
4729
  : ""
@@ -4534,6 +4736,57 @@ class FigInputAngle extends HTMLElement {
4534
4736
  return Math.floor(degrees / 360);
4535
4737
  }
4536
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
+
4537
4790
  #updateRotationDisplay() {
4538
4791
  if (!this.rotationSpan) return;
4539
4792
  const rotations = this.#getRotationCount();
@@ -4609,10 +4862,12 @@ class FigInputAngle extends HTMLElement {
4609
4862
  this.#updateRotationDisplay();
4610
4863
  this.plane?.addEventListener("mousedown", this.#boundHandleMouseDown);
4611
4864
  this.plane?.addEventListener("touchstart", this.#boundHandleTouchStart);
4865
+ this.plane?.addEventListener("keydown", this.#boundHandleDialKeyDown);
4612
4866
  window.addEventListener("keydown", this.#boundHandleKeyDown);
4613
4867
  window.addEventListener("keyup", this.#boundHandleKeyUp);
4614
4868
  if (this.text && this.angleInput) {
4615
4869
  this.angleInput.addEventListener("input", this.#boundHandleAngleInput);
4870
+ this.angleInput.addEventListener("change", this.#boundHandleAngleInput);
4616
4871
  }
4617
4872
  this.addEventListener("change", this.#boundHandleRawChange, true);
4618
4873
  }
@@ -4620,10 +4875,12 @@ class FigInputAngle extends HTMLElement {
4620
4875
  #cleanupListeners() {
4621
4876
  this.plane?.removeEventListener("mousedown", this.#boundHandleMouseDown);
4622
4877
  this.plane?.removeEventListener("touchstart", this.#boundHandleTouchStart);
4878
+ this.plane?.removeEventListener("keydown", this.#boundHandleDialKeyDown);
4623
4879
  window.removeEventListener("keydown", this.#boundHandleKeyDown);
4624
4880
  window.removeEventListener("keyup", this.#boundHandleKeyUp);
4625
4881
  if (this.text && this.angleInput) {
4626
4882
  this.angleInput.removeEventListener("input", this.#boundHandleAngleInput);
4883
+ this.angleInput.removeEventListener("change", this.#boundHandleAngleInput);
4627
4884
  }
4628
4885
  this.removeEventListener("change", this.#boundHandleRawChange, true);
4629
4886
  }
@@ -4645,12 +4902,10 @@ class FigInputAngle extends HTMLElement {
4645
4902
 
4646
4903
  #handleAngleInput(e) {
4647
4904
  e.stopPropagation();
4648
- this.angle = Number(e.target.value);
4649
- this.#calculateAdjacentAndOpposite();
4650
- this.#syncHandlePosition();
4651
- this.#updateRotationDisplay();
4652
- this.#emitInputEvent();
4653
- 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();
4654
4909
  }
4655
4910
 
4656
4911
  #calculateAdjacentAndOpposite() {
@@ -4683,7 +4938,7 @@ class FigInputAngle extends HTMLElement {
4683
4938
  const isBounded = this.min !== null || this.max !== null;
4684
4939
 
4685
4940
  if (isBounded) {
4686
- this.angle = this.#fromDegrees(normalizedAngle);
4941
+ this.angle = this.#clampValue(this.#fromDegrees(normalizedAngle));
4687
4942
  } else {
4688
4943
  if (this.#prevRawAngle === null) {
4689
4944
  this.#prevRawAngle = normalizedAngle;
@@ -4692,23 +4947,25 @@ class FigInputAngle extends HTMLElement {
4692
4947
  let delta = normalizedAngle - currentMod;
4693
4948
  if (delta > 180) delta -= 360;
4694
4949
  if (delta < -180) delta += 360;
4695
- this.angle += this.#fromDegrees(delta);
4950
+ this.angle = this.#clampValue(this.angle + this.#fromDegrees(delta));
4696
4951
  } else {
4697
4952
  let delta = normalizedAngle - this.#prevRawAngle;
4698
4953
  if (delta > 180) delta -= 360;
4699
4954
  if (delta < -180) delta += 360;
4700
- this.angle += this.#fromDegrees(delta);
4955
+ this.angle = this.#clampValue(this.angle + this.#fromDegrees(delta));
4701
4956
  this.#prevRawAngle = normalizedAngle;
4702
4957
  }
4703
4958
  }
4704
4959
 
4705
4960
  this.#calculateAdjacentAndOpposite();
4961
+ this.setAttribute("value", String(this.angle));
4706
4962
 
4707
4963
  this.#syncHandlePosition();
4708
4964
  if (this.text && this.angleInput) {
4709
4965
  this.angleInput.setAttribute("value", this.angle.toFixed(this.precision));
4710
4966
  }
4711
4967
  this.#updateRotationDisplay();
4968
+ this.#syncDialState();
4712
4969
 
4713
4970
  this.#emitInputEvent();
4714
4971
  }
@@ -4745,6 +5002,8 @@ class FigInputAngle extends HTMLElement {
4745
5002
  }
4746
5003
 
4747
5004
  #handleMouseDown(e) {
5005
+ if (this.#isDisabled() || e.button !== 0) return;
5006
+ this.#cancelGesture();
4748
5007
  this.isDragging = true;
4749
5008
  this.#prevRawAngle = null;
4750
5009
  this.#updateAngle(e);
@@ -4754,21 +5013,36 @@ class FigInputAngle extends HTMLElement {
4754
5013
  if (this.isDragging) this.#updateAngle(e);
4755
5014
  };
4756
5015
 
4757
- const handleMouseUp = () => {
5016
+ const controller = new AbortController();
5017
+ const finish = (commit = false) => {
4758
5018
  this.isDragging = false;
4759
5019
  this.#prevRawAngle = null;
4760
5020
  this.plane.classList.remove("dragging");
4761
- window.removeEventListener("mousemove", handleMouseMove);
4762
- window.removeEventListener("mouseup", handleMouseUp);
4763
- this.#emitChangeEvent();
5021
+ controller.abort();
5022
+ if (this.#gestureController === controller) {
5023
+ this.#gestureController = null;
5024
+ this.#gestureCleanup = null;
5025
+ }
5026
+ if (commit) this.#emitChangeEvent();
4764
5027
  };
5028
+ this.#gestureController = controller;
5029
+ this.#gestureCleanup = finish;
4765
5030
 
4766
- window.addEventListener("mousemove", handleMouseMove);
4767
- 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
+ });
4768
5040
  }
4769
5041
 
4770
5042
  #handleTouchStart(e) {
5043
+ if (this.#isDisabled()) return;
4771
5044
  e.preventDefault();
5045
+ this.#cancelGesture();
4772
5046
  this.isDragging = true;
4773
5047
  this.#prevRawAngle = null;
4774
5048
  this.#updateAngle(e.touches[0]);
@@ -4778,17 +5052,56 @@ class FigInputAngle extends HTMLElement {
4778
5052
  if (this.isDragging) this.#updateAngle(e.touches[0]);
4779
5053
  };
4780
5054
 
4781
- const handleTouchEnd = () => {
5055
+ const controller = new AbortController();
5056
+ const finish = (commit = false) => {
4782
5057
  this.isDragging = false;
4783
5058
  this.#prevRawAngle = null;
4784
5059
  this.plane.classList.remove("dragging");
4785
- window.removeEventListener("touchmove", handleTouchMove);
4786
- window.removeEventListener("touchend", handleTouchEnd);
4787
- this.#emitChangeEvent();
5060
+ controller.abort();
5061
+ if (this.#gestureController === controller) {
5062
+ this.#gestureController = null;
5063
+ this.#gestureCleanup = null;
5064
+ }
5065
+ if (commit) this.#emitChangeEvent();
4788
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
+ }
5080
+
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
+ }
4789
5090
 
4790
- window.addEventListener("touchmove", handleTouchMove);
4791
- window.addEventListener("touchend", handleTouchEnd);
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();
4792
5105
  }
4793
5106
 
4794
5107
  #handleKeyDown(e) {
@@ -4814,6 +5127,9 @@ class FigInputAngle extends HTMLElement {
4814
5127
  "dial",
4815
5128
  "rotations",
4816
5129
  "show-rotations",
5130
+ "disabled",
5131
+ "aria-label",
5132
+ "name",
4817
5133
  ];
4818
5134
  }
4819
5135
 
@@ -4834,20 +5150,14 @@ class FigInputAngle extends HTMLElement {
4834
5150
  console.error("Invalid value: must be a number.");
4835
5151
  return;
4836
5152
  }
4837
- this.angle = value;
4838
- this.#calculateAdjacentAndOpposite();
4839
- this.#syncHandlePosition();
4840
- if (this.angleInput) {
4841
- this.angleInput.setAttribute("value", this.angle.toFixed(this.precision));
4842
- }
4843
- this.#updateRotationDisplay();
5153
+ this.#setValue(Number(value));
4844
5154
  }
4845
5155
 
4846
5156
  attributeChangedCallback(name, oldValue, newValue) {
4847
5157
  switch (name) {
4848
5158
  case "value":
4849
5159
  if (this.isDragging) break;
4850
- this.value = Number(newValue);
5160
+ if (newValue !== null) this.#setValue(Number(newValue), { reflect: false });
4851
5161
  break;
4852
5162
  case "precision":
4853
5163
  this.precision = parseInt(newValue);
@@ -4883,6 +5193,7 @@ class FigInputAngle extends HTMLElement {
4883
5193
  }
4884
5194
  case "min":
4885
5195
  this.min = newValue !== null ? Number(newValue) : null;
5196
+ this.#setValue(this.angle);
4886
5197
  if (this.isConnected) {
4887
5198
  this.#render();
4888
5199
  this.#setupListeners();
@@ -4891,6 +5202,23 @@ class FigInputAngle extends HTMLElement {
4891
5202
  break;
4892
5203
  case "max":
4893
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":
4894
5222
  if (this.isConnected) {
4895
5223
  this.#render();
4896
5224
  this.#setupListeners();
@@ -4909,7 +5237,7 @@ class FigInputAngle extends HTMLElement {
4909
5237
  }
4910
5238
  }
4911
5239
  }
4912
- customElements.define("fig-input-angle", FigInputAngle);
5240
+ figLabDefineElement("fig-input-angle", FigInputAngle);
4913
5241
 
4914
5242
  /* Reorder wrapper */
4915
5243
  class FigReorder extends HTMLElement {
@@ -4949,9 +5277,15 @@ class FigReorder extends HTMLElement {
4949
5277
  #bindings = new Map();
4950
5278
  #drag = null;
4951
5279
  #indicator = null;
5280
+ #liveRegion = null;
4952
5281
 
4953
5282
  connectedCallback() {
4954
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();
4955
5289
  this.#syncChildren();
4956
5290
  this.#childObserver = new MutationObserver(() => this.#syncChildren());
4957
5291
  this.#childObserver.observe(this, { childList: true });
@@ -4963,10 +5297,15 @@ class FigReorder extends HTMLElement {
4963
5297
  this.#unbindAll();
4964
5298
  this.#cancelDrag();
4965
5299
  this.#removeIndicator();
5300
+ this.#liveRegion?.remove();
5301
+ this.#liveRegion = null;
4966
5302
  }
4967
5303
 
4968
5304
  attributeChangedCallback() {
4969
- if (this.isConnected) this.#syncChildren();
5305
+ if (this.isConnected) {
5306
+ if (this.#disabled) this.#cancelDrag();
5307
+ this.#syncChildren();
5308
+ }
4970
5309
  }
4971
5310
 
4972
5311
  get #disabled() {
@@ -4985,7 +5324,11 @@ class FigReorder extends HTMLElement {
4985
5324
  }
4986
5325
 
4987
5326
  #getElementChildren() {
4988
- 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
+ );
4989
5332
  }
4990
5333
 
4991
5334
  #syncChildren() {
@@ -4999,6 +5342,8 @@ class FigReorder extends HTMLElement {
4999
5342
  binding.onPointerDown,
5000
5343
  true,
5001
5344
  );
5345
+ binding.target.removeEventListener("keydown", binding.onKeyDown);
5346
+ this.#restoreKeyboardAttrs(binding);
5002
5347
  this.#bindings.delete(child);
5003
5348
  }
5004
5349
  }
@@ -5034,12 +5379,20 @@ class FigReorder extends HTMLElement {
5034
5379
  #clearReorderItemMarks(children) {
5035
5380
  for (const child of children) {
5036
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
+ }
5037
5386
  }
5038
5387
  }
5039
5388
 
5040
5389
  #markReorderItems(children) {
5041
5390
  for (const child of children) {
5042
5391
  child.setAttribute("data-reorder-item", "");
5392
+ if (!child.hasAttribute("role")) {
5393
+ child.setAttribute("role", "listitem");
5394
+ child.setAttribute("data-reorder-generated-role", "");
5395
+ }
5043
5396
  }
5044
5397
  }
5045
5398
 
@@ -5047,7 +5400,10 @@ class FigReorder extends HTMLElement {
5047
5400
  for (const child of children) {
5048
5401
  child
5049
5402
  .querySelectorAll("[data-reorder-handle]")
5050
- .forEach((node) => node.removeAttribute("data-reorder-handle"));
5403
+ .forEach((node) => {
5404
+ node.removeAttribute("data-reorder-handle");
5405
+ node.removeAttribute("aria-roledescription");
5406
+ });
5051
5407
  }
5052
5408
  }
5053
5409
 
@@ -5055,7 +5411,10 @@ class FigReorder extends HTMLElement {
5055
5411
  if (!this.#handleSelector) return;
5056
5412
  for (const child of children) {
5057
5413
  const handle = child.querySelector(this.#handleSelector);
5058
- if (handle) handle.setAttribute("data-reorder-handle", "");
5414
+ if (handle) {
5415
+ handle.setAttribute("data-reorder-handle", "");
5416
+ handle.setAttribute("aria-roledescription", "reorder handle");
5417
+ }
5059
5418
  }
5060
5419
  }
5061
5420
 
@@ -5078,9 +5437,107 @@ class FigReorder extends HTMLElement {
5078
5437
  }
5079
5438
  this.#startPendingDrag(event, child, target);
5080
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
+ };
5081
5490
 
5082
5491
  target.addEventListener("pointerdown", onPointerDown, true);
5083
- 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
+ });
5084
5541
  }
5085
5542
 
5086
5543
  #isInteractiveTarget(target, child) {
@@ -5298,6 +5755,13 @@ class FigReorder extends HTMLElement {
5298
5755
  if (ref !== item) this.insertBefore(item, ref);
5299
5756
  }
5300
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
+
5301
5765
  #finishDrag(state, revert) {
5302
5766
  const { item, oldIndex, active, onMove, onUp, onKeyDown } = state;
5303
5767
 
@@ -5317,6 +5781,13 @@ class FigReorder extends HTMLElement {
5317
5781
  detail: { oldIndex, newIndex, item },
5318
5782
  }),
5319
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
+ );
5320
5791
  }
5321
5792
  }
5322
5793
  }
@@ -5334,6 +5805,8 @@ class FigReorder extends HTMLElement {
5334
5805
  binding.onPointerDown,
5335
5806
  true,
5336
5807
  );
5808
+ binding.target.removeEventListener("keydown", binding.onKeyDown);
5809
+ this.#restoreKeyboardAttrs(binding);
5337
5810
  }
5338
5811
  this.#bindings.clear();
5339
5812
  }
@@ -5344,1187 +5817,169 @@ class FigReorder extends HTMLElement {
5344
5817
  }
5345
5818
  }
5346
5819
 
5347
- customElements.define("fig-reorder", FigReorder);
5820
+ figLabDefineElement("fig-reorder", FigReorder);
5348
5821
 
5349
- /* Select — dropdown-styled trigger + fig-popup listbox */
5350
- let figLabSelectId = 0;
5351
- function figLabUniqueId(prefix = "fig-select") {
5352
- figLabSelectId += 1;
5353
- return `${prefix}-${figLabSelectId}`;
5354
- }
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();
5355
5830
 
5356
- /** Parse options attr — same formats as fig-options / propskit-select. */
5357
- function figLabParseOptionsAttribute(raw) {
5358
- const text = raw || "";
5359
- if (text.startsWith("[")) {
5360
- try {
5361
- const parsed = JSON.parse(text);
5362
- return Array.isArray(parsed) ? parsed : [];
5363
- } catch {
5364
- /* fall through */
5365
- }
5366
- }
5367
- const delimiter = text.includes("\n") ? "\n" : ",";
5368
- return text
5369
- .split(delimiter)
5370
- .map((s) => s.trim())
5371
- .filter(Boolean);
5831
+ function figLabSelectOptions(select) {
5832
+ return Array.from(select.querySelectorAll("fig-select-option"));
5372
5833
  }
5373
5834
 
5374
- function figLabOptionEntryValue(opt) {
5375
- if (opt && typeof opt === "object") {
5376
- return String(opt.value ?? opt.label ?? "");
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];
5377
5846
  }
5378
- return String(opt ?? "");
5379
- }
5847
+ if (!option) return;
5380
5848
 
5381
- function figLabOptionEntryLabel(opt) {
5382
- if (opt && typeof opt === "object") {
5383
- return String(opt.label ?? opt.value ?? "");
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));
5384
5856
  }
5385
- return String(opt ?? "");
5386
- }
5387
-
5388
- /**
5389
- * A selectable option for fig-select.
5390
- * Supports light-DOM slots: `slot="prepend"` (leading) and `slot="append"` (trailing).
5391
- * Use the `label` attribute for the closed-trigger label when option content is rich.
5392
- *
5393
- * @attr {string} value - Option value
5394
- * @attr {string} label - Optional display label for the select trigger
5395
- * @attr {boolean} disabled - Whether the option is disabled
5396
- * @attr {boolean} selected - Whether the option is selected
5397
- */
5398
- class FigSelectOption extends HTMLElement {
5399
- static get observedAttributes() {
5400
- return ["value", "disabled", "selected", "label"];
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
+ );
5401
5872
  }
5873
+ }
5402
5874
 
5403
- get value() {
5404
- const attr = this.getAttribute("value");
5405
- if (attr !== null) return attr;
5406
- return (this.textContent || "").trim();
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;
5407
5883
  }
5884
+ figLabEnhancedSelects.add(select);
5408
5885
 
5409
- set value(val) {
5410
- if (val === null || val === undefined) {
5411
- this.removeAttribute("value");
5412
- } else {
5413
- this.setAttribute("value", String(val));
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;
5896
+ }
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;
5904
+ }
5414
5905
  }
5415
- }
5416
-
5417
- get disabled() {
5418
- return figLabBooleanAttribute(this, "disabled");
5419
- }
5420
-
5421
- set disabled(val) {
5422
- if (val) this.setAttribute("disabled", "");
5423
- else this.removeAttribute("disabled");
5424
- }
5425
-
5426
- get selected() {
5427
- return figLabBooleanAttribute(this, "selected");
5428
- }
5429
-
5430
- set selected(val) {
5431
- if (val) this.setAttribute("selected", "");
5432
- else this.removeAttribute("selected");
5433
- }
5434
-
5435
- connectedCallback() {
5436
- if (!this.hasAttribute("role")) this.setAttribute("role", "option");
5437
- if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
5438
- this.#syncDisabled();
5439
- }
5440
-
5441
- attributeChangedCallback(name, oldValue, newValue) {
5442
- if (oldValue === newValue) return;
5443
- if (name === "disabled") this.#syncDisabled();
5444
- }
5445
-
5446
- #syncDisabled() {
5447
- const disabled = this.disabled;
5448
- if (disabled) {
5449
- this.setAttribute("aria-disabled", "true");
5450
- this.setAttribute("tabindex", "-1");
5451
- } else {
5452
- this.removeAttribute("aria-disabled");
5453
- if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
5906
+ if (selectedOption) {
5907
+ const optionValue = String(selectedOption.value ?? "");
5908
+ if ((select.getAttribute("value") ?? "") !== optionValue) {
5909
+ figLabSyncSelectedOption(select, selectedOption);
5910
+ }
5911
+ } else if (selectedWasRemoved) {
5912
+ figLabSyncSelectedOption(select, null);
5454
5913
  }
5455
- }
5456
- }
5457
- customElements.define("fig-select-option", FigSelectOption);
5458
-
5459
- function figLabSyncOverflowState(host, scrollEl, threshold = 2) {
5460
- if (!host || !scrollEl) return false;
5461
- const scrollable = scrollEl.scrollHeight - scrollEl.clientHeight > threshold;
5462
- const atStart = !scrollable || scrollEl.scrollTop <= threshold;
5463
- const atEnd =
5464
- !scrollable ||
5465
- scrollEl.scrollTop + scrollEl.clientHeight >=
5466
- scrollEl.scrollHeight - threshold;
5467
- host.classList.toggle("overflow-start", !atStart);
5468
- host.classList.toggle("overflow-end", !atEnd);
5469
- return scrollable;
5470
- }
5471
-
5472
- function figLabScrollOverflowPage(scrollEl, direction = 1) {
5473
- if (!scrollEl) return;
5474
- scrollEl.scrollBy({
5475
- top: scrollEl.clientHeight * 0.8 * direction,
5476
- behavior: "smooth",
5914
+ });
5915
+ observer.observe(select, {
5916
+ attributes: true,
5917
+ subtree: true,
5918
+ attributeFilter: ["selected"],
5477
5919
  });
5478
5920
  }
5479
5921
 
5480
- function figLabCreateOverflowButtons({ onStart, onEnd } = {}) {
5481
- const makeButton = (direction, onClick) => {
5482
- const button = document.createElement("button");
5483
- button.type = "button";
5484
- button.className = `fig-overflow fig-overflow-${direction}`;
5485
- button.dataset.figOverflow = direction;
5486
- button.setAttribute("data-fig-select-nav", direction);
5487
- button.setAttribute("tabindex", "-1");
5488
- button.setAttribute(
5489
- "aria-label",
5490
- direction === "start" ? "Scroll up" : "Scroll down",
5491
- );
5492
- const icon = document.createElement("fig-icon");
5493
- icon.setAttribute("name", "chevron");
5494
- icon.setAttribute("size", "small");
5495
- icon.className = "fig-overflow-chevron";
5496
- button.appendChild(icon);
5497
- button.addEventListener("click", (event) => {
5498
- event.preventDefault();
5499
- event.stopPropagation();
5500
- onClick?.(event);
5501
- });
5502
- return button;
5503
- };
5504
- return {
5505
- start: makeButton("start", onStart),
5506
- end: makeButton("end", onEnd),
5507
- };
5508
- }
5509
-
5510
- /** Light-DOM panel wrapper projected into fig-select's popup; owns overflow buttons. */
5511
- class FigSelectOptions extends HTMLElement {
5512
- #navStart = null;
5513
- #navEnd = null;
5514
- #resizeObserver = null;
5515
- #boundSyncOverflow = this.syncOverflow.bind(this);
5516
-
5517
- connectedCallback() {
5518
- if (!this.hasAttribute("slot")) this.setAttribute("slot", "panel");
5519
- this.#unwrapLegacyChooser();
5520
- this.#ensureNavButtons();
5521
- this.addEventListener("scroll", this.#boundSyncOverflow, { passive: true });
5522
- this.#resizeObserver?.disconnect();
5523
- this.#resizeObserver = new ResizeObserver(() => this.syncOverflow());
5524
- this.#resizeObserver.observe(this);
5525
- requestAnimationFrame(() => this.syncOverflow());
5526
- }
5527
-
5528
- disconnectedCallback() {
5529
- this.removeEventListener("scroll", this.#boundSyncOverflow);
5530
- this.#resizeObserver?.disconnect();
5531
- this.#resizeObserver = null;
5532
- this.#removeNavButtons();
5533
- }
5534
-
5535
- syncOverflow() {
5536
- return figLabSyncOverflowState(this, this);
5537
- }
5538
-
5539
- scrollToOption(option, behavior = "auto") {
5540
- if (!option || !this.contains(option)) return;
5541
- requestAnimationFrame(() => {
5542
- if (!option.isConnected) return;
5543
- if (this.scrollHeight <= this.clientHeight + 1) {
5544
- this.syncOverflow();
5545
- return;
5546
- }
5547
- const optionRect = option.getBoundingClientRect();
5548
- const hostRect = this.getBoundingClientRect();
5549
- const optionTop = optionRect.top - hostRect.top + this.scrollTop;
5550
- const maxScroll = this.scrollHeight - this.clientHeight;
5551
- const top = Math.max(
5552
- 0,
5553
- Math.min(
5554
- optionTop + optionRect.height / 2 - this.clientHeight / 2,
5555
- maxScroll,
5556
- ),
5557
- );
5558
- this.scrollTo({ top, behavior });
5559
- this.syncOverflow();
5560
- });
5561
- }
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);
5562
5927
 
5563
- #unwrapLegacyChooser() {
5564
- const chooser = this.querySelector(":scope > fig-chooser");
5565
- if (!chooser) return;
5566
- while (chooser.firstChild) {
5567
- this.insertBefore(chooser.firstChild, chooser);
5568
- }
5569
- chooser.remove();
5570
- }
5571
-
5572
- #ensureNavButtons() {
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");
5573
5936
  if (
5574
- this.#navStart &&
5575
- this.#navEnd &&
5576
- this.contains(this.#navStart) &&
5577
- this.contains(this.#navEnd)
5937
+ !option ||
5938
+ !select.contains(option) ||
5939
+ figLabBooleanAttribute(option, "disabled")
5578
5940
  ) {
5579
5941
  return;
5580
5942
  }
5581
- this.#removeNavButtons();
5582
- const buttons = figLabCreateOverflowButtons({
5583
- onStart: () => figLabScrollOverflowPage(this, -1),
5584
- onEnd: () => figLabScrollOverflowPage(this, 1),
5585
- });
5586
- this.#navStart = buttons.start;
5587
- this.#navEnd = buttons.end;
5588
- this.prepend(this.#navStart);
5589
- this.append(this.#navEnd);
5590
- }
5591
-
5592
- #removeNavButtons() {
5593
- this.#navStart?.remove();
5594
- this.#navEnd?.remove();
5595
- this.#navStart = null;
5596
- this.#navEnd = null;
5597
- this.classList.remove("overflow-start", "overflow-end");
5598
- }
5943
+ figLabSyncSelectedOption(select, option);
5944
+ select.open = false;
5945
+ });
5599
5946
  }
5600
- customElements.define("fig-select-options", FigSelectOptions);
5601
-
5602
- class FigSelect extends HTMLElement {
5603
- #button = null;
5604
- #popup = null;
5605
- #labelEl = null;
5606
- #panelSlot = null;
5607
- #observer = null;
5608
- #initialized = false;
5609
- #focusedIndex = -1;
5610
- #syncingValue = false;
5611
- #popupPositionPatched = false;
5612
- #originalPositionPopup = null;
5613
- /**
5614
- * After open align, ignore content/scroll-driven positionPopup passes so
5615
- * overflow paging isn't yanked back. Still realign when the trigger moves
5616
- * or the viewport size changes (window resize, layout shift, page scroll).
5617
- */
5618
- #freezeMenuPosition = false;
5619
- #frozenLabelRect = null;
5620
- #frozenViewport = null;
5621
- #syncingOptions = false;
5622
- #boundTriggerClick = this.#handleTriggerClick.bind(this);
5623
- #boundOptionClick = this.#handleOptionClick.bind(this);
5624
- #boundKeydown = this.#handleKeydown.bind(this);
5625
- #boundPopupClose = this.#handlePopupClose.bind(this);
5626
- #boundSlotChange = this.#handleSlotChange.bind(this);
5627
-
5628
- static get observedAttributes() {
5629
- return [
5630
- "value",
5631
- "disabled",
5632
- "label",
5633
- "options",
5634
- "position",
5635
- "offset",
5636
- "closedby",
5637
- "open",
5638
- ];
5639
- }
5640
-
5641
- get value() {
5642
- return this.getAttribute("value") ?? "";
5643
- }
5644
-
5645
- set value(val) {
5646
- if (val === null || val === undefined) this.removeAttribute("value");
5647
- else this.setAttribute("value", String(val));
5648
- }
5649
-
5650
- get open() {
5651
- return figLabBooleanAttribute(this, "open");
5652
- }
5653
-
5654
- set open(val) {
5655
- if (val) this.setAttribute("open", "");
5656
- else this.removeAttribute("open");
5657
- }
5658
-
5659
- connectedCallback() {
5660
- if (!this.#initialized) this.#initialize();
5661
- this.#ensurePanelSlotAttrs();
5662
- this.#syncOptionsFromAttribute();
5663
- this.#syncDisabled();
5664
- this.#syncPopupAttrs();
5665
- this.#syncValue();
5666
- this.#setupObserver();
5667
- if (this.open) this.#openList();
5668
- }
5669
-
5670
- disconnectedCallback() {
5671
- this.#teardownListeners();
5672
- document.removeEventListener("keydown", this.#boundKeydown, true);
5673
- this.#observer?.disconnect();
5674
- this.#observer = null;
5675
- }
5676
-
5677
- attributeChangedCallback(name, oldValue, newValue) {
5678
- if (oldValue === newValue || !this.#initialized) return;
5679
- if (name === "options") {
5680
- this.#syncOptionsFromAttribute();
5681
- this.#syncValue();
5682
- return;
5683
- }
5684
- if (name === "value" || name === "label") {
5685
- this.#syncValue();
5686
- return;
5687
- }
5688
- if (name === "disabled") {
5689
- this.#syncDisabled();
5690
- return;
5691
- }
5692
- if (name === "open") {
5693
- if (newValue === null || newValue === "false") this.#closeList();
5694
- else this.#openList();
5695
- return;
5696
- }
5697
- if (name === "position" || name === "offset" || name === "closedby") {
5698
- this.#syncPopupAttrs();
5699
- }
5700
- }
5701
-
5702
- focus(options) {
5703
- this.#button?.focus(options);
5704
- }
5705
-
5706
- blur() {
5707
- this.#button?.blur();
5708
- }
5709
-
5710
- #isMenuChild(node) {
5711
- return (
5712
- node?.nodeType === 1 &&
5713
- (node.tagName === "FIG-SELECT-OPTION" ||
5714
- node.tagName === "FIG-MENU-SEPARATOR" ||
5715
- node.tagName === "FIG-SELECT-OPTIONS")
5716
- );
5717
- }
5718
-
5719
- #ensurePanelSlotAttrs() {
5720
- for (const panel of this.querySelectorAll(":scope > fig-select-options")) {
5721
- if (!panel.hasAttribute("slot")) panel.setAttribute("slot", "panel");
5722
- }
5723
- }
5724
-
5725
- #getPanel() {
5726
- const assigned = this.#panelSlot?.assignedElements({ flatten: true }) ?? [];
5727
- const fromSlot = assigned.find(
5728
- (el) => el.tagName === "FIG-SELECT-OPTIONS",
5729
- );
5730
- if (fromSlot) return fromSlot;
5731
- return this.querySelector(":scope > fig-select-options");
5732
- }
5733
-
5734
- #hasAuthoredOptions() {
5735
- return Boolean(
5736
- this.querySelector(
5737
- ":scope > fig-select-option:not([data-fig-generated]), :scope > fig-select-options > fig-select-option:not([data-fig-generated])",
5738
- ),
5739
- );
5740
- }
5741
-
5742
- #ensureOptionsPanel() {
5743
- let panel = this.#getPanel();
5744
- if (panel) {
5745
- if (!panel.hasAttribute("slot")) panel.setAttribute("slot", "panel");
5746
- return panel;
5747
- }
5748
- panel = document.createElement("fig-select-options");
5749
- panel.setAttribute("slot", "panel");
5750
- panel.setAttribute("data-fig-generated", "");
5751
- this.appendChild(panel);
5752
- return panel;
5753
- }
5754
-
5755
- /**
5756
- * When no authored fig-select-option exists, build panel/options from the
5757
- * options attribute (comma / newline / JSON — same as fig-options).
5758
- */
5759
- #syncOptionsFromAttribute() {
5760
- if (this.#hasAuthoredOptions()) return;
5761
5947
 
5762
- const hasOptionsAttr = this.hasAttribute("options");
5763
- const panel = hasOptionsAttr
5764
- ? this.#ensureOptionsPanel()
5765
- : this.#getPanel();
5766
- if (!panel) return;
5767
-
5768
- this.#syncingOptions = true;
5769
- try {
5770
- for (const opt of panel.querySelectorAll(
5771
- ":scope > fig-select-option[data-fig-generated]",
5772
- )) {
5773
- opt.remove();
5774
- }
5775
-
5776
- if (!hasOptionsAttr) return;
5777
-
5778
- const parsed = figLabParseOptionsAttribute(this.getAttribute("options"));
5779
- const endBtn = panel.querySelector(":scope > .fig-overflow-end");
5780
- for (const entry of parsed) {
5781
- const el = document.createElement("fig-select-option");
5782
- el.setAttribute("data-fig-generated", "");
5783
- el.setAttribute("value", figLabOptionEntryValue(entry));
5784
- el.textContent = figLabOptionEntryLabel(entry);
5785
- if (endBtn) panel.insertBefore(el, endBtn);
5786
- else panel.appendChild(el);
5787
- }
5788
- } finally {
5789
- this.#syncingOptions = false;
5790
- }
5791
- }
5792
-
5793
- #initialize() {
5794
- this.#initialized = true;
5795
- const shadow = this.attachShadow({ mode: "open" });
5796
- shadow.innerHTML = `
5797
- <style>
5798
- :host {
5799
- display: inline-flex;
5800
- position: relative;
5801
- align-items: center;
5802
- min-width: 0;
5803
- }
5804
- :host([full]:not([full="false"])) {
5805
- display: flex;
5806
- width: 100%;
5807
- }
5808
- .fig-select-trigger {
5809
- display: flex;
5810
- align-items: center;
5811
- justify-content: flex-start;
5812
- flex: 1;
5813
- min-width: 0;
5814
- width: var(--fig-select-trigger-width, 100%);
5815
- height: 100%;
5816
- margin: 0;
5817
- padding: 0 var(--spacer-4, 1rem) 0 var(--spacer-2, 0.5rem);
5818
- border: 0;
5819
- border-radius: inherit;
5820
- background: transparent;
5821
- box-shadow: none;
5822
- color: inherit;
5823
- font: inherit;
5824
- font-weight: inherit;
5825
- text-align: left;
5826
- white-space: nowrap;
5827
- overflow: hidden;
5828
- text-overflow: ellipsis;
5829
- cursor: default;
5830
- }
5831
- .fig-select-trigger:hover,
5832
- .fig-select-trigger:active,
5833
- .fig-select-trigger:active:hover {
5834
- background: transparent;
5835
- box-shadow: none;
5836
- color: inherit;
5837
- }
5838
- .fig-select-trigger:focus-visible,
5839
- .fig-select-trigger[data-focus-visible] {
5840
- outline: var(--figma-focus-outline);
5841
- outline-offset: var(--figma-focus-outline-offset);
5842
- }
5843
- :host([disabled]:not([disabled="false"])) .fig-select-trigger,
5844
- :host([disabled]:not([disabled="false"])) .fig-select-label {
5845
- color: var(--figma-color-text-tertiary);
5846
- }
5847
- .fig-select-label {
5848
- display: block;
5849
- width: 100%;
5850
- min-width: 0;
5851
- overflow: hidden;
5852
- text-overflow: ellipsis;
5853
- white-space: nowrap;
5854
- text-align: left;
5855
- }
5856
- /* Listbox chrome from document fig-select::part(listbox).
5857
- Overflow UI lives on slotted fig-select-options.
5858
- Never set display except when open — closed <dialog> must stay display:none. */
5859
- dialog[is="fig-popup"] {
5860
- flex-direction: column;
5861
- overflow: hidden;
5862
- }
5863
- dialog[is="fig-popup"][open] {
5864
- display: flex;
5865
- }
5866
- ::slotted(fig-select-options) {
5867
- flex: 1 1 auto;
5868
- min-height: 0;
5869
- max-height: inherit;
5870
- }
5871
- </style>
5872
- `;
5873
-
5874
- const button = document.createElement("fig-button");
5875
- button.className = "fig-select-trigger";
5876
- button.setAttribute("part", "trigger");
5877
- button.setAttribute("variant", "ghost");
5878
- button.setAttribute("aria-haspopup", "listbox");
5879
- button.setAttribute("aria-expanded", "false");
5880
-
5881
- const labelEl = document.createElement("span");
5882
- labelEl.className = "fig-select-label";
5883
- labelEl.setAttribute("part", "label");
5884
- button.appendChild(labelEl);
5885
-
5886
- const popup = document.createElement("dialog", { is: "fig-popup" });
5887
- popup.setAttribute("is", "fig-popup");
5888
- popup.setAttribute("part", "listbox");
5889
- popup.setAttribute("theme", "menu");
5890
- popup.setAttribute("role", "listbox");
5891
- // Top-layer via popover so the menu escapes ancestor contain/overflow
5892
- // (e.g. fig-fill-picker-dialog). Stays in shadow so option slots still work —
5893
- // unlike tooltips, we cannot portal this popup to the overlay root.
5894
- if ("popover" in HTMLElement.prototype) {
5895
- popup.setAttribute("popover", "manual");
5896
- }
5897
- popup.id = figLabUniqueId("fig-select-list");
5898
- button.setAttribute("aria-controls", popup.id);
5899
-
5900
- const panelSlot = document.createElement("slot");
5901
- panelSlot.setAttribute("name", "panel");
5902
- popup.appendChild(panelSlot);
5903
-
5904
- shadow.append(button, popup);
5905
-
5906
- this.#button = button;
5907
- this.#labelEl = labelEl;
5908
- this.#popup = popup;
5909
- this.#panelSlot = panelSlot;
5910
- popup.anchor = button;
5911
-
5912
- this.#ensurePanelSlotAttrs();
5913
- this.#setupListeners();
5914
- this.#installPopupPositioning();
5915
-
5916
- if (!this.hasAttribute("value")) {
5917
- const selected = this.#getOptions().find((opt) =>
5918
- figLabBooleanAttribute(opt, "selected"),
5919
- );
5920
- if (selected) this.setAttribute("value", selected.value);
5921
- }
5922
- }
5923
-
5924
- #installPopupPositioning() {
5925
- if (!this.#popup || this.#popupPositionPatched) return;
5926
- if (typeof this.#popup.positionPopup !== "function") return;
5927
- this.#originalPositionPopup = this.#popup.positionPopup.bind(this.#popup);
5928
- this.#popup.positionPopup = () => {
5929
- if (!this.open) {
5930
- this.#originalPositionPopup?.();
5931
- return;
5932
- }
5933
- this.#positionPopupOverSelected();
5934
- };
5935
- this.#popupPositionPatched = true;
5936
- }
5937
-
5938
- #getOptionTextRect(option) {
5939
- if (!option) return null;
5940
- const range = document.createRange();
5941
- range.selectNodeContents(option);
5942
- const rects = [...range.getClientRects()].filter(
5943
- (rect) => rect.width > 0 && rect.height > 0,
5944
- );
5945
- if (rects.length) return rects[0];
5946
- return option.getBoundingClientRect();
5947
- }
5948
-
5949
- #getViewportMargins() {
5950
- if (typeof this.#popup?.parseViewportMargins === "function") {
5951
- return this.#popup.parseViewportMargins();
5952
- }
5953
- return { top: 8, right: 8, bottom: 8, left: 8 };
5954
- }
5955
-
5956
- #readLabelRectSnapshot() {
5957
- const rect = this.#labelEl?.getBoundingClientRect();
5958
- if (!rect) return null;
5959
- return {
5960
- x: rect.x,
5961
- y: rect.y,
5962
- width: rect.width,
5963
- height: rect.height,
5964
- };
5965
- }
5966
-
5967
- #readViewportSnapshot() {
5968
- const vv = window.visualViewport;
5969
- return {
5970
- width: vv?.width ?? window.innerWidth,
5971
- height: vv?.height ?? window.innerHeight,
5972
- offsetLeft: vv?.offsetLeft ?? 0,
5973
- offsetTop: vv?.offsetTop ?? 0,
5974
- };
5975
- }
5976
-
5977
- #rectSnapshotChanged(prev, next, epsilon = 0.25) {
5978
- if (!prev && !next) return false;
5979
- if (!prev || !next) return true;
5980
- return (
5981
- Math.abs(prev.x - next.x) > epsilon ||
5982
- Math.abs(prev.y - next.y) > epsilon ||
5983
- Math.abs(prev.width - next.width) > epsilon ||
5984
- Math.abs(prev.height - next.height) > epsilon
5985
- );
5986
- }
5987
-
5988
- #viewportSnapshotChanged(prev, next, epsilon = 0.25) {
5989
- if (!prev && !next) return false;
5990
- if (!prev || !next) return true;
5991
- return (
5992
- Math.abs(prev.width - next.width) > epsilon ||
5993
- Math.abs(prev.height - next.height) > epsilon ||
5994
- Math.abs(prev.offsetLeft - next.offsetLeft) > epsilon ||
5995
- Math.abs(prev.offsetTop - next.offsetTop) > epsilon
5996
- );
5997
- }
5998
-
5999
- #shouldSkipFrozenPositionPass() {
6000
- if (!this.#freezeMenuPosition) return false;
6001
- const labelMoved = this.#rectSnapshotChanged(
6002
- this.#frozenLabelRect,
6003
- this.#readLabelRectSnapshot(),
6004
- );
6005
- const viewportChanged = this.#viewportSnapshotChanged(
6006
- this.#frozenViewport,
6007
- this.#readViewportSnapshot(),
6008
- );
6009
- // Skip only when neither the trigger nor the viewport moved — typical of
6010
- // overflow scroll / content sync fighting the open-time alignment.
6011
- return !labelMoved && !viewportChanged;
5948
+ function figLabMarkDisconnectedSelectTree(root) {
5949
+ if (root instanceof Element && root.matches("fig-select")) {
5950
+ figLabDisconnectedSelects.add(root);
6012
5951
  }
5952
+ root
5953
+ .querySelectorAll?.("fig-select")
5954
+ .forEach((select) => figLabDisconnectedSelects.add(select));
5955
+ }
6013
5956
 
6014
- #rememberFrozenGeometry() {
6015
- this.#frozenLabelRect = this.#readLabelRectSnapshot();
6016
- this.#frozenViewport = this.#readViewportSnapshot();
5957
+ function figLabEnhanceSelectTree(root) {
5958
+ if (root instanceof Element && root.matches("fig-select")) {
5959
+ figLabEnhanceSelect(root);
6017
5960
  }
5961
+ root
5962
+ .querySelectorAll?.("fig-select")
5963
+ .forEach((select) => figLabEnhanceSelect(select));
5964
+ }
6018
5965
 
6019
- #positionPopupOverSelected() {
6020
- // Content ResizeObserver / overflow scroll re-enter here; keep the
6021
- // open-time alignment unless the trigger or viewport actually changed.
6022
- if (this.#shouldSkipFrozenPositionPass()) return;
6023
-
6024
- const popup = this.#popup;
6025
- const label = this.#labelEl;
6026
- if (!popup || !label) {
6027
- this.#originalPositionPopup?.();
6028
- return;
6029
- }
6030
-
6031
- const options = this.#getOptions();
6032
- const selected =
6033
- options.find((opt) => this.#optionValue(opt) === this.value) ||
6034
- options[0];
6035
- if (!selected) {
6036
- this.#originalPositionPopup?.();
6037
- return;
6038
- }
6039
-
6040
- // Lay out with the default positioning first so option metrics are valid.
6041
- this.#originalPositionPopup?.();
6042
-
6043
- const popupRect = popup.getBoundingClientRect();
6044
- const labelRect = label.getBoundingClientRect();
6045
- const optionTextRect = this.#getOptionTextRect(selected);
6046
- if (
6047
- !popupRect.width ||
6048
- !popupRect.height ||
6049
- !labelRect.width ||
6050
- !optionTextRect
6051
- ) {
6052
- return;
6053
- }
6054
-
6055
- const selectedOffsetX = optionTextRect.left - popupRect.left;
6056
- const selectedOffsetY = optionTextRect.top - popupRect.top;
6057
- const full = figLabBooleanAttribute(this, "full");
6058
- // [full]: pin menu to host width/edges. Otherwise overlay selected
6059
- // option text on the trigger label (blend-mode style).
6060
- let left = full
6061
- ? this.getBoundingClientRect().left
6062
- : labelRect.left - selectedOffsetX;
6063
- let top = labelRect.top - selectedOffsetY;
6064
-
6065
- // Keep the whole menu in-view when aligning over the selected option
6066
- // would otherwise push it past a viewport edge (corners / far sides).
6067
- const margins = this.#getViewportMargins();
6068
- if (typeof popup.clampToViewport === "function") {
6069
- ({ left, top } = popup.clampToViewport({ left, top }, popupRect, margins));
6070
- } else {
6071
- const minLeft = margins.left;
6072
- const minTop = margins.top;
6073
- const maxLeft = window.innerWidth - popupRect.width - margins.right;
6074
- const maxTop = window.innerHeight - popupRect.height - margins.bottom;
6075
- left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft));
6076
- top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop));
6077
- }
6078
-
6079
- // !important: fig-select::part(listbox) and dialog UA rules can otherwise
6080
- // keep the menu at its static/anchor position past the viewport edge.
6081
- popup.style.setProperty("right", "auto", "important");
6082
- popup.style.setProperty("bottom", "auto", "important");
6083
- popup.style.setProperty("left", `${Math.round(left)}px`, "important");
6084
- popup.style.setProperty("top", `${Math.round(top)}px`, "important");
6085
-
6086
- // Nudge the panel scroller so the selected label stays over the trigger.
6087
- const panel = this.#getPanel();
6088
- const alignedTextRect = this.#getOptionTextRect(selected);
6089
- if (
6090
- alignedTextRect &&
6091
- panel &&
6092
- panel.scrollHeight > panel.clientHeight + 1
6093
- ) {
6094
- const deltaY = alignedTextRect.top - labelRect.top;
6095
- if (Math.abs(deltaY) > 0.5) {
6096
- panel.scrollTop += deltaY;
6097
- }
6098
- panel.syncOverflow?.();
6099
- }
6100
-
6101
- if (this.#freezeMenuPosition || this.open) {
6102
- this.#rememberFrozenGeometry();
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);
6103
5971
  }
6104
- }
6105
-
6106
- #setupListeners() {
6107
- this.#button?.addEventListener("click", this.#boundTriggerClick);
6108
- this.#button?.addEventListener("keydown", this.#boundKeydown);
6109
- // Host click: slotted options stay in light DOM (not dialog.contains).
6110
- this.addEventListener("click", this.#boundOptionClick);
6111
- this.#popup?.addEventListener("keydown", this.#boundKeydown);
6112
- this.#popup?.addEventListener("close", this.#boundPopupClose);
6113
- this.#panelSlot?.addEventListener("slotchange", this.#boundSlotChange);
6114
- }
6115
-
6116
- #teardownListeners() {
6117
- this.#button?.removeEventListener("click", this.#boundTriggerClick);
6118
- this.#button?.removeEventListener("keydown", this.#boundKeydown);
6119
- this.removeEventListener("click", this.#boundOptionClick);
6120
- this.#popup?.removeEventListener("keydown", this.#boundKeydown);
6121
- this.#popup?.removeEventListener("close", this.#boundPopupClose);
6122
- this.#panelSlot?.removeEventListener("slotchange", this.#boundSlotChange);
6123
- }
6124
-
6125
- #handleSlotChange() {
6126
- this.#ensurePanelSlotAttrs();
6127
- this.#syncValue();
6128
- }
6129
-
6130
- #setupObserver() {
6131
- if (this.#observer) return;
6132
- this.#observer = new MutationObserver((mutations) => {
6133
- if (this.#syncingValue || this.#syncingOptions) return;
6134
- let needsSync = false;
6135
- for (const mutation of mutations) {
6136
- if (mutation.type === "childList") {
6137
- if (
6138
- [...mutation.addedNodes].some((node) => this.#isMenuChild(node)) ||
6139
- [...mutation.removedNodes].some((node) => this.#isMenuChild(node))
6140
- ) {
6141
- needsSync = true;
6142
- }
6143
- }
6144
- if (
6145
- mutation.type === "attributes" &&
6146
- mutation.target?.tagName === "FIG-SELECT-OPTION" &&
6147
- (mutation.attributeName === "value" ||
6148
- mutation.attributeName === "disabled" ||
6149
- mutation.attributeName === "label")
6150
- ) {
6151
- needsSync = true;
6152
- }
6153
- if (
6154
- mutation.type === "characterData" &&
6155
- mutation.target?.parentElement?.tagName === "FIG-SELECT-OPTION"
6156
- ) {
6157
- needsSync = true;
6158
- }
6159
- }
6160
- if (needsSync) this.#syncValue();
6161
- });
6162
- this.#observer.observe(this, {
6163
- childList: true,
6164
- subtree: true,
6165
- characterData: true,
6166
- attributes: true,
6167
- attributeFilter: ["value", "disabled", "selected", "label"],
6168
- });
6169
- }
6170
-
6171
- #getOptions({ enabledOnly = false } = {}) {
6172
- const panel = this.#getPanel();
6173
- const options = panel
6174
- ? Array.from(panel.querySelectorAll(":scope > fig-select-option"))
6175
- : [];
6176
- if (!enabledOnly) return options;
6177
- return options.filter((opt) => !figLabBooleanAttribute(opt, "disabled"));
6178
- }
6179
-
6180
- #optionValue(option) {
6181
- if (!option) return "";
6182
- if (typeof option.value === "string") return option.value;
6183
- const attr = option.getAttribute?.("value");
6184
- if (attr != null) return attr;
6185
- return (option.textContent || "").trim();
6186
- }
6187
-
6188
- #optionLabel(option) {
6189
- if (!option) return "";
6190
- const labelAttr = option.getAttribute?.("label");
6191
- if (labelAttr != null && labelAttr !== "") return labelAttr.trim();
6192
-
6193
- // Ignore prepend/append slot content when deriving a label from children.
6194
- const parts = [];
6195
- for (const node of option.childNodes) {
6196
- if (node.nodeType === Node.TEXT_NODE) {
6197
- const text = node.textContent?.trim();
6198
- if (text) parts.push(text);
6199
- continue;
6200
- }
5972
+ for (const node of mutation.addedNodes) {
6201
5973
  if (!(node instanceof Element)) continue;
6202
- const slot = node.getAttribute("slot");
6203
- if (slot === "prepend" || slot === "append") continue;
6204
- const text = node.textContent?.trim();
6205
- if (text) parts.push(text);
6206
- }
6207
- if (parts.length) return parts.join(" ").trim();
6208
- return (option.textContent || "").trim();
6209
- }
6210
-
6211
- #syncPopupAttrs() {
6212
- if (!this.#popup) return;
6213
- this.#popup.setAttribute(
6214
- "position",
6215
- this.getAttribute("position") || "bottom left",
6216
- );
6217
- const offset = this.getAttribute("offset");
6218
- if (offset) this.#popup.setAttribute("offset", offset);
6219
- else this.#popup.removeAttribute("offset");
6220
- const closedby = this.getAttribute("closedby");
6221
- if (closedby) this.#popup.setAttribute("closedby", closedby);
6222
- else this.#popup.removeAttribute("closedby");
6223
- }
6224
-
6225
- #syncDisabled() {
6226
- const disabled = figLabBooleanAttribute(this, "disabled");
6227
- if (this.#button) {
6228
- if (disabled) this.#button.setAttribute("disabled", "");
6229
- else this.#button.removeAttribute("disabled");
6230
- }
6231
- if (disabled && this.open) this.open = false;
6232
- }
6233
-
6234
- #pickFallbackOption(options) {
6235
- if (!options.length) return null;
6236
- const selected = options.find((opt) =>
6237
- figLabBooleanAttribute(opt, "selected"),
6238
- );
6239
- if (selected && !figLabBooleanAttribute(selected, "disabled")) {
6240
- return selected;
6241
- }
6242
- return (
6243
- options.find((opt) => !figLabBooleanAttribute(opt, "disabled")) ||
6244
- options[0] ||
6245
- null
6246
- );
6247
- }
6248
-
6249
- #emitValueEvents(value) {
6250
- this.dispatchEvent(
6251
- new CustomEvent("input", {
6252
- detail: value,
6253
- bubbles: true,
6254
- composed: true,
6255
- }),
6256
- );
6257
- this.dispatchEvent(
6258
- new CustomEvent("change", {
6259
- detail: value,
6260
- bubbles: true,
6261
- composed: true,
6262
- }),
6263
- );
6264
- }
6265
-
6266
- #syncValue() {
6267
- if (this.#syncingValue) return;
6268
- this.#syncingValue = true;
6269
- try {
6270
- const options = this.#getOptions();
6271
- const hasValueAttr = this.hasAttribute("value");
6272
- const previousValue = hasValueAttr ? this.getAttribute("value") : null;
6273
- let match = hasValueAttr
6274
- ? options.find((opt) => this.#optionValue(opt) === previousValue)
6275
- : null;
6276
- let valueCorrected = false;
6277
-
6278
- if (!match) {
6279
- if (hasValueAttr) {
6280
- // Options may not be built yet (options attr sync). Keep value until then.
6281
- if (!options.length) {
6282
- if (this.#labelEl) {
6283
- this.#labelEl.textContent =
6284
- previousValue || this.getAttribute("label") || "";
6285
- }
6286
- return;
6287
- }
6288
- // Value orphaned (option removed / value attr changed) — clamp or clear.
6289
- match = this.#pickFallbackOption(options);
6290
- if (match) {
6291
- const nextValue = this.#optionValue(match);
6292
- if (previousValue !== nextValue) {
6293
- this.setAttribute("value", nextValue);
6294
- valueCorrected = true;
6295
- }
6296
- } else {
6297
- this.removeAttribute("value");
6298
- valueCorrected = true;
6299
- }
6300
- } else {
6301
- // No host value yet — honor a selected option if present.
6302
- match = options.find((opt) =>
6303
- figLabBooleanAttribute(opt, "selected"),
6304
- );
6305
- if (match) {
6306
- this.setAttribute("value", this.#optionValue(match));
6307
- valueCorrected = true;
6308
- }
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);
6309
5981
  }
6310
5982
  }
6311
-
6312
- for (const opt of options) {
6313
- const selected = opt === match;
6314
- opt.setAttribute("aria-selected", selected ? "true" : "false");
6315
- if (selected) opt.setAttribute("selected", "");
6316
- else opt.removeAttribute("selected");
6317
- }
6318
-
6319
- const label =
6320
- (match && this.#optionLabel(match)) || this.getAttribute("label") || "";
6321
- if (this.#labelEl) this.#labelEl.textContent = label;
6322
-
6323
- const ariaLabel = this.getAttribute("label") || "Select";
6324
- this.#button?.setAttribute("aria-label", ariaLabel);
6325
-
6326
- // Don't scrollToOption while open — reposition/sync would fight overflow paging.
6327
- this.#getPanel()?.syncOverflow?.();
6328
-
6329
- if (valueCorrected) {
6330
- this.#emitValueEvents(this.getAttribute("value") ?? "");
6331
- }
6332
- } finally {
6333
- this.#syncingValue = false;
6334
5983
  }
6335
5984
  }
6336
-
6337
- #handleTriggerClick(e) {
6338
- if (figLabBooleanAttribute(this, "disabled")) return;
6339
- e.preventDefault();
6340
- e.stopPropagation();
6341
- const nextOpen = !this.open;
6342
- if (nextOpen && this.#popup && this.#button) {
6343
- this.#popup.anchor = this.#button;
6344
- }
6345
- this.open = nextOpen;
6346
- }
6347
-
6348
- #handleOptionClick(e) {
6349
- const path = typeof e.composedPath === "function" ? e.composedPath() : [];
6350
- const option = path.find(
6351
- (node) => node?.tagName === "FIG-SELECT-OPTION",
6352
- );
6353
- if (!option || !this.contains(option)) return;
6354
- if (figLabBooleanAttribute(option, "disabled")) return;
6355
- // Do not stopPropagation — React light-DOM onClick must still fire.
6356
- this.#selectOption(option);
6357
- }
6358
-
6359
- #handleKeydown(e) {
6360
- if (e.currentTarget === document && e.key !== "Escape") return;
6361
-
6362
- const listOpen = this.open && (this.#popup?.matches?.(":open") ?? false);
6363
- if (!listOpen) {
6364
- if (
6365
- this.#button?.contains(e.target) &&
6366
- (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ")
6367
- ) {
6368
- e.preventDefault();
6369
- if (this.#popup && this.#button) this.#popup.anchor = this.#button;
6370
- this.open = true;
6371
- requestAnimationFrame(() => {
6372
- const options = this.#getOptions({ enabledOnly: true });
6373
- const selectedIndex = options.findIndex(
6374
- (opt) => this.#optionValue(opt) === this.value,
6375
- );
6376
- this.#focusOptionAt(selectedIndex >= 0 ? selectedIndex : 0);
6377
- });
6378
- }
6379
- return;
6380
- }
6381
-
6382
- const options = this.#getOptions({ enabledOnly: true });
6383
- if (!options.length) return;
6384
-
6385
- switch (e.key) {
6386
- case "ArrowDown":
6387
- e.preventDefault();
6388
- this.#syncFocusedIndex();
6389
- this.#focusOptionAt(this.#focusedIndex + 1);
6390
- break;
6391
- case "ArrowUp":
6392
- e.preventDefault();
6393
- this.#syncFocusedIndex();
6394
- this.#focusOptionAt(this.#focusedIndex - 1);
6395
- break;
6396
- case "Home":
6397
- e.preventDefault();
6398
- this.#focusOptionAt(0);
6399
- break;
6400
- case "End":
6401
- e.preventDefault();
6402
- this.#focusOptionAt(options.length - 1);
6403
- break;
6404
- case "Escape":
6405
- e.preventDefault();
6406
- this.open = false;
6407
- this.#button?.focus();
6408
- break;
6409
- case "Enter":
6410
- case " ": {
6411
- this.#syncFocusedIndex();
6412
- const focused = options[this.#focusedIndex];
6413
- if (!focused) return;
6414
- e.preventDefault();
6415
- this.#selectOption(focused);
6416
- break;
6417
- }
6418
- }
6419
- }
6420
-
6421
- #handlePopupClose() {
6422
- if (this.hasAttribute("open")) this.removeAttribute("open");
6423
- this.#button?.setAttribute("aria-expanded", "false");
6424
- this.#button?.focus();
6425
- this.#focusedIndex = -1;
6426
- }
6427
-
6428
- #selectOption(option) {
6429
- const value = this.#optionValue(option);
6430
- this.setAttribute("value", value);
6431
- this.#syncValue();
6432
- this.#emitValueEvents(value);
6433
- this.open = false;
6434
- }
6435
-
6436
- #getEnabledOptions() {
6437
- return this.#getOptions({ enabledOnly: true });
6438
- }
6439
-
6440
- #syncFocusedIndex() {
6441
- const options = this.#getEnabledOptions();
6442
- if (!options.length) {
6443
- this.#focusedIndex = -1;
6444
- return;
6445
- }
6446
- const active = options.find((opt) => opt === document.activeElement);
6447
- const index = active ? options.indexOf(active) : -1;
6448
- this.#focusedIndex = index >= 0 ? index : this.#focusedIndex;
6449
- }
6450
-
6451
- #focusOptionAt(index) {
6452
- const options = this.#getEnabledOptions();
6453
- if (!options.length) return;
6454
- const next = ((index % options.length) + options.length) % options.length;
6455
- this.#focusedIndex = next;
6456
- options[next]?.focus();
6457
- }
6458
-
6459
- #syncPopupWidth() {
6460
- if (!this.#popup || !this.#button) return;
6461
- const hostWidth = Math.ceil(this.getBoundingClientRect().width);
6462
- const triggerWidth = Math.ceil(this.#button.getBoundingClientRect().width);
6463
- const anchorWidth = Math.max(hostWidth, triggerWidth, 96);
6464
- const full = figLabBooleanAttribute(this, "full");
6465
-
6466
- // Use !important — fig-select::part(listbox) width rules beat element.style.
6467
- // [full]: lock to host. Otherwise content-sized with host as min and 20rem max.
6468
- if (full) {
6469
- this.#popup.style.setProperty("width", `${anchorWidth}px`, "important");
6470
- this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
6471
- this.#popup.style.setProperty("max-width", `${anchorWidth}px`, "important");
6472
- } else {
6473
- this.#popup.style.setProperty("width", "max-content", "important");
6474
- this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
6475
- this.#popup.style.setProperty(
6476
- "max-width",
6477
- "min(20rem, calc(100vw - 1rem))",
6478
- "important",
6479
- );
6480
- }
6481
- }
6482
-
6483
- #openList() {
6484
- if (!this.#popup || figLabBooleanAttribute(this, "disabled")) return;
6485
- if (this.#button) this.#popup.anchor = this.#button;
6486
- this.#installPopupPositioning();
6487
- this.#freezeMenuPosition = false;
6488
- this.#frozenLabelRect = null;
6489
- this.#frozenViewport = null;
6490
- this.#syncValue();
6491
- this.#syncPopupWidth();
6492
- this.#popup.open = true;
6493
- document.addEventListener("keydown", this.#boundKeydown, true);
6494
- this.#button?.setAttribute("aria-expanded", "true");
6495
- this.#focusedIndex = -1;
6496
- requestAnimationFrame(() => {
6497
- this.#syncPopupWidth();
6498
- this.#positionPopupOverSelected();
6499
- const panel = this.#getPanel();
6500
- const options = this.#getEnabledOptions();
6501
- const selectedIndex = options.findIndex(
6502
- (opt) => this.#optionValue(opt) === this.value,
6503
- );
6504
- if (selectedIndex >= 0) {
6505
- this.#focusOptionAt(selectedIndex);
6506
- } else if (
6507
- this.#button?.hasAttribute("data-focus-visible") ||
6508
- this.#button?.matches?.(":focus-visible")
6509
- ) {
6510
- this.#focusOptionAt(0);
6511
- }
6512
- panel?.syncOverflow?.();
6513
- // Freeze after open align so later positionPopup passes don't undo scroll.
6514
- // Window resize / trigger movement still realigns via geometry checks.
6515
- this.#freezeMenuPosition = true;
6516
- this.#rememberFrozenGeometry();
6517
- });
6518
- }
6519
-
6520
- #closeList() {
6521
- if (!this.#popup) return;
6522
- this.#freezeMenuPosition = false;
6523
- this.#frozenLabelRect = null;
6524
- this.#frozenViewport = null;
6525
- document.removeEventListener("keydown", this.#boundKeydown, true);
6526
- this.#popup.open = false;
6527
- this.#button?.setAttribute("aria-expanded", "false");
6528
- }
6529
- }
6530
- customElements.define("fig-select", FigSelect);
5985
+ }).observe(document.documentElement, { childList: true, subtree: true });