@rogieking/figui3 6.21.1 → 6.23.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.js CHANGED
@@ -105,9 +105,12 @@ function createFigOverflowButtons({
105
105
  startClass = "",
106
106
  endClass = "",
107
107
  chevronClass = "",
108
+ startLabel = "Scroll back",
109
+ endLabel = "Scroll forward",
108
110
  } = {}) {
109
111
  const makeButton = (direction, onPointerDown) => {
110
112
  const button = document.createElement("button");
113
+ button.type = "button";
111
114
  button.className = [
112
115
  "fig-overflow",
113
116
  `fig-overflow-${direction}`,
@@ -118,7 +121,10 @@ function createFigOverflowButtons({
118
121
  button.dataset.figOverflow = direction;
119
122
  if (owner) button.setAttribute(`data-fig-${owner}-nav`, direction);
120
123
  button.setAttribute("tabindex", "-1");
121
- button.setAttribute("aria-label", direction === "start" ? "Scroll back" : "Scroll forward");
124
+ button.setAttribute(
125
+ "aria-label",
126
+ direction === "start" ? startLabel : endLabel,
127
+ );
122
128
  button.appendChild(
123
129
  createFigIcon("chevron", {
124
130
  size: "small",
@@ -164,6 +170,48 @@ function figScrollOverflowPage(scrollEl, axis = "x", direction = 1) {
164
170
  });
165
171
  }
166
172
 
173
+ function figScrollElementToCenter(
174
+ scrollEl,
175
+ element,
176
+ axis = "y",
177
+ behavior = "auto",
178
+ ) {
179
+ if (!scrollEl || !element || !scrollEl.contains(element)) return;
180
+ requestAnimationFrame(() => {
181
+ if (!scrollEl.isConnected || !element.isConnected) return;
182
+ const isHorizontal = axis === "x";
183
+ const scrollSize = isHorizontal
184
+ ? scrollEl.scrollWidth
185
+ : scrollEl.scrollHeight;
186
+ const clientSize = isHorizontal
187
+ ? scrollEl.clientWidth
188
+ : scrollEl.clientHeight;
189
+ if (scrollSize <= clientSize + 1) {
190
+ figSyncOverflowState(scrollEl, scrollEl, axis);
191
+ return;
192
+ }
193
+ const elementRect = element.getBoundingClientRect();
194
+ const hostRect = scrollEl.getBoundingClientRect();
195
+ const currentScroll = isHorizontal
196
+ ? scrollEl.scrollLeft
197
+ : scrollEl.scrollTop;
198
+ const elementStart =
199
+ (isHorizontal ? elementRect.left - hostRect.left : elementRect.top - hostRect.top) +
200
+ currentScroll;
201
+ const elementSize = isHorizontal ? elementRect.width : elementRect.height;
202
+ const maxScroll = scrollSize - clientSize;
203
+ const nextScroll = Math.max(
204
+ 0,
205
+ Math.min(elementStart + elementSize / 2 - clientSize / 2, maxScroll),
206
+ );
207
+ scrollEl.scrollTo({
208
+ [isHorizontal ? "left" : "top"]: nextScroll,
209
+ behavior,
210
+ });
211
+ figSyncOverflowState(scrollEl, scrollEl, axis);
212
+ });
213
+ }
214
+
167
215
  function hasFigFillPicker() {
168
216
  return typeof customElements !== "undefined" && !!customElements.get("fig-fill-picker");
169
217
  }
@@ -330,9 +378,11 @@ function figSupportsPopover() {
330
378
  class FigButton extends HTMLElement {
331
379
  type;
332
380
  #selected;
381
+ #slottedDisabledStates = new WeakMap();
333
382
  #a11yAttributes = ["aria-label", "aria-labelledby", "aria-describedby", "title"];
334
383
  #boundHandleControlKeydown = this.#handleControlKeydown.bind(this);
335
384
  #boundHandleClick = this.#handleClick.bind(this);
385
+ #boundHandleSlotChange = () => this.#syncSlottedControlDisabled();
336
386
  #boundHandleFocus = () => {
337
387
  if (this.button?.matches(":focus-visible")) {
338
388
  this.setAttribute("data-focus-visible", "");
@@ -398,6 +448,9 @@ class FigButton extends HTMLElement {
398
448
  this.button.addEventListener("blur", this.#boundHandleBlur);
399
449
  }
400
450
 
451
+ const slot = this.shadowRoot.querySelector("slot");
452
+ slot?.removeEventListener("slotchange", this.#boundHandleSlotChange);
453
+ slot?.addEventListener("slotchange", this.#boundHandleSlotChange);
401
454
  this.removeEventListener("keydown", this.#boundHandleControlKeydown);
402
455
  this.addEventListener("keydown", this.#boundHandleControlKeydown);
403
456
 
@@ -514,9 +567,30 @@ class FigButton extends HTMLElement {
514
567
  this.button.type = "button";
515
568
  this.button.setAttribute("type", "button");
516
569
  }
570
+ this.#syncSlottedControlDisabled();
517
571
  this.#syncA11yAttributes();
518
572
  this.#syncPressedState();
519
573
  }
574
+ #syncSlottedControlDisabled() {
575
+ const control = this.#getSlottedControl();
576
+ if (!control) return;
577
+ const disabled = this.#isDisabled();
578
+ if (disabled) {
579
+ if (!this.#slottedDisabledStates.has(control)) {
580
+ this.#slottedDisabledStates.set(
581
+ control,
582
+ control.hasAttribute("disabled") &&
583
+ control.getAttribute("disabled") !== "false",
584
+ );
585
+ }
586
+ control.setAttribute("disabled", "");
587
+ } else if (this.#slottedDisabledStates.has(control)) {
588
+ const wasDisabled = this.#slottedDisabledStates.get(control);
589
+ this.#slottedDisabledStates.delete(control);
590
+ if (wasDisabled) control.setAttribute("disabled", "");
591
+ else control.removeAttribute("disabled");
592
+ }
593
+ }
520
594
  static get observedAttributes() {
521
595
  return [
522
596
  "disabled",
@@ -555,6 +629,9 @@ class FigButton extends HTMLElement {
555
629
  }
556
630
  disconnectedCallback() {
557
631
  this.removeEventListener("keydown", this.#boundHandleControlKeydown);
632
+ this.shadowRoot
633
+ ?.querySelector("slot")
634
+ ?.removeEventListener("slotchange", this.#boundHandleSlotChange);
558
635
  }
559
636
  }
560
637
  figDefineElement("fig-button", FigButton);
@@ -570,8 +647,6 @@ class FigDropdown extends HTMLElement {
570
647
  #boundHandleSelectInput;
571
648
  #boundHandleSelectChange;
572
649
  #boundHandleSelectKeydown;
573
- #selectedContentEnabled = false;
574
- #selectedContentEl = null;
575
650
 
576
651
  get label() {
577
652
  return this.#label;
@@ -591,54 +666,6 @@ class FigDropdown extends HTMLElement {
591
666
  this.#boundSlotChange = this.slotChange.bind(this);
592
667
  }
593
668
 
594
- #supportsSelectedContent() {
595
- if (typeof CSS === "undefined" || typeof CSS.supports !== "function")
596
- return false;
597
- try {
598
- return (
599
- CSS.supports("appearance: base-select") &&
600
- CSS.supports("selector(::picker(select))")
601
- );
602
- } catch {
603
- return false;
604
- }
605
- }
606
-
607
- #enableSelectedContentIfNeeded() {
608
- const experimental = this.getAttribute("experimental") || "";
609
- const wantsModern = experimental
610
- .split(/\s+/)
611
- .filter(Boolean)
612
- .includes("modern");
613
-
614
- if (!wantsModern || !this.#supportsSelectedContent()) {
615
- this.#selectedContentEnabled = false;
616
- return;
617
- }
618
-
619
- const button = document.createElement("button");
620
- button.setAttribute("type", "button");
621
- button.setAttribute("aria-hidden", "true");
622
- const selected = document.createElement("selectedcontent");
623
- button.appendChild(selected);
624
- this.select.appendChild(button);
625
- this.#selectedContentEnabled = true;
626
- this.#selectedContentEl = selected;
627
- }
628
-
629
- #syncSelectedContent() {
630
- if (!this.#selectedContentEl) return;
631
- const selectedOption = this.select.selectedOptions?.[0];
632
- if (!selectedOption) {
633
- this.#selectedContentEl.textContent = "";
634
- return;
635
- }
636
- // Fallback mirror for browsers that don't auto-project selectedcontent reliably.
637
- this.#selectedContentEl.replaceChildren(
638
- ...Array.from(selectedOption.childNodes, (node) => node.cloneNode(true)),
639
- );
640
- }
641
-
642
669
  #addEventListeners() {
643
670
  this.select.addEventListener("input", this.#boundHandleSelectInput);
644
671
  this.select.addEventListener("change", this.#boundHandleSelectChange);
@@ -703,8 +730,6 @@ class FigDropdown extends HTMLElement {
703
730
  this.select.firstChild.remove();
704
731
  }
705
732
 
706
- this.#enableSelectedContentIfNeeded();
707
-
708
733
  if (this.type === "dropdown") {
709
734
  const hiddenOption = document.createElement("option");
710
735
  hiddenOption.setAttribute("hidden", "true");
@@ -720,7 +745,6 @@ class FigDropdown extends HTMLElement {
720
745
  if (selectedValue !== null) {
721
746
  this.#syncSelectedValue(selectedValue);
722
747
  }
723
- this.#syncSelectedContent();
724
748
  if (this.type === "dropdown") {
725
749
  this.select.selectedIndex = -1;
726
750
  }
@@ -743,7 +767,6 @@ class FigDropdown extends HTMLElement {
743
767
  this.#selectedValue = selectedValue;
744
768
  }
745
769
  this.setAttribute("value", selectedValue);
746
- this.#syncSelectedContent();
747
770
  this.dispatchEvent(
748
771
  new CustomEvent("input", {
749
772
  detail: selectedValue,
@@ -771,7 +794,6 @@ class FigDropdown extends HTMLElement {
771
794
  if (this.type === "dropdown") {
772
795
  this.select.selectedIndex = -1;
773
796
  }
774
- this.#syncSelectedContent();
775
797
  this.dispatchEvent(
776
798
  new CustomEvent("change", {
777
799
  detail: selectedValue,
@@ -784,7 +806,6 @@ class FigDropdown extends HTMLElement {
784
806
  #handleSelectKeydown(e) {
785
807
  if (this.closest('fig-button[type="select"]')) return;
786
808
  if (e.key !== "Enter" || e.defaultPrevented) return;
787
- if (this.#selectedContentEnabled && this.select.matches(":open")) return;
788
809
  if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
789
810
  if (this.select.disabled || this.select.multiple) return;
790
811
  if (typeof this.select.showPicker !== "function") return;
@@ -817,7 +838,7 @@ class FigDropdown extends HTMLElement {
817
838
  this.setAttribute("value", value);
818
839
  }
819
840
  static get observedAttributes() {
820
- return ["value", "type", "experimental", "label", "disabled"];
841
+ return ["value", "type", "label", "disabled"];
821
842
  }
822
843
  #syncDisabled() {
823
844
  const disabled =
@@ -830,7 +851,6 @@ class FigDropdown extends HTMLElement {
830
851
  return;
831
852
  }
832
853
  if (this.select) this.select.value = value ?? "";
833
- this.#syncSelectedContent();
834
854
  }
835
855
  attributeChangedCallback(name, oldValue, newValue) {
836
856
  if (name === "value") {
@@ -840,9 +860,6 @@ class FigDropdown extends HTMLElement {
840
860
  this.type = newValue || "select";
841
861
  if (this.isConnected) this.slotChange();
842
862
  }
843
- if (name === "experimental") {
844
- this.slotChange();
845
- }
846
863
  if (name === "label") {
847
864
  this.#label = newValue || "Menu";
848
865
  this.select.setAttribute("aria-label", this.#label);
@@ -887,6 +904,8 @@ class FigTooltip extends HTMLElement {
887
904
  #boundHidePopupOutsideClick;
888
905
  #boundShowDelayedPopup;
889
906
  #boundHandlePointerLeave;
907
+ #boundHandleFocus;
908
+ #boundHandleBlur;
890
909
  #boundHandleTouchStart;
891
910
  #boundHandleTouchMove;
892
911
  #boundHandleTouchEnd;
@@ -896,6 +915,7 @@ class FigTooltip extends HTMLElement {
896
915
  #parentDialog = null;
897
916
  #triggerEl = null;
898
917
  #childObserver = null;
918
+ #suppressFocusOpen = false;
899
919
  #touchTimeout;
900
920
  #isTouching = false;
901
921
  constructor() {
@@ -908,6 +928,13 @@ class FigTooltip extends HTMLElement {
908
928
  this.#boundHidePopupOutsideClick = this.hidePopupOutsideClick.bind(this);
909
929
  this.#boundShowDelayedPopup = this.showDelayedPopup.bind(this);
910
930
  this.#boundHandlePointerLeave = this.#handlePointerLeave.bind(this);
931
+ this.#boundHandleFocus = () => {
932
+ if (!this.#suppressFocusOpen) this.showDelayedPopup();
933
+ };
934
+ this.#boundHandleBlur = () => {
935
+ this.#suppressFocusOpen = false;
936
+ this.hidePopup();
937
+ };
911
938
  this.#boundHandleTouchStart = this.#handleTouchStart.bind(this);
912
939
  this.#boundHandleTouchMove = this.#handleTouchMove.bind(this);
913
940
  this.#boundHandleTouchEnd = this.#handleTouchEnd.bind(this);
@@ -1002,6 +1029,8 @@ class FigTooltip extends HTMLElement {
1002
1029
  trigger.addEventListener("touchcancel", this.#boundHandleTouchCancel, {
1003
1030
  passive: true,
1004
1031
  });
1032
+ trigger.addEventListener("focus", this.#boundHandleFocus);
1033
+ trigger.addEventListener("blur", this.#boundHandleBlur);
1005
1034
  } else if (this.action === "click") {
1006
1035
  trigger.addEventListener("click", this.#boundShowDelayedPopup);
1007
1036
  trigger.addEventListener("touchstart", this.#boundShowDelayedPopup, {
@@ -1020,6 +1049,8 @@ class FigTooltip extends HTMLElement {
1020
1049
  trigger.removeEventListener("touchmove", this.#boundHandleTouchMove);
1021
1050
  trigger.removeEventListener("touchend", this.#boundHandleTouchEnd);
1022
1051
  trigger.removeEventListener("touchcancel", this.#boundHandleTouchCancel);
1052
+ trigger.removeEventListener("focus", this.#boundHandleFocus);
1053
+ trigger.removeEventListener("blur", this.#boundHandleBlur);
1023
1054
  } else if (this.action === "click") {
1024
1055
  trigger.removeEventListener("click", this.#boundShowDelayedPopup);
1025
1056
  trigger.removeEventListener("touchstart", this.#boundShowDelayedPopup);
@@ -1421,6 +1452,16 @@ class FigTooltip extends HTMLElement {
1421
1452
  if (!(node instanceof FigTooltip)) continue;
1422
1453
  if (node.action !== "hover") continue;
1423
1454
  if (node.#showPersisted) continue;
1455
+ node.#suppressFocusOpen = true;
1456
+ setTimeout(() => {
1457
+ const trigger = node.#triggerEl;
1458
+ if (
1459
+ trigger !== document.activeElement &&
1460
+ !trigger?.matches?.(":focus, :focus-within")
1461
+ ) {
1462
+ node.#suppressFocusOpen = false;
1463
+ }
1464
+ }, 0);
1424
1465
  if (node.isOpen || node.timeout) node.hidePopup();
1425
1466
  }
1426
1467
  for (const anchor of Array.from(FigTooltip.#programmaticAnchors)) {
@@ -5051,77 +5092,1209 @@ class FigOptions extends HTMLElement {
5051
5092
  }
5052
5093
  }
5053
5094
 
5054
- #syncValueToChild() {
5055
- if (!this.#childControl || this.#suppressEvents) return;
5056
- const val = this.getAttribute("value") || "";
5057
- this.#childControl.value = val;
5058
- }
5059
-
5060
- #syncAttrToChild(attr) {
5061
- if (!this.#childControl) return;
5062
- if (this.hasAttribute(attr)) {
5063
- this.#childControl.setAttribute(attr, this.getAttribute(attr) || "");
5064
- } else {
5065
- this.#childControl.removeAttribute(attr);
5066
- }
5095
+ #syncValueToChild() {
5096
+ if (!this.#childControl || this.#suppressEvents) return;
5097
+ const val = this.getAttribute("value") || "";
5098
+ this.#childControl.value = val;
5099
+ }
5100
+
5101
+ #syncAttrToChild(attr) {
5102
+ if (!this.#childControl) return;
5103
+ if (this.hasAttribute(attr)) {
5104
+ this.#childControl.setAttribute(attr, this.getAttribute(attr) || "");
5105
+ } else {
5106
+ this.#childControl.removeAttribute(attr);
5107
+ }
5108
+ }
5109
+
5110
+ #startResizeObserver() {
5111
+ this.#resizeObserver?.disconnect();
5112
+ this.#resizeObserver = new ResizeObserver(() => {
5113
+ this.#checkOverflow();
5114
+ });
5115
+ this.#resizeObserver.observe(this);
5116
+ }
5117
+
5118
+ #isSegmentTruncated(seg) {
5119
+ const range = document.createRange();
5120
+ range.selectNodeContents(seg);
5121
+ const textWidth = range.getBoundingClientRect().width;
5122
+ const segRect = seg.getBoundingClientRect();
5123
+ const segWidth = segRect.width;
5124
+ const cs = getComputedStyle(seg);
5125
+ const padL = parseFloat(cs.paddingLeft) || 0;
5126
+ const padR = parseFloat(cs.paddingRight) || 0;
5127
+ const contentWidth = segWidth - padL - padR;
5128
+ return textWidth > contentWidth + 0.5;
5129
+ }
5130
+
5131
+ #anySegmentTruncated() {
5132
+ const segments = this.querySelectorAll("fig-segment");
5133
+ for (const seg of segments) {
5134
+ if (this.#isSegmentTruncated(seg)) return true;
5135
+ }
5136
+ return false;
5137
+ }
5138
+
5139
+ #checkOverflow() {
5140
+ if (this.#parsedOptions.length <= 1) return;
5141
+
5142
+ if (this.#currentMode === "segments") {
5143
+ const sc = this.#childControl;
5144
+ const containerOverflow = sc && sc.scrollWidth > sc.clientWidth + 1;
5145
+ if (containerOverflow || this.#anySegmentTruncated()) {
5146
+ this.#naturalWidth = this.clientWidth;
5147
+ this.#renderDropdown();
5148
+ }
5149
+ } else {
5150
+ if (this.#naturalWidth > 0 && this.clientWidth >= this.#naturalWidth) {
5151
+ this.#renderSegments();
5152
+ requestAnimationFrame(() => {
5153
+ requestAnimationFrame(() => {
5154
+ const sc = this.#childControl;
5155
+ const containerOverflow = sc && sc.scrollWidth > sc.clientWidth + 1;
5156
+ if (containerOverflow || this.#anySegmentTruncated()) {
5157
+ this.#renderDropdown();
5158
+ }
5159
+ });
5160
+ });
5161
+ }
5162
+ }
5163
+ }
5164
+ }
5165
+ figDefineElement("fig-options", FigOptions);
5166
+
5167
+ /* Select — dropdown-styled trigger + fig-popup listbox */
5168
+ /** Parse options attr — same formats as fig-options / propskit-select. */
5169
+ function figSelectParseOptionsAttribute(raw) {
5170
+ const text = raw || "";
5171
+ if (text.startsWith("[")) {
5172
+ try {
5173
+ const parsed = JSON.parse(text);
5174
+ return Array.isArray(parsed) ? parsed : [];
5175
+ } catch {
5176
+ /* fall through */
5177
+ }
5178
+ }
5179
+ const delimiter = text.includes("\n") ? "\n" : ",";
5180
+ return text
5181
+ .split(delimiter)
5182
+ .map((s) => s.trim())
5183
+ .filter(Boolean);
5184
+ }
5185
+
5186
+ function figSelectOptionEntryValue(opt) {
5187
+ if (opt && typeof opt === "object") {
5188
+ return String(opt.value ?? opt.label ?? "");
5189
+ }
5190
+ return String(opt ?? "");
5191
+ }
5192
+
5193
+ function figSelectOptionEntryLabel(opt) {
5194
+ if (opt && typeof opt === "object") {
5195
+ return String(opt.label ?? opt.value ?? "");
5196
+ }
5197
+ return String(opt ?? "");
5198
+ }
5199
+
5200
+ /**
5201
+ * A selectable option for fig-select.
5202
+ * Supports light-DOM slots: `slot="prepend"` (leading) and `slot="append"` (trailing).
5203
+ * Use the `label` attribute for the closed-trigger label when option content is rich.
5204
+ *
5205
+ * @attr {string} value - Option value
5206
+ * @attr {string} label - Optional display label for the select trigger
5207
+ * @attr {boolean} disabled - Whether the option is disabled
5208
+ * @attr {boolean} selected - Whether the option is selected
5209
+ */
5210
+ class FigSelectOption extends HTMLElement {
5211
+ static get observedAttributes() {
5212
+ return ["value", "disabled", "selected", "label"];
5213
+ }
5214
+
5215
+ get value() {
5216
+ const attr = this.getAttribute("value");
5217
+ if (attr !== null) return attr;
5218
+ return (this.textContent || "").trim();
5219
+ }
5220
+
5221
+ set value(val) {
5222
+ if (val === null || val === undefined) {
5223
+ this.removeAttribute("value");
5224
+ } else {
5225
+ this.setAttribute("value", String(val));
5226
+ }
5227
+ }
5228
+
5229
+ get disabled() {
5230
+ return figBooleanAttribute(this, "disabled");
5231
+ }
5232
+
5233
+ set disabled(val) {
5234
+ if (val) this.setAttribute("disabled", "");
5235
+ else this.removeAttribute("disabled");
5236
+ }
5237
+
5238
+ get selected() {
5239
+ return figBooleanAttribute(this, "selected");
5240
+ }
5241
+
5242
+ set selected(val) {
5243
+ if (val) this.setAttribute("selected", "");
5244
+ else this.removeAttribute("selected");
5245
+ }
5246
+
5247
+ connectedCallback() {
5248
+ if (!this.hasAttribute("role")) this.setAttribute("role", "option");
5249
+ if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
5250
+ this.#syncDisabled();
5251
+ }
5252
+
5253
+ attributeChangedCallback(name, oldValue, newValue) {
5254
+ if (oldValue === newValue) return;
5255
+ if (name === "disabled") this.#syncDisabled();
5256
+ }
5257
+
5258
+ #syncDisabled() {
5259
+ const disabled = this.disabled;
5260
+ if (disabled) {
5261
+ this.setAttribute("aria-disabled", "true");
5262
+ this.setAttribute("tabindex", "-1");
5263
+ } else {
5264
+ this.removeAttribute("aria-disabled");
5265
+ if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
5266
+ }
5267
+ }
5268
+ }
5269
+ figDefineElement("fig-select-option", FigSelectOption);
5270
+
5271
+ /** Light-DOM panel wrapper projected into fig-select's popup; owns overflow buttons. */
5272
+ class FigSelectOptions extends HTMLElement {
5273
+ #navStart = null;
5274
+ #navEnd = null;
5275
+ #resizeObserver = null;
5276
+ #boundSyncOverflow = this.syncOverflow.bind(this);
5277
+
5278
+ connectedCallback() {
5279
+ if (!this.hasAttribute("slot")) this.setAttribute("slot", "panel");
5280
+ this.#unwrapLegacyChooser();
5281
+ this.#ensureNavButtons();
5282
+ this.addEventListener("scroll", this.#boundSyncOverflow, { passive: true });
5283
+ this.#resizeObserver?.disconnect();
5284
+ this.#resizeObserver = new ResizeObserver(() => this.syncOverflow());
5285
+ this.#resizeObserver.observe(this);
5286
+ requestAnimationFrame(() => this.syncOverflow());
5287
+ }
5288
+
5289
+ disconnectedCallback() {
5290
+ this.removeEventListener("scroll", this.#boundSyncOverflow);
5291
+ this.#resizeObserver?.disconnect();
5292
+ this.#resizeObserver = null;
5293
+ this.#removeNavButtons();
5294
+ }
5295
+
5296
+ syncOverflow() {
5297
+ return figSyncOverflowState(this, this, "y");
5298
+ }
5299
+
5300
+ scrollToOption(option, behavior = "auto") {
5301
+ figScrollElementToCenter(this, option, "y", behavior);
5302
+ }
5303
+
5304
+ #unwrapLegacyChooser() {
5305
+ const chooser = this.querySelector(":scope > fig-chooser");
5306
+ if (!chooser) return;
5307
+ while (chooser.firstChild) {
5308
+ this.insertBefore(chooser.firstChild, chooser);
5309
+ }
5310
+ chooser.remove();
5311
+ }
5312
+
5313
+ #ensureNavButtons() {
5314
+ if (
5315
+ this.#navStart &&
5316
+ this.#navEnd &&
5317
+ this.contains(this.#navStart) &&
5318
+ this.contains(this.#navEnd)
5319
+ ) {
5320
+ return;
5321
+ }
5322
+ this.#removeNavButtons();
5323
+ const buttons = createFigOverflowButtons({
5324
+ owner: "select",
5325
+ startLabel: "Scroll up",
5326
+ endLabel: "Scroll down",
5327
+ onStart: () => figScrollOverflowPage(this, "y", -1),
5328
+ onEnd: () => figScrollOverflowPage(this, "y", 1),
5329
+ });
5330
+ this.#navStart = buttons.start;
5331
+ this.#navEnd = buttons.end;
5332
+ this.prepend(this.#navStart);
5333
+ this.append(this.#navEnd);
5334
+ }
5335
+
5336
+ #removeNavButtons() {
5337
+ this.#navStart?.remove();
5338
+ this.#navEnd?.remove();
5339
+ this.#navStart = null;
5340
+ this.#navEnd = null;
5341
+ this.classList.remove("overflow-start", "overflow-end");
5342
+ }
5343
+ }
5344
+ figDefineElement("fig-select-options", FigSelectOptions);
5345
+
5346
+ class FigSelect extends HTMLElement {
5347
+ #button = null;
5348
+ #popup = null;
5349
+ #prependEl = null;
5350
+ #labelEl = null;
5351
+ #panelSlot = null;
5352
+ #observer = null;
5353
+ #initialized = false;
5354
+ #focusedIndex = -1;
5355
+ #syncingValue = false;
5356
+ #popupPositionPatched = false;
5357
+ #originalPositionPopup = null;
5358
+ /**
5359
+ * After open align, ignore content/scroll-driven positionPopup passes so
5360
+ * overflow paging isn't yanked back. Still realign when the trigger moves
5361
+ * or the viewport size changes (window resize, layout shift, page scroll).
5362
+ */
5363
+ #freezeMenuPosition = false;
5364
+ #frozenLabelRect = null;
5365
+ #frozenViewport = null;
5366
+ #syncingOptions = false;
5367
+ #boundTriggerClick = this.#handleTriggerClick.bind(this);
5368
+ #boundOptionClick = this.#handleOptionClick.bind(this);
5369
+ #boundKeydown = this.#handleKeydown.bind(this);
5370
+ #boundPopupClose = this.#handlePopupClose.bind(this);
5371
+ #boundSlotChange = this.#handleSlotChange.bind(this);
5372
+
5373
+ static get observedAttributes() {
5374
+ return [
5375
+ "value",
5376
+ "disabled",
5377
+ "label",
5378
+ "options",
5379
+ "position",
5380
+ "offset",
5381
+ "closedby",
5382
+ "open",
5383
+ ];
5384
+ }
5385
+
5386
+ get value() {
5387
+ return this.getAttribute("value") ?? "";
5388
+ }
5389
+
5390
+ set value(val) {
5391
+ if (val === null || val === undefined) this.removeAttribute("value");
5392
+ else this.setAttribute("value", String(val));
5393
+ }
5394
+
5395
+ get open() {
5396
+ return figBooleanAttribute(this, "open");
5397
+ }
5398
+
5399
+ set open(val) {
5400
+ if (val) this.setAttribute("open", "");
5401
+ else this.removeAttribute("open");
5402
+ }
5403
+
5404
+ connectedCallback() {
5405
+ if (!this.#initialized) this.#initialize();
5406
+ this.#ensurePanelSlotAttrs();
5407
+ this.#syncOptionsFromAttribute();
5408
+ this.#syncDisabled();
5409
+ this.#syncPopupAttrs();
5410
+ this.#syncValue();
5411
+ this.#setupObserver();
5412
+ if (this.open) this.#openList();
5413
+ }
5414
+
5415
+ disconnectedCallback() {
5416
+ this.#teardownListeners();
5417
+ document.removeEventListener("keydown", this.#boundKeydown, true);
5418
+ this.#observer?.disconnect();
5419
+ this.#observer = null;
5420
+ }
5421
+
5422
+ attributeChangedCallback(name, oldValue, newValue) {
5423
+ if (oldValue === newValue || !this.#initialized) return;
5424
+ if (name === "options") {
5425
+ this.#syncOptionsFromAttribute();
5426
+ this.#syncValue();
5427
+ return;
5428
+ }
5429
+ if (name === "value" || name === "label") {
5430
+ this.#syncValue();
5431
+ return;
5432
+ }
5433
+ if (name === "disabled") {
5434
+ this.#syncDisabled();
5435
+ return;
5436
+ }
5437
+ if (name === "open") {
5438
+ if (newValue === null || newValue === "false") this.#closeList();
5439
+ else this.#openList();
5440
+ return;
5441
+ }
5442
+ if (name === "position" || name === "offset" || name === "closedby") {
5443
+ this.#syncPopupAttrs();
5444
+ }
5445
+ }
5446
+
5447
+ focus(options) {
5448
+ this.#button?.focus(options);
5449
+ }
5450
+
5451
+ blur() {
5452
+ this.#button?.blur();
5453
+ }
5454
+
5455
+ #isMenuChild(node) {
5456
+ return (
5457
+ node?.nodeType === 1 &&
5458
+ (node.tagName === "FIG-SELECT-OPTION" ||
5459
+ node.tagName === "FIG-MENU-SEPARATOR" ||
5460
+ node.tagName === "FIG-SELECT-OPTIONS")
5461
+ );
5462
+ }
5463
+
5464
+ #ensurePanelSlotAttrs() {
5465
+ for (const panel of this.querySelectorAll(":scope > fig-select-options")) {
5466
+ if (!panel.hasAttribute("slot")) panel.setAttribute("slot", "panel");
5467
+ }
5468
+ }
5469
+
5470
+ #getPanel() {
5471
+ const assigned = this.#panelSlot?.assignedElements({ flatten: true }) ?? [];
5472
+ const fromSlot = assigned.find(
5473
+ (el) => el.tagName === "FIG-SELECT-OPTIONS",
5474
+ );
5475
+ if (fromSlot) return fromSlot;
5476
+ return this.querySelector(":scope > fig-select-options");
5477
+ }
5478
+
5479
+ #hasAuthoredOptions() {
5480
+ return Boolean(
5481
+ this.querySelector(
5482
+ ":scope > fig-select-option:not([data-fig-generated]), :scope > fig-select-options > fig-select-option:not([data-fig-generated])",
5483
+ ),
5484
+ );
5485
+ }
5486
+
5487
+ #ensureOptionsPanel() {
5488
+ let panel = this.#getPanel();
5489
+ if (panel) {
5490
+ if (!panel.hasAttribute("slot")) panel.setAttribute("slot", "panel");
5491
+ return panel;
5492
+ }
5493
+ panel = document.createElement("fig-select-options");
5494
+ panel.setAttribute("slot", "panel");
5495
+ panel.setAttribute("data-fig-generated", "");
5496
+ this.appendChild(panel);
5497
+ return panel;
5498
+ }
5499
+
5500
+ /**
5501
+ * When no authored fig-select-option exists, build panel/options from the
5502
+ * options attribute (comma / newline / JSON — same as fig-options).
5503
+ */
5504
+ #syncOptionsFromAttribute() {
5505
+ if (this.#hasAuthoredOptions()) return;
5506
+
5507
+ const hasOptionsAttr = this.hasAttribute("options");
5508
+ const panel = hasOptionsAttr
5509
+ ? this.#ensureOptionsPanel()
5510
+ : this.#getPanel();
5511
+ if (!panel) return;
5512
+
5513
+ this.#syncingOptions = true;
5514
+ try {
5515
+ for (const opt of panel.querySelectorAll(
5516
+ ":scope > fig-select-option[data-fig-generated]",
5517
+ )) {
5518
+ opt.remove();
5519
+ }
5520
+
5521
+ if (!hasOptionsAttr) return;
5522
+
5523
+ const parsed = figSelectParseOptionsAttribute(this.getAttribute("options"));
5524
+ const endBtn = panel.querySelector(":scope > .fig-overflow-end");
5525
+ for (const entry of parsed) {
5526
+ const el = document.createElement("fig-select-option");
5527
+ el.setAttribute("data-fig-generated", "");
5528
+ el.setAttribute("value", figSelectOptionEntryValue(entry));
5529
+ el.textContent = figSelectOptionEntryLabel(entry);
5530
+ if (endBtn) panel.insertBefore(el, endBtn);
5531
+ else panel.appendChild(el);
5532
+ }
5533
+ } finally {
5534
+ this.#syncingOptions = false;
5535
+ }
5536
+ }
5537
+
5538
+ #initialize() {
5539
+ this.#initialized = true;
5540
+ const shadow = this.attachShadow({ mode: "open" });
5541
+ shadow.innerHTML = `
5542
+ <style>
5543
+ :host {
5544
+ display: inline-flex;
5545
+ position: relative;
5546
+ align-items: center;
5547
+ min-width: 0;
5548
+ }
5549
+ :host([full]:not([full="false"])) {
5550
+ display: flex;
5551
+ width: 100%;
5552
+ }
5553
+ .fig-select-trigger {
5554
+ display: flex;
5555
+ align-items: center;
5556
+ justify-content: flex-start;
5557
+ flex: 1;
5558
+ min-width: 0;
5559
+ width: var(--fig-select-trigger-width, 100%);
5560
+ height: 100%;
5561
+ margin: 0;
5562
+ padding: 0 var(--spacer-4, 1rem) 0 var(--spacer-2, 0.5rem);
5563
+ border: 0;
5564
+ border-radius: inherit;
5565
+ background: transparent;
5566
+ box-shadow: none;
5567
+ color: inherit;
5568
+ font: inherit;
5569
+ font-weight: inherit;
5570
+ text-align: left;
5571
+ white-space: nowrap;
5572
+ overflow: hidden;
5573
+ text-overflow: ellipsis;
5574
+ cursor: default;
5575
+ }
5576
+ .fig-select-trigger:has(.fig-select-prepend:not(:empty)) {
5577
+ padding-left: 0;
5578
+ }
5579
+ .fig-select-trigger:hover,
5580
+ .fig-select-trigger:active,
5581
+ .fig-select-trigger:active:hover {
5582
+ background: transparent;
5583
+ box-shadow: none;
5584
+ color: inherit;
5585
+ }
5586
+ .fig-select-trigger:focus-visible,
5587
+ .fig-select-trigger[data-focus-visible] {
5588
+ outline: var(--figma-focus-outline);
5589
+ outline-offset: var(--figma-focus-outline-offset);
5590
+ }
5591
+ :host([disabled]:not([disabled="false"])) .fig-select-trigger,
5592
+ :host([disabled]:not([disabled="false"])) .fig-select-label {
5593
+ color: var(--figma-color-text-tertiary);
5594
+ }
5595
+ .fig-select-label {
5596
+ display: block;
5597
+ width: 100%;
5598
+ min-width: 0;
5599
+ overflow: hidden;
5600
+ text-overflow: ellipsis;
5601
+ white-space: nowrap;
5602
+ text-align: left;
5603
+ }
5604
+ .fig-select-prepend {
5605
+ display: inline-flex;
5606
+ flex: 0 0 auto;
5607
+ align-items: center;
5608
+ margin-right: var(--spacer-1, 0.25rem);
5609
+ pointer-events: none;
5610
+ }
5611
+ .fig-select-prepend:empty {
5612
+ display: none;
5613
+ }
5614
+ /* Listbox chrome from document fig-select::part(listbox).
5615
+ Overflow UI lives on slotted fig-select-options.
5616
+ Never set display except when open — closed <dialog> must stay display:none. */
5617
+ dialog[is="fig-popup"] {
5618
+ flex-direction: column;
5619
+ overflow: hidden;
5620
+ }
5621
+ dialog[is="fig-popup"][open] {
5622
+ display: flex;
5623
+ }
5624
+ ::slotted(fig-select-options) {
5625
+ flex: 1 1 auto;
5626
+ min-height: 0;
5627
+ max-height: inherit;
5628
+ }
5629
+ </style>
5630
+ `;
5631
+
5632
+ const button = document.createElement("fig-button");
5633
+ button.className = "fig-select-trigger";
5634
+ button.setAttribute("part", "trigger");
5635
+ button.setAttribute("variant", "ghost");
5636
+ button.setAttribute("aria-haspopup", "listbox");
5637
+ button.setAttribute("aria-expanded", "false");
5638
+
5639
+ const prependEl = document.createElement("span");
5640
+ prependEl.className = "fig-select-prepend";
5641
+ prependEl.setAttribute("part", "prepend");
5642
+ prependEl.setAttribute("aria-hidden", "true");
5643
+
5644
+ const labelEl = document.createElement("span");
5645
+ labelEl.className = "fig-select-label";
5646
+ labelEl.setAttribute("part", "label");
5647
+ button.append(prependEl, labelEl);
5648
+
5649
+ const popup = document.createElement("dialog", { is: "fig-popup" });
5650
+ popup.setAttribute("is", "fig-popup");
5651
+ popup.setAttribute("part", "listbox");
5652
+ popup.setAttribute("theme", "menu");
5653
+ popup.setAttribute("role", "listbox");
5654
+ // Top-layer via popover so the menu escapes ancestor contain/overflow
5655
+ // (e.g. fig-fill-picker-dialog). Stays in shadow so option slots still work —
5656
+ // unlike tooltips, we cannot portal this popup to the overlay root.
5657
+ if ("popover" in HTMLElement.prototype) {
5658
+ popup.setAttribute("popover", "manual");
5659
+ }
5660
+ popup.id = figUniqueId();
5661
+ button.setAttribute("aria-controls", popup.id);
5662
+
5663
+ const panelSlot = document.createElement("slot");
5664
+ panelSlot.setAttribute("name", "panel");
5665
+ popup.appendChild(panelSlot);
5666
+
5667
+ shadow.append(button, popup);
5668
+
5669
+ this.#button = button;
5670
+ this.#prependEl = prependEl;
5671
+ this.#labelEl = labelEl;
5672
+ this.#popup = popup;
5673
+ this.#panelSlot = panelSlot;
5674
+ popup.anchor = button;
5675
+
5676
+ this.#ensurePanelSlotAttrs();
5677
+ this.#setupListeners();
5678
+ this.#installPopupPositioning();
5679
+
5680
+ if (!this.hasAttribute("value")) {
5681
+ const selected = this.#getOptions().find((opt) =>
5682
+ figBooleanAttribute(opt, "selected"),
5683
+ );
5684
+ if (selected) this.setAttribute("value", selected.value);
5685
+ }
5686
+ }
5687
+
5688
+ #installPopupPositioning() {
5689
+ if (!this.#popup || this.#popupPositionPatched) return;
5690
+ if (typeof this.#popup.positionPopup !== "function") return;
5691
+ this.#originalPositionPopup = this.#popup.positionPopup.bind(this.#popup);
5692
+ this.#popup.positionPopup = () => {
5693
+ if (!this.open) {
5694
+ this.#originalPositionPopup?.();
5695
+ return;
5696
+ }
5697
+ this.#positionPopupOverSelected();
5698
+ };
5699
+ this.#popupPositionPatched = true;
5700
+ }
5701
+
5702
+ #getOptionTextRect(option) {
5703
+ if (!option) return null;
5704
+ const range = document.createRange();
5705
+ range.selectNodeContents(option);
5706
+ const rects = [...range.getClientRects()].filter(
5707
+ (rect) => rect.width > 0 && rect.height > 0,
5708
+ );
5709
+ if (rects.length) return rects[0];
5710
+ return option.getBoundingClientRect();
5711
+ }
5712
+
5713
+ #getViewportMargins() {
5714
+ if (typeof this.#popup?.parseViewportMargins === "function") {
5715
+ return this.#popup.parseViewportMargins();
5716
+ }
5717
+ return { top: 8, right: 8, bottom: 8, left: 8 };
5718
+ }
5719
+
5720
+ #readLabelRectSnapshot() {
5721
+ const rect = this.#labelEl?.getBoundingClientRect();
5722
+ if (!rect) return null;
5723
+ return {
5724
+ x: rect.x,
5725
+ y: rect.y,
5726
+ width: rect.width,
5727
+ height: rect.height,
5728
+ };
5729
+ }
5730
+
5731
+ #readViewportSnapshot() {
5732
+ const vv = window.visualViewport;
5733
+ return {
5734
+ width: vv?.width ?? window.innerWidth,
5735
+ height: vv?.height ?? window.innerHeight,
5736
+ offsetLeft: vv?.offsetLeft ?? 0,
5737
+ offsetTop: vv?.offsetTop ?? 0,
5738
+ };
5739
+ }
5740
+
5741
+ #rectSnapshotChanged(prev, next, epsilon = 0.25) {
5742
+ if (!prev && !next) return false;
5743
+ if (!prev || !next) return true;
5744
+ return (
5745
+ Math.abs(prev.x - next.x) > epsilon ||
5746
+ Math.abs(prev.y - next.y) > epsilon ||
5747
+ Math.abs(prev.width - next.width) > epsilon ||
5748
+ Math.abs(prev.height - next.height) > epsilon
5749
+ );
5750
+ }
5751
+
5752
+ #viewportSnapshotChanged(prev, next, epsilon = 0.25) {
5753
+ if (!prev && !next) return false;
5754
+ if (!prev || !next) return true;
5755
+ return (
5756
+ Math.abs(prev.width - next.width) > epsilon ||
5757
+ Math.abs(prev.height - next.height) > epsilon ||
5758
+ Math.abs(prev.offsetLeft - next.offsetLeft) > epsilon ||
5759
+ Math.abs(prev.offsetTop - next.offsetTop) > epsilon
5760
+ );
5761
+ }
5762
+
5763
+ #shouldSkipFrozenPositionPass() {
5764
+ if (!this.#freezeMenuPosition) return false;
5765
+ const labelMoved = this.#rectSnapshotChanged(
5766
+ this.#frozenLabelRect,
5767
+ this.#readLabelRectSnapshot(),
5768
+ );
5769
+ const viewportChanged = this.#viewportSnapshotChanged(
5770
+ this.#frozenViewport,
5771
+ this.#readViewportSnapshot(),
5772
+ );
5773
+ // Skip only when neither the trigger nor the viewport moved — typical of
5774
+ // overflow scroll / content sync fighting the open-time alignment.
5775
+ return !labelMoved && !viewportChanged;
5776
+ }
5777
+
5778
+ #rememberFrozenGeometry() {
5779
+ this.#frozenLabelRect = this.#readLabelRectSnapshot();
5780
+ this.#frozenViewport = this.#readViewportSnapshot();
5781
+ }
5782
+
5783
+ #positionPopupOverSelected() {
5784
+ // Content ResizeObserver / overflow scroll re-enter here; keep the
5785
+ // open-time alignment unless the trigger or viewport actually changed.
5786
+ if (this.#shouldSkipFrozenPositionPass()) return;
5787
+
5788
+ const popup = this.#popup;
5789
+ const label = this.#labelEl;
5790
+ if (!popup || !label) {
5791
+ this.#originalPositionPopup?.();
5792
+ return;
5793
+ }
5794
+
5795
+ const options = this.#getOptions();
5796
+ const selected =
5797
+ options.find((opt) => this.#optionValue(opt) === this.value) ||
5798
+ options[0];
5799
+ if (!selected) {
5800
+ this.#originalPositionPopup?.();
5801
+ return;
5802
+ }
5803
+
5804
+ // Lay out with the default positioning first so option metrics are valid.
5805
+ this.#originalPositionPopup?.();
5806
+
5807
+ const popupRect = popup.getBoundingClientRect();
5808
+ const labelRect = label.getBoundingClientRect();
5809
+ const optionTextRect = this.#getOptionTextRect(selected);
5810
+ if (
5811
+ !popupRect.width ||
5812
+ !popupRect.height ||
5813
+ !labelRect.width ||
5814
+ !optionTextRect
5815
+ ) {
5816
+ return;
5817
+ }
5818
+
5819
+ const selectedOffsetX = optionTextRect.left - popupRect.left;
5820
+ const selectedOffsetY = optionTextRect.top - popupRect.top;
5821
+ const full = figBooleanAttribute(this, "full");
5822
+ // [full]: pin menu to host width/edges. Otherwise overlay selected
5823
+ // option text on the trigger label (blend-mode style).
5824
+ let left = full
5825
+ ? this.getBoundingClientRect().left
5826
+ : labelRect.left - selectedOffsetX;
5827
+ let top = labelRect.top - selectedOffsetY;
5828
+
5829
+ // Keep the whole menu in-view when aligning over the selected option
5830
+ // would otherwise push it past a viewport edge (corners / far sides).
5831
+ const margins = this.#getViewportMargins();
5832
+ if (typeof popup.clampToViewport === "function") {
5833
+ ({ left, top } = popup.clampToViewport({ left, top }, popupRect, margins));
5834
+ } else {
5835
+ const minLeft = margins.left;
5836
+ const minTop = margins.top;
5837
+ const maxLeft = window.innerWidth - popupRect.width - margins.right;
5838
+ const maxTop = window.innerHeight - popupRect.height - margins.bottom;
5839
+ left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft));
5840
+ top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop));
5841
+ }
5842
+
5843
+ // !important: fig-select::part(listbox) and dialog UA rules can otherwise
5844
+ // keep the menu at its static/anchor position past the viewport edge.
5845
+ popup.style.setProperty("right", "auto", "important");
5846
+ popup.style.setProperty("bottom", "auto", "important");
5847
+ popup.style.setProperty("left", `${Math.round(left)}px`, "important");
5848
+ popup.style.setProperty("top", `${Math.round(top)}px`, "important");
5849
+
5850
+ // Nudge the panel scroller so the selected label stays over the trigger.
5851
+ const panel = this.#getPanel();
5852
+ const alignedTextRect = this.#getOptionTextRect(selected);
5853
+ if (
5854
+ alignedTextRect &&
5855
+ panel &&
5856
+ panel.scrollHeight > panel.clientHeight + 1
5857
+ ) {
5858
+ const deltaY = alignedTextRect.top - labelRect.top;
5859
+ if (Math.abs(deltaY) > 0.5) {
5860
+ panel.scrollTop += deltaY;
5861
+ }
5862
+ panel.syncOverflow?.();
5863
+ }
5864
+
5865
+ if (this.#freezeMenuPosition || this.open) {
5866
+ this.#rememberFrozenGeometry();
5867
+ }
5868
+ }
5869
+
5870
+ #setupListeners() {
5871
+ this.#button?.addEventListener("click", this.#boundTriggerClick);
5872
+ this.#button?.addEventListener("keydown", this.#boundKeydown);
5873
+ // Host click: slotted options stay in light DOM (not dialog.contains).
5874
+ this.addEventListener("click", this.#boundOptionClick);
5875
+ this.#popup?.addEventListener("keydown", this.#boundKeydown);
5876
+ this.#popup?.addEventListener("close", this.#boundPopupClose);
5877
+ this.#panelSlot?.addEventListener("slotchange", this.#boundSlotChange);
5878
+ }
5879
+
5880
+ #teardownListeners() {
5881
+ this.#button?.removeEventListener("click", this.#boundTriggerClick);
5882
+ this.#button?.removeEventListener("keydown", this.#boundKeydown);
5883
+ this.removeEventListener("click", this.#boundOptionClick);
5884
+ this.#popup?.removeEventListener("keydown", this.#boundKeydown);
5885
+ this.#popup?.removeEventListener("close", this.#boundPopupClose);
5886
+ this.#panelSlot?.removeEventListener("slotchange", this.#boundSlotChange);
5887
+ }
5888
+
5889
+ #handleSlotChange() {
5890
+ this.#ensurePanelSlotAttrs();
5891
+ this.#syncValue();
5892
+ }
5893
+
5894
+ #setupObserver() {
5895
+ if (this.#observer) return;
5896
+ this.#observer = new MutationObserver((mutations) => {
5897
+ if (this.#syncingValue || this.#syncingOptions) return;
5898
+ let needsSync = false;
5899
+ for (const mutation of mutations) {
5900
+ if (mutation.type === "childList") {
5901
+ if (
5902
+ [...mutation.addedNodes].some((node) => this.#isMenuChild(node)) ||
5903
+ [...mutation.removedNodes].some((node) => this.#isMenuChild(node)) ||
5904
+ mutation.target?.closest?.("fig-select-option")
5905
+ ) {
5906
+ needsSync = true;
5907
+ }
5908
+ }
5909
+ if (
5910
+ mutation.type === "attributes" &&
5911
+ mutation.target?.tagName === "FIG-SELECT-OPTION" &&
5912
+ (mutation.attributeName === "value" ||
5913
+ mutation.attributeName === "disabled" ||
5914
+ mutation.attributeName === "label")
5915
+ ) {
5916
+ needsSync = true;
5917
+ }
5918
+ if (
5919
+ mutation.type === "characterData" &&
5920
+ mutation.target?.parentElement?.tagName === "FIG-SELECT-OPTION"
5921
+ ) {
5922
+ needsSync = true;
5923
+ }
5924
+ }
5925
+ if (needsSync) this.#syncValue();
5926
+ });
5927
+ this.#observer.observe(this, {
5928
+ childList: true,
5929
+ subtree: true,
5930
+ characterData: true,
5931
+ attributes: true,
5932
+ attributeFilter: ["value", "disabled", "selected", "label"],
5933
+ });
5934
+ }
5935
+
5936
+ #getOptions({ enabledOnly = false } = {}) {
5937
+ const panel = this.#getPanel();
5938
+ const options = panel
5939
+ ? Array.from(panel.querySelectorAll(":scope > fig-select-option"))
5940
+ : [];
5941
+ if (!enabledOnly) return options;
5942
+ return options.filter((opt) => !figBooleanAttribute(opt, "disabled"));
5943
+ }
5944
+
5945
+ #optionValue(option) {
5946
+ if (!option) return "";
5947
+ if (typeof option.value === "string") return option.value;
5948
+ const attr = option.getAttribute?.("value");
5949
+ if (attr != null) return attr;
5950
+ return (option.textContent || "").trim();
5951
+ }
5952
+
5953
+ #optionLabel(option) {
5954
+ if (!option) return "";
5955
+ const labelAttr = option.getAttribute?.("label");
5956
+ if (labelAttr != null && labelAttr !== "") return labelAttr.trim();
5957
+
5958
+ // Ignore prepend/append slot content when deriving a label from children.
5959
+ const parts = [];
5960
+ for (const node of option.childNodes) {
5961
+ if (node.nodeType === Node.TEXT_NODE) {
5962
+ const text = node.textContent?.trim();
5963
+ if (text) parts.push(text);
5964
+ continue;
5965
+ }
5966
+ if (!(node instanceof Element)) continue;
5967
+ const slot = node.getAttribute("slot");
5968
+ if (slot === "prepend" || slot === "append") continue;
5969
+ const text = node.textContent?.trim();
5970
+ if (text) parts.push(text);
5971
+ }
5972
+ if (parts.length) return parts.join(" ").trim();
5973
+ return (option.textContent || "").trim();
5974
+ }
5975
+
5976
+ #syncPrepend(option) {
5977
+ if (!this.#prependEl) return;
5978
+ const source = option?.querySelector?.(':scope > [slot="prepend"]');
5979
+ this.#prependEl.replaceChildren(
5980
+ ...Array.from(source?.childNodes ?? [], (node) => node.cloneNode(true)),
5981
+ );
5982
+ }
5983
+
5984
+ #syncPopupAttrs() {
5985
+ if (!this.#popup) return;
5986
+ this.#popup.setAttribute(
5987
+ "position",
5988
+ this.getAttribute("position") || "bottom left",
5989
+ );
5990
+ const offset = this.getAttribute("offset");
5991
+ if (offset) this.#popup.setAttribute("offset", offset);
5992
+ else this.#popup.removeAttribute("offset");
5993
+ const closedby = this.getAttribute("closedby");
5994
+ if (closedby) this.#popup.setAttribute("closedby", closedby);
5995
+ else this.#popup.removeAttribute("closedby");
5996
+ }
5997
+
5998
+ #syncDisabled() {
5999
+ const disabled = figBooleanAttribute(this, "disabled");
6000
+ if (this.#button) {
6001
+ if (disabled) this.#button.setAttribute("disabled", "");
6002
+ else this.#button.removeAttribute("disabled");
6003
+ }
6004
+ if (disabled && this.open) this.open = false;
6005
+ }
6006
+
6007
+ #pickFallbackOption(options) {
6008
+ if (!options.length) return null;
6009
+ const selected = options.find((opt) =>
6010
+ figBooleanAttribute(opt, "selected"),
6011
+ );
6012
+ if (selected && !figBooleanAttribute(selected, "disabled")) {
6013
+ return selected;
6014
+ }
6015
+ return (
6016
+ options.find((opt) => !figBooleanAttribute(opt, "disabled")) ||
6017
+ options[0] ||
6018
+ null
6019
+ );
6020
+ }
6021
+
6022
+ #emitValueEvents(value) {
6023
+ this.dispatchEvent(
6024
+ new CustomEvent("input", {
6025
+ detail: value,
6026
+ bubbles: true,
6027
+ composed: true,
6028
+ }),
6029
+ );
6030
+ this.dispatchEvent(
6031
+ new CustomEvent("change", {
6032
+ detail: value,
6033
+ bubbles: true,
6034
+ composed: true,
6035
+ }),
6036
+ );
6037
+ }
6038
+
6039
+ #syncValue() {
6040
+ if (this.#syncingValue) return;
6041
+ this.#syncingValue = true;
6042
+ try {
6043
+ const options = this.#getOptions();
6044
+ const hasValueAttr = this.hasAttribute("value");
6045
+ const previousValue = hasValueAttr ? this.getAttribute("value") : null;
6046
+ let match = hasValueAttr
6047
+ ? options.find((opt) => this.#optionValue(opt) === previousValue)
6048
+ : null;
6049
+ let valueCorrected = false;
6050
+
6051
+ if (!match) {
6052
+ if (hasValueAttr) {
6053
+ // Options may not be built yet (options attr sync). Keep value until then.
6054
+ if (!options.length) {
6055
+ if (this.#labelEl) {
6056
+ this.#labelEl.textContent =
6057
+ previousValue || this.getAttribute("label") || "";
6058
+ }
6059
+ return;
6060
+ }
6061
+ // Value orphaned (option removed / value attr changed) — clamp or clear.
6062
+ match = this.#pickFallbackOption(options);
6063
+ if (match) {
6064
+ const nextValue = this.#optionValue(match);
6065
+ if (previousValue !== nextValue) {
6066
+ this.setAttribute("value", nextValue);
6067
+ valueCorrected = true;
6068
+ }
6069
+ } else {
6070
+ this.removeAttribute("value");
6071
+ valueCorrected = true;
6072
+ }
6073
+ } else {
6074
+ // No host value yet — honor a selected option if present.
6075
+ match = options.find((opt) =>
6076
+ figBooleanAttribute(opt, "selected"),
6077
+ );
6078
+ if (match) {
6079
+ this.setAttribute("value", this.#optionValue(match));
6080
+ valueCorrected = true;
6081
+ }
6082
+ }
6083
+ }
6084
+
6085
+ for (const opt of options) {
6086
+ const selected = opt === match;
6087
+ opt.setAttribute("aria-selected", selected ? "true" : "false");
6088
+ if (selected) opt.setAttribute("selected", "");
6089
+ else opt.removeAttribute("selected");
6090
+ }
6091
+
6092
+ const label =
6093
+ (match && this.#optionLabel(match)) || this.getAttribute("label") || "";
6094
+ if (this.#labelEl) this.#labelEl.textContent = label;
6095
+ this.#syncPrepend(match);
6096
+
6097
+ const ariaLabel = this.getAttribute("label") || "Select";
6098
+ this.#button?.setAttribute("aria-label", ariaLabel);
6099
+
6100
+ // Don't scrollToOption while open — reposition/sync would fight overflow paging.
6101
+ this.#getPanel()?.syncOverflow?.();
6102
+
6103
+ if (valueCorrected) {
6104
+ this.#emitValueEvents(this.getAttribute("value") ?? "");
6105
+ }
6106
+ } finally {
6107
+ this.#syncingValue = false;
6108
+ }
6109
+ }
6110
+
6111
+ #handleTriggerClick(e) {
6112
+ if (figBooleanAttribute(this, "disabled")) return;
6113
+ e.preventDefault();
6114
+ e.stopPropagation();
6115
+ const nextOpen = !this.open;
6116
+ if (nextOpen && this.#popup && this.#button) {
6117
+ this.#popup.anchor = this.#button;
6118
+ }
6119
+ this.open = nextOpen;
6120
+ }
6121
+
6122
+ #handleOptionClick(e) {
6123
+ const path = typeof e.composedPath === "function" ? e.composedPath() : [];
6124
+ const option = path.find(
6125
+ (node) => node?.tagName === "FIG-SELECT-OPTION",
6126
+ );
6127
+ if (!option || !this.contains(option)) return;
6128
+ if (figBooleanAttribute(option, "disabled")) return;
6129
+ // Do not stopPropagation — React light-DOM onClick must still fire.
6130
+ this.#selectOption(option);
6131
+ }
6132
+
6133
+ #handleKeydown(e) {
6134
+ if (e.currentTarget === document && e.key !== "Escape") return;
6135
+
6136
+ const listOpen = this.open && (this.#popup?.matches?.(":open") ?? false);
6137
+ if (!listOpen) {
6138
+ if (
6139
+ this.#button?.contains(e.target) &&
6140
+ (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ")
6141
+ ) {
6142
+ e.preventDefault();
6143
+ if (this.#popup && this.#button) this.#popup.anchor = this.#button;
6144
+ this.open = true;
6145
+ requestAnimationFrame(() => {
6146
+ const options = this.#getOptions({ enabledOnly: true });
6147
+ const selectedIndex = options.findIndex(
6148
+ (opt) => this.#optionValue(opt) === this.value,
6149
+ );
6150
+ this.#focusOptionAt(selectedIndex >= 0 ? selectedIndex : 0);
6151
+ });
6152
+ }
6153
+ return;
6154
+ }
6155
+
6156
+ const options = this.#getOptions({ enabledOnly: true });
6157
+ if (!options.length) return;
6158
+
6159
+ switch (e.key) {
6160
+ case "ArrowDown":
6161
+ e.preventDefault();
6162
+ this.#syncFocusedIndex();
6163
+ this.#focusOptionAt(this.#focusedIndex + 1);
6164
+ break;
6165
+ case "ArrowUp":
6166
+ e.preventDefault();
6167
+ this.#syncFocusedIndex();
6168
+ this.#focusOptionAt(this.#focusedIndex - 1);
6169
+ break;
6170
+ case "Home":
6171
+ e.preventDefault();
6172
+ this.#focusOptionAt(0);
6173
+ break;
6174
+ case "End":
6175
+ e.preventDefault();
6176
+ this.#focusOptionAt(options.length - 1);
6177
+ break;
6178
+ case "Escape":
6179
+ e.preventDefault();
6180
+ this.open = false;
6181
+ this.#button?.focus();
6182
+ break;
6183
+ case "Enter":
6184
+ case " ": {
6185
+ this.#syncFocusedIndex();
6186
+ const focused = options[this.#focusedIndex];
6187
+ if (!focused) return;
6188
+ e.preventDefault();
6189
+ this.#selectOption(focused);
6190
+ break;
6191
+ }
6192
+ }
6193
+ }
6194
+
6195
+ #handlePopupClose() {
6196
+ if (this.hasAttribute("open")) this.removeAttribute("open");
6197
+ this.#button?.setAttribute("aria-expanded", "false");
6198
+ this.#button?.focus();
6199
+ this.#focusedIndex = -1;
5067
6200
  }
5068
6201
 
5069
- #startResizeObserver() {
5070
- this.#resizeObserver?.disconnect();
5071
- this.#resizeObserver = new ResizeObserver(() => {
5072
- this.#checkOverflow();
5073
- });
5074
- this.#resizeObserver.observe(this);
6202
+ #selectOption(option) {
6203
+ const value = this.#optionValue(option);
6204
+ this.setAttribute("value", value);
6205
+ this.#syncValue();
6206
+ this.#emitValueEvents(value);
6207
+ this.open = false;
5075
6208
  }
5076
6209
 
5077
- #isSegmentTruncated(seg) {
5078
- const range = document.createRange();
5079
- range.selectNodeContents(seg);
5080
- const textWidth = range.getBoundingClientRect().width;
5081
- const segRect = seg.getBoundingClientRect();
5082
- const segWidth = segRect.width;
5083
- const cs = getComputedStyle(seg);
5084
- const padL = parseFloat(cs.paddingLeft) || 0;
5085
- const padR = parseFloat(cs.paddingRight) || 0;
5086
- const contentWidth = segWidth - padL - padR;
5087
- return textWidth > contentWidth + 0.5;
6210
+ #getEnabledOptions() {
6211
+ return this.#getOptions({ enabledOnly: true });
5088
6212
  }
5089
6213
 
5090
- #anySegmentTruncated() {
5091
- const segments = this.querySelectorAll("fig-segment");
5092
- for (const seg of segments) {
5093
- if (this.#isSegmentTruncated(seg)) return true;
6214
+ #syncFocusedIndex() {
6215
+ const options = this.#getEnabledOptions();
6216
+ if (!options.length) {
6217
+ this.#focusedIndex = -1;
6218
+ return;
5094
6219
  }
5095
- return false;
6220
+ const active = options.find((opt) => opt === document.activeElement);
6221
+ const index = active ? options.indexOf(active) : -1;
6222
+ this.#focusedIndex = index >= 0 ? index : this.#focusedIndex;
6223
+ }
6224
+
6225
+ #focusOptionAt(index) {
6226
+ const options = this.#getEnabledOptions();
6227
+ if (!options.length) return;
6228
+ const next = ((index % options.length) + options.length) % options.length;
6229
+ this.#focusedIndex = next;
6230
+ options[next]?.focus();
6231
+ }
6232
+
6233
+ #syncPopupWidth() {
6234
+ if (!this.#popup || !this.#button) return;
6235
+ const hostWidth = Math.ceil(this.getBoundingClientRect().width);
6236
+ const triggerWidth = Math.ceil(this.#button.getBoundingClientRect().width);
6237
+ const anchorWidth = Math.max(hostWidth, triggerWidth, 96);
6238
+
6239
+ // Use !important — fig-select::part(listbox) width rules beat element.style.
6240
+ // Menus size to their options while remaining at least as wide as the trigger.
6241
+ this.#popup.style.setProperty("width", "max-content", "important");
6242
+ this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
6243
+ this.#popup.style.setProperty(
6244
+ "max-width",
6245
+ "min(20rem, calc(100vw - 1rem))",
6246
+ "important",
6247
+ );
5096
6248
  }
5097
6249
 
5098
- #checkOverflow() {
5099
- if (this.#parsedOptions.length <= 1) return;
5100
-
5101
- if (this.#currentMode === "segments") {
5102
- const sc = this.#childControl;
5103
- const containerOverflow = sc && sc.scrollWidth > sc.clientWidth + 1;
5104
- if (containerOverflow || this.#anySegmentTruncated()) {
5105
- this.#naturalWidth = this.clientWidth;
5106
- this.#renderDropdown();
5107
- }
5108
- } else {
5109
- if (this.#naturalWidth > 0 && this.clientWidth >= this.#naturalWidth) {
5110
- this.#renderSegments();
5111
- requestAnimationFrame(() => {
5112
- requestAnimationFrame(() => {
5113
- const sc = this.#childControl;
5114
- const containerOverflow = sc && sc.scrollWidth > sc.clientWidth + 1;
5115
- if (containerOverflow || this.#anySegmentTruncated()) {
5116
- this.#renderDropdown();
5117
- }
5118
- });
5119
- });
6250
+ #openList() {
6251
+ if (!this.#popup || figBooleanAttribute(this, "disabled")) return;
6252
+ if (this.#button) this.#popup.anchor = this.#button;
6253
+ this.#installPopupPositioning();
6254
+ this.#freezeMenuPosition = false;
6255
+ this.#frozenLabelRect = null;
6256
+ this.#frozenViewport = null;
6257
+ this.#syncValue();
6258
+ this.#syncPopupWidth();
6259
+ this.#popup.open = true;
6260
+ document.addEventListener("keydown", this.#boundKeydown, true);
6261
+ this.#button?.setAttribute("aria-expanded", "true");
6262
+ this.#focusedIndex = -1;
6263
+ requestAnimationFrame(() => {
6264
+ this.#syncPopupWidth();
6265
+ this.#positionPopupOverSelected();
6266
+ const panel = this.#getPanel();
6267
+ const options = this.#getEnabledOptions();
6268
+ const selectedIndex = options.findIndex(
6269
+ (opt) => this.#optionValue(opt) === this.value,
6270
+ );
6271
+ if (selectedIndex >= 0) {
6272
+ this.#focusOptionAt(selectedIndex);
6273
+ } else if (
6274
+ this.#button?.hasAttribute("data-focus-visible") ||
6275
+ this.#button?.matches?.(":focus-visible")
6276
+ ) {
6277
+ this.#focusOptionAt(0);
5120
6278
  }
5121
- }
6279
+ panel?.syncOverflow?.();
6280
+ // Freeze after open align so later positionPopup passes don't undo scroll.
6281
+ // Window resize / trigger movement still realigns via geometry checks.
6282
+ this.#freezeMenuPosition = true;
6283
+ this.#rememberFrozenGeometry();
6284
+ });
6285
+ }
6286
+
6287
+ #closeList() {
6288
+ if (!this.#popup) return;
6289
+ this.#freezeMenuPosition = false;
6290
+ this.#frozenLabelRect = null;
6291
+ this.#frozenViewport = null;
6292
+ document.removeEventListener("keydown", this.#boundKeydown, true);
6293
+ this.#popup.open = false;
6294
+ this.#button?.setAttribute("aria-expanded", "false");
5122
6295
  }
5123
6296
  }
5124
- figDefineElement("fig-options", FigOptions);
6297
+ figDefineElement("fig-select", FigSelect);
5125
6298
 
5126
6299
  /* Slider */
5127
6300
  /**
@@ -5823,6 +6996,8 @@ figDefineElement("fig-slider", FigSlider);
5823
6996
  class FigInputText extends HTMLElement {
5824
6997
  #isInteracting = false;
5825
6998
  #passwordVisible = false;
6999
+ #value = "";
7000
+ #reflectingValue = false;
5826
7001
  #boundMouseMove;
5827
7002
  #boundMouseUp;
5828
7003
  #boundWindowBlur;
@@ -5866,7 +7041,9 @@ class FigInputText extends HTMLElement {
5866
7041
  new CustomEvent("input", { detail: this.value, bubbles: true }),
5867
7042
  );
5868
7043
  };
5869
- this.#boundFocusControl = this.focus.bind(this);
7044
+ this.#boundFocusControl = () => {
7045
+ if (!this.disabled) this.focus();
7046
+ };
5870
7047
  this.#boundAdornmentClick = this.#handleAdornmentClick.bind(this);
5871
7048
  }
5872
7049
 
@@ -5907,6 +7084,7 @@ class FigInputText extends HTMLElement {
5907
7084
  this.#syncSearchClear();
5908
7085
  this.#syncSearchClearVisibility();
5909
7086
  this.#syncPasswordToggle();
7087
+ this.#syncGeneratedAdornmentDisabled();
5910
7088
  figNormalizeTextOnlyInputSlots(this);
5911
7089
  this.#startObserver();
5912
7090
 
@@ -5982,6 +7160,7 @@ class FigInputText extends HTMLElement {
5982
7160
  #handleAdornmentClick(event) {
5983
7161
  const adornment = event.target?.closest?.("[slot]");
5984
7162
  if (!adornment || adornment.parentElement !== this) return;
7163
+ if (this.disabled) return;
5985
7164
  this.focus();
5986
7165
  }
5987
7166
  #startObserver() {
@@ -6013,6 +7192,7 @@ class FigInputText extends HTMLElement {
6013
7192
  this.#syncSearchClear();
6014
7193
  this.#syncSearchClearVisibility();
6015
7194
  this.#syncPasswordToggle();
7195
+ this.#syncGeneratedAdornmentDisabled();
6016
7196
  figNormalizeTextOnlyInputSlots(this);
6017
7197
  }
6018
7198
  #syncInputA11yAttributes() {
@@ -6097,6 +7277,7 @@ class FigInputText extends HTMLElement {
6097
7277
  button.addEventListener("click", (e) => {
6098
7278
  e.preventDefault();
6099
7279
  e.stopPropagation();
7280
+ if (this.disabled) return;
6100
7281
  if (!this.input || this.input.value === "") {
6101
7282
  this.focus();
6102
7283
  return;
@@ -6155,6 +7336,7 @@ class FigInputText extends HTMLElement {
6155
7336
  button.addEventListener("click", (e) => {
6156
7337
  e.preventDefault();
6157
7338
  e.stopPropagation();
7339
+ if (this.disabled) return;
6158
7340
  this.#passwordVisible = !this.#passwordVisible;
6159
7341
  if (this.input) {
6160
7342
  this.input.type = this.#passwordVisible ? "text" : "password";
@@ -6172,6 +7354,14 @@ class FigInputText extends HTMLElement {
6172
7354
  button?.setAttribute("aria-label", label);
6173
7355
  icon?.setAttribute("name", this.#passwordVisible ? "visible" : "hidden");
6174
7356
  }
7357
+ #syncGeneratedAdornmentDisabled() {
7358
+ this.querySelectorAll(
7359
+ '[data-generated="search-clear"] fig-button, [data-generated="password-toggle"] fig-button',
7360
+ ).forEach((button) => {
7361
+ if (this.disabled) button.setAttribute("disabled", "");
7362
+ else button.removeAttribute("disabled");
7363
+ });
7364
+ }
6175
7365
  #transformNumber(value) {
6176
7366
  if (value === "") return "";
6177
7367
  let transformed = Number(value) * (this.transform || 1);
@@ -6275,15 +7465,29 @@ class FigInputText extends HTMLElement {
6275
7465
  return Number.isInteger(rounded) ? rounded : rounded.toFixed(precision);
6276
7466
  }
6277
7467
 
6278
- /*
6279
7468
  get value() {
6280
- return this.value;
7469
+ return this.#value;
6281
7470
  }
6282
7471
 
6283
7472
  set value(val) {
6284
- this.value = val;
6285
- this.setAttribute("value", val);
6286
- }*/
7473
+ const value = val ?? "";
7474
+ this.#value = value;
7475
+ const reflected = String(value);
7476
+ if (this.getAttribute("value") !== reflected) {
7477
+ this.#reflectingValue = true;
7478
+ this.setAttribute("value", reflected);
7479
+ this.#reflectingValue = false;
7480
+ }
7481
+ this.#syncRenderedValue(value);
7482
+ }
7483
+
7484
+ #syncRenderedValue(value) {
7485
+ if (!this.input || this.#isInteracting) return;
7486
+ const rendered =
7487
+ this.type === "number" ? String(this.#transformNumber(value)) : String(value ?? "");
7488
+ if (this.input.value !== rendered) this.input.value = rendered;
7489
+ this.#syncSearchClearVisibility();
7490
+ }
6287
7491
 
6288
7492
  static get observedAttributes() {
6289
7493
  return [
@@ -6313,6 +7517,7 @@ class FigInputText extends HTMLElement {
6313
7517
  case "disabled":
6314
7518
  this.disabled = this.input.disabled =
6315
7519
  newValue !== null && newValue !== "false";
7520
+ this.#syncGeneratedAdornmentDisabled();
6316
7521
  break;
6317
7522
  case "readonly":
6318
7523
  this.readonly = newValue !== null && newValue !== "false";
@@ -6329,13 +7534,9 @@ class FigInputText extends HTMLElement {
6329
7534
  let value = newValue;
6330
7535
  if (this.type === "number") {
6331
7536
  value = this.#sanitizeInput(value, false);
6332
- this.value = value;
6333
- this.input.value = this.#transformNumber(value);
6334
- } else {
6335
- this.value = value;
6336
- this.input.value = value;
6337
7537
  }
6338
- this.#syncSearchClearVisibility();
7538
+ this.#value = value ?? "";
7539
+ if (!this.#reflectingValue) this.#syncRenderedValue(this.#value);
6339
7540
  break;
6340
7541
  case "min":
6341
7542
  case "max":
@@ -6378,6 +7579,7 @@ class FigInputText extends HTMLElement {
6378
7579
  this.#syncSearchClear();
6379
7580
  this.#syncSearchClearVisibility();
6380
7581
  this.#syncPasswordToggle();
7582
+ this.#syncGeneratedAdornmentDisabled();
6381
7583
  break;
6382
7584
  case "multiline": {
6383
7585
  const next = newValue !== null && newValue !== "false";
@@ -7405,10 +8607,12 @@ class FigInputColor extends HTMLElement {
7405
8607
 
7406
8608
  #fillPickerAttrs() {
7407
8609
  const attrs = {};
7408
- const experimental = this.getAttribute("experimental");
7409
- if (experimental) attrs["experimental"] = experimental;
7410
8610
  for (const { name, value } of this.attributes) {
7411
- if (name.startsWith("picker-") && name !== "picker-anchor") {
8611
+ if (
8612
+ name.startsWith("picker-") &&
8613
+ name !== "picker-anchor" &&
8614
+ name !== "picker-experimental"
8615
+ ) {
7412
8616
  attrs[name.slice(7)] = value;
7413
8617
  }
7414
8618
  }
@@ -7615,8 +8819,8 @@ class FigInputColor extends HTMLElement {
7615
8819
 
7616
8820
  const picker = document.createElement("fig-fill-picker");
7617
8821
  picker.innerHTML = "<span hidden></span>";
7618
- picker.addEventListener("input", this.#handleFillPickerInput.bind(this));
7619
- picker.addEventListener("change", this.#handleChange.bind(this));
8822
+ picker.addEventListener("input", this.#boundFillPickerInput);
8823
+ picker.addEventListener("change", this.#boundChange);
7620
8824
  this.appendChild(picker);
7621
8825
  this.#fillPicker = picker;
7622
8826
  this.#syncFillPicker();
@@ -7681,8 +8885,22 @@ class FigInputColor extends HTMLElement {
7681
8885
  }
7682
8886
 
7683
8887
  #setValues(hexValue) {
7684
- const colorValue = hexValue || "#D9D9D9";
7685
- this.rgba = this.convertToRGBA(colorValue);
8888
+ let colorValue =
8889
+ typeof hexValue === "string" && hexValue.trim()
8890
+ ? hexValue.trim()
8891
+ : "#D9D9D9";
8892
+ let rgba = this.convertToRGBA(colorValue);
8893
+ if (
8894
+ !rgba ||
8895
+ !Number.isFinite(rgba.r) ||
8896
+ !Number.isFinite(rgba.g) ||
8897
+ !Number.isFinite(rgba.b) ||
8898
+ !Number.isFinite(rgba.a)
8899
+ ) {
8900
+ colorValue = "#D9D9D9";
8901
+ rgba = { r: 217, g: 217, b: 217, a: 1 };
8902
+ }
8903
+ this.rgba = rgba;
7686
8904
  this.value = this.rgbAlphaToHex(
7687
8905
  {
7688
8906
  r: isNaN(this.rgba.r) ? 0 : this.rgba.r,
@@ -7873,7 +9091,6 @@ class FigInputColor extends HTMLElement {
7873
9091
  "value",
7874
9092
  "style",
7875
9093
  "mode",
7876
- "experimental",
7877
9094
  "alpha",
7878
9095
  "text",
7879
9096
  "disabled",
@@ -7991,6 +9208,7 @@ class FigInputColor extends HTMLElement {
7991
9208
  }
7992
9209
 
7993
9210
  convertToRGBA(color) {
9211
+ if (typeof color !== "string") return null;
7994
9212
  let r,
7995
9213
  g,
7996
9214
  b,
@@ -8415,7 +9633,6 @@ class FigInputFill extends HTMLElement {
8415
9633
  "value",
8416
9634
  "disabled",
8417
9635
  "mode",
8418
- "experimental",
8419
9636
  "alpha",
8420
9637
  "aria-label",
8421
9638
  "aria-describedby",
@@ -8499,13 +9716,15 @@ class FigInputFill extends HTMLElement {
8499
9716
  // Backward-compat: direct attributes forwarded to fill picker
8500
9717
  const mode = this.getAttribute("mode");
8501
9718
  if (mode) attrs["mode"] = mode;
8502
- const experimental = this.getAttribute("experimental");
8503
- if (experimental) attrs["experimental"] = experimental;
8504
9719
  const alpha = this.getAttribute("alpha");
8505
9720
  if (alpha) attrs["alpha"] = alpha;
8506
9721
  // picker-* overrides (except anchor, handled programmatically)
8507
9722
  for (const { name, value } of this.attributes) {
8508
- if (name.startsWith("picker-") && name !== "picker-anchor") {
9723
+ if (
9724
+ name.startsWith("picker-") &&
9725
+ name !== "picker-anchor" &&
9726
+ name !== "picker-experimental"
9727
+ ) {
8509
9728
  attrs[name.slice(7)] = value;
8510
9729
  }
8511
9730
  }
@@ -8741,7 +9960,6 @@ class FigInputFill extends HTMLElement {
8741
9960
  if (detail.video) this.#video = detail.video;
8742
9961
  break;
8743
9962
  }
8744
-
8745
9963
  // Update controls (don't re-render to keep dialog open)
8746
9964
  if (typeChanged) {
8747
9965
  this.#updateControlsForType();
@@ -9141,7 +10359,6 @@ class FigInputFill extends HTMLElement {
9141
10359
  this.#syncDisabled();
9142
10360
  break;
9143
10361
  case "mode":
9144
- case "experimental":
9145
10362
  // Pass through to internal fill picker
9146
10363
  if (this.#fillPicker) {
9147
10364
  if (newValue) {
@@ -9183,6 +10400,7 @@ class FigInputPalette extends HTMLElement {
9183
10400
  #expandedPickers = [];
9184
10401
  #renderRAF = null;
9185
10402
  #boundHandleKeyDown = this.#handleKeyDown.bind(this);
10403
+ #boundHandleHostFocus = () => this.focus();
9186
10404
 
9187
10405
  static get observedAttributes() {
9188
10406
  return ["value", "disabled", "min", "max", "open", "fixed"];
@@ -9226,7 +10444,9 @@ class FigInputPalette extends HTMLElement {
9226
10444
  }
9227
10445
 
9228
10446
  connectedCallback() {
9229
- if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "0");
10447
+ this.setAttribute("tabindex", "-1");
10448
+ this.removeEventListener("focus", this.#boundHandleHostFocus);
10449
+ this.addEventListener("focus", this.#boundHandleHostFocus);
9230
10450
  this.removeEventListener("keydown", this.#boundHandleKeyDown);
9231
10451
  this.addEventListener("keydown", this.#boundHandleKeyDown);
9232
10452
  if (this.#renderRAF) cancelAnimationFrame(this.#renderRAF);
@@ -9244,13 +10464,14 @@ class FigInputPalette extends HTMLElement {
9244
10464
  this.#renderRAF = null;
9245
10465
  }
9246
10466
  this.removeEventListener("keydown", this.#boundHandleKeyDown);
10467
+ this.removeEventListener("focus", this.#boundHandleHostFocus);
9247
10468
  this.#inlinePickers = [];
9248
10469
  this.#expandedPickers = [];
9249
10470
  }
9250
10471
 
9251
10472
  #handleKeyDown(event) {
9252
10473
  if (event.key !== "Enter" && event.key !== " ") return;
9253
- if (event.target !== this && !event.target?.closest?.(".palette-colors-inline")) return;
10474
+ if (event.target !== this.querySelector(".palette-colors-inline")) return;
9254
10475
  if (this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") return;
9255
10476
  event.preventDefault();
9256
10477
  event.stopPropagation();
@@ -9275,7 +10496,7 @@ class FigInputPalette extends HTMLElement {
9275
10496
  this.#render();
9276
10497
  break;
9277
10498
  case "open":
9278
- // CSS handles visibility; no re-render needed
10499
+ this.#syncTriggerState();
9279
10500
  break;
9280
10501
  }
9281
10502
  }
@@ -9371,8 +10592,13 @@ class FigInputPalette extends HTMLElement {
9371
10592
  const inlineWrap = document.createElement("div");
9372
10593
  inlineWrap.className = "palette-colors-inline";
9373
10594
  inlineWrap.setAttribute("role", "button");
10595
+ inlineWrap.setAttribute("tabindex", disabled ? "-1" : "+0");
9374
10596
  inlineWrap.setAttribute("aria-expanded", String(this.open));
9375
10597
  inlineWrap.setAttribute("aria-label", "Edit palette colors");
10598
+ inlineWrap.addEventListener("blur", () => {
10599
+ inlineWrap.style.removeProperty("outline");
10600
+ inlineWrap.style.removeProperty("outline-offset");
10601
+ });
9376
10602
  const openPalette = () => {
9377
10603
  if (
9378
10604
  this.hasAttribute("disabled") &&
@@ -9383,12 +10609,6 @@ class FigInputPalette extends HTMLElement {
9383
10609
  inlineWrap.setAttribute("aria-expanded", "true");
9384
10610
  };
9385
10611
  inlineWrap.addEventListener("click", openPalette);
9386
- inlineWrap.addEventListener("keydown", (event) => {
9387
- if (event.key !== "Enter" && event.key !== " ") return;
9388
- event.preventDefault();
9389
- event.stopPropagation();
9390
- openPalette();
9391
- });
9392
10612
 
9393
10613
  const wrap = document.createElement("div");
9394
10614
  wrap.className = "palette-colors";
@@ -9399,6 +10619,7 @@ class FigInputPalette extends HTMLElement {
9399
10619
  });
9400
10620
  inlineWrap.appendChild(wrap);
9401
10621
  this.appendChild(inlineWrap);
10622
+ this.#syncTriggerState();
9402
10623
 
9403
10624
  if (!this.#isFixed) this.#createAddButton(disabled, this);
9404
10625
 
@@ -9595,6 +10816,31 @@ class FigInputPalette extends HTMLElement {
9595
10816
  else addBtn.removeAttribute("disabled");
9596
10817
  }
9597
10818
  this.#syncRemoveButtons(disabled);
10819
+ this.#syncTriggerState();
10820
+ }
10821
+
10822
+ #syncTriggerState() {
10823
+ const trigger = this.querySelector(".palette-colors-inline");
10824
+ if (!trigger) return;
10825
+ const disabled =
10826
+ this.hasAttribute("disabled") &&
10827
+ this.getAttribute("disabled") !== "false";
10828
+ trigger.setAttribute("tabindex", disabled ? "-1" : "+0");
10829
+ trigger.setAttribute("aria-disabled", String(disabled));
10830
+ trigger.setAttribute("aria-expanded", String(this.open));
10831
+ }
10832
+
10833
+ focus() {
10834
+ if (
10835
+ this.hasAttribute("disabled") &&
10836
+ this.getAttribute("disabled") !== "false"
10837
+ )
10838
+ return;
10839
+ const trigger = this.querySelector(".palette-colors-inline");
10840
+ if (!trigger) return;
10841
+ trigger.style.outline = "var(--figma-focus-outline)";
10842
+ trigger.style.outlineOffset = "var(--figma-focus-outline-offset)";
10843
+ trigger.focus();
9598
10844
  }
9599
10845
 
9600
10846
  #syncRemoveButtons(disabled = this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") {
@@ -9923,11 +11169,9 @@ class FigInputGradient extends HTMLElement {
9923
11169
  const mode = this.#editMode;
9924
11170
 
9925
11171
  if (mode === "picker" && hasFigFillPicker()) {
9926
- const experimental = this.getAttribute("experimental");
9927
- const expAttr = experimental ? ` experimental="${experimental}"` : "";
9928
11172
  const gradientValue = JSON.stringify(this.value);
9929
11173
  this.innerHTML = `
9930
- <fig-fill-picker mode="gradient"${expAttr} value='${gradientValue}'${disabled ? " disabled" : ""}>
11174
+ <fig-fill-picker mode="gradient" value='${gradientValue}'${disabled ? " disabled" : ""}>
9931
11175
  <fig-swatch background="${this.#buildGradientCSS()}"${this.#swatchSizeAttr()}${disabled ? " disabled" : ""}></fig-swatch>
9932
11176
  </fig-fill-picker>`;
9933
11177
  this.#swatch = this.querySelector("fig-swatch");
@@ -10819,7 +12063,6 @@ figDefineElement("fig-switch", FigSwitch);
10819
12063
  * @attr {string} placeholder - Placeholder text for the input
10820
12064
  * @attr {string} value - The current input value
10821
12065
  * @attr {boolean} disabled - Disables the input and dropdown button
10822
- * @attr {string} experimental - Feature flag passed to internal fig-dropdown
10823
12066
  */
10824
12067
  class FigComboInput extends HTMLElement {
10825
12068
  static observedAttributes = [
@@ -10827,7 +12070,6 @@ class FigComboInput extends HTMLElement {
10827
12070
  "placeholder",
10828
12071
  "value",
10829
12072
  "disabled",
10830
- "experimental",
10831
12073
  "aria-label",
10832
12074
  "aria-labelledby",
10833
12075
  "aria-describedby",
@@ -10908,15 +12150,11 @@ class FigComboInput extends HTMLElement {
10908
12150
  const options = this.#getOptions();
10909
12151
  const placeholder = this.getAttribute("placeholder") || "";
10910
12152
  const currentValue = this.value;
10911
- const experimental = this.getAttribute("experimental");
10912
- const expAttr = experimental
10913
- ? ` experimental="${figEscapeAttribute(experimental)}"`
10914
- : "";
10915
12153
  const dropdownLabel = this.#dropdownLabel();
10916
12154
 
10917
12155
  const dropdownHTML = this.#usesCustomDropdown
10918
12156
  ? ""
10919
- : `<fig-dropdown type="dropdown" label="${figEscapeAttribute(dropdownLabel)}"${expAttr}>${options.map((o) => `<option>${figEscapeAttribute(o.trim())}</option>`).join("")}</fig-dropdown>`;
12157
+ : `<fig-dropdown type="dropdown" label="${figEscapeAttribute(dropdownLabel)}">${options.map((o) => `<option>${figEscapeAttribute(o.trim())}</option>`).join("")}</fig-dropdown>`;
10920
12158
 
10921
12159
  this.innerHTML = `<div class="input-combo">
10922
12160
  <fig-input-text placeholder="${figEscapeAttribute(placeholder)}" value="${figEscapeAttribute(currentValue)}"></fig-input-text>
@@ -10938,9 +12176,6 @@ class FigComboInput extends HTMLElement {
10938
12176
  if (!this.#customDropdown.hasAttribute("label")) {
10939
12177
  this.#customDropdown.setAttribute("label", dropdownLabel);
10940
12178
  }
10941
- if (experimental) {
10942
- this.#customDropdown.setAttribute("experimental", experimental);
10943
- }
10944
12179
  this.#button.append(this.#customDropdown);
10945
12180
  }
10946
12181
 
@@ -11088,13 +12323,6 @@ class FigComboInput extends HTMLElement {
11088
12323
  case "disabled":
11089
12324
  this.#applyDisabled(newValue !== null && newValue !== "false");
11090
12325
  break;
11091
- case "experimental":
11092
- if (this.#dropdown) {
11093
- if (newValue) this.#dropdown.setAttribute("experimental", newValue);
11094
- else if (!this.#usesCustomDropdown)
11095
- this.#dropdown.removeAttribute("experimental");
11096
- }
11097
- break;
11098
12326
  case "aria-label":
11099
12327
  case "aria-labelledby":
11100
12328
  case "aria-describedby":
@@ -12892,9 +14120,7 @@ class FigEasingCurve extends HTMLElement {
12892
14120
  #line2 = null;
12893
14121
  #handle1 = null;
12894
14122
  #handle2 = null;
12895
- #bezierEndpointStart = null;
12896
- #bezierEndpointEnd = null;
12897
- #dropdown = null;
14123
+ #select = null;
12898
14124
  #valueInput = null;
12899
14125
  #presetName = null;
12900
14126
  #targetLine = null;
@@ -12902,10 +14128,10 @@ class FigEasingCurve extends HTMLElement {
12902
14128
  #drawWidth = 200;
12903
14129
  #drawHeight = 200;
12904
14130
  #bounds = null;
12905
- #diagonal = null;
14131
+ #boundaryTop = null;
14132
+ #boundaryBottom = null;
12906
14133
  #resizeObserver = null;
12907
14134
  #bezierHandleRadius = 5;
12908
- #bezierEndpointRadius = 2;
12909
14135
  #durationBarWidth = 10;
12910
14136
  #durationBarHeight = 10;
12911
14137
  #durationBarRadius = 3;
@@ -13023,7 +14249,7 @@ class FigEasingCurve extends HTMLElement {
13023
14249
  this.#render();
13024
14250
  } else {
13025
14251
  if (this.#svg) this.#updatePaths();
13026
- this.#syncDropdown();
14252
+ this.#syncSelect();
13027
14253
  this.#syncValueInput();
13028
14254
  }
13029
14255
  }
@@ -13246,14 +14472,15 @@ class FigEasingCurve extends HTMLElement {
13246
14472
  .replace(/>/g, "&gt;");
13247
14473
  }
13248
14474
 
13249
- #getDropdownHTML() {
14475
+ #getSelectHTML() {
13250
14476
  let optionsHTML = "";
13251
14477
  let currentGroup = undefined;
13252
14478
  for (const p of FigEasingCurve.PRESETS) {
13253
14479
  if (!this.#isEditEnabled() && !p.value && !p.spring) continue;
13254
14480
  if (p.group !== currentGroup) {
13255
- if (currentGroup !== undefined) optionsHTML += `</optgroup>`;
13256
- if (p.group) optionsHTML += `<optgroup label="${p.group}">`;
14481
+ if (p.group) {
14482
+ optionsHTML += `<fig-menu-separator label="${FigEasingCurve.#escapeAttribute(p.group)}"></fig-menu-separator>`;
14483
+ }
13257
14484
  currentGroup = p.group;
13258
14485
  }
13259
14486
  let icon;
@@ -13269,40 +14496,37 @@ class FigEasingCurve extends HTMLElement {
13269
14496
  ];
13270
14497
  icon = FigEasingCurve.curveIcon(...v);
13271
14498
  }
13272
- const selected = p.name === this.#presetName ? " selected" : "";
13273
- optionsHTML += `<option value="${p.name}"${selected}>${icon} ${p.name}</option>`;
14499
+ const name = FigEasingCurve.#escapeAttribute(p.name);
14500
+ optionsHTML += `<fig-select-option value="${name}" label="${name}"><span slot="prepend">${icon}</span><span>${name}</span></fig-select-option>`;
13274
14501
  }
13275
- if (currentGroup) optionsHTML += `</optgroup>`;
13276
- return `<fig-dropdown class="fig-easing-curve-dropdown" full experimental="modern">${optionsHTML}</fig-dropdown>`;
14502
+ const value = FigEasingCurve.#escapeAttribute(this.#presetName);
14503
+ return `<fig-select class="fig-easing-curve-select" label="Easing preset" value="${value}" full><fig-select-options>${optionsHTML}</fig-select-options></fig-select>`;
13277
14504
  }
13278
14505
 
13279
14506
  #getInnerHTML() {
13280
14507
  const size = 200;
13281
- const dropdown = this.#getDropdownHTML();
13282
- if (!this.#isEditEnabled()) return dropdown;
14508
+ const select = this.#getSelectHTML();
14509
+ if (!this.#isEditEnabled()) return select;
13283
14510
  const valueInput = `<fig-input-text class="fig-easing-curve-value-input" value="${FigEasingCurve.#escapeAttribute(this.value)}" full></fig-input-text>`;
13284
14511
 
13285
14512
  if (this.#mode === "spring") {
13286
14513
  const targetY = 40;
13287
- const startY = 180;
13288
- return `${dropdown}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
14514
+ return `${select}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
13289
14515
  <rect class="fig-easing-curve-bounds" x="0" y="0" width="${size}" height="${size}"/>
13290
14516
  <line class="fig-easing-curve-target" x1="0" y1="${targetY}" x2="${size}" y2="${targetY}"/>
13291
- <line class="fig-easing-curve-diagonal" x1="0" y1="${startY}" x2="0" y2="${startY}"/>
13292
14517
  <path class="fig-easing-curve-path"/>
13293
14518
  <foreignObject class="fig-easing-curve-handle" data-handle="bounce" width="20" height="20"><fig-handle size="small" aria-label="Spring bounce handle"></fig-handle></foreignObject>
13294
14519
  <foreignObject class="fig-easing-curve-handle fig-easing-curve-duration-bar" data-handle="duration" width="20" height="20"><fig-handle size="small" aria-label="Spring duration handle"></fig-handle></foreignObject>
13295
14520
  </svg></div>${valueInput}`;
13296
14521
  }
13297
14522
 
13298
- return `${dropdown}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
14523
+ return `${select}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
13299
14524
  <rect class="fig-easing-curve-bounds" x="0" y="0" width="${size}" height="${size}"/>
13300
- <line class="fig-easing-curve-diagonal" x1="0" y1="${size}" x2="${size}" y2="0"/>
14525
+ <line class="fig-easing-curve-boundary" data-boundary="top" x1="0" y1="0" x2="${size}" y2="0"/>
14526
+ <line class="fig-easing-curve-boundary" data-boundary="bottom" x1="0" y1="${size}" x2="${size}" y2="${size}"/>
14527
+ <path class="fig-easing-curve-path"/>
13301
14528
  <line class="fig-easing-curve-arm" data-arm="1"/>
13302
14529
  <line class="fig-easing-curve-arm" data-arm="2"/>
13303
- <path class="fig-easing-curve-path"/>
13304
- <circle class="fig-easing-curve-endpoint" data-endpoint="start" r="${this.#bezierEndpointRadius}"/>
13305
- <circle class="fig-easing-curve-endpoint" data-endpoint="end" r="${this.#bezierEndpointRadius}"/>
13306
14530
  <foreignObject class="fig-easing-curve-handle" data-handle="1" width="20" height="20"><fig-handle size="small" aria-label="First easing control point"></fig-handle></foreignObject>
13307
14531
  <foreignObject class="fig-easing-curve-handle" data-handle="2" width="20" height="20"><fig-handle size="small" aria-label="Second easing control point"></fig-handle></foreignObject>
13308
14532
  </svg></div>${valueInput}`;
@@ -13316,10 +14540,6 @@ class FigEasingCurve extends HTMLElement {
13316
14540
  }
13317
14541
 
13318
14542
  #syncMetricsFromCSS() {
13319
- this.#bezierEndpointRadius = this.#readCssNumber(
13320
- "--easing-bezier-endpoint-radius",
13321
- this.#bezierEndpointRadius,
13322
- );
13323
14543
  this.#durationBarRadius = this.#readCssNumber(
13324
14544
  "--easing-duration-bar-radius",
13325
14545
  this.#durationBarRadius,
@@ -13337,13 +14557,12 @@ class FigEasingCurve extends HTMLElement {
13337
14557
  this.#handle2 =
13338
14558
  this.querySelector('[data-handle="2"]') ||
13339
14559
  this.querySelector('[data-handle="duration"]');
13340
- this.#bezierEndpointStart = this.querySelector('[data-endpoint="start"]');
13341
- this.#bezierEndpointEnd = this.querySelector('[data-endpoint="end"]');
13342
- this.#dropdown = this.querySelector(".fig-easing-curve-dropdown");
14560
+ this.#select = this.querySelector(".fig-easing-curve-select");
13343
14561
  this.#valueInput = this.querySelector(".fig-easing-curve-value-input");
13344
14562
  this.#targetLine = this.querySelector(".fig-easing-curve-target");
13345
14563
  this.#bounds = this.querySelector(".fig-easing-curve-bounds");
13346
- this.#diagonal = this.querySelector(".fig-easing-curve-diagonal");
14564
+ this.#boundaryTop = this.querySelector('[data-boundary="top"]');
14565
+ this.#boundaryBottom = this.querySelector('[data-boundary="bottom"]');
13347
14566
  }
13348
14567
 
13349
14568
  #syncHandleSizes() {
@@ -13392,12 +14611,37 @@ class FigEasingCurve extends HTMLElement {
13392
14611
 
13393
14612
  // --- Coordinate helpers ---
13394
14613
 
14614
+ #bezierDomain() {
14615
+ const minVal = Math.min(0, this.#cp1.y, this.#cp2.y);
14616
+ const maxVal = Math.max(1, this.#cp1.y, this.#cp2.y);
14617
+ const range = maxVal - minVal || 1;
14618
+ const pad = Math.min(
14619
+ this.#bezierHandleRadius,
14620
+ Math.max(0, (this.#drawHeight - 1) / 2),
14621
+ );
14622
+ return {
14623
+ minVal,
14624
+ maxVal,
14625
+ range,
14626
+ pad,
14627
+ draw: Math.max(1, this.#drawHeight - pad * 2),
14628
+ };
14629
+ }
14630
+
13395
14631
  #toSVG(nx, ny) {
13396
- return { x: nx * this.#drawWidth, y: (1 - ny) * this.#drawHeight };
14632
+ const { maxVal, range, pad, draw } = this.#bezierDomain();
14633
+ return {
14634
+ x: nx * this.#drawWidth,
14635
+ y: pad + ((maxVal - ny) / range) * draw,
14636
+ };
13397
14637
  }
13398
14638
 
13399
14639
  #fromSVG(sx, sy) {
13400
- return { x: sx / this.#drawWidth, y: 1 - sy / this.#drawHeight };
14640
+ const { maxVal, range, pad, draw } = this.#bezierDomain();
14641
+ return {
14642
+ x: sx / this.#drawWidth,
14643
+ y: maxVal - ((sy - pad) / draw) * range,
14644
+ };
13401
14645
  }
13402
14646
 
13403
14647
  #springScale = { minVal: 0, maxVal: 1.2, totalTime: 1 };
@@ -13442,17 +14686,20 @@ class FigEasingCurve extends HTMLElement {
13442
14686
  this.#bounds.setAttribute("width", this.#drawWidth);
13443
14687
  this.#bounds.setAttribute("height", this.#drawHeight);
13444
14688
  }
13445
- if (this.#diagonal) {
13446
- this.#diagonal.setAttribute("x1", "0");
13447
- this.#diagonal.setAttribute("y1", this.#drawHeight);
13448
- this.#diagonal.setAttribute("x2", this.#drawWidth);
13449
- this.#diagonal.setAttribute("y2", "0");
13450
- }
13451
-
13452
14689
  const p0 = this.#toSVG(0, 0);
13453
14690
  const p1 = this.#toSVG(this.#cp1.x, this.#cp1.y);
13454
14691
  const p2 = this.#toSVG(this.#cp2.x, this.#cp2.y);
13455
14692
  const p3 = this.#toSVG(1, 1);
14693
+ for (const [boundary, y] of [
14694
+ [this.#boundaryTop, p3.y],
14695
+ [this.#boundaryBottom, p0.y],
14696
+ ]) {
14697
+ if (!boundary) continue;
14698
+ boundary.setAttribute("x1", "0");
14699
+ boundary.setAttribute("y1", y);
14700
+ boundary.setAttribute("x2", this.#drawWidth);
14701
+ boundary.setAttribute("y2", y);
14702
+ }
13456
14703
 
13457
14704
  this.#curve.setAttribute(
13458
14705
  "d",
@@ -13471,14 +14718,6 @@ class FigEasingCurve extends HTMLElement {
13471
14718
  this.#handle1.setAttribute("y", p1.y - hr);
13472
14719
  this.#handle2.setAttribute("x", p2.x - hr);
13473
14720
  this.#handle2.setAttribute("y", p2.y - hr);
13474
- if (this.#bezierEndpointStart) {
13475
- this.#bezierEndpointStart.setAttribute("cx", p0.x);
13476
- this.#bezierEndpointStart.setAttribute("cy", p0.y);
13477
- }
13478
- if (this.#bezierEndpointEnd) {
13479
- this.#bezierEndpointEnd.setAttribute("cx", p3.x);
13480
- this.#bezierEndpointEnd.setAttribute("cy", p3.y);
13481
- }
13482
14721
  this.#syncBezierHandleTabOrder();
13483
14722
  }
13484
14723
 
@@ -13571,11 +14810,11 @@ class FigEasingCurve extends HTMLElement {
13571
14810
  return peak;
13572
14811
  }
13573
14812
 
13574
- // --- Dropdown ---
14813
+ // --- Select ---
13575
14814
 
13576
- #syncDropdown() {
13577
- if (!this.#dropdown) return;
13578
- this.#dropdown.value = this.#presetName;
14815
+ #syncSelect() {
14816
+ if (!this.#select) return;
14817
+ this.#select.value = this.#presetName;
13579
14818
  this.#refreshCustomPresetIcons();
13580
14819
  }
13581
14820
 
@@ -13614,23 +14853,24 @@ class FigEasingCurve extends HTMLElement {
13614
14853
  this.#render();
13615
14854
  } else {
13616
14855
  this.#updatePaths();
13617
- this.#syncDropdown();
14856
+ this.#syncSelect();
13618
14857
  if (eventType === "change") this.#syncValueInput();
13619
14858
  }
13620
14859
  this.#emit(eventType);
13621
14860
  }
13622
14861
 
13623
- #setOptionIconByValue(root, optionValue, icon) {
13624
- if (!root) return;
13625
- for (const option of root.querySelectorAll("option")) {
14862
+ #setOptionIconByValue(optionValue, icon) {
14863
+ if (!this.#select) return;
14864
+ for (const option of this.#select.querySelectorAll("fig-select-option")) {
13626
14865
  if (option.value === optionValue) {
13627
- option.innerHTML = `${icon} ${optionValue}`;
14866
+ const prepend = option.querySelector(':scope > [slot="prepend"]');
14867
+ if (prepend) prepend.innerHTML = icon;
13628
14868
  }
13629
14869
  }
13630
14870
  }
13631
14871
 
13632
14872
  #refreshCustomPresetIcons() {
13633
- if (!this.#dropdown) return;
14873
+ if (!this.#select) return;
13634
14874
  if (!this.#isEditEnabled()) return;
13635
14875
  const bezierIcon = FigEasingCurve.curveIcon(
13636
14876
  this.#cp1.x,
@@ -13640,25 +14880,14 @@ class FigEasingCurve extends HTMLElement {
13640
14880
  );
13641
14881
  const springIcon = FigEasingCurve.#springIcon(this.#spring);
13642
14882
 
13643
- // Update both slotted options and the cloned native select options.
13644
- this.#setOptionIconByValue(this.#dropdown, "Custom bezier", bezierIcon);
13645
- this.#setOptionIconByValue(this.#dropdown, "Custom spring", springIcon);
13646
- this.#setOptionIconByValue(
13647
- this.#dropdown.select,
13648
- "Custom bezier",
13649
- bezierIcon,
13650
- );
13651
- this.#setOptionIconByValue(
13652
- this.#dropdown.select,
13653
- "Custom spring",
13654
- springIcon,
13655
- );
14883
+ this.#setOptionIconByValue("Custom bezier", bezierIcon);
14884
+ this.#setOptionIconByValue("Custom spring", springIcon);
13656
14885
  }
13657
14886
 
13658
14887
  #syncAfterHandleInput(eventType) {
13659
14888
  this.#updatePaths();
13660
14889
  this.#presetName = this.#matchPreset();
13661
- this.#syncDropdown();
14890
+ this.#syncSelect();
13662
14891
  this.#syncValueInput();
13663
14892
  this.#emit(eventType);
13664
14893
  }
@@ -13847,8 +15076,8 @@ class FigEasingCurve extends HTMLElement {
13847
15076
  }
13848
15077
  }
13849
15078
 
13850
- if (this.#dropdown) {
13851
- this.#dropdown.addEventListener("change", (e) => {
15079
+ if (this.#select) {
15080
+ this.#select.addEventListener("change", (e) => {
13852
15081
  const name = e.detail;
13853
15082
  const preset = FigEasingCurve.PRESETS.find((p) => p.name === name);
13854
15083
  if (!preset) return;
@@ -13921,11 +15150,30 @@ class FigEasingCurve extends HTMLElement {
13921
15150
  e.preventDefault();
13922
15151
  this.#isDragging = handle;
13923
15152
  this.#syncActiveBezierArm();
15153
+ const svgRect = this.#svg.getBoundingClientRect();
15154
+ const startClientX = e.clientX;
15155
+ const startClientY = e.clientY;
15156
+ const fromHandle = e.target?.closest?.(
15157
+ ".fig-easing-curve-handle, fig-handle",
15158
+ );
15159
+ const currentPoint = handle === 1 ? this.#cp1 : this.#cp2;
15160
+ const svgPoint = this.#clientToSVG(e);
15161
+ const startPoint = fromHandle
15162
+ ? { ...currentPoint }
15163
+ : this.#fromSVG(svgPoint.x, svgPoint.y);
15164
+ const { range, draw } = this.#bezierDomain();
15165
+ const unitsPerClientY =
15166
+ range /
15167
+ Math.max(1, (draw / this.#drawHeight) * Math.max(1, svgRect.height));
13924
15168
 
13925
15169
  const onMove = (e) => {
13926
15170
  if (!this.#isDragging) return;
13927
- const svgPt = this.#clientToSVG(e);
13928
- const norm = this.#fromSVG(svgPt.x, svgPt.y);
15171
+ const norm = {
15172
+ x:
15173
+ startPoint.x +
15174
+ (e.clientX - startClientX) / Math.max(1, svgRect.width),
15175
+ y: startPoint.y - (e.clientY - startClientY) * unitsPerClientY,
15176
+ };
13929
15177
 
13930
15178
  norm.x = Math.round(norm.x * 100) / 100;
13931
15179
  norm.y = Math.round(norm.y * 100) / 100;
@@ -13940,7 +15188,7 @@ class FigEasingCurve extends HTMLElement {
13940
15188
  }
13941
15189
  this.#updatePaths();
13942
15190
  this.#presetName = this.#matchPreset();
13943
- this.#syncDropdown();
15191
+ this.#syncSelect();
13944
15192
  this.#syncValueInput();
13945
15193
  this.#emit("input");
13946
15194
  };
@@ -13991,7 +15239,7 @@ class FigEasingCurve extends HTMLElement {
13991
15239
 
13992
15240
  this.#updatePaths();
13993
15241
  this.#presetName = this.#matchPreset();
13994
- this.#syncDropdown();
15242
+ this.#syncSelect();
13995
15243
  this.#syncValueInput();
13996
15244
  this.#emit("input");
13997
15245
  };
@@ -16576,6 +17824,7 @@ class FigChooser extends HTMLElement {
16576
17824
  this.#setupDrag();
16577
17825
  this.#startObserver();
16578
17826
  this.#startResizeObserver();
17827
+ this.#syncDisabledChoices();
16579
17828
 
16580
17829
  figNextFrame(this, () => {
16581
17830
  this.#syncSelection();
@@ -16629,17 +17878,7 @@ class FigChooser extends HTMLElement {
16629
17878
  this.#selectByValue(newValue);
16630
17879
  }
16631
17880
  if (name === "disabled") {
16632
- const isDisabled = newValue !== null && newValue !== "false";
16633
- const choices = this.choices;
16634
- for (const choice of choices) {
16635
- if (isDisabled) {
16636
- choice.setAttribute("aria-disabled", "true");
16637
- choice.setAttribute("tabindex", "-1");
16638
- } else {
16639
- choice.removeAttribute("aria-disabled");
16640
- choice.setAttribute("tabindex", "0");
16641
- }
16642
- }
17881
+ this.#syncDisabledChoices();
16643
17882
  }
16644
17883
  if (name === "choice-element") {
16645
17884
  requestAnimationFrame(() => this.#syncSelection());
@@ -16691,6 +17930,23 @@ class FigChooser extends HTMLElement {
16691
17930
  this.selectedChoice = choices[0];
16692
17931
  }
16693
17932
 
17933
+ #syncDisabledChoices() {
17934
+ const chooserDisabled = figBooleanAttribute(this, "disabled");
17935
+ if (chooserDisabled) this.setAttribute("aria-disabled", "true");
17936
+ else this.removeAttribute("aria-disabled");
17937
+ for (const choice of this.choices) {
17938
+ const disabled =
17939
+ chooserDisabled || figBooleanAttribute(choice, "disabled");
17940
+ if (disabled) {
17941
+ choice.setAttribute("aria-disabled", "true");
17942
+ choice.setAttribute("tabindex", "-1");
17943
+ } else {
17944
+ choice.removeAttribute("aria-disabled");
17945
+ choice.setAttribute("tabindex", "0");
17946
+ }
17947
+ }
17948
+ }
17949
+
16694
17950
  #selectByValue(value) {
16695
17951
  const choices = this.choices;
16696
17952
  for (const choice of choices) {
@@ -17053,6 +18309,7 @@ class FigChooser extends HTMLElement {
17053
18309
  if (this.#isUnwrapping) return;
17054
18310
  this.#removeLegacyScroller();
17055
18311
  this.#applyOverflowMode();
18312
+ this.#syncDisabledChoices();
17056
18313
  const choices = this.choices;
17057
18314
  if (this.#selectedChoice && !choices.includes(this.#selectedChoice)) {
17058
18315
  this.#selectedChoice = null;
@@ -17089,6 +18346,7 @@ class FigHandle extends HTMLElement {
17089
18346
  #isDragging = false;
17090
18347
  #didDrag = false;
17091
18348
  #boundPointerDown = null;
18349
+ #activeDragCleanup = null;
17092
18350
  #applyingValue = false;
17093
18351
  #colorTip = null;
17094
18352
  #directColorPicker = null;
@@ -17527,11 +18785,14 @@ class FigHandle extends HTMLElement {
17527
18785
  }
17528
18786
 
17529
18787
  #teardownDrag() {
18788
+ this.#activeDragCleanup?.();
18789
+ this.#activeDragCleanup = null;
17530
18790
  if (this.#boundPointerDown) {
17531
18791
  this.removeEventListener("pointerdown", this.#boundPointerDown);
17532
18792
  this.#boundPointerDown = null;
17533
18793
  }
17534
18794
  this.#isDragging = false;
18795
+ this.#didDrag = false;
17535
18796
  }
17536
18797
 
17537
18798
  #onPointerDown(e) {
@@ -17540,6 +18801,7 @@ class FigHandle extends HTMLElement {
17540
18801
  const container = this.#getContainer();
17541
18802
  if (!container) return;
17542
18803
 
18804
+ this.#activeDragCleanup?.();
17543
18805
  this.#isDragging = true;
17544
18806
  const axes = this.#axes;
17545
18807
  let lastRect = null;
@@ -17617,11 +18879,7 @@ class FigHandle extends HTMLElement {
17617
18879
  };
17618
18880
 
17619
18881
  const onUp = (e) => {
17620
- this.#isDragging = false;
17621
- this.style.cursor = "";
17622
- this.classList.remove("dragging");
17623
- window.removeEventListener("pointermove", onMove);
17624
- window.removeEventListener("pointerup", onUp);
18882
+ cleanup();
17625
18883
  if (this.#didDrag) {
17626
18884
  clampAndApply(e.clientX, e.clientY, e.shiftKey);
17627
18885
  this.#syncValueAttribute();
@@ -17646,6 +18904,17 @@ class FigHandle extends HTMLElement {
17646
18904
  this.#didDrag = false;
17647
18905
  };
17648
18906
 
18907
+ const cleanup = () => {
18908
+ window.removeEventListener("pointermove", onMove);
18909
+ window.removeEventListener("pointerup", onUp);
18910
+ this.#isDragging = false;
18911
+ this.style.cursor = "";
18912
+ this.classList.remove("dragging");
18913
+ if (this.#activeDragCleanup === cleanup) {
18914
+ this.#activeDragCleanup = null;
18915
+ }
18916
+ };
18917
+ this.#activeDragCleanup = cleanup;
17649
18918
  window.addEventListener("pointermove", onMove);
17650
18919
  window.addEventListener("pointerup", onUp);
17651
18920
  }
@@ -18077,14 +19346,19 @@ figDefineElement("fig-menu-separator", FigMenuSeparator);
18077
19346
 
18078
19347
  class FigMenu extends HTMLElement {
18079
19348
  #popup = null;
19349
+ #panel = null;
18080
19350
  #trigger = null;
18081
19351
  #virtualAnchor = null;
18082
19352
  #observer = null;
19353
+ #resizeObserver = null;
19354
+ #navStart = null;
19355
+ #navEnd = null;
18083
19356
  #boundTriggerClick;
18084
19357
  #boundTriggerContextMenu;
18085
19358
  #boundPopupClick;
18086
19359
  #boundMenuKeydown;
18087
19360
  #boundPopupClose;
19361
+ #boundSyncOverflow = this.#syncOverflow.bind(this);
18088
19362
  #focusedIndex = -1;
18089
19363
 
18090
19364
  static get observedAttributes() {
@@ -18114,6 +19388,7 @@ class FigMenu extends HTMLElement {
18114
19388
 
18115
19389
  set open(val) {
18116
19390
  if (val) {
19391
+ if (this.#isDisabled()) return;
18117
19392
  this.setAttribute("open", "");
18118
19393
  } else {
18119
19394
  this.removeAttribute("open");
@@ -18125,6 +19400,7 @@ class FigMenu extends HTMLElement {
18125
19400
  this.#createPopup();
18126
19401
  this.#moveItemsToPopup();
18127
19402
  this.#setupListeners();
19403
+ this.#setupOverflow();
18128
19404
  this.#setupObserver();
18129
19405
  this.#syncDisabled();
18130
19406
 
@@ -18135,6 +19411,7 @@ class FigMenu extends HTMLElement {
18135
19411
 
18136
19412
  disconnectedCallback() {
18137
19413
  this.#teardownListeners();
19414
+ this.#teardownOverflow();
18138
19415
  document.removeEventListener("keydown", this.#boundMenuKeydown, true);
18139
19416
  if (this.#observer) {
18140
19417
  this.#observer.disconnect();
@@ -18143,13 +19420,14 @@ class FigMenu extends HTMLElement {
18143
19420
  if (this.#popup) {
18144
19421
  this.#popup.removeEventListener("close", this.#boundPopupClose);
18145
19422
  const items = Array.from(
18146
- this.#popup.querySelectorAll(
19423
+ this.#panel?.querySelectorAll(
18147
19424
  ":scope > fig-menu-item, :scope > fig-menu-separator",
18148
- ),
19425
+ ) ?? [],
18149
19426
  );
18150
19427
  for (const item of items) this.insertBefore(item, this.#popup);
18151
19428
  this.#popup.remove();
18152
19429
  this.#popup = null;
19430
+ this.#panel = null;
18153
19431
  }
18154
19432
  }
18155
19433
 
@@ -18160,19 +19438,17 @@ class FigMenu extends HTMLElement {
18160
19438
  if (newValue === null || newValue === "false") {
18161
19439
  this.#closeMenu();
18162
19440
  } else {
19441
+ if (this.#isDisabled()) {
19442
+ this.removeAttribute("open");
19443
+ return;
19444
+ }
18163
19445
  this.#openMenu();
18164
19446
  }
18165
19447
  return;
18166
19448
  }
18167
19449
 
18168
19450
  if (name === "disabled") {
18169
- if (this.#trigger) {
18170
- if (newValue !== null && newValue !== "false") {
18171
- this.#trigger.setAttribute("disabled", "");
18172
- } else {
18173
- this.#trigger.removeAttribute("disabled");
18174
- }
18175
- }
19451
+ this.#syncDisabled();
18176
19452
  return;
18177
19453
  }
18178
19454
 
@@ -18199,6 +19475,7 @@ class FigMenu extends HTMLElement {
18199
19475
  #createPopup() {
18200
19476
  this.#popup = document.createElement("dialog", { is: "fig-popup" });
18201
19477
  this.#popup.setAttribute("is", "fig-popup");
19478
+ this.#popup.classList.add("fig-menu-popup");
18202
19479
  this.#popup.setAttribute("theme", "menu");
18203
19480
  this.#popup.setAttribute("role", "menu");
18204
19481
  this.#popup.setAttribute("id", this.#popup.getAttribute("id") || figUniqueId());
@@ -18216,16 +19493,21 @@ class FigMenu extends HTMLElement {
18216
19493
  this.#popup.anchor = this.#trigger;
18217
19494
  }
18218
19495
 
19496
+ this.#panel = document.createElement("div");
19497
+ this.#panel.className = "fig-menu-options";
19498
+ this.#panel.setAttribute("role", "presentation");
19499
+ this.#popup.appendChild(this.#panel);
18219
19500
  this.#popup.addEventListener("close", this.#boundPopupClose);
18220
19501
  this.appendChild(this.#popup);
18221
19502
  }
18222
19503
 
18223
19504
  #moveItemsToPopup() {
19505
+ if (!this.#panel) return;
18224
19506
  const items = Array.from(this.querySelectorAll(
18225
19507
  ":scope > fig-menu-item, :scope > fig-menu-separator"
18226
19508
  ));
18227
19509
  for (const item of items) {
18228
- this.#popup.appendChild(item);
19510
+ this.#panel.appendChild(item);
18229
19511
  }
18230
19512
  }
18231
19513
 
@@ -18265,7 +19547,7 @@ class FigMenu extends HTMLElement {
18265
19547
  (node.tagName === "FIG-MENU-ITEM" || node.tagName === "FIG-MENU-SEPARATOR") &&
18266
19548
  node.parentElement === this
18267
19549
  ) {
18268
- this.#popup.appendChild(node);
19550
+ this.#panel?.appendChild(node);
18269
19551
  } else if (!this.#trigger && node.parentElement === this) {
18270
19552
  this.#detectTrigger();
18271
19553
  if (this.#trigger) {
@@ -18280,13 +19562,68 @@ class FigMenu extends HTMLElement {
18280
19562
  }
18281
19563
  }
18282
19564
  }
19565
+ this.#syncOverflow();
18283
19566
  });
18284
19567
  this.#observer.observe(this, { childList: true });
18285
19568
  }
18286
19569
 
19570
+ #setupOverflow() {
19571
+ if (!this.#panel) return;
19572
+ this.#ensureNavButtons();
19573
+ this.#panel.addEventListener("scroll", this.#boundSyncOverflow, {
19574
+ passive: true,
19575
+ });
19576
+ this.#resizeObserver?.disconnect();
19577
+ this.#resizeObserver = new ResizeObserver(() => this.#syncOverflow());
19578
+ this.#resizeObserver.observe(this.#panel);
19579
+ requestAnimationFrame(() => this.#syncOverflow());
19580
+ }
19581
+
19582
+ #teardownOverflow() {
19583
+ this.#panel?.removeEventListener("scroll", this.#boundSyncOverflow);
19584
+ this.#resizeObserver?.disconnect();
19585
+ this.#resizeObserver = null;
19586
+ this.#removeNavButtons();
19587
+ }
19588
+
19589
+ #syncOverflow() {
19590
+ return figSyncOverflowState(this.#panel, this.#panel, "y");
19591
+ }
19592
+
19593
+ #ensureNavButtons() {
19594
+ if (
19595
+ this.#navStart &&
19596
+ this.#navEnd &&
19597
+ this.#panel?.contains(this.#navStart) &&
19598
+ this.#panel?.contains(this.#navEnd)
19599
+ ) {
19600
+ return;
19601
+ }
19602
+ this.#removeNavButtons();
19603
+ const buttons = createFigOverflowButtons({
19604
+ owner: "menu",
19605
+ startLabel: "Scroll up",
19606
+ endLabel: "Scroll down",
19607
+ onStart: () => figScrollOverflowPage(this.#panel, "y", -1),
19608
+ onEnd: () => figScrollOverflowPage(this.#panel, "y", 1),
19609
+ });
19610
+ this.#navStart = buttons.start;
19611
+ this.#navEnd = buttons.end;
19612
+ this.#panel.prepend(this.#navStart);
19613
+ this.#panel.append(this.#navEnd);
19614
+ }
19615
+
19616
+ #removeNavButtons() {
19617
+ this.#navStart?.remove();
19618
+ this.#navEnd?.remove();
19619
+ this.#navStart = null;
19620
+ this.#navEnd = null;
19621
+ this.#panel?.classList.remove("overflow-start", "overflow-end");
19622
+ }
19623
+
18287
19624
  #getItems() {
18288
- if (!this.#popup) return [];
18289
- return Array.from(this.#popup.querySelectorAll("fig-menu-item")).filter(
19625
+ if (!this.#panel) return [];
19626
+ return Array.from(this.#panel.querySelectorAll("fig-menu-item")).filter(
18290
19627
  (item) =>
18291
19628
  !item.hasAttribute("disabled") || item.getAttribute("disabled") === "false",
18292
19629
  );
@@ -18310,12 +19647,18 @@ class FigMenu extends HTMLElement {
18310
19647
  if (!items.length) return;
18311
19648
  const wrapped = (index + items.length) % items.length;
18312
19649
  this.#focusedIndex = wrapped;
18313
- items[wrapped].focus();
19650
+ const item = items[wrapped];
19651
+ item.focus();
19652
+ figScrollElementToCenter(this.#panel, item, "y");
18314
19653
  }
18315
19654
 
18316
19655
  #syncDisabled() {
19656
+ const disabled = this.#isDisabled();
19657
+ if (disabled) {
19658
+ if (this.open) this.removeAttribute("open");
19659
+ else this.#closeMenu();
19660
+ }
18317
19661
  if (!this.#trigger) return;
18318
- const disabled = this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
18319
19662
  if (disabled) {
18320
19663
  this.#trigger.setAttribute("disabled", "");
18321
19664
  this.#trigger.setAttribute("aria-disabled", "true");
@@ -18326,9 +19669,15 @@ class FigMenu extends HTMLElement {
18326
19669
  }
18327
19670
  }
18328
19671
 
19672
+ #isDisabled() {
19673
+ return (
19674
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false"
19675
+ );
19676
+ }
19677
+
18329
19678
  #handleTriggerClick(e) {
18330
19679
  if (this.#usesContextMenuTrigger()) return;
18331
- if (this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") return;
19680
+ if (this.#isDisabled()) return;
18332
19681
  e.stopPropagation();
18333
19682
  const popupShowing = this.#popup?.matches?.(":open") ?? false;
18334
19683
  if (this.open && !popupShowing) {
@@ -18347,7 +19696,7 @@ class FigMenu extends HTMLElement {
18347
19696
 
18348
19697
  #handleTriggerContextMenu(e) {
18349
19698
  if (!this.#usesContextMenuTrigger()) return;
18350
- if (this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") return;
19699
+ if (this.#isDisabled()) return;
18351
19700
  e.preventDefault();
18352
19701
  e.stopPropagation();
18353
19702
  this.#showAtAfterPointerRelease(e.clientX, e.clientY);
@@ -18381,6 +19730,7 @@ class FigMenu extends HTMLElement {
18381
19730
  }
18382
19731
 
18383
19732
  #handlePopupClick(e) {
19733
+ if (this.#isDisabled()) return;
18384
19734
  const item = e.target.closest("fig-menu-item");
18385
19735
  if (!item) return;
18386
19736
  if (item.hasAttribute("disabled") && item.getAttribute("disabled") !== "false") return;
@@ -18389,6 +19739,7 @@ class FigMenu extends HTMLElement {
18389
19739
  }
18390
19740
 
18391
19741
  #handleMenuKeydown(e) {
19742
+ if (this.#isDisabled()) return;
18392
19743
  if (e.currentTarget === document && e.key !== "Escape") return;
18393
19744
  if (e.currentTarget === this && this.#popup?.contains(e.target)) return;
18394
19745
  if (!this.open || !this.#popup?.matches?.(":open")) {
@@ -18474,6 +19825,7 @@ class FigMenu extends HTMLElement {
18474
19825
  }
18475
19826
 
18476
19827
  showAt(x, y) {
19828
+ if (this.#isDisabled()) return;
18477
19829
  this.#virtualAnchor = {
18478
19830
  getBoundingClientRect: () => ({
18479
19831
  width: 0,
@@ -18491,12 +19843,16 @@ class FigMenu extends HTMLElement {
18491
19843
  }
18492
19844
  if (this.open) this.open = false;
18493
19845
  requestAnimationFrame(() => {
19846
+ if (this.#isDisabled()) return;
18494
19847
  this.open = true;
18495
19848
  });
18496
19849
  }
18497
19850
 
18498
19851
  #openMenu() {
18499
- if (!this.#popup) return;
19852
+ if (!this.#popup || this.#isDisabled()) {
19853
+ if (this.hasAttribute("open")) this.removeAttribute("open");
19854
+ return;
19855
+ }
18500
19856
  this.#popup.open = true;
18501
19857
  document.addEventListener("keydown", this.#boundMenuKeydown, true);
18502
19858
  if (this.#trigger) {
@@ -18504,6 +19860,7 @@ class FigMenu extends HTMLElement {
18504
19860
  }
18505
19861
  this.#focusedIndex = -1;
18506
19862
  requestAnimationFrame(() => {
19863
+ this.#syncOverflow();
18507
19864
  if (!this.#trigger?.matches?.(":focus-visible")) return;
18508
19865
  this.#focusItemAt(0);
18509
19866
  });