@rogieking/figui3 9.0.10 → 9.0.12

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-editor.js CHANGED
@@ -495,6 +495,8 @@ figEditorDefineElement("fig-select-options", FigSelectOptions);
495
495
  * A dropdown-styled select.
496
496
  * @attr {string} variant - Visual style. Use `ghost` for a borderless control.
497
497
  * @attr {string} size - Control size. Use `large` for a 32px-tall control.
498
+ * @attr {string} menu-anchor - CSS selector for an alternate menu anchor.
499
+ * @property {Element|string|null} menuAnchor - Alternate menu anchor element or selector.
498
500
  * @event optionfocus - Fires with the focused option value during open-menu keyboard navigation.
499
501
  */
500
502
  class FigSelect extends HTMLElement {
@@ -521,6 +523,7 @@ class FigSelect extends HTMLElement {
521
523
  #frozenLabelRect = null;
522
524
  #frozenViewport = null;
523
525
  #syncingOptions = false;
526
+ #menuAnchorRef = null;
524
527
  #boundTriggerClick = this.#handleTriggerClick.bind(this);
525
528
  #boundOptionClick = this.#handleOptionClick.bind(this);
526
529
  #boundOptionPointerOver = this.#handleOptionPointerOver.bind(this);
@@ -540,6 +543,7 @@ class FigSelect extends HTMLElement {
540
543
  "open",
541
544
  "variant",
542
545
  "size",
546
+ "menu-anchor",
543
547
  "aria-label",
544
548
  ];
545
549
  }
@@ -562,6 +566,26 @@ class FigSelect extends HTMLElement {
562
566
  else this.removeAttribute("open");
563
567
  }
564
568
 
569
+ get menuAnchor() {
570
+ return this.#menuAnchorRef ?? this.getAttribute("menu-anchor");
571
+ }
572
+
573
+ set menuAnchor(value) {
574
+ if (value instanceof Element) {
575
+ if (this.hasAttribute("menu-anchor")) {
576
+ this.removeAttribute("menu-anchor");
577
+ }
578
+ this.#menuAnchorRef = value;
579
+ } else if (typeof value === "string" && value.trim()) {
580
+ this.#menuAnchorRef = null;
581
+ this.setAttribute("menu-anchor", value);
582
+ } else {
583
+ this.#menuAnchorRef = null;
584
+ this.removeAttribute("menu-anchor");
585
+ }
586
+ this.#syncMenuAnchor();
587
+ }
588
+
565
589
  connectedCallback() {
566
590
  if (!this.#initialized) this.#initialize();
567
591
  this.#ensurePanelSlotAttrs();
@@ -606,8 +630,14 @@ class FigSelect extends HTMLElement {
606
630
  else this.#openList();
607
631
  return;
608
632
  }
633
+ if (name === "menu-anchor") {
634
+ this.#menuAnchorRef = null;
635
+ this.#syncMenuAnchor();
636
+ return;
637
+ }
609
638
  if (name === "position" || name === "offset" || name === "closedby") {
610
639
  this.#syncPopupAttrs();
640
+ if (this.open) this.#placeOpenMenu();
611
641
  }
612
642
  }
613
643
 
@@ -875,7 +905,7 @@ class FigSelect extends HTMLElement {
875
905
  this.#labelEl = labelEl;
876
906
  this.#popup = popup;
877
907
  this.#panelSlot = panelSlot;
878
- popup.anchor = button;
908
+ this.#syncMenuAnchor();
879
909
 
880
910
  this.#ensurePanelSlotAttrs();
881
911
  this.#installPopupPositioning();
@@ -897,7 +927,7 @@ class FigSelect extends HTMLElement {
897
927
  this.#originalPositionPopup?.();
898
928
  return;
899
929
  }
900
- this.#positionPopupOverSelected();
930
+ this.#placeOpenMenu();
901
931
  };
902
932
  this.#popupPositionPatched = true;
903
933
  }
@@ -920,7 +950,53 @@ class FigSelect extends HTMLElement {
920
950
  return { top: 8, right: 8, bottom: 8, left: 8 };
921
951
  }
922
952
 
953
+ #resolveMenuAnchor() {
954
+ if (this.#menuAnchorRef?.isConnected) return this.#menuAnchorRef;
955
+
956
+ const selector = this.getAttribute("menu-anchor");
957
+ if (selector) {
958
+ try {
959
+ const localMatch = this.parentElement?.querySelector(selector);
960
+ if (
961
+ localMatch &&
962
+ localMatch !== this &&
963
+ !this.contains(localMatch)
964
+ ) {
965
+ return localMatch;
966
+ }
967
+
968
+ const root = this.getRootNode();
969
+ const rootMatch = root?.querySelector?.(selector);
970
+ if (
971
+ rootMatch &&
972
+ rootMatch !== this &&
973
+ !this.contains(rootMatch)
974
+ ) {
975
+ return rootMatch;
976
+ }
977
+ } catch {}
978
+ }
979
+
980
+ return this.#button;
981
+ }
982
+
983
+ #syncMenuAnchor() {
984
+ if (!this.#popup) return;
985
+ this.#popup.anchor = this.#resolveMenuAnchor();
986
+ if (!this.open) return;
987
+ this.#freezeMenuPosition = false;
988
+ this.#frozenLabelRect = null;
989
+ this.#frozenViewport = null;
990
+ this.#popup.queueReposition?.();
991
+ }
992
+
923
993
  #getTriggerAlignmentRect() {
994
+ const menuAnchor = this.#resolveMenuAnchor();
995
+ if (menuAnchor && menuAnchor !== this.#button) {
996
+ const anchorRect = menuAnchor.getBoundingClientRect();
997
+ if (anchorRect?.width && anchorRect?.height) return anchorRect;
998
+ }
999
+
924
1000
  const labelRect = this.#labelEl?.getBoundingClientRect();
925
1001
  if (labelRect?.width && labelRect?.height) return labelRect;
926
1002
  const customTriggerRect = this.querySelector(
@@ -995,10 +1071,56 @@ class FigSelect extends HTMLElement {
995
1071
  this.#frozenViewport = this.#readViewportSnapshot();
996
1072
  }
997
1073
 
1074
+ #usesExplicitPosition() {
1075
+ const value = this.getAttribute("position");
1076
+ return Boolean(value && value.trim());
1077
+ }
1078
+
1079
+ #clearOverlayPositionStyles() {
1080
+ if (!this.#popup) return;
1081
+ this.#popup.style.removeProperty("left");
1082
+ this.#popup.style.removeProperty("top");
1083
+ this.#popup.style.removeProperty("right");
1084
+ this.#popup.style.removeProperty("bottom");
1085
+ }
1086
+
1087
+ #placeOpenMenu() {
1088
+ if (this.#usesExplicitPosition()) {
1089
+ this.#freezeMenuPosition = false;
1090
+ this.#frozenLabelRect = null;
1091
+ this.#frozenViewport = null;
1092
+ this.#clearOverlayPositionStyles();
1093
+ this.#originalPositionPopup?.();
1094
+ this.#clampMenuToViewport();
1095
+ return;
1096
+ }
1097
+ this.#positionPopupOverSelected();
1098
+ }
1099
+
1100
+ #clampMenuToViewport() {
1101
+ const popup = this.#popup;
1102
+ if (!popup || typeof popup.clampToViewport !== "function") return;
1103
+ const popupRect = popup.getBoundingClientRect();
1104
+ if (!popupRect.width || !popupRect.height) return;
1105
+ const margins = this.#getViewportMargins();
1106
+ const { left, top } = popup.clampToViewport(
1107
+ { left: popupRect.left, top: popupRect.top },
1108
+ popupRect,
1109
+ margins,
1110
+ );
1111
+ popup.style.setProperty("right", "auto", "important");
1112
+ popup.style.setProperty("bottom", "auto", "important");
1113
+ popup.style.setProperty("left", `${Math.round(left)}px`, "important");
1114
+ popup.style.setProperty("top", `${Math.round(top)}px`, "important");
1115
+ }
1116
+
998
1117
  #positionPopupOverSelected() {
999
1118
  // Content ResizeObserver / overflow scroll re-enter here; keep the
1000
1119
  // open-time alignment unless the trigger or viewport actually changed.
1001
- if (this.#shouldSkipFrozenPositionPass()) return;
1120
+ if (this.#shouldSkipFrozenPositionPass()) {
1121
+ this.#clampMenuToViewport();
1122
+ return;
1123
+ }
1002
1124
 
1003
1125
  const popup = this.#popup;
1004
1126
  const label = this.#labelEl;
@@ -1395,9 +1517,7 @@ class FigSelect extends HTMLElement {
1395
1517
  e.preventDefault();
1396
1518
  e.stopPropagation();
1397
1519
  const nextOpen = !this.open;
1398
- if (nextOpen && this.#popup && this.#button) {
1399
- this.#popup.anchor = this.#button;
1400
- }
1520
+ if (nextOpen) this.#syncMenuAnchor();
1401
1521
  this.open = nextOpen;
1402
1522
  }
1403
1523
 
@@ -1448,7 +1568,7 @@ class FigSelect extends HTMLElement {
1448
1568
  (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ")
1449
1569
  ) {
1450
1570
  e.preventDefault();
1451
- if (this.#popup && this.#button) this.#popup.anchor = this.#button;
1571
+ this.#syncMenuAnchor();
1452
1572
  this.open = true;
1453
1573
  requestAnimationFrame(() => {
1454
1574
  const options = this.#getOptions({ enabledOnly: true });
@@ -1557,18 +1677,41 @@ class FigSelect extends HTMLElement {
1557
1677
 
1558
1678
  // Use !important — fig-select::part(listbox) width rules beat element.style.
1559
1679
  // Menus size to their options while remaining at least as wide as the trigger.
1560
- this.#popup.style.setProperty("width", "max-content", "important");
1561
- this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
1680
+ const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
1681
+ const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
1682
+ const rem =
1683
+ parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
1684
+ const maxWidth = Math.max(
1685
+ 0,
1686
+ Math.min(Math.floor(20 * rem), Math.floor(viewportWidth - 16)),
1687
+ );
1688
+ const maxHeight = Math.max(0, Math.floor(viewportHeight - 16));
1689
+ this.#popup.style.setProperty("box-sizing", "border-box", "important");
1690
+ this.#popup.style.setProperty("padding", "0", "important");
1691
+ this.#popup.style.setProperty("border", "0", "important");
1692
+ this.#popup.style.setProperty("margin", "0", "important");
1562
1693
  this.#popup.style.setProperty(
1563
- "max-width",
1564
- "min(20rem, calc(100vw - 1rem))",
1694
+ "width",
1695
+ `min(max-content, ${maxWidth}px)`,
1565
1696
  "important",
1566
1697
  );
1698
+ this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
1699
+ this.#popup.style.setProperty("max-width", `${maxWidth}px`, "important");
1700
+ this.#popup.style.setProperty("max-height", `${maxHeight}px`, "important");
1701
+ this.#popup.style.setProperty("height", "auto", "important");
1702
+ this.#popup.style.setProperty("overflow", "hidden", "important");
1703
+ const used = this.#popup.getBoundingClientRect();
1704
+ if (used.height > maxHeight) {
1705
+ this.#popup.style.setProperty("height", `${maxHeight}px`, "important");
1706
+ }
1707
+ if (used.width > maxWidth) {
1708
+ this.#popup.style.setProperty("width", `${maxWidth}px`, "important");
1709
+ }
1567
1710
  }
1568
1711
 
1569
1712
  #openList() {
1570
1713
  if (!this.#popup || figEditorBooleanAttribute(this, "disabled")) return;
1571
- if (this.#button) this.#popup.anchor = this.#button;
1714
+ this.#syncMenuAnchor();
1572
1715
  this.#installPopupPositioning();
1573
1716
  this.#freezeMenuPosition = false;
1574
1717
  this.#frozenLabelRect = null;
@@ -1580,26 +1723,33 @@ class FigSelect extends HTMLElement {
1580
1723
  this.#button?.setAttribute("aria-expanded", "true");
1581
1724
  this.#focusedIndex = -1;
1582
1725
  requestAnimationFrame(() => {
1583
- this.#syncPopupWidth();
1584
- this.#positionPopupOverSelected();
1585
- const panel = this.#getPanel();
1586
- const options = this.#getEnabledOptions();
1587
- const selectedIndex = options.findIndex(
1588
- (opt) => this.#optionValue(opt) === this.value,
1589
- );
1590
- if (selectedIndex >= 0) {
1591
- this.#focusOptionAt(selectedIndex);
1592
- } else if (
1593
- this.#button?.hasAttribute("data-focus-visible") ||
1594
- this.#button?.matches?.(":focus-visible")
1595
- ) {
1596
- this.#focusOptionAt(0);
1597
- }
1598
- panel?.syncOverflow?.();
1599
- // Freeze after open align so later positionPopup passes don't undo scroll.
1600
- // Window resize / trigger movement still realigns via geometry checks.
1601
- this.#freezeMenuPosition = true;
1602
- this.#rememberFrozenGeometry();
1726
+ requestAnimationFrame(() => {
1727
+ this.#syncPopupWidth();
1728
+ this.#placeOpenMenu();
1729
+ this.#syncPopupWidth();
1730
+ this.#clampMenuToViewport();
1731
+ const panel = this.#getPanel();
1732
+ const options = this.#getEnabledOptions();
1733
+ const selectedIndex = options.findIndex(
1734
+ (opt) => this.#optionValue(opt) === this.value,
1735
+ );
1736
+ if (selectedIndex >= 0) {
1737
+ this.#focusOptionAt(selectedIndex);
1738
+ } else if (
1739
+ this.#button?.hasAttribute("data-focus-visible") ||
1740
+ this.#button?.matches?.(":focus-visible")
1741
+ ) {
1742
+ this.#focusOptionAt(0);
1743
+ }
1744
+ panel?.syncOverflow?.();
1745
+ // Freeze overlay after layout settles so later positionPopup passes
1746
+ // don't undo scroll. Still clamp if content grows past the viewport.
1747
+ // Explicit position stays live so the popup can follow the anchor.
1748
+ if (!this.#usesExplicitPosition()) {
1749
+ this.#freezeMenuPosition = true;
1750
+ this.#rememberFrozenGeometry();
1751
+ }
1752
+ });
1603
1753
  });
1604
1754
  }
1605
1755
 
package/fig.js CHANGED
@@ -318,6 +318,10 @@ fig-select-options > .fig-overflow-end::before,
318
318
  .fig-menu-options > .fig-overflow-end::before {
319
319
  border-radius: 0 0 var(--radius-large) var(--radius-large);
320
320
  }
321
+ fig-select-options.overflow-start > :is(fig-separator, fig-menu-separator)[sticky]:not([sticky="false"]),
322
+ .fig-menu-options.overflow-start > :is(fig-separator, fig-menu-separator)[sticky]:not([sticky="false"]) {
323
+ top: var(--fig-vertical-overflow-size, var(--spacer-4)) !important;
324
+ }
321
325
  `;
322
326
 
323
327
  const FIG_SHADOW_ICON_CSS = `
@@ -3933,10 +3937,12 @@ class FigPopup extends HTMLDialogElement {
3933
3937
  const bounds = this.getViewportBounds(m);
3934
3938
  const maxLeft = bounds.maxRight - popupRect.width;
3935
3939
  const maxTop = bounds.maxBottom - popupRect.height;
3940
+ const clampLeftMax = Math.max(bounds.minLeft, maxLeft);
3941
+ const clampTopMax = Math.max(bounds.minTop, maxTop);
3936
3942
 
3937
3943
  return {
3938
- left: Math.min(maxLeft, Math.max(bounds.minLeft, coords.left)),
3939
- top: Math.min(maxTop, Math.max(bounds.minTop, coords.top)),
3944
+ left: Math.min(clampLeftMax, Math.max(bounds.minLeft, coords.left)),
3945
+ top: Math.min(clampTopMax, Math.max(bounds.minTop, coords.top)),
3940
3946
  };
3941
3947
  }
3942
3948
 
@@ -18464,6 +18470,12 @@ class FigChooser extends HTMLElement {
18464
18470
  }
18465
18471
  };
18466
18472
 
18473
+ const onDragStart = (e) => {
18474
+ if (this.#dragState?.active) {
18475
+ e.preventDefault();
18476
+ }
18477
+ };
18478
+
18467
18479
  this.#dragState = {
18468
18480
  active: false,
18469
18481
  didDrag: false,
@@ -18478,6 +18490,7 @@ class FigChooser extends HTMLElement {
18478
18490
  onPointerUp,
18479
18491
  onClick,
18480
18492
  onPointerUpCapture,
18493
+ onDragStart,
18481
18494
  };
18482
18495
 
18483
18496
  this.addEventListener("pointerdown", onPointerDown);
@@ -18485,6 +18498,7 @@ class FigChooser extends HTMLElement {
18485
18498
  window.addEventListener("pointerup", onPointerUp);
18486
18499
  this.addEventListener("pointerup", onPointerUpCapture, true);
18487
18500
  this.addEventListener("click", onClick, true);
18501
+ this.addEventListener("dragstart", onDragStart);
18488
18502
  }
18489
18503
 
18490
18504
  #teardownDrag() {
@@ -18498,6 +18512,7 @@ class FigChooser extends HTMLElement {
18498
18512
  true,
18499
18513
  );
18500
18514
  this.removeEventListener("click", this.#dragState.onClick, true);
18515
+ this.removeEventListener("dragstart", this.#dragState.onDragStart);
18501
18516
  this.style.cursor = "";
18502
18517
  this.style.userSelect = "";
18503
18518
  this.#dragState = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "9.0.10",
3
+ "version": "9.0.12",
4
4
  "description": "A lightweight web components library for building Figma plugin and widget UIs with native look and feel",
5
5
  "author": "Rogie King",
6
6
  "license": "SEE LICENSE IN LICENSE",