@rogieking/figui3 8.9.10 → 8.9.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.js CHANGED
@@ -204,6 +204,195 @@ function createFigOverflowButtons({
204
204
  };
205
205
  }
206
206
 
207
+ /* Shared by light-DOM fig-select-options and shadow fig-menu .fig-menu-options.
208
+ Adopted on document and on fig-menu's shadow so hover/icon/overflow stay one source. */
209
+ const FIG_VERTICAL_OVERFLOW_CSS = `
210
+ fig-select-options,
211
+ .fig-menu-options {
212
+ --fig-vertical-overflow-size: var(--spacer-4);
213
+ box-sizing: border-box;
214
+ display: flex;
215
+ flex-direction: column;
216
+ gap: 0;
217
+ width: 100%;
218
+ max-width: 100%;
219
+ max-height: calc(100vh - 1rem);
220
+ padding: 0;
221
+ overflow: hidden auto;
222
+ scrollbar-width: none;
223
+ }
224
+ fig-select-options::-webkit-scrollbar,
225
+ .fig-menu-options::-webkit-scrollbar {
226
+ display: none;
227
+ }
228
+ fig-select-options > :not(.fig-overflow),
229
+ .fig-menu-options > :not(.fig-overflow) {
230
+ flex-shrink: 0;
231
+ }
232
+ fig-select-options > :nth-child(1 of :not(.fig-overflow)),
233
+ .fig-menu-options > :nth-child(1 of :not(.fig-overflow)) {
234
+ margin-top: var(--spacer-2);
235
+ }
236
+ fig-select-options > :not(.fig-overflow):last-child,
237
+ .fig-menu-options > :not(.fig-overflow):last-child,
238
+ fig-select-options > :not(.fig-overflow):has(+ .fig-overflow-end),
239
+ .fig-menu-options > :not(.fig-overflow):has(+ .fig-overflow-end) {
240
+ margin-bottom: var(--spacer-2);
241
+ }
242
+ fig-select-options > .fig-overflow,
243
+ .fig-menu-options > .fig-overflow {
244
+ all: unset;
245
+ box-sizing: border-box;
246
+ position: sticky;
247
+ left: 0;
248
+ z-index: 3;
249
+ flex: 0 0 var(--fig-vertical-overflow-size);
250
+ display: flex;
251
+ align-items: center;
252
+ justify-content: center;
253
+ width: 100%;
254
+ height: var(--fig-vertical-overflow-size);
255
+ margin: 0;
256
+ cursor: pointer;
257
+ opacity: 0;
258
+ pointer-events: none;
259
+ color: var(--figma-color-text-menu);
260
+ background: var(--figma-color-bg-menu) !important;
261
+ }
262
+ fig-select-options > .fig-overflow::before,
263
+ .fig-menu-options > .fig-overflow::before {
264
+ content: "";
265
+ position: absolute;
266
+ inset: 0;
267
+ z-index: -1;
268
+ border-radius: 0;
269
+ background: var(--figma-color-bg-menu) !important;
270
+ }
271
+ fig-select-options > .fig-overflow:hover,
272
+ .fig-menu-options > .fig-overflow:hover {
273
+ color: var(--figma-color-text-menu);
274
+ }
275
+ fig-select-options > .fig-overflow:hover::before,
276
+ .fig-menu-options > .fig-overflow:hover::before {
277
+ background: light-dark(
278
+ rgba(0, 0, 0, 0.06),
279
+ rgba(255, 255, 255, 0.08)
280
+ ) !important;
281
+ }
282
+ fig-select-options > .fig-overflow .fig-overflow-chevron,
283
+ .fig-menu-options > .fig-overflow .fig-overflow-chevron {
284
+ position: relative;
285
+ }
286
+ fig-select-options > .fig-overflow:active .fig-overflow-chevron,
287
+ .fig-menu-options > .fig-overflow:active .fig-overflow-chevron {
288
+ transform: translateY(1px);
289
+ }
290
+ fig-select-options.overflow-start > .fig-overflow-start,
291
+ .fig-menu-options.overflow-start > .fig-overflow-start,
292
+ fig-select-options.overflow-end > .fig-overflow-end,
293
+ .fig-menu-options.overflow-end > .fig-overflow-end {
294
+ opacity: 1;
295
+ pointer-events: auto;
296
+ }
297
+ fig-select-options > .fig-overflow-start,
298
+ .fig-menu-options > .fig-overflow-start {
299
+ order: -1;
300
+ top: 0;
301
+ margin-block-end: calc(-1 * var(--fig-vertical-overflow-size));
302
+ }
303
+ fig-select-options > .fig-overflow-start::before,
304
+ .fig-menu-options > .fig-overflow-start::before {
305
+ border-radius: var(--radius-large) var(--radius-large) 0 0;
306
+ }
307
+ fig-select-options > .fig-overflow-start .fig-overflow-chevron,
308
+ .fig-menu-options > .fig-overflow-start .fig-overflow-chevron {
309
+ rotate: 180deg;
310
+ }
311
+ fig-select-options > .fig-overflow-end,
312
+ .fig-menu-options > .fig-overflow-end {
313
+ order: 1;
314
+ bottom: 0;
315
+ margin-block-start: calc(-1 * var(--fig-vertical-overflow-size));
316
+ }
317
+ fig-select-options > .fig-overflow-end::before,
318
+ .fig-menu-options > .fig-overflow-end::before {
319
+ border-radius: 0 0 var(--radius-large) var(--radius-large);
320
+ }
321
+ `;
322
+
323
+ const FIG_SHADOW_ICON_CSS = `
324
+ fig-icon {
325
+ contain: strict;
326
+ --size: var(--spacer-4);
327
+ display: inline-flex;
328
+ width: var(--size);
329
+ height: var(--size);
330
+ flex-shrink: 0;
331
+ background: currentColor;
332
+ mask-image: var(--icon);
333
+ mask-size: contain;
334
+ mask-repeat: no-repeat;
335
+ mask-position: center;
336
+ -webkit-mask-image: var(--icon);
337
+ -webkit-mask-size: contain;
338
+ -webkit-mask-repeat: no-repeat;
339
+ -webkit-mask-position: center;
340
+ }
341
+ fig-icon[size="small"] {
342
+ --size: var(--spacer-3);
343
+ }
344
+ `;
345
+
346
+ const figVerticalOverflowSheet =
347
+ typeof CSSStyleSheet === "function" &&
348
+ typeof CSSStyleSheet.prototype.replaceSync === "function"
349
+ ? new CSSStyleSheet()
350
+ : null;
351
+ figVerticalOverflowSheet?.replaceSync(FIG_VERTICAL_OVERFLOW_CSS);
352
+
353
+ const figShadowIconSheet =
354
+ typeof CSSStyleSheet === "function" &&
355
+ typeof CSSStyleSheet.prototype.replaceSync === "function"
356
+ ? new CSSStyleSheet()
357
+ : null;
358
+ figShadowIconSheet?.replaceSync(FIG_SHADOW_ICON_CSS);
359
+
360
+ function figAdoptConstructedSheet(root, sheet, cssText, marker) {
361
+ if (!root || !cssText) return;
362
+ if (sheet && "adoptedStyleSheets" in root) {
363
+ if ([...root.adoptedStyleSheets].includes(sheet)) return;
364
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
365
+ return;
366
+ }
367
+ const host = root === document ? document.documentElement : root;
368
+ if (host.querySelector?.(`style[data-fig-${marker}]`)) return;
369
+ const style = document.createElement("style");
370
+ style.setAttribute(`data-fig-${marker}`, "");
371
+ style.textContent = cssText;
372
+ host.prepend(style);
373
+ }
374
+
375
+ function figAdoptVerticalOverflowStyles(root, options = {}) {
376
+ figAdoptConstructedSheet(
377
+ root,
378
+ figVerticalOverflowSheet,
379
+ FIG_VERTICAL_OVERFLOW_CSS,
380
+ "vertical-overflow",
381
+ );
382
+ if (options.icons) {
383
+ figAdoptConstructedSheet(
384
+ root,
385
+ figShadowIconSheet,
386
+ FIG_SHADOW_ICON_CSS,
387
+ "shadow-icon",
388
+ );
389
+ }
390
+ }
391
+
392
+ if (typeof document !== "undefined") {
393
+ figAdoptVerticalOverflowStyles(document);
394
+ }
395
+
207
396
  function figSyncOverflowState(host, scrollEl, axis = "x", threshold = 2) {
208
397
  if (!host || !scrollEl) return false;
209
398
  const isHorizontal = axis === "x";
@@ -673,6 +862,7 @@ figDefineElement("fig-button", FigButton);
673
862
  * A custom dropdown/select element.
674
863
  * @attr {string} type - The dropdown type: "select" (default) or "dropdown"
675
864
  * @attr {string} value - The currently selected value
865
+ * @attr {string} variant - Visual style. Use `ghost` for a borderless control.
676
866
  */
677
867
  class FigDropdown extends HTMLElement {
678
868
  #label = "Menu";
@@ -871,7 +1061,7 @@ class FigDropdown extends HTMLElement {
871
1061
  this.setAttribute("value", value);
872
1062
  }
873
1063
  static get observedAttributes() {
874
- return ["value", "type", "label", "disabled"];
1064
+ return ["value", "type", "label", "disabled", "variant"];
875
1065
  }
876
1066
  #syncDisabled() {
877
1067
  const disabled =
@@ -1663,6 +1853,27 @@ class FigTruncate extends HTMLElement {
1663
1853
  }
1664
1854
  figDefineElement("fig-truncate", FigTruncate);
1665
1855
 
1856
+ function figEnsureAutoDialogHeader(host) {
1857
+ if (host.querySelector("fig-header[dialog-header]")) return;
1858
+ const header = document.createElement("fig-header");
1859
+ header.setAttribute("dialog-header", "");
1860
+ header.setAttribute("data-auto", "");
1861
+ const h3 = document.createElement("h3");
1862
+ h3.textContent = host.getAttribute("title") || "Dialog";
1863
+ const tooltip = document.createElement("fig-tooltip");
1864
+ tooltip.setAttribute("text", "Close");
1865
+ const btn = document.createElement("fig-button");
1866
+ btn.setAttribute("variant", "ghost");
1867
+ btn.setAttribute("icon", "");
1868
+ btn.setAttribute("aria-label", "Close dialog");
1869
+ btn.setAttribute("close-dialog", "");
1870
+ btn.appendChild(createFigIcon("close"));
1871
+ tooltip.appendChild(btn);
1872
+ header.appendChild(h3);
1873
+ header.appendChild(tooltip);
1874
+ host.prepend(header);
1875
+ }
1876
+
1666
1877
  /* Dialog */
1667
1878
  /**
1668
1879
  * A custom dialog element for modal and non-modal dialogs.
@@ -1972,24 +2183,7 @@ class FigDialog extends HTMLDialogElement {
1972
2183
  }
1973
2184
 
1974
2185
  _ensureHeader() {
1975
- if (this.querySelector("fig-header[dialog-header]")) return;
1976
- const header = document.createElement("fig-header");
1977
- header.setAttribute("dialog-header", "");
1978
- header.setAttribute("data-auto", "");
1979
- const h3 = document.createElement("h3");
1980
- h3.textContent = this.getAttribute("title") || "Dialog";
1981
- const tooltip = document.createElement("fig-tooltip");
1982
- tooltip.setAttribute("text", "Close");
1983
- const btn = document.createElement("fig-button");
1984
- btn.setAttribute("variant", "ghost");
1985
- btn.setAttribute("icon", "");
1986
- btn.setAttribute("aria-label", "Close dialog");
1987
- btn.setAttribute("close-dialog", "");
1988
- btn.appendChild(createFigIcon("close"));
1989
- tooltip.appendChild(btn);
1990
- header.appendChild(h3);
1991
- header.appendChild(tooltip);
1992
- this.prepend(header);
2186
+ figEnsureAutoDialogHeader(this);
1993
2187
  }
1994
2188
 
1995
2189
  _addCloseListeners() {
@@ -2587,6 +2781,7 @@ figDefineCustomizedBuiltIn("fig-toast", FigToast, { extends: "dialog" });
2587
2781
  * @attr {string} offset - Horizontal and vertical offset as "x y" (default: "0 0").
2588
2782
  * @attr {string} variant - Visual variant. Use variant="popover" to show an anchor beak.
2589
2783
  * @attr {string} theme - Visual theme: "light", "dark", or "menu".
2784
+ * @attr {string} title - Title text for the auto-generated header (same as fig-dialog).
2590
2785
  * @attr {boolean|string} open - Open when present and not "false".
2591
2786
  */
2592
2787
  class FigPopup extends HTMLDialogElement {
@@ -2634,6 +2829,9 @@ class FigPopup extends HTMLDialogElement {
2634
2829
  this._boundPointerUp = this.handlePointerUp.bind(this);
2635
2830
  this._boundDocumentKeydown = this.handleDocumentKeydown.bind(this);
2636
2831
  this._boundNativeClose = this.handleNativeClose.bind(this);
2832
+ this._boundAutoClose = () => {
2833
+ this.open = false;
2834
+ };
2637
2835
  }
2638
2836
 
2639
2837
  ensureInitialized() {
@@ -2693,6 +2891,11 @@ class FigPopup extends HTMLDialogElement {
2693
2891
  if (typeof this._boundNativeClose !== "function") {
2694
2892
  this._boundNativeClose = this.handleNativeClose.bind(this);
2695
2893
  }
2894
+ if (typeof this._boundAutoClose !== "function") {
2895
+ this._boundAutoClose = () => {
2896
+ this.open = false;
2897
+ };
2898
+ }
2696
2899
  }
2697
2900
 
2698
2901
  static get observedAttributes() {
@@ -2707,6 +2910,7 @@ class FigPopup extends HTMLDialogElement {
2707
2910
  "handle",
2708
2911
  "autoresize",
2709
2912
  "viewport-margin",
2913
+ "title",
2710
2914
  ];
2711
2915
  }
2712
2916
 
@@ -2777,6 +2981,8 @@ class FigPopup extends HTMLDialogElement {
2777
2981
  this.drag =
2778
2982
  this.hasAttribute("drag") && this.getAttribute("drag") !== "false";
2779
2983
 
2984
+ this._ensureHeader();
2985
+
2780
2986
  this.removeEventListener("close", this._boundNativeClose);
2781
2987
  this.addEventListener("close", this._boundNativeClose);
2782
2988
 
@@ -2835,6 +3041,13 @@ class FigPopup extends HTMLDialogElement {
2835
3041
  return;
2836
3042
  }
2837
3043
 
3044
+ if (name === "title") {
3045
+ this._ensureHeader();
3046
+ const autoHeader = this.querySelector("fig-header[data-auto] h3");
3047
+ if (autoHeader) autoHeader.textContent = newValue || "Dialog";
3048
+ return;
3049
+ }
3050
+
2838
3051
  if (this.open) {
2839
3052
  this.queueReposition();
2840
3053
  this.setupObservers();
@@ -3109,6 +3322,20 @@ class FigPopup extends HTMLDialogElement {
3109
3322
  this._lastAnchorRect = null;
3110
3323
  }
3111
3324
 
3325
+ _ensureHeader() {
3326
+ if (
3327
+ !this.hasAttribute("title") ||
3328
+ this.getAttribute("variant") === "tooltip"
3329
+ ) {
3330
+ return;
3331
+ }
3332
+ figEnsureAutoDialogHeader(this);
3333
+ this.querySelectorAll("fig-button[close-dialog]").forEach((button) => {
3334
+ button.removeEventListener("click", this._boundAutoClose);
3335
+ button.addEventListener("click", this._boundAutoClose);
3336
+ });
3337
+ }
3338
+
3112
3339
  handleOutsidePointerDown(event) {
3113
3340
  if (
3114
3341
  !this.open ||
@@ -18728,10 +18955,15 @@ figDefineElement("fig-menu-item", FigMenuItem);
18728
18955
  * @attr {string} label - Optional group label shown in secondary text under the line.
18729
18956
  * @attr {string} direction - Orientation: "vertical" for a tall rule; default is horizontal.
18730
18957
  * @attr {boolean} borderless - Hides the separator line when present or "true".
18958
+ * @attr {boolean} sticky - Stick to the top of the nearest scrollport while scrolling.
18731
18959
  */
18732
18960
  class FigSeparator extends HTMLElement {
18961
+ #scrollRoot = null;
18962
+ #resizeObserver = null;
18963
+ #boundSyncStuck = this.#syncStuck.bind(this);
18964
+
18733
18965
  static get observedAttributes() {
18734
- return ["label", "direction"];
18966
+ return ["label", "direction", "sticky"];
18735
18967
  }
18736
18968
 
18737
18969
  get label() {
@@ -18745,11 +18977,17 @@ class FigSeparator extends HTMLElement {
18745
18977
 
18746
18978
  connectedCallback() {
18747
18979
  this.#sync();
18980
+ this.#bindStuck();
18981
+ }
18982
+
18983
+ disconnectedCallback() {
18984
+ this.#unbindStuck();
18748
18985
  }
18749
18986
 
18750
18987
  attributeChangedCallback(name, oldValue, newValue) {
18751
18988
  if (oldValue === newValue) return;
18752
18989
  this.#sync();
18990
+ if (name === "sticky") this.#bindStuck();
18753
18991
  }
18754
18992
 
18755
18993
  #sync() {
@@ -18766,36 +19004,106 @@ class FigSeparator extends HTMLElement {
18766
19004
  if (label) this.setAttribute("aria-label", label);
18767
19005
  else this.removeAttribute("aria-label");
18768
19006
  }
19007
+
19008
+ #isSticky() {
19009
+ return this.hasAttribute("sticky") && this.getAttribute("sticky") !== "false";
19010
+ }
19011
+
19012
+ #bindStuck() {
19013
+ this.#unbindStuck();
19014
+ if (!this.isConnected || !this.#isSticky()) {
19015
+ this.removeAttribute("stuck");
19016
+ return;
19017
+ }
19018
+ const root = figNearestScrollRoot(this);
19019
+ this.#scrollRoot = root;
19020
+ root?.addEventListener("scroll", this.#boundSyncStuck, { passive: true });
19021
+ this.#resizeObserver = new ResizeObserver(this.#boundSyncStuck);
19022
+ this.#resizeObserver.observe(this);
19023
+ if (root instanceof Element) this.#resizeObserver.observe(root);
19024
+ requestAnimationFrame(this.#boundSyncStuck);
19025
+ }
19026
+
19027
+ #unbindStuck() {
19028
+ this.#scrollRoot?.removeEventListener?.("scroll", this.#boundSyncStuck);
19029
+ this.#scrollRoot = null;
19030
+ this.#resizeObserver?.disconnect();
19031
+ this.#resizeObserver = null;
19032
+ }
19033
+
19034
+ #syncStuck() {
19035
+ if (!this.#isSticky() || !this.#scrollRoot) {
19036
+ this.removeAttribute("stuck");
19037
+ return;
19038
+ }
19039
+ const stickyTop = parseFloat(getComputedStyle(this).top) || 0;
19040
+ const root = this.#scrollRoot;
19041
+ const rootRect =
19042
+ root === document.scrollingElement || root === document.documentElement
19043
+ ? { top: 0 }
19044
+ : root.getBoundingClientRect();
19045
+ const paddingTop =
19046
+ root instanceof Element ? parseFloat(getComputedStyle(root).paddingTop) || 0 : 0;
19047
+ const line = rootRect.top + paddingTop + stickyTop;
19048
+ this.toggleAttribute("stuck", this.getBoundingClientRect().top <= line + 1);
19049
+ }
18769
19050
  }
18770
19051
  figDefineElement("fig-separator", FigSeparator);
18771
19052
 
19053
+ function figNearestScrollRoot(el) {
19054
+ let node = el.parentElement;
19055
+ while (node) {
19056
+ const style = getComputedStyle(node);
19057
+ const y = style.overflowY || style.overflow;
19058
+ if (y && y !== "visible" && y !== "clip") return node;
19059
+ node = node.parentElement;
19060
+ }
19061
+ return document.scrollingElement;
19062
+ }
19063
+
18772
19064
  // Backwards-compatible alias. Custom element constructors cannot be registered
18773
19065
  // under multiple tag names, so the legacy tag uses an otherwise empty subclass.
18774
19066
  class FigMenuSeparator extends FigSeparator {}
18775
19067
  figDefineElement("fig-menu-separator", FigMenuSeparator);
18776
19068
 
18777
- const FIG_MENU_CHILD_SELECTOR =
18778
- ":scope > fig-menu-item, :scope > fig-separator, :scope > fig-menu-separator";
18779
19069
  const FIG_MENU_CHILD_TAGS = new Set([
18780
19070
  "FIG-MENU-ITEM",
18781
19071
  "FIG-SEPARATOR",
18782
19072
  "FIG-MENU-SEPARATOR",
18783
19073
  ]);
19074
+ const FIG_MENU_TRIGGER_SLOT = "trigger";
19075
+
19076
+ function figIsMenuChild(node) {
19077
+ return node?.nodeType === 1 && FIG_MENU_CHILD_TAGS.has(node.tagName);
19078
+ }
19079
+
19080
+ function figMenuTriggerHost(menu, trigger) {
19081
+ if (!menu || !trigger) return null;
19082
+ let host = trigger;
19083
+ while (host.parentElement && host.parentElement !== menu) {
19084
+ host = host.parentElement;
19085
+ }
19086
+ return host.parentElement === menu ? host : null;
19087
+ }
18784
19088
 
18785
19089
  class FigMenu extends HTMLElement {
18786
19090
  #popup = null;
18787
19091
  #panel = null;
19092
+ #itemSlot = null;
18788
19093
  #trigger = null;
19094
+ #triggerHost = null;
18789
19095
  #virtualAnchor = null;
18790
19096
  #observer = null;
18791
19097
  #resizeObserver = null;
18792
19098
  #navStart = null;
18793
19099
  #navEnd = null;
19100
+ #initialized = false;
18794
19101
  #boundTriggerClick;
18795
19102
  #boundTriggerContextMenu;
18796
- #boundPopupClick;
19103
+ #boundHostClick;
18797
19104
  #boundMenuKeydown;
18798
19105
  #boundPopupClose;
19106
+ #boundSlotChange;
18799
19107
  #boundSyncOverflow = this.#syncOverflow.bind(this);
18800
19108
  #focusedIndex = -1;
18801
19109
 
@@ -18807,9 +19115,11 @@ class FigMenu extends HTMLElement {
18807
19115
  super();
18808
19116
  this.#boundTriggerClick = this.#handleTriggerClick.bind(this);
18809
19117
  this.#boundTriggerContextMenu = this.#handleTriggerContextMenu.bind(this);
18810
- this.#boundPopupClick = this.#handlePopupClick.bind(this);
19118
+ this.#boundHostClick = this.#handleHostClick.bind(this);
18811
19119
  this.#boundMenuKeydown = this.#handleMenuKeydown.bind(this);
18812
19120
  this.#boundPopupClose = this.#handlePopupClose.bind(this);
19121
+ this.#boundSlotChange = this.#syncOverflow.bind(this);
19122
+ this.#initialize();
18813
19123
  }
18814
19124
 
18815
19125
  get value() {
@@ -18835,8 +19145,6 @@ class FigMenu extends HTMLElement {
18835
19145
 
18836
19146
  connectedCallback() {
18837
19147
  this.#detectTrigger();
18838
- this.#createPopup();
18839
- this.#moveItemsToPopup();
18840
19148
  this.#setupListeners();
18841
19149
  this.#setupOverflow();
18842
19150
  this.#setupObserver();
@@ -18855,16 +19163,6 @@ class FigMenu extends HTMLElement {
18855
19163
  this.#observer.disconnect();
18856
19164
  this.#observer = null;
18857
19165
  }
18858
- if (this.#popup) {
18859
- this.#popup.removeEventListener("close", this.#boundPopupClose);
18860
- const items = Array.from(
18861
- this.#panel?.querySelectorAll(FIG_MENU_CHILD_SELECTOR) ?? [],
18862
- );
18863
- for (const item of items) this.insertBefore(item, this.#popup);
18864
- this.#popup.remove();
18865
- this.#popup = null;
18866
- this.#panel = null;
18867
- }
18868
19166
  }
18869
19167
 
18870
19168
  attributeChangedCallback(name, oldValue, newValue) {
@@ -18897,112 +19195,164 @@ class FigMenu extends HTMLElement {
18897
19195
  }
18898
19196
  }
18899
19197
 
18900
- #detectTrigger() {
18901
- this.#trigger =
18902
- this.querySelector("[fig-menu-trigger]") ||
18903
- this.querySelector(
18904
- ":scope > :not(fig-menu-item):not(fig-separator):not(fig-menu-separator)",
18905
- );
18906
- }
18907
-
18908
- #usesContextMenuTrigger() {
18909
- const trigger = (this.getAttribute("trigger") || "").toLowerCase();
18910
- return trigger === "contextmenu" || this.hasAttribute("contextmenu");
18911
- }
18912
-
18913
- #createPopup() {
18914
- this.#popup = document.createElement("dialog", { is: "fig-popup" });
18915
- this.#popup.setAttribute("is", "fig-popup");
18916
- this.#popup.classList.add("fig-menu-popup");
18917
- this.#popup.setAttribute("theme", "menu");
18918
- this.#popup.setAttribute("role", "menu");
18919
- this.#popup.setAttribute("id", this.#popup.getAttribute("id") || figUniqueId());
18920
- // Top-layer via popover so the menu escapes ancestor filter/contain
18921
- // (nested fig-popup with variant="popover" creates a fixed containing block).
19198
+ #initialize() {
19199
+ if (this.#initialized) return;
19200
+ this.#initialized = true;
19201
+ const shadow = this.attachShadow({ mode: "open" });
19202
+ figAdoptVerticalOverflowStyles(shadow, { icons: true });
19203
+ const style = document.createElement("style");
19204
+ style.textContent = `
19205
+ :host {
19206
+ display: contents;
19207
+ }
19208
+ /* Listbox chrome comes from document fig-menu::part(listbox).
19209
+ Stay in this shadow so item slots keep working — unlike tooltips,
19210
+ we cannot portal this popup to the overlay root.
19211
+ Overflow/hover/icon chrome lives in figVerticalOverflowSheet. */
19212
+ dialog[is="fig-popup"] {
19213
+ flex-direction: column;
19214
+ overflow: hidden;
19215
+ padding: 0;
19216
+ }
19217
+ dialog[is="fig-popup"][open] {
19218
+ display: flex;
19219
+ }
19220
+ .fig-menu-items {
19221
+ display: flex;
19222
+ flex-direction: column;
19223
+ flex: 0 0 auto;
19224
+ }
19225
+ .fig-menu-items > ::slotted(fig-menu-item),
19226
+ .fig-menu-items > ::slotted(fig-separator),
19227
+ .fig-menu-items > ::slotted(fig-menu-separator) {
19228
+ flex-shrink: 0;
19229
+ }
19230
+ `;
19231
+
19232
+ const triggerSlot = document.createElement("slot");
19233
+ triggerSlot.setAttribute("name", FIG_MENU_TRIGGER_SLOT);
19234
+
19235
+ const popup = document.createElement("dialog", { is: "fig-popup" });
19236
+ popup.setAttribute("is", "fig-popup");
19237
+ popup.classList.add("fig-menu-popup");
19238
+ popup.setAttribute("part", "listbox");
19239
+ popup.setAttribute("theme", "menu");
19240
+ popup.setAttribute("role", "menu");
19241
+ popup.id = figUniqueId();
18922
19242
  if ("popover" in HTMLElement.prototype) {
18923
- this.#popup.setAttribute("popover", "manual");
19243
+ popup.setAttribute("popover", "manual");
18924
19244
  }
18925
19245
 
19246
+ const panel = document.createElement("div");
19247
+ panel.className = "fig-menu-options";
19248
+ panel.setAttribute("part", "options");
19249
+ panel.setAttribute("role", "presentation");
19250
+
19251
+ const items = document.createElement("div");
19252
+ items.className = "fig-menu-items";
19253
+ const itemSlot = document.createElement("slot");
19254
+ items.appendChild(itemSlot);
19255
+ panel.appendChild(items);
19256
+ popup.appendChild(panel);
19257
+
19258
+ shadow.append(style, triggerSlot, popup);
19259
+
19260
+ this.#popup = popup;
19261
+ this.#panel = panel;
19262
+ this.#itemSlot = itemSlot;
19263
+ this.#syncPopupAttrs();
19264
+ popup.addEventListener("close", this.#boundPopupClose);
19265
+ itemSlot.addEventListener("slotchange", this.#boundSlotChange);
19266
+ }
19267
+
19268
+ #syncPopupAttrs() {
19269
+ if (!this.#popup) return;
18926
19270
  const position = this.getAttribute("position") || "bottom left";
18927
19271
  this.#popup.setAttribute("position", position);
18928
-
18929
19272
  const offset = this.getAttribute("offset");
18930
19273
  if (offset) this.#popup.setAttribute("offset", offset);
18931
-
19274
+ else this.#popup.removeAttribute("offset");
18932
19275
  const closedby = this.getAttribute("closedby");
18933
19276
  if (closedby) this.#popup.setAttribute("closedby", closedby);
19277
+ else this.#popup.removeAttribute("closedby");
19278
+ }
18934
19279
 
18935
- if (this.#trigger) {
18936
- this.#popup.anchor = this.#trigger;
19280
+ #detectTrigger() {
19281
+ this.#trigger =
19282
+ this.querySelector("[fig-menu-trigger]") ||
19283
+ this.querySelector(
19284
+ `:scope > :not(fig-menu-item):not(fig-separator):not(fig-menu-separator):not([slot="${FIG_MENU_TRIGGER_SLOT}"])`,
19285
+ ) ||
19286
+ this.querySelector(`[slot="${FIG_MENU_TRIGGER_SLOT}"]`);
19287
+ this.#assignTriggerSlot();
19288
+ }
19289
+
19290
+ #assignTriggerSlot() {
19291
+ const host = figMenuTriggerHost(this, this.#trigger);
19292
+ this.#triggerHost = host;
19293
+ if (!host) return;
19294
+ if (host.getAttribute("slot") !== FIG_MENU_TRIGGER_SLOT) {
19295
+ host.setAttribute("slot", FIG_MENU_TRIGGER_SLOT);
18937
19296
  }
19297
+ }
18938
19298
 
18939
- this.#panel = document.createElement("div");
18940
- this.#panel.className = "fig-menu-options";
18941
- this.#panel.setAttribute("role", "presentation");
18942
- this.#popup.appendChild(this.#panel);
18943
- this.#popup.addEventListener("close", this.#boundPopupClose);
18944
- this.appendChild(this.#popup);
19299
+ #usesContextMenuTrigger() {
19300
+ const trigger = (this.getAttribute("trigger") || "").toLowerCase();
19301
+ return trigger === "contextmenu" || this.hasAttribute("contextmenu");
18945
19302
  }
18946
19303
 
18947
- #moveItemsToPopup() {
18948
- if (!this.#panel) return;
18949
- const items = Array.from(this.querySelectorAll(FIG_MENU_CHILD_SELECTOR));
18950
- for (const item of items) {
18951
- this.#panel.appendChild(item);
18952
- }
19304
+ #bindTrigger() {
19305
+ if (!this.#trigger || !this.#popup) return;
19306
+ this.#trigger.addEventListener("click", this.#boundTriggerClick);
19307
+ this.#trigger.addEventListener("contextmenu", this.#boundTriggerContextMenu);
19308
+ this.#trigger.setAttribute("aria-haspopup", "menu");
19309
+ this.#trigger.setAttribute("aria-expanded", this.open ? "true" : "false");
19310
+ this.#trigger.setAttribute("aria-controls", this.#popup.id);
19311
+ this.#popup.anchor = this.#trigger;
19312
+ }
19313
+
19314
+ #unbindTrigger() {
19315
+ if (!this.#trigger) return;
19316
+ this.#trigger.removeEventListener("click", this.#boundTriggerClick);
19317
+ this.#trigger.removeEventListener("contextmenu", this.#boundTriggerContextMenu);
18953
19318
  }
18954
19319
 
18955
19320
  #setupListeners() {
18956
19321
  this.addEventListener("keydown", this.#boundMenuKeydown);
18957
- if (this.#trigger) {
18958
- this.#trigger.addEventListener("click", this.#boundTriggerClick);
18959
- this.#trigger.addEventListener("contextmenu", this.#boundTriggerContextMenu);
18960
- this.#trigger.setAttribute("aria-haspopup", "menu");
18961
- this.#trigger.setAttribute("aria-expanded", "false");
18962
- this.#trigger.setAttribute("aria-controls", this.#popup.getAttribute("id"));
18963
- }
18964
- if (this.#popup) {
18965
- this.#popup.addEventListener("click", this.#boundPopupClick);
18966
- this.#popup.addEventListener("keydown", this.#boundMenuKeydown);
18967
- }
19322
+ this.addEventListener("click", this.#boundHostClick);
19323
+ this.#bindTrigger();
19324
+ this.#popup?.addEventListener("keydown", this.#boundMenuKeydown);
18968
19325
  }
18969
19326
 
18970
19327
  #teardownListeners() {
18971
19328
  this.removeEventListener("keydown", this.#boundMenuKeydown);
18972
- if (this.#trigger) {
18973
- this.#trigger.removeEventListener("click", this.#boundTriggerClick);
18974
- this.#trigger.removeEventListener("contextmenu", this.#boundTriggerContextMenu);
18975
- }
18976
- if (this.#popup) {
18977
- this.#popup.removeEventListener("click", this.#boundPopupClick);
18978
- this.#popup.removeEventListener("keydown", this.#boundMenuKeydown);
18979
- }
19329
+ this.removeEventListener("click", this.#boundHostClick);
19330
+ this.#unbindTrigger();
19331
+ this.#popup?.removeEventListener("keydown", this.#boundMenuKeydown);
18980
19332
  }
18981
19333
 
18982
19334
  #setupObserver() {
19335
+ if (this.#observer) return;
18983
19336
  this.#observer = new MutationObserver((mutations) => {
19337
+ let triggerChanged = false;
18984
19338
  for (const mutation of mutations) {
18985
19339
  for (const node of mutation.addedNodes) {
18986
- if (node.nodeType !== 1 || node === this.#popup) continue;
18987
- if (
18988
- FIG_MENU_CHILD_TAGS.has(node.tagName) &&
18989
- node.parentElement === this
18990
- ) {
18991
- this.#panel?.appendChild(node);
18992
- } else if (!this.#trigger && node.parentElement === this) {
18993
- this.#detectTrigger();
18994
- if (this.#trigger) {
18995
- this.#trigger.addEventListener("click", this.#boundTriggerClick);
18996
- this.#trigger.addEventListener("contextmenu", this.#boundTriggerContextMenu);
18997
- this.#trigger.setAttribute("aria-haspopup", "menu");
18998
- this.#trigger.setAttribute("aria-expanded", "false");
18999
- this.#trigger.setAttribute("aria-controls", this.#popup.getAttribute("id"));
19000
- this.#popup.anchor = this.#trigger;
19001
- this.#syncDisabled();
19002
- }
19340
+ if (node.nodeType !== 1) continue;
19341
+ if (figIsMenuChild(node)) continue;
19342
+ if (node.parentElement === this) triggerChanged = true;
19343
+ }
19344
+ for (const node of mutation.removedNodes) {
19345
+ if (node === this.#trigger || node === this.#triggerHost) {
19346
+ triggerChanged = true;
19003
19347
  }
19004
19348
  }
19005
19349
  }
19350
+ if (triggerChanged || !this.#trigger) {
19351
+ this.#unbindTrigger();
19352
+ this.#detectTrigger();
19353
+ this.#bindTrigger();
19354
+ this.#syncDisabled();
19355
+ }
19006
19356
  this.#syncOverflow();
19007
19357
  });
19008
19358
  this.#observer.observe(this, { childList: true });
@@ -19027,8 +19377,25 @@ class FigMenu extends HTMLElement {
19027
19377
  this.#removeNavButtons();
19028
19378
  }
19029
19379
 
19380
+ #markFirstSeparatorBorderless() {
19381
+ const first = Array.from(this.children).find((child) => figIsMenuChild(child));
19382
+ if (first && (first.tagName === "FIG-SEPARATOR" || first.tagName === "FIG-MENU-SEPARATOR")) {
19383
+ first.setAttribute("borderless", "");
19384
+ }
19385
+ }
19386
+
19030
19387
  #syncOverflow() {
19031
- return figSyncOverflowState(this.#panel, this.#panel, "y");
19388
+ this.#markFirstSeparatorBorderless();
19389
+ const scrollable = figSyncOverflowState(this.#panel, this.#panel, "y");
19390
+ this.classList.toggle(
19391
+ "overflow-start",
19392
+ Boolean(this.#panel?.classList.contains("overflow-start")),
19393
+ );
19394
+ this.classList.toggle(
19395
+ "overflow-end",
19396
+ Boolean(this.#panel?.classList.contains("overflow-end")),
19397
+ );
19398
+ return scrollable;
19032
19399
  }
19033
19400
 
19034
19401
  #ensureNavButtons() {
@@ -19060,13 +19427,14 @@ class FigMenu extends HTMLElement {
19060
19427
  this.#navStart = null;
19061
19428
  this.#navEnd = null;
19062
19429
  this.#panel?.classList.remove("overflow-start", "overflow-end");
19430
+ this.classList.remove("overflow-start", "overflow-end");
19063
19431
  }
19064
19432
 
19065
19433
  #getItems() {
19066
- if (!this.#panel) return [];
19067
- return Array.from(this.#panel.querySelectorAll("fig-menu-item")).filter(
19434
+ return Array.from(this.querySelectorAll(":scope > fig-menu-item")).filter(
19068
19435
  (item) =>
19069
- !item.hasAttribute("disabled") || item.getAttribute("disabled") === "false",
19436
+ !item.hidden &&
19437
+ (!item.hasAttribute("disabled") || item.getAttribute("disabled") === "false"),
19070
19438
  );
19071
19439
  }
19072
19440
 
@@ -19090,7 +19458,31 @@ class FigMenu extends HTMLElement {
19090
19458
  this.#focusedIndex = wrapped;
19091
19459
  const item = items[wrapped];
19092
19460
  item.focus();
19093
- figScrollElementToCenter(this.#panel, item, "y");
19461
+ this.#scrollItemIntoView(item);
19462
+ }
19463
+
19464
+ #scrollItemIntoView(item) {
19465
+ const panel = this.#panel;
19466
+ if (!panel || !item) return;
19467
+ requestAnimationFrame(() => {
19468
+ if (!panel.isConnected || !item.isConnected) return;
19469
+ const scrollSize = panel.scrollHeight;
19470
+ const clientSize = panel.clientHeight;
19471
+ if (scrollSize <= clientSize + 1) {
19472
+ this.#syncOverflow();
19473
+ return;
19474
+ }
19475
+ const itemRect = item.getBoundingClientRect();
19476
+ const hostRect = panel.getBoundingClientRect();
19477
+ const elementStart = itemRect.top - hostRect.top + panel.scrollTop;
19478
+ const maxScroll = scrollSize - clientSize;
19479
+ const nextScroll = Math.max(
19480
+ 0,
19481
+ Math.min(elementStart + itemRect.height / 2 - clientSize / 2, maxScroll),
19482
+ );
19483
+ panel.scrollTo({ top: nextScroll, behavior: "auto" });
19484
+ this.#syncOverflow();
19485
+ });
19094
19486
  }
19095
19487
 
19096
19488
  #syncDisabled() {
@@ -19170,19 +19562,30 @@ class FigMenu extends HTMLElement {
19170
19562
  fallbackTimer = window.setTimeout(openMenu, 180);
19171
19563
  }
19172
19564
 
19173
- #handlePopupClick(e) {
19565
+ #handleHostClick(e) {
19174
19566
  if (this.#isDisabled()) return;
19175
- const item = e.target.closest("fig-menu-item");
19176
- if (!item) return;
19177
- if (item.hasAttribute("disabled") && item.getAttribute("disabled") !== "false") return;
19178
-
19567
+ if (this.#trigger?.contains(e.target) || this.#triggerHost?.contains(e.target)) {
19568
+ return;
19569
+ }
19570
+ const item = e.target.closest?.("fig-menu-item");
19571
+ // Direct children only — nested fig-menu inside an item has its own host.
19572
+ if (!item || item.parentElement !== this) return;
19573
+ if (item.hasAttribute("disabled") && item.getAttribute("disabled") !== "false") {
19574
+ return;
19575
+ }
19576
+ e.stopPropagation();
19179
19577
  this.#selectItem(item);
19180
19578
  }
19181
19579
 
19182
19580
  #handleMenuKeydown(e) {
19183
19581
  if (this.#isDisabled()) return;
19184
19582
  if (e.currentTarget === document && e.key !== "Escape") return;
19185
- if (e.currentTarget === this && this.#popup?.contains(e.target)) return;
19583
+ // Slotted item keydown crosses the shadow slot, so the popup listener
19584
+ // already handles it. Skip the host to avoid double-stepping.
19585
+ if (e.currentTarget === this) {
19586
+ const path = typeof e.composedPath === "function" ? e.composedPath() : [];
19587
+ if (this.#popup && path.includes(this.#popup)) return;
19588
+ }
19186
19589
  if (!this.open || !this.#popup?.matches?.(":open")) {
19187
19590
  if (
19188
19591
  this.#trigger?.contains(e.target) &&