@rogieking/figui3 8.9.11 → 8.9.13

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";
@@ -1664,6 +1853,27 @@ class FigTruncate extends HTMLElement {
1664
1853
  }
1665
1854
  figDefineElement("fig-truncate", FigTruncate);
1666
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
+
1667
1877
  /* Dialog */
1668
1878
  /**
1669
1879
  * A custom dialog element for modal and non-modal dialogs.
@@ -1973,24 +2183,7 @@ class FigDialog extends HTMLDialogElement {
1973
2183
  }
1974
2184
 
1975
2185
  _ensureHeader() {
1976
- if (this.querySelector("fig-header[dialog-header]")) return;
1977
- const header = document.createElement("fig-header");
1978
- header.setAttribute("dialog-header", "");
1979
- header.setAttribute("data-auto", "");
1980
- const h3 = document.createElement("h3");
1981
- h3.textContent = this.getAttribute("title") || "Dialog";
1982
- const tooltip = document.createElement("fig-tooltip");
1983
- tooltip.setAttribute("text", "Close");
1984
- const btn = document.createElement("fig-button");
1985
- btn.setAttribute("variant", "ghost");
1986
- btn.setAttribute("icon", "");
1987
- btn.setAttribute("aria-label", "Close dialog");
1988
- btn.setAttribute("close-dialog", "");
1989
- btn.appendChild(createFigIcon("close"));
1990
- tooltip.appendChild(btn);
1991
- header.appendChild(h3);
1992
- header.appendChild(tooltip);
1993
- this.prepend(header);
2186
+ figEnsureAutoDialogHeader(this);
1994
2187
  }
1995
2188
 
1996
2189
  _addCloseListeners() {
@@ -2588,6 +2781,7 @@ figDefineCustomizedBuiltIn("fig-toast", FigToast, { extends: "dialog" });
2588
2781
  * @attr {string} offset - Horizontal and vertical offset as "x y" (default: "0 0").
2589
2782
  * @attr {string} variant - Visual variant. Use variant="popover" to show an anchor beak.
2590
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).
2591
2785
  * @attr {boolean|string} open - Open when present and not "false".
2592
2786
  */
2593
2787
  class FigPopup extends HTMLDialogElement {
@@ -2635,6 +2829,9 @@ class FigPopup extends HTMLDialogElement {
2635
2829
  this._boundPointerUp = this.handlePointerUp.bind(this);
2636
2830
  this._boundDocumentKeydown = this.handleDocumentKeydown.bind(this);
2637
2831
  this._boundNativeClose = this.handleNativeClose.bind(this);
2832
+ this._boundAutoClose = () => {
2833
+ this.open = false;
2834
+ };
2638
2835
  }
2639
2836
 
2640
2837
  ensureInitialized() {
@@ -2694,6 +2891,11 @@ class FigPopup extends HTMLDialogElement {
2694
2891
  if (typeof this._boundNativeClose !== "function") {
2695
2892
  this._boundNativeClose = this.handleNativeClose.bind(this);
2696
2893
  }
2894
+ if (typeof this._boundAutoClose !== "function") {
2895
+ this._boundAutoClose = () => {
2896
+ this.open = false;
2897
+ };
2898
+ }
2697
2899
  }
2698
2900
 
2699
2901
  static get observedAttributes() {
@@ -2708,6 +2910,7 @@ class FigPopup extends HTMLDialogElement {
2708
2910
  "handle",
2709
2911
  "autoresize",
2710
2912
  "viewport-margin",
2913
+ "title",
2711
2914
  ];
2712
2915
  }
2713
2916
 
@@ -2778,6 +2981,8 @@ class FigPopup extends HTMLDialogElement {
2778
2981
  this.drag =
2779
2982
  this.hasAttribute("drag") && this.getAttribute("drag") !== "false";
2780
2983
 
2984
+ this._ensureHeader();
2985
+
2781
2986
  this.removeEventListener("close", this._boundNativeClose);
2782
2987
  this.addEventListener("close", this._boundNativeClose);
2783
2988
 
@@ -2836,6 +3041,13 @@ class FigPopup extends HTMLDialogElement {
2836
3041
  return;
2837
3042
  }
2838
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
+
2839
3051
  if (this.open) {
2840
3052
  this.queueReposition();
2841
3053
  this.setupObservers();
@@ -3110,6 +3322,20 @@ class FigPopup extends HTMLDialogElement {
3110
3322
  this._lastAnchorRect = null;
3111
3323
  }
3112
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
+
3113
3339
  handleOutsidePointerDown(event) {
3114
3340
  if (
3115
3341
  !this.open ||
@@ -18729,10 +18955,15 @@ figDefineElement("fig-menu-item", FigMenuItem);
18729
18955
  * @attr {string} label - Optional group label shown in secondary text under the line.
18730
18956
  * @attr {string} direction - Orientation: "vertical" for a tall rule; default is horizontal.
18731
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.
18732
18959
  */
18733
18960
  class FigSeparator extends HTMLElement {
18961
+ #scrollRoot = null;
18962
+ #resizeObserver = null;
18963
+ #boundSyncStuck = this.#syncStuck.bind(this);
18964
+
18734
18965
  static get observedAttributes() {
18735
- return ["label", "direction"];
18966
+ return ["label", "direction", "sticky"];
18736
18967
  }
18737
18968
 
18738
18969
  get label() {
@@ -18746,11 +18977,17 @@ class FigSeparator extends HTMLElement {
18746
18977
 
18747
18978
  connectedCallback() {
18748
18979
  this.#sync();
18980
+ this.#bindStuck();
18981
+ }
18982
+
18983
+ disconnectedCallback() {
18984
+ this.#unbindStuck();
18749
18985
  }
18750
18986
 
18751
18987
  attributeChangedCallback(name, oldValue, newValue) {
18752
18988
  if (oldValue === newValue) return;
18753
18989
  this.#sync();
18990
+ if (name === "sticky") this.#bindStuck();
18754
18991
  }
18755
18992
 
18756
18993
  #sync() {
@@ -18767,36 +19004,106 @@ class FigSeparator extends HTMLElement {
18767
19004
  if (label) this.setAttribute("aria-label", label);
18768
19005
  else this.removeAttribute("aria-label");
18769
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
+ }
18770
19050
  }
18771
19051
  figDefineElement("fig-separator", FigSeparator);
18772
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
+
18773
19064
  // Backwards-compatible alias. Custom element constructors cannot be registered
18774
19065
  // under multiple tag names, so the legacy tag uses an otherwise empty subclass.
18775
19066
  class FigMenuSeparator extends FigSeparator {}
18776
19067
  figDefineElement("fig-menu-separator", FigMenuSeparator);
18777
19068
 
18778
- const FIG_MENU_CHILD_SELECTOR =
18779
- ":scope > fig-menu-item, :scope > fig-separator, :scope > fig-menu-separator";
18780
19069
  const FIG_MENU_CHILD_TAGS = new Set([
18781
19070
  "FIG-MENU-ITEM",
18782
19071
  "FIG-SEPARATOR",
18783
19072
  "FIG-MENU-SEPARATOR",
18784
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
+ }
18785
19088
 
18786
19089
  class FigMenu extends HTMLElement {
18787
19090
  #popup = null;
18788
19091
  #panel = null;
19092
+ #itemSlot = null;
18789
19093
  #trigger = null;
19094
+ #triggerHost = null;
18790
19095
  #virtualAnchor = null;
18791
19096
  #observer = null;
18792
19097
  #resizeObserver = null;
18793
19098
  #navStart = null;
18794
19099
  #navEnd = null;
19100
+ #initialized = false;
18795
19101
  #boundTriggerClick;
18796
19102
  #boundTriggerContextMenu;
18797
- #boundPopupClick;
19103
+ #boundHostClick;
18798
19104
  #boundMenuKeydown;
18799
19105
  #boundPopupClose;
19106
+ #boundSlotChange;
18800
19107
  #boundSyncOverflow = this.#syncOverflow.bind(this);
18801
19108
  #focusedIndex = -1;
18802
19109
 
@@ -18808,9 +19115,11 @@ class FigMenu extends HTMLElement {
18808
19115
  super();
18809
19116
  this.#boundTriggerClick = this.#handleTriggerClick.bind(this);
18810
19117
  this.#boundTriggerContextMenu = this.#handleTriggerContextMenu.bind(this);
18811
- this.#boundPopupClick = this.#handlePopupClick.bind(this);
19118
+ this.#boundHostClick = this.#handleHostClick.bind(this);
18812
19119
  this.#boundMenuKeydown = this.#handleMenuKeydown.bind(this);
18813
19120
  this.#boundPopupClose = this.#handlePopupClose.bind(this);
19121
+ this.#boundSlotChange = this.#syncOverflow.bind(this);
19122
+ this.#initialize();
18814
19123
  }
18815
19124
 
18816
19125
  get value() {
@@ -18836,8 +19145,6 @@ class FigMenu extends HTMLElement {
18836
19145
 
18837
19146
  connectedCallback() {
18838
19147
  this.#detectTrigger();
18839
- this.#createPopup();
18840
- this.#moveItemsToPopup();
18841
19148
  this.#setupListeners();
18842
19149
  this.#setupOverflow();
18843
19150
  this.#setupObserver();
@@ -18856,16 +19163,6 @@ class FigMenu extends HTMLElement {
18856
19163
  this.#observer.disconnect();
18857
19164
  this.#observer = null;
18858
19165
  }
18859
- if (this.#popup) {
18860
- this.#popup.removeEventListener("close", this.#boundPopupClose);
18861
- const items = Array.from(
18862
- this.#panel?.querySelectorAll(FIG_MENU_CHILD_SELECTOR) ?? [],
18863
- );
18864
- for (const item of items) this.insertBefore(item, this.#popup);
18865
- this.#popup.remove();
18866
- this.#popup = null;
18867
- this.#panel = null;
18868
- }
18869
19166
  }
18870
19167
 
18871
19168
  attributeChangedCallback(name, oldValue, newValue) {
@@ -18898,112 +19195,164 @@ class FigMenu extends HTMLElement {
18898
19195
  }
18899
19196
  }
18900
19197
 
18901
- #detectTrigger() {
18902
- this.#trigger =
18903
- this.querySelector("[fig-menu-trigger]") ||
18904
- this.querySelector(
18905
- ":scope > :not(fig-menu-item):not(fig-separator):not(fig-menu-separator)",
18906
- );
18907
- }
18908
-
18909
- #usesContextMenuTrigger() {
18910
- const trigger = (this.getAttribute("trigger") || "").toLowerCase();
18911
- return trigger === "contextmenu" || this.hasAttribute("contextmenu");
18912
- }
18913
-
18914
- #createPopup() {
18915
- this.#popup = document.createElement("dialog", { is: "fig-popup" });
18916
- this.#popup.setAttribute("is", "fig-popup");
18917
- this.#popup.classList.add("fig-menu-popup");
18918
- this.#popup.setAttribute("theme", "menu");
18919
- this.#popup.setAttribute("role", "menu");
18920
- this.#popup.setAttribute("id", this.#popup.getAttribute("id") || figUniqueId());
18921
- // Top-layer via popover so the menu escapes ancestor filter/contain
18922
- // (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();
18923
19242
  if ("popover" in HTMLElement.prototype) {
18924
- this.#popup.setAttribute("popover", "manual");
19243
+ popup.setAttribute("popover", "manual");
18925
19244
  }
18926
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;
18927
19270
  const position = this.getAttribute("position") || "bottom left";
18928
19271
  this.#popup.setAttribute("position", position);
18929
-
18930
19272
  const offset = this.getAttribute("offset");
18931
19273
  if (offset) this.#popup.setAttribute("offset", offset);
18932
-
19274
+ else this.#popup.removeAttribute("offset");
18933
19275
  const closedby = this.getAttribute("closedby");
18934
19276
  if (closedby) this.#popup.setAttribute("closedby", closedby);
19277
+ else this.#popup.removeAttribute("closedby");
19278
+ }
18935
19279
 
18936
- if (this.#trigger) {
18937
- 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);
18938
19296
  }
19297
+ }
18939
19298
 
18940
- this.#panel = document.createElement("div");
18941
- this.#panel.className = "fig-menu-options";
18942
- this.#panel.setAttribute("role", "presentation");
18943
- this.#popup.appendChild(this.#panel);
18944
- this.#popup.addEventListener("close", this.#boundPopupClose);
18945
- this.appendChild(this.#popup);
19299
+ #usesContextMenuTrigger() {
19300
+ const trigger = (this.getAttribute("trigger") || "").toLowerCase();
19301
+ return trigger === "contextmenu" || this.hasAttribute("contextmenu");
18946
19302
  }
18947
19303
 
18948
- #moveItemsToPopup() {
18949
- if (!this.#panel) return;
18950
- const items = Array.from(this.querySelectorAll(FIG_MENU_CHILD_SELECTOR));
18951
- for (const item of items) {
18952
- this.#panel.appendChild(item);
18953
- }
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);
18954
19318
  }
18955
19319
 
18956
19320
  #setupListeners() {
18957
19321
  this.addEventListener("keydown", this.#boundMenuKeydown);
18958
- if (this.#trigger) {
18959
- this.#trigger.addEventListener("click", this.#boundTriggerClick);
18960
- this.#trigger.addEventListener("contextmenu", this.#boundTriggerContextMenu);
18961
- this.#trigger.setAttribute("aria-haspopup", "menu");
18962
- this.#trigger.setAttribute("aria-expanded", "false");
18963
- this.#trigger.setAttribute("aria-controls", this.#popup.getAttribute("id"));
18964
- }
18965
- if (this.#popup) {
18966
- this.#popup.addEventListener("click", this.#boundPopupClick);
18967
- this.#popup.addEventListener("keydown", this.#boundMenuKeydown);
18968
- }
19322
+ this.addEventListener("click", this.#boundHostClick);
19323
+ this.#bindTrigger();
19324
+ this.#popup?.addEventListener("keydown", this.#boundMenuKeydown);
18969
19325
  }
18970
19326
 
18971
19327
  #teardownListeners() {
18972
19328
  this.removeEventListener("keydown", this.#boundMenuKeydown);
18973
- if (this.#trigger) {
18974
- this.#trigger.removeEventListener("click", this.#boundTriggerClick);
18975
- this.#trigger.removeEventListener("contextmenu", this.#boundTriggerContextMenu);
18976
- }
18977
- if (this.#popup) {
18978
- this.#popup.removeEventListener("click", this.#boundPopupClick);
18979
- this.#popup.removeEventListener("keydown", this.#boundMenuKeydown);
18980
- }
19329
+ this.removeEventListener("click", this.#boundHostClick);
19330
+ this.#unbindTrigger();
19331
+ this.#popup?.removeEventListener("keydown", this.#boundMenuKeydown);
18981
19332
  }
18982
19333
 
18983
19334
  #setupObserver() {
19335
+ if (this.#observer) return;
18984
19336
  this.#observer = new MutationObserver((mutations) => {
19337
+ let triggerChanged = false;
18985
19338
  for (const mutation of mutations) {
18986
19339
  for (const node of mutation.addedNodes) {
18987
- if (node.nodeType !== 1 || node === this.#popup) continue;
18988
- if (
18989
- FIG_MENU_CHILD_TAGS.has(node.tagName) &&
18990
- node.parentElement === this
18991
- ) {
18992
- this.#panel?.appendChild(node);
18993
- } else if (!this.#trigger && node.parentElement === this) {
18994
- this.#detectTrigger();
18995
- if (this.#trigger) {
18996
- this.#trigger.addEventListener("click", this.#boundTriggerClick);
18997
- this.#trigger.addEventListener("contextmenu", this.#boundTriggerContextMenu);
18998
- this.#trigger.setAttribute("aria-haspopup", "menu");
18999
- this.#trigger.setAttribute("aria-expanded", "false");
19000
- this.#trigger.setAttribute("aria-controls", this.#popup.getAttribute("id"));
19001
- this.#popup.anchor = this.#trigger;
19002
- this.#syncDisabled();
19003
- }
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;
19004
19347
  }
19005
19348
  }
19006
19349
  }
19350
+ if (triggerChanged || !this.#trigger) {
19351
+ this.#unbindTrigger();
19352
+ this.#detectTrigger();
19353
+ this.#bindTrigger();
19354
+ this.#syncDisabled();
19355
+ }
19007
19356
  this.#syncOverflow();
19008
19357
  });
19009
19358
  this.#observer.observe(this, { childList: true });
@@ -19028,8 +19377,25 @@ class FigMenu extends HTMLElement {
19028
19377
  this.#removeNavButtons();
19029
19378
  }
19030
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
+
19031
19387
  #syncOverflow() {
19032
- 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;
19033
19399
  }
19034
19400
 
19035
19401
  #ensureNavButtons() {
@@ -19061,13 +19427,14 @@ class FigMenu extends HTMLElement {
19061
19427
  this.#navStart = null;
19062
19428
  this.#navEnd = null;
19063
19429
  this.#panel?.classList.remove("overflow-start", "overflow-end");
19430
+ this.classList.remove("overflow-start", "overflow-end");
19064
19431
  }
19065
19432
 
19066
19433
  #getItems() {
19067
- if (!this.#panel) return [];
19068
- return Array.from(this.#panel.querySelectorAll("fig-menu-item")).filter(
19434
+ return Array.from(this.querySelectorAll(":scope > fig-menu-item")).filter(
19069
19435
  (item) =>
19070
- !item.hasAttribute("disabled") || item.getAttribute("disabled") === "false",
19436
+ !item.hidden &&
19437
+ (!item.hasAttribute("disabled") || item.getAttribute("disabled") === "false"),
19071
19438
  );
19072
19439
  }
19073
19440
 
@@ -19091,7 +19458,31 @@ class FigMenu extends HTMLElement {
19091
19458
  this.#focusedIndex = wrapped;
19092
19459
  const item = items[wrapped];
19093
19460
  item.focus();
19094
- 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
+ });
19095
19486
  }
19096
19487
 
19097
19488
  #syncDisabled() {
@@ -19171,19 +19562,30 @@ class FigMenu extends HTMLElement {
19171
19562
  fallbackTimer = window.setTimeout(openMenu, 180);
19172
19563
  }
19173
19564
 
19174
- #handlePopupClick(e) {
19565
+ #handleHostClick(e) {
19175
19566
  if (this.#isDisabled()) return;
19176
- const item = e.target.closest("fig-menu-item");
19177
- if (!item) return;
19178
- if (item.hasAttribute("disabled") && item.getAttribute("disabled") !== "false") return;
19179
-
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();
19180
19577
  this.#selectItem(item);
19181
19578
  }
19182
19579
 
19183
19580
  #handleMenuKeydown(e) {
19184
19581
  if (this.#isDisabled()) return;
19185
19582
  if (e.currentTarget === document && e.key !== "Escape") return;
19186
- 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
+ }
19187
19589
  if (!this.open || !this.#popup?.matches?.(":open")) {
19188
19590
  if (
19189
19591
  this.#trigger?.contains(e.target) &&