@rogieking/figui3 6.21.0 → 6.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig.js CHANGED
@@ -223,6 +223,12 @@ function figDefineCustomizedBuiltIn(name, constructor, options) {
223
223
  });
224
224
  }
225
225
 
226
+ function figDefineElement(name, constructor) {
227
+ if (!customElements.get(name)) {
228
+ customElements.define(name, constructor);
229
+ }
230
+ }
231
+
226
232
  function figUniqueId() {
227
233
  return Date.now().toString(36) + Math.random().toString(36).substring(2);
228
234
  }
@@ -324,9 +330,11 @@ function figSupportsPopover() {
324
330
  class FigButton extends HTMLElement {
325
331
  type;
326
332
  #selected;
333
+ #slottedDisabledStates = new WeakMap();
327
334
  #a11yAttributes = ["aria-label", "aria-labelledby", "aria-describedby", "title"];
328
335
  #boundHandleControlKeydown = this.#handleControlKeydown.bind(this);
329
336
  #boundHandleClick = this.#handleClick.bind(this);
337
+ #boundHandleSlotChange = () => this.#syncSlottedControlDisabled();
330
338
  #boundHandleFocus = () => {
331
339
  if (this.button?.matches(":focus-visible")) {
332
340
  this.setAttribute("data-focus-visible", "");
@@ -392,6 +400,9 @@ class FigButton extends HTMLElement {
392
400
  this.button.addEventListener("blur", this.#boundHandleBlur);
393
401
  }
394
402
 
403
+ const slot = this.shadowRoot.querySelector("slot");
404
+ slot?.removeEventListener("slotchange", this.#boundHandleSlotChange);
405
+ slot?.addEventListener("slotchange", this.#boundHandleSlotChange);
395
406
  this.removeEventListener("keydown", this.#boundHandleControlKeydown);
396
407
  this.addEventListener("keydown", this.#boundHandleControlKeydown);
397
408
 
@@ -508,9 +519,30 @@ class FigButton extends HTMLElement {
508
519
  this.button.type = "button";
509
520
  this.button.setAttribute("type", "button");
510
521
  }
522
+ this.#syncSlottedControlDisabled();
511
523
  this.#syncA11yAttributes();
512
524
  this.#syncPressedState();
513
525
  }
526
+ #syncSlottedControlDisabled() {
527
+ const control = this.#getSlottedControl();
528
+ if (!control) return;
529
+ const disabled = this.#isDisabled();
530
+ if (disabled) {
531
+ if (!this.#slottedDisabledStates.has(control)) {
532
+ this.#slottedDisabledStates.set(
533
+ control,
534
+ control.hasAttribute("disabled") &&
535
+ control.getAttribute("disabled") !== "false",
536
+ );
537
+ }
538
+ control.setAttribute("disabled", "");
539
+ } else if (this.#slottedDisabledStates.has(control)) {
540
+ const wasDisabled = this.#slottedDisabledStates.get(control);
541
+ this.#slottedDisabledStates.delete(control);
542
+ if (wasDisabled) control.setAttribute("disabled", "");
543
+ else control.removeAttribute("disabled");
544
+ }
545
+ }
514
546
  static get observedAttributes() {
515
547
  return [
516
548
  "disabled",
@@ -549,9 +581,12 @@ class FigButton extends HTMLElement {
549
581
  }
550
582
  disconnectedCallback() {
551
583
  this.removeEventListener("keydown", this.#boundHandleControlKeydown);
584
+ this.shadowRoot
585
+ ?.querySelector("slot")
586
+ ?.removeEventListener("slotchange", this.#boundHandleSlotChange);
552
587
  }
553
588
  }
554
- customElements.define("fig-button", FigButton);
589
+ figDefineElement("fig-button", FigButton);
555
590
 
556
591
  /**
557
592
  * A custom dropdown/select element.
@@ -564,8 +599,6 @@ class FigDropdown extends HTMLElement {
564
599
  #boundHandleSelectInput;
565
600
  #boundHandleSelectChange;
566
601
  #boundHandleSelectKeydown;
567
- #selectedContentEnabled = false;
568
- #selectedContentEl = null;
569
602
 
570
603
  get label() {
571
604
  return this.#label;
@@ -585,54 +618,6 @@ class FigDropdown extends HTMLElement {
585
618
  this.#boundSlotChange = this.slotChange.bind(this);
586
619
  }
587
620
 
588
- #supportsSelectedContent() {
589
- if (typeof CSS === "undefined" || typeof CSS.supports !== "function")
590
- return false;
591
- try {
592
- return (
593
- CSS.supports("appearance: base-select") &&
594
- CSS.supports("selector(::picker(select))")
595
- );
596
- } catch {
597
- return false;
598
- }
599
- }
600
-
601
- #enableSelectedContentIfNeeded() {
602
- const experimental = this.getAttribute("experimental") || "";
603
- const wantsModern = experimental
604
- .split(/\s+/)
605
- .filter(Boolean)
606
- .includes("modern");
607
-
608
- if (!wantsModern || !this.#supportsSelectedContent()) {
609
- this.#selectedContentEnabled = false;
610
- return;
611
- }
612
-
613
- const button = document.createElement("button");
614
- button.setAttribute("type", "button");
615
- button.setAttribute("aria-hidden", "true");
616
- const selected = document.createElement("selectedcontent");
617
- button.appendChild(selected);
618
- this.select.appendChild(button);
619
- this.#selectedContentEnabled = true;
620
- this.#selectedContentEl = selected;
621
- }
622
-
623
- #syncSelectedContent() {
624
- if (!this.#selectedContentEl) return;
625
- const selectedOption = this.select.selectedOptions?.[0];
626
- if (!selectedOption) {
627
- this.#selectedContentEl.textContent = "";
628
- return;
629
- }
630
- // Fallback mirror for browsers that don't auto-project selectedcontent reliably.
631
- this.#selectedContentEl.replaceChildren(
632
- ...Array.from(selectedOption.childNodes, (node) => node.cloneNode(true)),
633
- );
634
- }
635
-
636
621
  #addEventListeners() {
637
622
  this.select.addEventListener("input", this.#boundHandleSelectInput);
638
623
  this.select.addEventListener("change", this.#boundHandleSelectChange);
@@ -697,8 +682,6 @@ class FigDropdown extends HTMLElement {
697
682
  this.select.firstChild.remove();
698
683
  }
699
684
 
700
- this.#enableSelectedContentIfNeeded();
701
-
702
685
  if (this.type === "dropdown") {
703
686
  const hiddenOption = document.createElement("option");
704
687
  hiddenOption.setAttribute("hidden", "true");
@@ -714,7 +697,6 @@ class FigDropdown extends HTMLElement {
714
697
  if (selectedValue !== null) {
715
698
  this.#syncSelectedValue(selectedValue);
716
699
  }
717
- this.#syncSelectedContent();
718
700
  if (this.type === "dropdown") {
719
701
  this.select.selectedIndex = -1;
720
702
  }
@@ -737,7 +719,6 @@ class FigDropdown extends HTMLElement {
737
719
  this.#selectedValue = selectedValue;
738
720
  }
739
721
  this.setAttribute("value", selectedValue);
740
- this.#syncSelectedContent();
741
722
  this.dispatchEvent(
742
723
  new CustomEvent("input", {
743
724
  detail: selectedValue,
@@ -765,7 +746,6 @@ class FigDropdown extends HTMLElement {
765
746
  if (this.type === "dropdown") {
766
747
  this.select.selectedIndex = -1;
767
748
  }
768
- this.#syncSelectedContent();
769
749
  this.dispatchEvent(
770
750
  new CustomEvent("change", {
771
751
  detail: selectedValue,
@@ -778,7 +758,6 @@ class FigDropdown extends HTMLElement {
778
758
  #handleSelectKeydown(e) {
779
759
  if (this.closest('fig-button[type="select"]')) return;
780
760
  if (e.key !== "Enter" || e.defaultPrevented) return;
781
- if (this.#selectedContentEnabled && this.select.matches(":open")) return;
782
761
  if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
783
762
  if (this.select.disabled || this.select.multiple) return;
784
763
  if (typeof this.select.showPicker !== "function") return;
@@ -811,7 +790,7 @@ class FigDropdown extends HTMLElement {
811
790
  this.setAttribute("value", value);
812
791
  }
813
792
  static get observedAttributes() {
814
- return ["value", "type", "experimental", "label", "disabled"];
793
+ return ["value", "type", "label", "disabled"];
815
794
  }
816
795
  #syncDisabled() {
817
796
  const disabled =
@@ -824,7 +803,6 @@ class FigDropdown extends HTMLElement {
824
803
  return;
825
804
  }
826
805
  if (this.select) this.select.value = value ?? "";
827
- this.#syncSelectedContent();
828
806
  }
829
807
  attributeChangedCallback(name, oldValue, newValue) {
830
808
  if (name === "value") {
@@ -834,9 +812,6 @@ class FigDropdown extends HTMLElement {
834
812
  this.type = newValue || "select";
835
813
  if (this.isConnected) this.slotChange();
836
814
  }
837
- if (name === "experimental") {
838
- this.slotChange();
839
- }
840
815
  if (name === "label") {
841
816
  this.#label = newValue || "Menu";
842
817
  this.select.setAttribute("aria-label", this.#label);
@@ -854,7 +829,7 @@ class FigDropdown extends HTMLElement {
854
829
  }
855
830
  }
856
831
 
857
- customElements.define("fig-dropdown", FigDropdown);
832
+ figDefineElement("fig-dropdown", FigDropdown);
858
833
 
859
834
  /* Tooltip */
860
835
  /**
@@ -881,6 +856,8 @@ class FigTooltip extends HTMLElement {
881
856
  #boundHidePopupOutsideClick;
882
857
  #boundShowDelayedPopup;
883
858
  #boundHandlePointerLeave;
859
+ #boundHandleFocus;
860
+ #boundHandleBlur;
884
861
  #boundHandleTouchStart;
885
862
  #boundHandleTouchMove;
886
863
  #boundHandleTouchEnd;
@@ -890,6 +867,7 @@ class FigTooltip extends HTMLElement {
890
867
  #parentDialog = null;
891
868
  #triggerEl = null;
892
869
  #childObserver = null;
870
+ #suppressFocusOpen = false;
893
871
  #touchTimeout;
894
872
  #isTouching = false;
895
873
  constructor() {
@@ -902,6 +880,13 @@ class FigTooltip extends HTMLElement {
902
880
  this.#boundHidePopupOutsideClick = this.hidePopupOutsideClick.bind(this);
903
881
  this.#boundShowDelayedPopup = this.showDelayedPopup.bind(this);
904
882
  this.#boundHandlePointerLeave = this.#handlePointerLeave.bind(this);
883
+ this.#boundHandleFocus = () => {
884
+ if (!this.#suppressFocusOpen) this.showDelayedPopup();
885
+ };
886
+ this.#boundHandleBlur = () => {
887
+ this.#suppressFocusOpen = false;
888
+ this.hidePopup();
889
+ };
905
890
  this.#boundHandleTouchStart = this.#handleTouchStart.bind(this);
906
891
  this.#boundHandleTouchMove = this.#handleTouchMove.bind(this);
907
892
  this.#boundHandleTouchEnd = this.#handleTouchEnd.bind(this);
@@ -996,6 +981,8 @@ class FigTooltip extends HTMLElement {
996
981
  trigger.addEventListener("touchcancel", this.#boundHandleTouchCancel, {
997
982
  passive: true,
998
983
  });
984
+ trigger.addEventListener("focus", this.#boundHandleFocus);
985
+ trigger.addEventListener("blur", this.#boundHandleBlur);
999
986
  } else if (this.action === "click") {
1000
987
  trigger.addEventListener("click", this.#boundShowDelayedPopup);
1001
988
  trigger.addEventListener("touchstart", this.#boundShowDelayedPopup, {
@@ -1014,6 +1001,8 @@ class FigTooltip extends HTMLElement {
1014
1001
  trigger.removeEventListener("touchmove", this.#boundHandleTouchMove);
1015
1002
  trigger.removeEventListener("touchend", this.#boundHandleTouchEnd);
1016
1003
  trigger.removeEventListener("touchcancel", this.#boundHandleTouchCancel);
1004
+ trigger.removeEventListener("focus", this.#boundHandleFocus);
1005
+ trigger.removeEventListener("blur", this.#boundHandleBlur);
1017
1006
  } else if (this.action === "click") {
1018
1007
  trigger.removeEventListener("click", this.#boundShowDelayedPopup);
1019
1008
  trigger.removeEventListener("touchstart", this.#boundShowDelayedPopup);
@@ -1415,6 +1404,16 @@ class FigTooltip extends HTMLElement {
1415
1404
  if (!(node instanceof FigTooltip)) continue;
1416
1405
  if (node.action !== "hover") continue;
1417
1406
  if (node.#showPersisted) continue;
1407
+ node.#suppressFocusOpen = true;
1408
+ setTimeout(() => {
1409
+ const trigger = node.#triggerEl;
1410
+ if (
1411
+ trigger !== document.activeElement &&
1412
+ !trigger?.matches?.(":focus, :focus-within")
1413
+ ) {
1414
+ node.#suppressFocusOpen = false;
1415
+ }
1416
+ }, 0);
1418
1417
  if (node.isOpen || node.timeout) node.hidePopup();
1419
1418
  }
1420
1419
  for (const anchor of Array.from(FigTooltip.#programmaticAnchors)) {
@@ -1495,7 +1494,7 @@ class FigTooltip extends HTMLElement {
1495
1494
  }
1496
1495
  }
1497
1496
 
1498
- customElements.define("fig-tooltip", FigTooltip);
1497
+ figDefineElement("fig-tooltip", FigTooltip);
1499
1498
 
1500
1499
  /* Text Truncation */
1501
1500
  class FigTruncate extends HTMLElement {
@@ -1574,7 +1573,7 @@ class FigTruncate extends HTMLElement {
1574
1573
  FigTooltip.hide(this);
1575
1574
  }
1576
1575
  }
1577
- customElements.define("fig-truncate", FigTruncate);
1576
+ figDefineElement("fig-truncate", FigTruncate);
1578
1577
 
1579
1578
  /* Dialog */
1580
1579
  /**
@@ -3868,7 +3867,7 @@ class FigTab extends HTMLElement {
3868
3867
  }
3869
3868
  }
3870
3869
  }
3871
- customElements.define("fig-tab", FigTab);
3870
+ figDefineElement("fig-tab", FigTab);
3872
3871
 
3873
3872
  /**
3874
3873
  * A custom tabs container element.
@@ -4198,7 +4197,7 @@ class FigTabs extends HTMLElement {
4198
4197
  }
4199
4198
  }
4200
4199
  }
4201
- customElements.define("fig-tabs", FigTabs);
4200
+ figDefineElement("fig-tabs", FigTabs);
4202
4201
 
4203
4202
  /* Segmented Control */
4204
4203
  /**
@@ -4286,7 +4285,7 @@ class FigSegment extends HTMLElement {
4286
4285
  }
4287
4286
  }
4288
4287
  }
4289
- customElements.define("fig-segment", FigSegment);
4288
+ figDefineElement("fig-segment", FigSegment);
4290
4289
 
4291
4290
  /**
4292
4291
  * A custom segmented control container element.
@@ -4770,7 +4769,7 @@ class FigSegmentedControl extends HTMLElement {
4770
4769
  }
4771
4770
  }
4772
4771
  }
4773
- customElements.define("fig-segmented-control", FigSegmentedControl);
4772
+ figDefineElement("fig-segmented-control", FigSegmentedControl);
4774
4773
 
4775
4774
  /* Options */
4776
4775
  /**
@@ -5029,93 +5028,1293 @@ class FigOptions extends HTMLElement {
5029
5028
  );
5030
5029
  });
5031
5030
 
5032
- this.appendChild(dd);
5033
- this.#childControl = dd;
5034
- this.#currentMode = "dropdown";
5031
+ this.appendChild(dd);
5032
+ this.#childControl = dd;
5033
+ this.#currentMode = "dropdown";
5034
+ }
5035
+
5036
+ #rebuildCurrentControl() {
5037
+ if (this.#currentMode === "segments") {
5038
+ this.#renderSegments();
5039
+ requestAnimationFrame(() => {
5040
+ requestAnimationFrame(() => this.#checkOverflow());
5041
+ });
5042
+ } else {
5043
+ this.#renderDropdown();
5044
+ }
5045
+ }
5046
+
5047
+ #syncValueToChild() {
5048
+ if (!this.#childControl || this.#suppressEvents) return;
5049
+ const val = this.getAttribute("value") || "";
5050
+ this.#childControl.value = val;
5051
+ }
5052
+
5053
+ #syncAttrToChild(attr) {
5054
+ if (!this.#childControl) return;
5055
+ if (this.hasAttribute(attr)) {
5056
+ this.#childControl.setAttribute(attr, this.getAttribute(attr) || "");
5057
+ } else {
5058
+ this.#childControl.removeAttribute(attr);
5059
+ }
5060
+ }
5061
+
5062
+ #startResizeObserver() {
5063
+ this.#resizeObserver?.disconnect();
5064
+ this.#resizeObserver = new ResizeObserver(() => {
5065
+ this.#checkOverflow();
5066
+ });
5067
+ this.#resizeObserver.observe(this);
5068
+ }
5069
+
5070
+ #isSegmentTruncated(seg) {
5071
+ const range = document.createRange();
5072
+ range.selectNodeContents(seg);
5073
+ const textWidth = range.getBoundingClientRect().width;
5074
+ const segRect = seg.getBoundingClientRect();
5075
+ const segWidth = segRect.width;
5076
+ const cs = getComputedStyle(seg);
5077
+ const padL = parseFloat(cs.paddingLeft) || 0;
5078
+ const padR = parseFloat(cs.paddingRight) || 0;
5079
+ const contentWidth = segWidth - padL - padR;
5080
+ return textWidth > contentWidth + 0.5;
5081
+ }
5082
+
5083
+ #anySegmentTruncated() {
5084
+ const segments = this.querySelectorAll("fig-segment");
5085
+ for (const seg of segments) {
5086
+ if (this.#isSegmentTruncated(seg)) return true;
5087
+ }
5088
+ return false;
5089
+ }
5090
+
5091
+ #checkOverflow() {
5092
+ if (this.#parsedOptions.length <= 1) return;
5093
+
5094
+ if (this.#currentMode === "segments") {
5095
+ const sc = this.#childControl;
5096
+ const containerOverflow = sc && sc.scrollWidth > sc.clientWidth + 1;
5097
+ if (containerOverflow || this.#anySegmentTruncated()) {
5098
+ this.#naturalWidth = this.clientWidth;
5099
+ this.#renderDropdown();
5100
+ }
5101
+ } else {
5102
+ if (this.#naturalWidth > 0 && this.clientWidth >= this.#naturalWidth) {
5103
+ this.#renderSegments();
5104
+ requestAnimationFrame(() => {
5105
+ requestAnimationFrame(() => {
5106
+ const sc = this.#childControl;
5107
+ const containerOverflow = sc && sc.scrollWidth > sc.clientWidth + 1;
5108
+ if (containerOverflow || this.#anySegmentTruncated()) {
5109
+ this.#renderDropdown();
5110
+ }
5111
+ });
5112
+ });
5113
+ }
5114
+ }
5115
+ }
5116
+ }
5117
+ figDefineElement("fig-options", FigOptions);
5118
+
5119
+ /* Select — dropdown-styled trigger + fig-popup listbox */
5120
+ /** Parse options attr — same formats as fig-options / propskit-select. */
5121
+ function figSelectParseOptionsAttribute(raw) {
5122
+ const text = raw || "";
5123
+ if (text.startsWith("[")) {
5124
+ try {
5125
+ const parsed = JSON.parse(text);
5126
+ return Array.isArray(parsed) ? parsed : [];
5127
+ } catch {
5128
+ /* fall through */
5129
+ }
5130
+ }
5131
+ const delimiter = text.includes("\n") ? "\n" : ",";
5132
+ return text
5133
+ .split(delimiter)
5134
+ .map((s) => s.trim())
5135
+ .filter(Boolean);
5136
+ }
5137
+
5138
+ function figSelectOptionEntryValue(opt) {
5139
+ if (opt && typeof opt === "object") {
5140
+ return String(opt.value ?? opt.label ?? "");
5141
+ }
5142
+ return String(opt ?? "");
5143
+ }
5144
+
5145
+ function figSelectOptionEntryLabel(opt) {
5146
+ if (opt && typeof opt === "object") {
5147
+ return String(opt.label ?? opt.value ?? "");
5148
+ }
5149
+ return String(opt ?? "");
5150
+ }
5151
+
5152
+ /**
5153
+ * A selectable option for fig-select.
5154
+ * Supports light-DOM slots: `slot="prepend"` (leading) and `slot="append"` (trailing).
5155
+ * Use the `label` attribute for the closed-trigger label when option content is rich.
5156
+ *
5157
+ * @attr {string} value - Option value
5158
+ * @attr {string} label - Optional display label for the select trigger
5159
+ * @attr {boolean} disabled - Whether the option is disabled
5160
+ * @attr {boolean} selected - Whether the option is selected
5161
+ */
5162
+ class FigSelectOption extends HTMLElement {
5163
+ static get observedAttributes() {
5164
+ return ["value", "disabled", "selected", "label"];
5165
+ }
5166
+
5167
+ get value() {
5168
+ const attr = this.getAttribute("value");
5169
+ if (attr !== null) return attr;
5170
+ return (this.textContent || "").trim();
5171
+ }
5172
+
5173
+ set value(val) {
5174
+ if (val === null || val === undefined) {
5175
+ this.removeAttribute("value");
5176
+ } else {
5177
+ this.setAttribute("value", String(val));
5178
+ }
5179
+ }
5180
+
5181
+ get disabled() {
5182
+ return figBooleanAttribute(this, "disabled");
5183
+ }
5184
+
5185
+ set disabled(val) {
5186
+ if (val) this.setAttribute("disabled", "");
5187
+ else this.removeAttribute("disabled");
5188
+ }
5189
+
5190
+ get selected() {
5191
+ return figBooleanAttribute(this, "selected");
5192
+ }
5193
+
5194
+ set selected(val) {
5195
+ if (val) this.setAttribute("selected", "");
5196
+ else this.removeAttribute("selected");
5197
+ }
5198
+
5199
+ connectedCallback() {
5200
+ if (!this.hasAttribute("role")) this.setAttribute("role", "option");
5201
+ if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
5202
+ this.#syncDisabled();
5203
+ }
5204
+
5205
+ attributeChangedCallback(name, oldValue, newValue) {
5206
+ if (oldValue === newValue) return;
5207
+ if (name === "disabled") this.#syncDisabled();
5208
+ }
5209
+
5210
+ #syncDisabled() {
5211
+ const disabled = this.disabled;
5212
+ if (disabled) {
5213
+ this.setAttribute("aria-disabled", "true");
5214
+ this.setAttribute("tabindex", "-1");
5215
+ } else {
5216
+ this.removeAttribute("aria-disabled");
5217
+ if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
5218
+ }
5219
+ }
5220
+ }
5221
+ figDefineElement("fig-select-option", FigSelectOption);
5222
+
5223
+ function figSelectSyncOverflowState(host, scrollEl, threshold = 2) {
5224
+ if (!host || !scrollEl) return false;
5225
+ const scrollable = scrollEl.scrollHeight - scrollEl.clientHeight > threshold;
5226
+ const atStart = !scrollable || scrollEl.scrollTop <= threshold;
5227
+ const atEnd =
5228
+ !scrollable ||
5229
+ scrollEl.scrollTop + scrollEl.clientHeight >=
5230
+ scrollEl.scrollHeight - threshold;
5231
+ host.classList.toggle("overflow-start", !atStart);
5232
+ host.classList.toggle("overflow-end", !atEnd);
5233
+ return scrollable;
5234
+ }
5235
+
5236
+ function figSelectScrollOverflowPage(scrollEl, direction = 1) {
5237
+ if (!scrollEl) return;
5238
+ scrollEl.scrollBy({
5239
+ top: scrollEl.clientHeight * 0.8 * direction,
5240
+ behavior: "smooth",
5241
+ });
5242
+ }
5243
+
5244
+ function figSelectCreateOverflowButtons({ onStart, onEnd } = {}) {
5245
+ const makeButton = (direction, onClick) => {
5246
+ const button = document.createElement("button");
5247
+ button.type = "button";
5248
+ button.className = `fig-overflow fig-overflow-${direction}`;
5249
+ button.dataset.figOverflow = direction;
5250
+ button.setAttribute("data-fig-select-nav", direction);
5251
+ button.setAttribute("tabindex", "-1");
5252
+ button.setAttribute(
5253
+ "aria-label",
5254
+ direction === "start" ? "Scroll up" : "Scroll down",
5255
+ );
5256
+ const icon = document.createElement("fig-icon");
5257
+ icon.setAttribute("name", "chevron");
5258
+ icon.setAttribute("size", "small");
5259
+ icon.className = "fig-overflow-chevron";
5260
+ button.appendChild(icon);
5261
+ button.addEventListener("click", (event) => {
5262
+ event.preventDefault();
5263
+ event.stopPropagation();
5264
+ onClick?.(event);
5265
+ });
5266
+ return button;
5267
+ };
5268
+ return {
5269
+ start: makeButton("start", onStart),
5270
+ end: makeButton("end", onEnd),
5271
+ };
5272
+ }
5273
+
5274
+ /** Light-DOM panel wrapper projected into fig-select's popup; owns overflow buttons. */
5275
+ class FigSelectOptions extends HTMLElement {
5276
+ #navStart = null;
5277
+ #navEnd = null;
5278
+ #resizeObserver = null;
5279
+ #boundSyncOverflow = this.syncOverflow.bind(this);
5280
+
5281
+ connectedCallback() {
5282
+ if (!this.hasAttribute("slot")) this.setAttribute("slot", "panel");
5283
+ this.#unwrapLegacyChooser();
5284
+ this.#ensureNavButtons();
5285
+ this.addEventListener("scroll", this.#boundSyncOverflow, { passive: true });
5286
+ this.#resizeObserver?.disconnect();
5287
+ this.#resizeObserver = new ResizeObserver(() => this.syncOverflow());
5288
+ this.#resizeObserver.observe(this);
5289
+ requestAnimationFrame(() => this.syncOverflow());
5290
+ }
5291
+
5292
+ disconnectedCallback() {
5293
+ this.removeEventListener("scroll", this.#boundSyncOverflow);
5294
+ this.#resizeObserver?.disconnect();
5295
+ this.#resizeObserver = null;
5296
+ this.#removeNavButtons();
5297
+ }
5298
+
5299
+ syncOverflow() {
5300
+ return figSelectSyncOverflowState(this, this);
5301
+ }
5302
+
5303
+ scrollToOption(option, behavior = "auto") {
5304
+ if (!option || !this.contains(option)) return;
5305
+ requestAnimationFrame(() => {
5306
+ if (!option.isConnected) return;
5307
+ if (this.scrollHeight <= this.clientHeight + 1) {
5308
+ this.syncOverflow();
5309
+ return;
5310
+ }
5311
+ const optionRect = option.getBoundingClientRect();
5312
+ const hostRect = this.getBoundingClientRect();
5313
+ const optionTop = optionRect.top - hostRect.top + this.scrollTop;
5314
+ const maxScroll = this.scrollHeight - this.clientHeight;
5315
+ const top = Math.max(
5316
+ 0,
5317
+ Math.min(
5318
+ optionTop + optionRect.height / 2 - this.clientHeight / 2,
5319
+ maxScroll,
5320
+ ),
5321
+ );
5322
+ this.scrollTo({ top, behavior });
5323
+ this.syncOverflow();
5324
+ });
5325
+ }
5326
+
5327
+ #unwrapLegacyChooser() {
5328
+ const chooser = this.querySelector(":scope > fig-chooser");
5329
+ if (!chooser) return;
5330
+ while (chooser.firstChild) {
5331
+ this.insertBefore(chooser.firstChild, chooser);
5332
+ }
5333
+ chooser.remove();
5334
+ }
5335
+
5336
+ #ensureNavButtons() {
5337
+ if (
5338
+ this.#navStart &&
5339
+ this.#navEnd &&
5340
+ this.contains(this.#navStart) &&
5341
+ this.contains(this.#navEnd)
5342
+ ) {
5343
+ return;
5344
+ }
5345
+ this.#removeNavButtons();
5346
+ const buttons = figSelectCreateOverflowButtons({
5347
+ onStart: () => figSelectScrollOverflowPage(this, -1),
5348
+ onEnd: () => figSelectScrollOverflowPage(this, 1),
5349
+ });
5350
+ this.#navStart = buttons.start;
5351
+ this.#navEnd = buttons.end;
5352
+ this.prepend(this.#navStart);
5353
+ this.append(this.#navEnd);
5354
+ }
5355
+
5356
+ #removeNavButtons() {
5357
+ this.#navStart?.remove();
5358
+ this.#navEnd?.remove();
5359
+ this.#navStart = null;
5360
+ this.#navEnd = null;
5361
+ this.classList.remove("overflow-start", "overflow-end");
5362
+ }
5363
+ }
5364
+ figDefineElement("fig-select-options", FigSelectOptions);
5365
+
5366
+ class FigSelect extends HTMLElement {
5367
+ #button = null;
5368
+ #popup = null;
5369
+ #prependEl = null;
5370
+ #labelEl = null;
5371
+ #panelSlot = null;
5372
+ #observer = null;
5373
+ #initialized = false;
5374
+ #focusedIndex = -1;
5375
+ #syncingValue = false;
5376
+ #popupPositionPatched = false;
5377
+ #originalPositionPopup = null;
5378
+ /**
5379
+ * After open align, ignore content/scroll-driven positionPopup passes so
5380
+ * overflow paging isn't yanked back. Still realign when the trigger moves
5381
+ * or the viewport size changes (window resize, layout shift, page scroll).
5382
+ */
5383
+ #freezeMenuPosition = false;
5384
+ #frozenLabelRect = null;
5385
+ #frozenViewport = null;
5386
+ #syncingOptions = false;
5387
+ #boundTriggerClick = this.#handleTriggerClick.bind(this);
5388
+ #boundOptionClick = this.#handleOptionClick.bind(this);
5389
+ #boundKeydown = this.#handleKeydown.bind(this);
5390
+ #boundPopupClose = this.#handlePopupClose.bind(this);
5391
+ #boundSlotChange = this.#handleSlotChange.bind(this);
5392
+
5393
+ static get observedAttributes() {
5394
+ return [
5395
+ "value",
5396
+ "disabled",
5397
+ "label",
5398
+ "options",
5399
+ "position",
5400
+ "offset",
5401
+ "closedby",
5402
+ "open",
5403
+ ];
5404
+ }
5405
+
5406
+ get value() {
5407
+ return this.getAttribute("value") ?? "";
5408
+ }
5409
+
5410
+ set value(val) {
5411
+ if (val === null || val === undefined) this.removeAttribute("value");
5412
+ else this.setAttribute("value", String(val));
5413
+ }
5414
+
5415
+ get open() {
5416
+ return figBooleanAttribute(this, "open");
5417
+ }
5418
+
5419
+ set open(val) {
5420
+ if (val) this.setAttribute("open", "");
5421
+ else this.removeAttribute("open");
5422
+ }
5423
+
5424
+ connectedCallback() {
5425
+ if (!this.#initialized) this.#initialize();
5426
+ this.#ensurePanelSlotAttrs();
5427
+ this.#syncOptionsFromAttribute();
5428
+ this.#syncDisabled();
5429
+ this.#syncPopupAttrs();
5430
+ this.#syncValue();
5431
+ this.#setupObserver();
5432
+ if (this.open) this.#openList();
5433
+ }
5434
+
5435
+ disconnectedCallback() {
5436
+ this.#teardownListeners();
5437
+ document.removeEventListener("keydown", this.#boundKeydown, true);
5438
+ this.#observer?.disconnect();
5439
+ this.#observer = null;
5440
+ }
5441
+
5442
+ attributeChangedCallback(name, oldValue, newValue) {
5443
+ if (oldValue === newValue || !this.#initialized) return;
5444
+ if (name === "options") {
5445
+ this.#syncOptionsFromAttribute();
5446
+ this.#syncValue();
5447
+ return;
5448
+ }
5449
+ if (name === "value" || name === "label") {
5450
+ this.#syncValue();
5451
+ return;
5452
+ }
5453
+ if (name === "disabled") {
5454
+ this.#syncDisabled();
5455
+ return;
5456
+ }
5457
+ if (name === "open") {
5458
+ if (newValue === null || newValue === "false") this.#closeList();
5459
+ else this.#openList();
5460
+ return;
5461
+ }
5462
+ if (name === "position" || name === "offset" || name === "closedby") {
5463
+ this.#syncPopupAttrs();
5464
+ }
5465
+ }
5466
+
5467
+ focus(options) {
5468
+ this.#button?.focus(options);
5469
+ }
5470
+
5471
+ blur() {
5472
+ this.#button?.blur();
5473
+ }
5474
+
5475
+ #isMenuChild(node) {
5476
+ return (
5477
+ node?.nodeType === 1 &&
5478
+ (node.tagName === "FIG-SELECT-OPTION" ||
5479
+ node.tagName === "FIG-MENU-SEPARATOR" ||
5480
+ node.tagName === "FIG-SELECT-OPTIONS")
5481
+ );
5482
+ }
5483
+
5484
+ #ensurePanelSlotAttrs() {
5485
+ for (const panel of this.querySelectorAll(":scope > fig-select-options")) {
5486
+ if (!panel.hasAttribute("slot")) panel.setAttribute("slot", "panel");
5487
+ }
5488
+ }
5489
+
5490
+ #getPanel() {
5491
+ const assigned = this.#panelSlot?.assignedElements({ flatten: true }) ?? [];
5492
+ const fromSlot = assigned.find(
5493
+ (el) => el.tagName === "FIG-SELECT-OPTIONS",
5494
+ );
5495
+ if (fromSlot) return fromSlot;
5496
+ return this.querySelector(":scope > fig-select-options");
5497
+ }
5498
+
5499
+ #hasAuthoredOptions() {
5500
+ return Boolean(
5501
+ this.querySelector(
5502
+ ":scope > fig-select-option:not([data-fig-generated]), :scope > fig-select-options > fig-select-option:not([data-fig-generated])",
5503
+ ),
5504
+ );
5505
+ }
5506
+
5507
+ #ensureOptionsPanel() {
5508
+ let panel = this.#getPanel();
5509
+ if (panel) {
5510
+ if (!panel.hasAttribute("slot")) panel.setAttribute("slot", "panel");
5511
+ return panel;
5512
+ }
5513
+ panel = document.createElement("fig-select-options");
5514
+ panel.setAttribute("slot", "panel");
5515
+ panel.setAttribute("data-fig-generated", "");
5516
+ this.appendChild(panel);
5517
+ return panel;
5518
+ }
5519
+
5520
+ /**
5521
+ * When no authored fig-select-option exists, build panel/options from the
5522
+ * options attribute (comma / newline / JSON — same as fig-options).
5523
+ */
5524
+ #syncOptionsFromAttribute() {
5525
+ if (this.#hasAuthoredOptions()) return;
5526
+
5527
+ const hasOptionsAttr = this.hasAttribute("options");
5528
+ const panel = hasOptionsAttr
5529
+ ? this.#ensureOptionsPanel()
5530
+ : this.#getPanel();
5531
+ if (!panel) return;
5532
+
5533
+ this.#syncingOptions = true;
5534
+ try {
5535
+ for (const opt of panel.querySelectorAll(
5536
+ ":scope > fig-select-option[data-fig-generated]",
5537
+ )) {
5538
+ opt.remove();
5539
+ }
5540
+
5541
+ if (!hasOptionsAttr) return;
5542
+
5543
+ const parsed = figSelectParseOptionsAttribute(this.getAttribute("options"));
5544
+ const endBtn = panel.querySelector(":scope > .fig-overflow-end");
5545
+ for (const entry of parsed) {
5546
+ const el = document.createElement("fig-select-option");
5547
+ el.setAttribute("data-fig-generated", "");
5548
+ el.setAttribute("value", figSelectOptionEntryValue(entry));
5549
+ el.textContent = figSelectOptionEntryLabel(entry);
5550
+ if (endBtn) panel.insertBefore(el, endBtn);
5551
+ else panel.appendChild(el);
5552
+ }
5553
+ } finally {
5554
+ this.#syncingOptions = false;
5555
+ }
5556
+ }
5557
+
5558
+ #initialize() {
5559
+ this.#initialized = true;
5560
+ const shadow = this.attachShadow({ mode: "open" });
5561
+ shadow.innerHTML = `
5562
+ <style>
5563
+ :host {
5564
+ display: inline-flex;
5565
+ position: relative;
5566
+ align-items: center;
5567
+ min-width: 0;
5568
+ }
5569
+ :host([full]:not([full="false"])) {
5570
+ display: flex;
5571
+ width: 100%;
5572
+ }
5573
+ .fig-select-trigger {
5574
+ display: flex;
5575
+ align-items: center;
5576
+ justify-content: flex-start;
5577
+ flex: 1;
5578
+ min-width: 0;
5579
+ width: var(--fig-select-trigger-width, 100%);
5580
+ height: 100%;
5581
+ margin: 0;
5582
+ padding: 0 var(--spacer-4, 1rem) 0 var(--spacer-2, 0.5rem);
5583
+ border: 0;
5584
+ border-radius: inherit;
5585
+ background: transparent;
5586
+ box-shadow: none;
5587
+ color: inherit;
5588
+ font: inherit;
5589
+ font-weight: inherit;
5590
+ text-align: left;
5591
+ white-space: nowrap;
5592
+ overflow: hidden;
5593
+ text-overflow: ellipsis;
5594
+ cursor: default;
5595
+ }
5596
+ .fig-select-trigger:has(.fig-select-prepend:not(:empty)) {
5597
+ padding-left: 0;
5598
+ }
5599
+ .fig-select-trigger:hover,
5600
+ .fig-select-trigger:active,
5601
+ .fig-select-trigger:active:hover {
5602
+ background: transparent;
5603
+ box-shadow: none;
5604
+ color: inherit;
5605
+ }
5606
+ .fig-select-trigger:focus-visible,
5607
+ .fig-select-trigger[data-focus-visible] {
5608
+ outline: var(--figma-focus-outline);
5609
+ outline-offset: var(--figma-focus-outline-offset);
5610
+ }
5611
+ :host([disabled]:not([disabled="false"])) .fig-select-trigger,
5612
+ :host([disabled]:not([disabled="false"])) .fig-select-label {
5613
+ color: var(--figma-color-text-tertiary);
5614
+ }
5615
+ .fig-select-label {
5616
+ display: block;
5617
+ width: 100%;
5618
+ min-width: 0;
5619
+ overflow: hidden;
5620
+ text-overflow: ellipsis;
5621
+ white-space: nowrap;
5622
+ text-align: left;
5623
+ }
5624
+ .fig-select-prepend {
5625
+ display: inline-flex;
5626
+ flex: 0 0 auto;
5627
+ align-items: center;
5628
+ margin-right: var(--spacer-1, 0.25rem);
5629
+ pointer-events: none;
5630
+ }
5631
+ .fig-select-prepend:empty {
5632
+ display: none;
5633
+ }
5634
+ /* Listbox chrome from document fig-select::part(listbox).
5635
+ Overflow UI lives on slotted fig-select-options.
5636
+ Never set display except when open — closed <dialog> must stay display:none. */
5637
+ dialog[is="fig-popup"] {
5638
+ flex-direction: column;
5639
+ overflow: hidden;
5640
+ }
5641
+ dialog[is="fig-popup"][open] {
5642
+ display: flex;
5643
+ }
5644
+ ::slotted(fig-select-options) {
5645
+ flex: 1 1 auto;
5646
+ min-height: 0;
5647
+ max-height: inherit;
5648
+ }
5649
+ </style>
5650
+ `;
5651
+
5652
+ const button = document.createElement("fig-button");
5653
+ button.className = "fig-select-trigger";
5654
+ button.setAttribute("part", "trigger");
5655
+ button.setAttribute("variant", "ghost");
5656
+ button.setAttribute("aria-haspopup", "listbox");
5657
+ button.setAttribute("aria-expanded", "false");
5658
+
5659
+ const prependEl = document.createElement("span");
5660
+ prependEl.className = "fig-select-prepend";
5661
+ prependEl.setAttribute("part", "prepend");
5662
+ prependEl.setAttribute("aria-hidden", "true");
5663
+
5664
+ const labelEl = document.createElement("span");
5665
+ labelEl.className = "fig-select-label";
5666
+ labelEl.setAttribute("part", "label");
5667
+ button.append(prependEl, labelEl);
5668
+
5669
+ const popup = document.createElement("dialog", { is: "fig-popup" });
5670
+ popup.setAttribute("is", "fig-popup");
5671
+ popup.setAttribute("part", "listbox");
5672
+ popup.setAttribute("theme", "menu");
5673
+ popup.setAttribute("role", "listbox");
5674
+ // Top-layer via popover so the menu escapes ancestor contain/overflow
5675
+ // (e.g. fig-fill-picker-dialog). Stays in shadow so option slots still work —
5676
+ // unlike tooltips, we cannot portal this popup to the overlay root.
5677
+ if ("popover" in HTMLElement.prototype) {
5678
+ popup.setAttribute("popover", "manual");
5679
+ }
5680
+ popup.id = figUniqueId();
5681
+ button.setAttribute("aria-controls", popup.id);
5682
+
5683
+ const panelSlot = document.createElement("slot");
5684
+ panelSlot.setAttribute("name", "panel");
5685
+ popup.appendChild(panelSlot);
5686
+
5687
+ shadow.append(button, popup);
5688
+
5689
+ this.#button = button;
5690
+ this.#prependEl = prependEl;
5691
+ this.#labelEl = labelEl;
5692
+ this.#popup = popup;
5693
+ this.#panelSlot = panelSlot;
5694
+ popup.anchor = button;
5695
+
5696
+ this.#ensurePanelSlotAttrs();
5697
+ this.#setupListeners();
5698
+ this.#installPopupPositioning();
5699
+
5700
+ if (!this.hasAttribute("value")) {
5701
+ const selected = this.#getOptions().find((opt) =>
5702
+ figBooleanAttribute(opt, "selected"),
5703
+ );
5704
+ if (selected) this.setAttribute("value", selected.value);
5705
+ }
5706
+ }
5707
+
5708
+ #installPopupPositioning() {
5709
+ if (!this.#popup || this.#popupPositionPatched) return;
5710
+ if (typeof this.#popup.positionPopup !== "function") return;
5711
+ this.#originalPositionPopup = this.#popup.positionPopup.bind(this.#popup);
5712
+ this.#popup.positionPopup = () => {
5713
+ if (!this.open) {
5714
+ this.#originalPositionPopup?.();
5715
+ return;
5716
+ }
5717
+ this.#positionPopupOverSelected();
5718
+ };
5719
+ this.#popupPositionPatched = true;
5720
+ }
5721
+
5722
+ #getOptionTextRect(option) {
5723
+ if (!option) return null;
5724
+ const range = document.createRange();
5725
+ range.selectNodeContents(option);
5726
+ const rects = [...range.getClientRects()].filter(
5727
+ (rect) => rect.width > 0 && rect.height > 0,
5728
+ );
5729
+ if (rects.length) return rects[0];
5730
+ return option.getBoundingClientRect();
5731
+ }
5732
+
5733
+ #getViewportMargins() {
5734
+ if (typeof this.#popup?.parseViewportMargins === "function") {
5735
+ return this.#popup.parseViewportMargins();
5736
+ }
5737
+ return { top: 8, right: 8, bottom: 8, left: 8 };
5738
+ }
5739
+
5740
+ #readLabelRectSnapshot() {
5741
+ const rect = this.#labelEl?.getBoundingClientRect();
5742
+ if (!rect) return null;
5743
+ return {
5744
+ x: rect.x,
5745
+ y: rect.y,
5746
+ width: rect.width,
5747
+ height: rect.height,
5748
+ };
5749
+ }
5750
+
5751
+ #readViewportSnapshot() {
5752
+ const vv = window.visualViewport;
5753
+ return {
5754
+ width: vv?.width ?? window.innerWidth,
5755
+ height: vv?.height ?? window.innerHeight,
5756
+ offsetLeft: vv?.offsetLeft ?? 0,
5757
+ offsetTop: vv?.offsetTop ?? 0,
5758
+ };
5759
+ }
5760
+
5761
+ #rectSnapshotChanged(prev, next, epsilon = 0.25) {
5762
+ if (!prev && !next) return false;
5763
+ if (!prev || !next) return true;
5764
+ return (
5765
+ Math.abs(prev.x - next.x) > epsilon ||
5766
+ Math.abs(prev.y - next.y) > epsilon ||
5767
+ Math.abs(prev.width - next.width) > epsilon ||
5768
+ Math.abs(prev.height - next.height) > epsilon
5769
+ );
5770
+ }
5771
+
5772
+ #viewportSnapshotChanged(prev, next, epsilon = 0.25) {
5773
+ if (!prev && !next) return false;
5774
+ if (!prev || !next) return true;
5775
+ return (
5776
+ Math.abs(prev.width - next.width) > epsilon ||
5777
+ Math.abs(prev.height - next.height) > epsilon ||
5778
+ Math.abs(prev.offsetLeft - next.offsetLeft) > epsilon ||
5779
+ Math.abs(prev.offsetTop - next.offsetTop) > epsilon
5780
+ );
5781
+ }
5782
+
5783
+ #shouldSkipFrozenPositionPass() {
5784
+ if (!this.#freezeMenuPosition) return false;
5785
+ const labelMoved = this.#rectSnapshotChanged(
5786
+ this.#frozenLabelRect,
5787
+ this.#readLabelRectSnapshot(),
5788
+ );
5789
+ const viewportChanged = this.#viewportSnapshotChanged(
5790
+ this.#frozenViewport,
5791
+ this.#readViewportSnapshot(),
5792
+ );
5793
+ // Skip only when neither the trigger nor the viewport moved — typical of
5794
+ // overflow scroll / content sync fighting the open-time alignment.
5795
+ return !labelMoved && !viewportChanged;
5796
+ }
5797
+
5798
+ #rememberFrozenGeometry() {
5799
+ this.#frozenLabelRect = this.#readLabelRectSnapshot();
5800
+ this.#frozenViewport = this.#readViewportSnapshot();
5801
+ }
5802
+
5803
+ #positionPopupOverSelected() {
5804
+ // Content ResizeObserver / overflow scroll re-enter here; keep the
5805
+ // open-time alignment unless the trigger or viewport actually changed.
5806
+ if (this.#shouldSkipFrozenPositionPass()) return;
5807
+
5808
+ const popup = this.#popup;
5809
+ const label = this.#labelEl;
5810
+ if (!popup || !label) {
5811
+ this.#originalPositionPopup?.();
5812
+ return;
5813
+ }
5814
+
5815
+ const options = this.#getOptions();
5816
+ const selected =
5817
+ options.find((opt) => this.#optionValue(opt) === this.value) ||
5818
+ options[0];
5819
+ if (!selected) {
5820
+ this.#originalPositionPopup?.();
5821
+ return;
5822
+ }
5823
+
5824
+ // Lay out with the default positioning first so option metrics are valid.
5825
+ this.#originalPositionPopup?.();
5826
+
5827
+ const popupRect = popup.getBoundingClientRect();
5828
+ const labelRect = label.getBoundingClientRect();
5829
+ const optionTextRect = this.#getOptionTextRect(selected);
5830
+ if (
5831
+ !popupRect.width ||
5832
+ !popupRect.height ||
5833
+ !labelRect.width ||
5834
+ !optionTextRect
5835
+ ) {
5836
+ return;
5837
+ }
5838
+
5839
+ const selectedOffsetX = optionTextRect.left - popupRect.left;
5840
+ const selectedOffsetY = optionTextRect.top - popupRect.top;
5841
+ const full = figBooleanAttribute(this, "full");
5842
+ // [full]: pin menu to host width/edges. Otherwise overlay selected
5843
+ // option text on the trigger label (blend-mode style).
5844
+ let left = full
5845
+ ? this.getBoundingClientRect().left
5846
+ : labelRect.left - selectedOffsetX;
5847
+ let top = labelRect.top - selectedOffsetY;
5848
+
5849
+ // Keep the whole menu in-view when aligning over the selected option
5850
+ // would otherwise push it past a viewport edge (corners / far sides).
5851
+ const margins = this.#getViewportMargins();
5852
+ if (typeof popup.clampToViewport === "function") {
5853
+ ({ left, top } = popup.clampToViewport({ left, top }, popupRect, margins));
5854
+ } else {
5855
+ const minLeft = margins.left;
5856
+ const minTop = margins.top;
5857
+ const maxLeft = window.innerWidth - popupRect.width - margins.right;
5858
+ const maxTop = window.innerHeight - popupRect.height - margins.bottom;
5859
+ left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft));
5860
+ top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop));
5861
+ }
5862
+
5863
+ // !important: fig-select::part(listbox) and dialog UA rules can otherwise
5864
+ // keep the menu at its static/anchor position past the viewport edge.
5865
+ popup.style.setProperty("right", "auto", "important");
5866
+ popup.style.setProperty("bottom", "auto", "important");
5867
+ popup.style.setProperty("left", `${Math.round(left)}px`, "important");
5868
+ popup.style.setProperty("top", `${Math.round(top)}px`, "important");
5869
+
5870
+ // Nudge the panel scroller so the selected label stays over the trigger.
5871
+ const panel = this.#getPanel();
5872
+ const alignedTextRect = this.#getOptionTextRect(selected);
5873
+ if (
5874
+ alignedTextRect &&
5875
+ panel &&
5876
+ panel.scrollHeight > panel.clientHeight + 1
5877
+ ) {
5878
+ const deltaY = alignedTextRect.top - labelRect.top;
5879
+ if (Math.abs(deltaY) > 0.5) {
5880
+ panel.scrollTop += deltaY;
5881
+ }
5882
+ panel.syncOverflow?.();
5883
+ }
5884
+
5885
+ if (this.#freezeMenuPosition || this.open) {
5886
+ this.#rememberFrozenGeometry();
5887
+ }
5888
+ }
5889
+
5890
+ #setupListeners() {
5891
+ this.#button?.addEventListener("click", this.#boundTriggerClick);
5892
+ this.#button?.addEventListener("keydown", this.#boundKeydown);
5893
+ // Host click: slotted options stay in light DOM (not dialog.contains).
5894
+ this.addEventListener("click", this.#boundOptionClick);
5895
+ this.#popup?.addEventListener("keydown", this.#boundKeydown);
5896
+ this.#popup?.addEventListener("close", this.#boundPopupClose);
5897
+ this.#panelSlot?.addEventListener("slotchange", this.#boundSlotChange);
5898
+ }
5899
+
5900
+ #teardownListeners() {
5901
+ this.#button?.removeEventListener("click", this.#boundTriggerClick);
5902
+ this.#button?.removeEventListener("keydown", this.#boundKeydown);
5903
+ this.removeEventListener("click", this.#boundOptionClick);
5904
+ this.#popup?.removeEventListener("keydown", this.#boundKeydown);
5905
+ this.#popup?.removeEventListener("close", this.#boundPopupClose);
5906
+ this.#panelSlot?.removeEventListener("slotchange", this.#boundSlotChange);
5907
+ }
5908
+
5909
+ #handleSlotChange() {
5910
+ this.#ensurePanelSlotAttrs();
5911
+ this.#syncValue();
5912
+ }
5913
+
5914
+ #setupObserver() {
5915
+ if (this.#observer) return;
5916
+ this.#observer = new MutationObserver((mutations) => {
5917
+ if (this.#syncingValue || this.#syncingOptions) return;
5918
+ let needsSync = false;
5919
+ for (const mutation of mutations) {
5920
+ if (mutation.type === "childList") {
5921
+ if (
5922
+ [...mutation.addedNodes].some((node) => this.#isMenuChild(node)) ||
5923
+ [...mutation.removedNodes].some((node) => this.#isMenuChild(node)) ||
5924
+ mutation.target?.closest?.("fig-select-option")
5925
+ ) {
5926
+ needsSync = true;
5927
+ }
5928
+ }
5929
+ if (
5930
+ mutation.type === "attributes" &&
5931
+ mutation.target?.tagName === "FIG-SELECT-OPTION" &&
5932
+ (mutation.attributeName === "value" ||
5933
+ mutation.attributeName === "disabled" ||
5934
+ mutation.attributeName === "label")
5935
+ ) {
5936
+ needsSync = true;
5937
+ }
5938
+ if (
5939
+ mutation.type === "characterData" &&
5940
+ mutation.target?.parentElement?.tagName === "FIG-SELECT-OPTION"
5941
+ ) {
5942
+ needsSync = true;
5943
+ }
5944
+ }
5945
+ if (needsSync) this.#syncValue();
5946
+ });
5947
+ this.#observer.observe(this, {
5948
+ childList: true,
5949
+ subtree: true,
5950
+ characterData: true,
5951
+ attributes: true,
5952
+ attributeFilter: ["value", "disabled", "selected", "label"],
5953
+ });
5954
+ }
5955
+
5956
+ #getOptions({ enabledOnly = false } = {}) {
5957
+ const panel = this.#getPanel();
5958
+ const options = panel
5959
+ ? Array.from(panel.querySelectorAll(":scope > fig-select-option"))
5960
+ : [];
5961
+ if (!enabledOnly) return options;
5962
+ return options.filter((opt) => !figBooleanAttribute(opt, "disabled"));
5963
+ }
5964
+
5965
+ #optionValue(option) {
5966
+ if (!option) return "";
5967
+ if (typeof option.value === "string") return option.value;
5968
+ const attr = option.getAttribute?.("value");
5969
+ if (attr != null) return attr;
5970
+ return (option.textContent || "").trim();
5971
+ }
5972
+
5973
+ #optionLabel(option) {
5974
+ if (!option) return "";
5975
+ const labelAttr = option.getAttribute?.("label");
5976
+ if (labelAttr != null && labelAttr !== "") return labelAttr.trim();
5977
+
5978
+ // Ignore prepend/append slot content when deriving a label from children.
5979
+ const parts = [];
5980
+ for (const node of option.childNodes) {
5981
+ if (node.nodeType === Node.TEXT_NODE) {
5982
+ const text = node.textContent?.trim();
5983
+ if (text) parts.push(text);
5984
+ continue;
5985
+ }
5986
+ if (!(node instanceof Element)) continue;
5987
+ const slot = node.getAttribute("slot");
5988
+ if (slot === "prepend" || slot === "append") continue;
5989
+ const text = node.textContent?.trim();
5990
+ if (text) parts.push(text);
5991
+ }
5992
+ if (parts.length) return parts.join(" ").trim();
5993
+ return (option.textContent || "").trim();
5994
+ }
5995
+
5996
+ #syncPrepend(option) {
5997
+ if (!this.#prependEl) return;
5998
+ const source = option?.querySelector?.(':scope > [slot="prepend"]');
5999
+ this.#prependEl.replaceChildren(
6000
+ ...Array.from(source?.childNodes ?? [], (node) => node.cloneNode(true)),
6001
+ );
6002
+ }
6003
+
6004
+ #syncPopupAttrs() {
6005
+ if (!this.#popup) return;
6006
+ this.#popup.setAttribute(
6007
+ "position",
6008
+ this.getAttribute("position") || "bottom left",
6009
+ );
6010
+ const offset = this.getAttribute("offset");
6011
+ if (offset) this.#popup.setAttribute("offset", offset);
6012
+ else this.#popup.removeAttribute("offset");
6013
+ const closedby = this.getAttribute("closedby");
6014
+ if (closedby) this.#popup.setAttribute("closedby", closedby);
6015
+ else this.#popup.removeAttribute("closedby");
6016
+ }
6017
+
6018
+ #syncDisabled() {
6019
+ const disabled = figBooleanAttribute(this, "disabled");
6020
+ if (this.#button) {
6021
+ if (disabled) this.#button.setAttribute("disabled", "");
6022
+ else this.#button.removeAttribute("disabled");
6023
+ }
6024
+ if (disabled && this.open) this.open = false;
6025
+ }
6026
+
6027
+ #pickFallbackOption(options) {
6028
+ if (!options.length) return null;
6029
+ const selected = options.find((opt) =>
6030
+ figBooleanAttribute(opt, "selected"),
6031
+ );
6032
+ if (selected && !figBooleanAttribute(selected, "disabled")) {
6033
+ return selected;
6034
+ }
6035
+ return (
6036
+ options.find((opt) => !figBooleanAttribute(opt, "disabled")) ||
6037
+ options[0] ||
6038
+ null
6039
+ );
6040
+ }
6041
+
6042
+ #emitValueEvents(value) {
6043
+ this.dispatchEvent(
6044
+ new CustomEvent("input", {
6045
+ detail: value,
6046
+ bubbles: true,
6047
+ composed: true,
6048
+ }),
6049
+ );
6050
+ this.dispatchEvent(
6051
+ new CustomEvent("change", {
6052
+ detail: value,
6053
+ bubbles: true,
6054
+ composed: true,
6055
+ }),
6056
+ );
6057
+ }
6058
+
6059
+ #syncValue() {
6060
+ if (this.#syncingValue) return;
6061
+ this.#syncingValue = true;
6062
+ try {
6063
+ const options = this.#getOptions();
6064
+ const hasValueAttr = this.hasAttribute("value");
6065
+ const previousValue = hasValueAttr ? this.getAttribute("value") : null;
6066
+ let match = hasValueAttr
6067
+ ? options.find((opt) => this.#optionValue(opt) === previousValue)
6068
+ : null;
6069
+ let valueCorrected = false;
6070
+
6071
+ if (!match) {
6072
+ if (hasValueAttr) {
6073
+ // Options may not be built yet (options attr sync). Keep value until then.
6074
+ if (!options.length) {
6075
+ if (this.#labelEl) {
6076
+ this.#labelEl.textContent =
6077
+ previousValue || this.getAttribute("label") || "";
6078
+ }
6079
+ return;
6080
+ }
6081
+ // Value orphaned (option removed / value attr changed) — clamp or clear.
6082
+ match = this.#pickFallbackOption(options);
6083
+ if (match) {
6084
+ const nextValue = this.#optionValue(match);
6085
+ if (previousValue !== nextValue) {
6086
+ this.setAttribute("value", nextValue);
6087
+ valueCorrected = true;
6088
+ }
6089
+ } else {
6090
+ this.removeAttribute("value");
6091
+ valueCorrected = true;
6092
+ }
6093
+ } else {
6094
+ // No host value yet — honor a selected option if present.
6095
+ match = options.find((opt) =>
6096
+ figBooleanAttribute(opt, "selected"),
6097
+ );
6098
+ if (match) {
6099
+ this.setAttribute("value", this.#optionValue(match));
6100
+ valueCorrected = true;
6101
+ }
6102
+ }
6103
+ }
6104
+
6105
+ for (const opt of options) {
6106
+ const selected = opt === match;
6107
+ opt.setAttribute("aria-selected", selected ? "true" : "false");
6108
+ if (selected) opt.setAttribute("selected", "");
6109
+ else opt.removeAttribute("selected");
6110
+ }
6111
+
6112
+ const label =
6113
+ (match && this.#optionLabel(match)) || this.getAttribute("label") || "";
6114
+ if (this.#labelEl) this.#labelEl.textContent = label;
6115
+ this.#syncPrepend(match);
6116
+
6117
+ const ariaLabel = this.getAttribute("label") || "Select";
6118
+ this.#button?.setAttribute("aria-label", ariaLabel);
6119
+
6120
+ // Don't scrollToOption while open — reposition/sync would fight overflow paging.
6121
+ this.#getPanel()?.syncOverflow?.();
6122
+
6123
+ if (valueCorrected) {
6124
+ this.#emitValueEvents(this.getAttribute("value") ?? "");
6125
+ }
6126
+ } finally {
6127
+ this.#syncingValue = false;
6128
+ }
6129
+ }
6130
+
6131
+ #handleTriggerClick(e) {
6132
+ if (figBooleanAttribute(this, "disabled")) return;
6133
+ e.preventDefault();
6134
+ e.stopPropagation();
6135
+ const nextOpen = !this.open;
6136
+ if (nextOpen && this.#popup && this.#button) {
6137
+ this.#popup.anchor = this.#button;
6138
+ }
6139
+ this.open = nextOpen;
6140
+ }
6141
+
6142
+ #handleOptionClick(e) {
6143
+ const path = typeof e.composedPath === "function" ? e.composedPath() : [];
6144
+ const option = path.find(
6145
+ (node) => node?.tagName === "FIG-SELECT-OPTION",
6146
+ );
6147
+ if (!option || !this.contains(option)) return;
6148
+ if (figBooleanAttribute(option, "disabled")) return;
6149
+ // Do not stopPropagation — React light-DOM onClick must still fire.
6150
+ this.#selectOption(option);
5035
6151
  }
5036
6152
 
5037
- #rebuildCurrentControl() {
5038
- if (this.#currentMode === "segments") {
5039
- this.#renderSegments();
5040
- requestAnimationFrame(() => {
5041
- requestAnimationFrame(() => this.#checkOverflow());
5042
- });
5043
- } else {
5044
- this.#renderDropdown();
6153
+ #handleKeydown(e) {
6154
+ if (e.currentTarget === document && e.key !== "Escape") return;
6155
+
6156
+ const listOpen = this.open && (this.#popup?.matches?.(":open") ?? false);
6157
+ if (!listOpen) {
6158
+ if (
6159
+ this.#button?.contains(e.target) &&
6160
+ (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ")
6161
+ ) {
6162
+ e.preventDefault();
6163
+ if (this.#popup && this.#button) this.#popup.anchor = this.#button;
6164
+ this.open = true;
6165
+ requestAnimationFrame(() => {
6166
+ const options = this.#getOptions({ enabledOnly: true });
6167
+ const selectedIndex = options.findIndex(
6168
+ (opt) => this.#optionValue(opt) === this.value,
6169
+ );
6170
+ this.#focusOptionAt(selectedIndex >= 0 ? selectedIndex : 0);
6171
+ });
6172
+ }
6173
+ return;
5045
6174
  }
5046
- }
5047
6175
 
5048
- #syncValueToChild() {
5049
- if (!this.#childControl || this.#suppressEvents) return;
5050
- const val = this.getAttribute("value") || "";
5051
- this.#childControl.value = val;
5052
- }
6176
+ const options = this.#getOptions({ enabledOnly: true });
6177
+ if (!options.length) return;
5053
6178
 
5054
- #syncAttrToChild(attr) {
5055
- if (!this.#childControl) return;
5056
- if (this.hasAttribute(attr)) {
5057
- this.#childControl.setAttribute(attr, this.getAttribute(attr) || "");
5058
- } else {
5059
- this.#childControl.removeAttribute(attr);
6179
+ switch (e.key) {
6180
+ case "ArrowDown":
6181
+ e.preventDefault();
6182
+ this.#syncFocusedIndex();
6183
+ this.#focusOptionAt(this.#focusedIndex + 1);
6184
+ break;
6185
+ case "ArrowUp":
6186
+ e.preventDefault();
6187
+ this.#syncFocusedIndex();
6188
+ this.#focusOptionAt(this.#focusedIndex - 1);
6189
+ break;
6190
+ case "Home":
6191
+ e.preventDefault();
6192
+ this.#focusOptionAt(0);
6193
+ break;
6194
+ case "End":
6195
+ e.preventDefault();
6196
+ this.#focusOptionAt(options.length - 1);
6197
+ break;
6198
+ case "Escape":
6199
+ e.preventDefault();
6200
+ this.open = false;
6201
+ this.#button?.focus();
6202
+ break;
6203
+ case "Enter":
6204
+ case " ": {
6205
+ this.#syncFocusedIndex();
6206
+ const focused = options[this.#focusedIndex];
6207
+ if (!focused) return;
6208
+ e.preventDefault();
6209
+ this.#selectOption(focused);
6210
+ break;
6211
+ }
5060
6212
  }
5061
6213
  }
5062
6214
 
5063
- #startResizeObserver() {
5064
- this.#resizeObserver?.disconnect();
5065
- this.#resizeObserver = new ResizeObserver(() => {
5066
- this.#checkOverflow();
5067
- });
5068
- this.#resizeObserver.observe(this);
6215
+ #handlePopupClose() {
6216
+ if (this.hasAttribute("open")) this.removeAttribute("open");
6217
+ this.#button?.setAttribute("aria-expanded", "false");
6218
+ this.#button?.focus();
6219
+ this.#focusedIndex = -1;
5069
6220
  }
5070
6221
 
5071
- #isSegmentTruncated(seg) {
5072
- const range = document.createRange();
5073
- range.selectNodeContents(seg);
5074
- const textWidth = range.getBoundingClientRect().width;
5075
- const segRect = seg.getBoundingClientRect();
5076
- const segWidth = segRect.width;
5077
- const cs = getComputedStyle(seg);
5078
- const padL = parseFloat(cs.paddingLeft) || 0;
5079
- const padR = parseFloat(cs.paddingRight) || 0;
5080
- const contentWidth = segWidth - padL - padR;
5081
- return textWidth > contentWidth + 0.5;
6222
+ #selectOption(option) {
6223
+ const value = this.#optionValue(option);
6224
+ this.setAttribute("value", value);
6225
+ this.#syncValue();
6226
+ this.#emitValueEvents(value);
6227
+ this.open = false;
5082
6228
  }
5083
6229
 
5084
- #anySegmentTruncated() {
5085
- const segments = this.querySelectorAll("fig-segment");
5086
- for (const seg of segments) {
5087
- if (this.#isSegmentTruncated(seg)) return true;
5088
- }
5089
- return false;
6230
+ #getEnabledOptions() {
6231
+ return this.#getOptions({ enabledOnly: true });
5090
6232
  }
5091
6233
 
5092
- #checkOverflow() {
5093
- if (this.#parsedOptions.length <= 1) return;
6234
+ #syncFocusedIndex() {
6235
+ const options = this.#getEnabledOptions();
6236
+ if (!options.length) {
6237
+ this.#focusedIndex = -1;
6238
+ return;
6239
+ }
6240
+ const active = options.find((opt) => opt === document.activeElement);
6241
+ const index = active ? options.indexOf(active) : -1;
6242
+ this.#focusedIndex = index >= 0 ? index : this.#focusedIndex;
6243
+ }
6244
+
6245
+ #focusOptionAt(index) {
6246
+ const options = this.#getEnabledOptions();
6247
+ if (!options.length) return;
6248
+ const next = ((index % options.length) + options.length) % options.length;
6249
+ this.#focusedIndex = next;
6250
+ options[next]?.focus();
6251
+ }
6252
+
6253
+ #syncPopupWidth() {
6254
+ if (!this.#popup || !this.#button) return;
6255
+ const hostWidth = Math.ceil(this.getBoundingClientRect().width);
6256
+ const triggerWidth = Math.ceil(this.#button.getBoundingClientRect().width);
6257
+ const anchorWidth = Math.max(hostWidth, triggerWidth, 96);
6258
+
6259
+ // Use !important — fig-select::part(listbox) width rules beat element.style.
6260
+ // Menus size to their options while remaining at least as wide as the trigger.
6261
+ this.#popup.style.setProperty("width", "max-content", "important");
6262
+ this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
6263
+ this.#popup.style.setProperty(
6264
+ "max-width",
6265
+ "min(20rem, calc(100vw - 1rem))",
6266
+ "important",
6267
+ );
6268
+ }
5094
6269
 
5095
- if (this.#currentMode === "segments") {
5096
- const sc = this.#childControl;
5097
- const containerOverflow = sc && sc.scrollWidth > sc.clientWidth + 1;
5098
- if (containerOverflow || this.#anySegmentTruncated()) {
5099
- this.#naturalWidth = this.clientWidth;
5100
- this.#renderDropdown();
5101
- }
5102
- } else {
5103
- if (this.#naturalWidth > 0 && this.clientWidth >= this.#naturalWidth) {
5104
- this.#renderSegments();
5105
- requestAnimationFrame(() => {
5106
- requestAnimationFrame(() => {
5107
- const sc = this.#childControl;
5108
- const containerOverflow = sc && sc.scrollWidth > sc.clientWidth + 1;
5109
- if (containerOverflow || this.#anySegmentTruncated()) {
5110
- this.#renderDropdown();
5111
- }
5112
- });
5113
- });
6270
+ #openList() {
6271
+ if (!this.#popup || figBooleanAttribute(this, "disabled")) return;
6272
+ if (this.#button) this.#popup.anchor = this.#button;
6273
+ this.#installPopupPositioning();
6274
+ this.#freezeMenuPosition = false;
6275
+ this.#frozenLabelRect = null;
6276
+ this.#frozenViewport = null;
6277
+ this.#syncValue();
6278
+ this.#syncPopupWidth();
6279
+ this.#popup.open = true;
6280
+ document.addEventListener("keydown", this.#boundKeydown, true);
6281
+ this.#button?.setAttribute("aria-expanded", "true");
6282
+ this.#focusedIndex = -1;
6283
+ requestAnimationFrame(() => {
6284
+ this.#syncPopupWidth();
6285
+ this.#positionPopupOverSelected();
6286
+ const panel = this.#getPanel();
6287
+ const options = this.#getEnabledOptions();
6288
+ const selectedIndex = options.findIndex(
6289
+ (opt) => this.#optionValue(opt) === this.value,
6290
+ );
6291
+ if (selectedIndex >= 0) {
6292
+ this.#focusOptionAt(selectedIndex);
6293
+ } else if (
6294
+ this.#button?.hasAttribute("data-focus-visible") ||
6295
+ this.#button?.matches?.(":focus-visible")
6296
+ ) {
6297
+ this.#focusOptionAt(0);
5114
6298
  }
5115
- }
6299
+ panel?.syncOverflow?.();
6300
+ // Freeze after open align so later positionPopup passes don't undo scroll.
6301
+ // Window resize / trigger movement still realigns via geometry checks.
6302
+ this.#freezeMenuPosition = true;
6303
+ this.#rememberFrozenGeometry();
6304
+ });
6305
+ }
6306
+
6307
+ #closeList() {
6308
+ if (!this.#popup) return;
6309
+ this.#freezeMenuPosition = false;
6310
+ this.#frozenLabelRect = null;
6311
+ this.#frozenViewport = null;
6312
+ document.removeEventListener("keydown", this.#boundKeydown, true);
6313
+ this.#popup.open = false;
6314
+ this.#button?.setAttribute("aria-expanded", "false");
5116
6315
  }
5117
6316
  }
5118
- customElements.define("fig-options", FigOptions);
6317
+ figDefineElement("fig-select", FigSelect);
5119
6318
 
5120
6319
  /* Slider */
5121
6320
  /**
@@ -5800,7 +6999,7 @@ class FigSlider extends HTMLElement {
5800
6999
  }
5801
7000
  }
5802
7001
  }
5803
- customElements.define("fig-slider", FigSlider);
7002
+ figDefineElement("fig-slider", FigSlider);
5804
7003
 
5805
7004
  /**
5806
7005
  * A custom text input element.
@@ -5817,6 +7016,8 @@ customElements.define("fig-slider", FigSlider);
5817
7016
  class FigInputText extends HTMLElement {
5818
7017
  #isInteracting = false;
5819
7018
  #passwordVisible = false;
7019
+ #value = "";
7020
+ #reflectingValue = false;
5820
7021
  #boundMouseMove;
5821
7022
  #boundMouseUp;
5822
7023
  #boundWindowBlur;
@@ -5860,7 +7061,9 @@ class FigInputText extends HTMLElement {
5860
7061
  new CustomEvent("input", { detail: this.value, bubbles: true }),
5861
7062
  );
5862
7063
  };
5863
- this.#boundFocusControl = this.focus.bind(this);
7064
+ this.#boundFocusControl = () => {
7065
+ if (!this.disabled) this.focus();
7066
+ };
5864
7067
  this.#boundAdornmentClick = this.#handleAdornmentClick.bind(this);
5865
7068
  }
5866
7069
 
@@ -5901,6 +7104,7 @@ class FigInputText extends HTMLElement {
5901
7104
  this.#syncSearchClear();
5902
7105
  this.#syncSearchClearVisibility();
5903
7106
  this.#syncPasswordToggle();
7107
+ this.#syncGeneratedAdornmentDisabled();
5904
7108
  figNormalizeTextOnlyInputSlots(this);
5905
7109
  this.#startObserver();
5906
7110
 
@@ -5976,6 +7180,7 @@ class FigInputText extends HTMLElement {
5976
7180
  #handleAdornmentClick(event) {
5977
7181
  const adornment = event.target?.closest?.("[slot]");
5978
7182
  if (!adornment || adornment.parentElement !== this) return;
7183
+ if (this.disabled) return;
5979
7184
  this.focus();
5980
7185
  }
5981
7186
  #startObserver() {
@@ -6007,6 +7212,7 @@ class FigInputText extends HTMLElement {
6007
7212
  this.#syncSearchClear();
6008
7213
  this.#syncSearchClearVisibility();
6009
7214
  this.#syncPasswordToggle();
7215
+ this.#syncGeneratedAdornmentDisabled();
6010
7216
  figNormalizeTextOnlyInputSlots(this);
6011
7217
  }
6012
7218
  #syncInputA11yAttributes() {
@@ -6091,6 +7297,7 @@ class FigInputText extends HTMLElement {
6091
7297
  button.addEventListener("click", (e) => {
6092
7298
  e.preventDefault();
6093
7299
  e.stopPropagation();
7300
+ if (this.disabled) return;
6094
7301
  if (!this.input || this.input.value === "") {
6095
7302
  this.focus();
6096
7303
  return;
@@ -6149,6 +7356,7 @@ class FigInputText extends HTMLElement {
6149
7356
  button.addEventListener("click", (e) => {
6150
7357
  e.preventDefault();
6151
7358
  e.stopPropagation();
7359
+ if (this.disabled) return;
6152
7360
  this.#passwordVisible = !this.#passwordVisible;
6153
7361
  if (this.input) {
6154
7362
  this.input.type = this.#passwordVisible ? "text" : "password";
@@ -6166,6 +7374,14 @@ class FigInputText extends HTMLElement {
6166
7374
  button?.setAttribute("aria-label", label);
6167
7375
  icon?.setAttribute("name", this.#passwordVisible ? "visible" : "hidden");
6168
7376
  }
7377
+ #syncGeneratedAdornmentDisabled() {
7378
+ this.querySelectorAll(
7379
+ '[data-generated="search-clear"] fig-button, [data-generated="password-toggle"] fig-button',
7380
+ ).forEach((button) => {
7381
+ if (this.disabled) button.setAttribute("disabled", "");
7382
+ else button.removeAttribute("disabled");
7383
+ });
7384
+ }
6169
7385
  #transformNumber(value) {
6170
7386
  if (value === "") return "";
6171
7387
  let transformed = Number(value) * (this.transform || 1);
@@ -6269,15 +7485,29 @@ class FigInputText extends HTMLElement {
6269
7485
  return Number.isInteger(rounded) ? rounded : rounded.toFixed(precision);
6270
7486
  }
6271
7487
 
6272
- /*
6273
7488
  get value() {
6274
- return this.value;
7489
+ return this.#value;
6275
7490
  }
6276
7491
 
6277
7492
  set value(val) {
6278
- this.value = val;
6279
- this.setAttribute("value", val);
6280
- }*/
7493
+ const value = val ?? "";
7494
+ this.#value = value;
7495
+ const reflected = String(value);
7496
+ if (this.getAttribute("value") !== reflected) {
7497
+ this.#reflectingValue = true;
7498
+ this.setAttribute("value", reflected);
7499
+ this.#reflectingValue = false;
7500
+ }
7501
+ this.#syncRenderedValue(value);
7502
+ }
7503
+
7504
+ #syncRenderedValue(value) {
7505
+ if (!this.input || this.#isInteracting) return;
7506
+ const rendered =
7507
+ this.type === "number" ? String(this.#transformNumber(value)) : String(value ?? "");
7508
+ if (this.input.value !== rendered) this.input.value = rendered;
7509
+ this.#syncSearchClearVisibility();
7510
+ }
6281
7511
 
6282
7512
  static get observedAttributes() {
6283
7513
  return [
@@ -6307,6 +7537,7 @@ class FigInputText extends HTMLElement {
6307
7537
  case "disabled":
6308
7538
  this.disabled = this.input.disabled =
6309
7539
  newValue !== null && newValue !== "false";
7540
+ this.#syncGeneratedAdornmentDisabled();
6310
7541
  break;
6311
7542
  case "readonly":
6312
7543
  this.readonly = newValue !== null && newValue !== "false";
@@ -6323,13 +7554,9 @@ class FigInputText extends HTMLElement {
6323
7554
  let value = newValue;
6324
7555
  if (this.type === "number") {
6325
7556
  value = this.#sanitizeInput(value, false);
6326
- this.value = value;
6327
- this.input.value = this.#transformNumber(value);
6328
- } else {
6329
- this.value = value;
6330
- this.input.value = value;
6331
7557
  }
6332
- this.#syncSearchClearVisibility();
7558
+ this.#value = value ?? "";
7559
+ if (!this.#reflectingValue) this.#syncRenderedValue(this.#value);
6333
7560
  break;
6334
7561
  case "min":
6335
7562
  case "max":
@@ -6372,6 +7599,7 @@ class FigInputText extends HTMLElement {
6372
7599
  this.#syncSearchClear();
6373
7600
  this.#syncSearchClearVisibility();
6374
7601
  this.#syncPasswordToggle();
7602
+ this.#syncGeneratedAdornmentDisabled();
6375
7603
  break;
6376
7604
  case "multiline": {
6377
7605
  const next = newValue !== null && newValue !== "false";
@@ -6404,7 +7632,7 @@ class FigInputText extends HTMLElement {
6404
7632
  }
6405
7633
  }
6406
7634
  }
6407
- customElements.define("fig-input-text", FigInputText);
7635
+ figDefineElement("fig-input-text", FigInputText);
6408
7636
 
6409
7637
  /**
6410
7638
  * A custom numeric input element that uses type="text" with inputmode="decimal".
@@ -7109,7 +8337,7 @@ class FigInputNumber extends HTMLElement {
7109
8337
  }
7110
8338
  }
7111
8339
  }
7112
- customElements.define("fig-input-number", FigInputNumber);
8340
+ figDefineElement("fig-input-number", FigInputNumber);
7113
8341
 
7114
8342
  /* Avatar */
7115
8343
  class FigAvatar extends HTMLElement {
@@ -7161,7 +8389,7 @@ class FigAvatar extends HTMLElement {
7161
8389
  }
7162
8390
  }
7163
8391
  }
7164
- customElements.define("fig-avatar", FigAvatar);
8392
+ figDefineElement("fig-avatar", FigAvatar);
7165
8393
 
7166
8394
  /* Form Field */
7167
8395
  class FigField extends HTMLElement {
@@ -7360,7 +8588,7 @@ class FigField extends HTMLElement {
7360
8588
  }
7361
8589
  }
7362
8590
  }
7363
- customElements.define("fig-field", FigField);
8591
+ figDefineElement("fig-field", FigField);
7364
8592
 
7365
8593
  /* Color swatch */
7366
8594
  class FigInputColor extends HTMLElement {
@@ -7399,10 +8627,12 @@ class FigInputColor extends HTMLElement {
7399
8627
 
7400
8628
  #fillPickerAttrs() {
7401
8629
  const attrs = {};
7402
- const experimental = this.getAttribute("experimental");
7403
- if (experimental) attrs["experimental"] = experimental;
7404
8630
  for (const { name, value } of this.attributes) {
7405
- if (name.startsWith("picker-") && name !== "picker-anchor") {
8631
+ if (
8632
+ name.startsWith("picker-") &&
8633
+ name !== "picker-anchor" &&
8634
+ name !== "picker-experimental"
8635
+ ) {
7406
8636
  attrs[name.slice(7)] = value;
7407
8637
  }
7408
8638
  }
@@ -7609,8 +8839,8 @@ class FigInputColor extends HTMLElement {
7609
8839
 
7610
8840
  const picker = document.createElement("fig-fill-picker");
7611
8841
  picker.innerHTML = "<span hidden></span>";
7612
- picker.addEventListener("input", this.#handleFillPickerInput.bind(this));
7613
- picker.addEventListener("change", this.#handleChange.bind(this));
8842
+ picker.addEventListener("input", this.#boundFillPickerInput);
8843
+ picker.addEventListener("change", this.#boundChange);
7614
8844
  this.appendChild(picker);
7615
8845
  this.#fillPicker = picker;
7616
8846
  this.#syncFillPicker();
@@ -7675,8 +8905,22 @@ class FigInputColor extends HTMLElement {
7675
8905
  }
7676
8906
 
7677
8907
  #setValues(hexValue) {
7678
- const colorValue = hexValue || "#D9D9D9";
7679
- this.rgba = this.convertToRGBA(colorValue);
8908
+ let colorValue =
8909
+ typeof hexValue === "string" && hexValue.trim()
8910
+ ? hexValue.trim()
8911
+ : "#D9D9D9";
8912
+ let rgba = this.convertToRGBA(colorValue);
8913
+ if (
8914
+ !rgba ||
8915
+ !Number.isFinite(rgba.r) ||
8916
+ !Number.isFinite(rgba.g) ||
8917
+ !Number.isFinite(rgba.b) ||
8918
+ !Number.isFinite(rgba.a)
8919
+ ) {
8920
+ colorValue = "#D9D9D9";
8921
+ rgba = { r: 217, g: 217, b: 217, a: 1 };
8922
+ }
8923
+ this.rgba = rgba;
7680
8924
  this.value = this.rgbAlphaToHex(
7681
8925
  {
7682
8926
  r: isNaN(this.rgba.r) ? 0 : this.rgba.r,
@@ -7867,7 +9111,6 @@ class FigInputColor extends HTMLElement {
7867
9111
  "value",
7868
9112
  "style",
7869
9113
  "mode",
7870
- "experimental",
7871
9114
  "alpha",
7872
9115
  "text",
7873
9116
  "disabled",
@@ -7985,6 +9228,7 @@ class FigInputColor extends HTMLElement {
7985
9228
  }
7986
9229
 
7987
9230
  convertToRGBA(color) {
9231
+ if (typeof color !== "string") return null;
7988
9232
  let r,
7989
9233
  g,
7990
9234
  b,
@@ -8056,7 +9300,7 @@ class FigInputColor extends HTMLElement {
8056
9300
  return { r, g, b, a };
8057
9301
  }
8058
9302
  }
8059
- customElements.define("fig-input-color", FigInputColor);
9303
+ figDefineElement("fig-input-color", FigInputColor);
8060
9304
 
8061
9305
  /* Input Fill */
8062
9306
  const GRADIENT_INTERPOLATION_SPACES = [
@@ -8409,7 +9653,6 @@ class FigInputFill extends HTMLElement {
8409
9653
  "value",
8410
9654
  "disabled",
8411
9655
  "mode",
8412
- "experimental",
8413
9656
  "alpha",
8414
9657
  "aria-label",
8415
9658
  "aria-describedby",
@@ -8493,13 +9736,15 @@ class FigInputFill extends HTMLElement {
8493
9736
  // Backward-compat: direct attributes forwarded to fill picker
8494
9737
  const mode = this.getAttribute("mode");
8495
9738
  if (mode) attrs["mode"] = mode;
8496
- const experimental = this.getAttribute("experimental");
8497
- if (experimental) attrs["experimental"] = experimental;
8498
9739
  const alpha = this.getAttribute("alpha");
8499
9740
  if (alpha) attrs["alpha"] = alpha;
8500
9741
  // picker-* overrides (except anchor, handled programmatically)
8501
9742
  for (const { name, value } of this.attributes) {
8502
- if (name.startsWith("picker-") && name !== "picker-anchor") {
9743
+ if (
9744
+ name.startsWith("picker-") &&
9745
+ name !== "picker-anchor" &&
9746
+ name !== "picker-experimental"
9747
+ ) {
8503
9748
  attrs[name.slice(7)] = value;
8504
9749
  }
8505
9750
  }
@@ -8735,7 +9980,6 @@ class FigInputFill extends HTMLElement {
8735
9980
  if (detail.video) this.#video = detail.video;
8736
9981
  break;
8737
9982
  }
8738
-
8739
9983
  // Update controls (don't re-render to keep dialog open)
8740
9984
  if (typeChanged) {
8741
9985
  this.#updateControlsForType();
@@ -9135,7 +10379,6 @@ class FigInputFill extends HTMLElement {
9135
10379
  this.#syncDisabled();
9136
10380
  break;
9137
10381
  case "mode":
9138
- case "experimental":
9139
10382
  // Pass through to internal fill picker
9140
10383
  if (this.#fillPicker) {
9141
10384
  if (newValue) {
@@ -9157,7 +10400,7 @@ class FigInputFill extends HTMLElement {
9157
10400
  }
9158
10401
  }
9159
10402
  }
9160
- customElements.define("fig-input-fill", FigInputFill);
10403
+ figDefineElement("fig-input-fill", FigInputFill);
9161
10404
 
9162
10405
  /* Input Palette */
9163
10406
  /**
@@ -9177,6 +10420,7 @@ class FigInputPalette extends HTMLElement {
9177
10420
  #expandedPickers = [];
9178
10421
  #renderRAF = null;
9179
10422
  #boundHandleKeyDown = this.#handleKeyDown.bind(this);
10423
+ #boundHandleHostFocus = () => this.focus();
9180
10424
 
9181
10425
  static get observedAttributes() {
9182
10426
  return ["value", "disabled", "min", "max", "open", "fixed"];
@@ -9220,7 +10464,9 @@ class FigInputPalette extends HTMLElement {
9220
10464
  }
9221
10465
 
9222
10466
  connectedCallback() {
9223
- if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "0");
10467
+ this.setAttribute("tabindex", "-1");
10468
+ this.removeEventListener("focus", this.#boundHandleHostFocus);
10469
+ this.addEventListener("focus", this.#boundHandleHostFocus);
9224
10470
  this.removeEventListener("keydown", this.#boundHandleKeyDown);
9225
10471
  this.addEventListener("keydown", this.#boundHandleKeyDown);
9226
10472
  if (this.#renderRAF) cancelAnimationFrame(this.#renderRAF);
@@ -9238,13 +10484,14 @@ class FigInputPalette extends HTMLElement {
9238
10484
  this.#renderRAF = null;
9239
10485
  }
9240
10486
  this.removeEventListener("keydown", this.#boundHandleKeyDown);
10487
+ this.removeEventListener("focus", this.#boundHandleHostFocus);
9241
10488
  this.#inlinePickers = [];
9242
10489
  this.#expandedPickers = [];
9243
10490
  }
9244
10491
 
9245
10492
  #handleKeyDown(event) {
9246
10493
  if (event.key !== "Enter" && event.key !== " ") return;
9247
- if (event.target !== this && !event.target?.closest?.(".palette-colors-inline")) return;
10494
+ if (event.target !== this.querySelector(".palette-colors-inline")) return;
9248
10495
  if (this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") return;
9249
10496
  event.preventDefault();
9250
10497
  event.stopPropagation();
@@ -9269,7 +10516,7 @@ class FigInputPalette extends HTMLElement {
9269
10516
  this.#render();
9270
10517
  break;
9271
10518
  case "open":
9272
- // CSS handles visibility; no re-render needed
10519
+ this.#syncTriggerState();
9273
10520
  break;
9274
10521
  }
9275
10522
  }
@@ -9365,8 +10612,13 @@ class FigInputPalette extends HTMLElement {
9365
10612
  const inlineWrap = document.createElement("div");
9366
10613
  inlineWrap.className = "palette-colors-inline";
9367
10614
  inlineWrap.setAttribute("role", "button");
10615
+ inlineWrap.setAttribute("tabindex", disabled ? "-1" : "+0");
9368
10616
  inlineWrap.setAttribute("aria-expanded", String(this.open));
9369
10617
  inlineWrap.setAttribute("aria-label", "Edit palette colors");
10618
+ inlineWrap.addEventListener("blur", () => {
10619
+ inlineWrap.style.removeProperty("outline");
10620
+ inlineWrap.style.removeProperty("outline-offset");
10621
+ });
9370
10622
  const openPalette = () => {
9371
10623
  if (
9372
10624
  this.hasAttribute("disabled") &&
@@ -9377,12 +10629,6 @@ class FigInputPalette extends HTMLElement {
9377
10629
  inlineWrap.setAttribute("aria-expanded", "true");
9378
10630
  };
9379
10631
  inlineWrap.addEventListener("click", openPalette);
9380
- inlineWrap.addEventListener("keydown", (event) => {
9381
- if (event.key !== "Enter" && event.key !== " ") return;
9382
- event.preventDefault();
9383
- event.stopPropagation();
9384
- openPalette();
9385
- });
9386
10632
 
9387
10633
  const wrap = document.createElement("div");
9388
10634
  wrap.className = "palette-colors";
@@ -9393,6 +10639,7 @@ class FigInputPalette extends HTMLElement {
9393
10639
  });
9394
10640
  inlineWrap.appendChild(wrap);
9395
10641
  this.appendChild(inlineWrap);
10642
+ this.#syncTriggerState();
9396
10643
 
9397
10644
  if (!this.#isFixed) this.#createAddButton(disabled, this);
9398
10645
 
@@ -9589,6 +10836,31 @@ class FigInputPalette extends HTMLElement {
9589
10836
  else addBtn.removeAttribute("disabled");
9590
10837
  }
9591
10838
  this.#syncRemoveButtons(disabled);
10839
+ this.#syncTriggerState();
10840
+ }
10841
+
10842
+ #syncTriggerState() {
10843
+ const trigger = this.querySelector(".palette-colors-inline");
10844
+ if (!trigger) return;
10845
+ const disabled =
10846
+ this.hasAttribute("disabled") &&
10847
+ this.getAttribute("disabled") !== "false";
10848
+ trigger.setAttribute("tabindex", disabled ? "-1" : "+0");
10849
+ trigger.setAttribute("aria-disabled", String(disabled));
10850
+ trigger.setAttribute("aria-expanded", String(this.open));
10851
+ }
10852
+
10853
+ focus() {
10854
+ if (
10855
+ this.hasAttribute("disabled") &&
10856
+ this.getAttribute("disabled") !== "false"
10857
+ )
10858
+ return;
10859
+ const trigger = this.querySelector(".palette-colors-inline");
10860
+ if (!trigger) return;
10861
+ trigger.style.outline = "var(--figma-focus-outline)";
10862
+ trigger.style.outlineOffset = "var(--figma-focus-outline-offset)";
10863
+ trigger.focus();
9592
10864
  }
9593
10865
 
9594
10866
  #syncRemoveButtons(disabled = this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") {
@@ -9617,7 +10889,7 @@ class FigInputPalette extends HTMLElement {
9617
10889
  );
9618
10890
  }
9619
10891
  }
9620
- customElements.define("fig-input-palette", FigInputPalette);
10892
+ figDefineElement("fig-input-palette", FigInputPalette);
9621
10893
 
9622
10894
  /* Input Gradient */
9623
10895
  /**
@@ -9917,11 +11189,9 @@ class FigInputGradient extends HTMLElement {
9917
11189
  const mode = this.#editMode;
9918
11190
 
9919
11191
  if (mode === "picker" && hasFigFillPicker()) {
9920
- const experimental = this.getAttribute("experimental");
9921
- const expAttr = experimental ? ` experimental="${experimental}"` : "";
9922
11192
  const gradientValue = JSON.stringify(this.value);
9923
11193
  this.innerHTML = `
9924
- <fig-fill-picker mode="gradient"${expAttr} value='${gradientValue}'${disabled ? " disabled" : ""}>
11194
+ <fig-fill-picker mode="gradient" value='${gradientValue}'${disabled ? " disabled" : ""}>
9925
11195
  <fig-swatch background="${this.#buildGradientCSS()}"${this.#swatchSizeAttr()}${disabled ? " disabled" : ""}></fig-swatch>
9926
11196
  </fig-fill-picker>`;
9927
11197
  this.#swatch = this.querySelector("fig-swatch");
@@ -10529,7 +11799,7 @@ class FigInputGradient extends HTMLElement {
10529
11799
  }
10530
11800
  }
10531
11801
  }
10532
- customElements.define("fig-input-gradient", FigInputGradient);
11802
+ figDefineElement("fig-input-gradient", FigInputGradient);
10533
11803
 
10534
11804
  /* Checkbox */
10535
11805
  /**
@@ -10767,7 +12037,7 @@ class FigCheckbox extends HTMLElement {
10767
12037
  );
10768
12038
  }
10769
12039
  }
10770
- customElements.define("fig-checkbox", FigCheckbox);
12040
+ figDefineElement("fig-checkbox", FigCheckbox);
10771
12041
 
10772
12042
  /* Radio */
10773
12043
  /**
@@ -10787,7 +12057,7 @@ class FigRadio extends FigCheckbox {
10787
12057
  else this.input.removeAttribute("name");
10788
12058
  }
10789
12059
  }
10790
- customElements.define("fig-radio", FigRadio);
12060
+ figDefineElement("fig-radio", FigRadio);
10791
12061
 
10792
12062
  /* Switch */
10793
12063
  /**
@@ -10804,7 +12074,7 @@ class FigSwitch extends FigCheckbox {
10804
12074
  this.input.setAttribute("role", "switch");
10805
12075
  }
10806
12076
  }
10807
- customElements.define("fig-switch", FigSwitch);
12077
+ figDefineElement("fig-switch", FigSwitch);
10808
12078
 
10809
12079
  /* Combo Input */
10810
12080
  /**
@@ -10813,7 +12083,6 @@ customElements.define("fig-switch", FigSwitch);
10813
12083
  * @attr {string} placeholder - Placeholder text for the input
10814
12084
  * @attr {string} value - The current input value
10815
12085
  * @attr {boolean} disabled - Disables the input and dropdown button
10816
- * @attr {string} experimental - Feature flag passed to internal fig-dropdown
10817
12086
  */
10818
12087
  class FigComboInput extends HTMLElement {
10819
12088
  static observedAttributes = [
@@ -10821,7 +12090,6 @@ class FigComboInput extends HTMLElement {
10821
12090
  "placeholder",
10822
12091
  "value",
10823
12092
  "disabled",
10824
- "experimental",
10825
12093
  "aria-label",
10826
12094
  "aria-labelledby",
10827
12095
  "aria-describedby",
@@ -10902,15 +12170,11 @@ class FigComboInput extends HTMLElement {
10902
12170
  const options = this.#getOptions();
10903
12171
  const placeholder = this.getAttribute("placeholder") || "";
10904
12172
  const currentValue = this.value;
10905
- const experimental = this.getAttribute("experimental");
10906
- const expAttr = experimental
10907
- ? ` experimental="${figEscapeAttribute(experimental)}"`
10908
- : "";
10909
12173
  const dropdownLabel = this.#dropdownLabel();
10910
12174
 
10911
12175
  const dropdownHTML = this.#usesCustomDropdown
10912
12176
  ? ""
10913
- : `<fig-dropdown type="dropdown" label="${figEscapeAttribute(dropdownLabel)}"${expAttr}>${options.map((o) => `<option>${figEscapeAttribute(o.trim())}</option>`).join("")}</fig-dropdown>`;
12177
+ : `<fig-dropdown type="dropdown" label="${figEscapeAttribute(dropdownLabel)}">${options.map((o) => `<option>${figEscapeAttribute(o.trim())}</option>`).join("")}</fig-dropdown>`;
10914
12178
 
10915
12179
  this.innerHTML = `<div class="input-combo">
10916
12180
  <fig-input-text placeholder="${figEscapeAttribute(placeholder)}" value="${figEscapeAttribute(currentValue)}"></fig-input-text>
@@ -10932,9 +12196,6 @@ class FigComboInput extends HTMLElement {
10932
12196
  if (!this.#customDropdown.hasAttribute("label")) {
10933
12197
  this.#customDropdown.setAttribute("label", dropdownLabel);
10934
12198
  }
10935
- if (experimental) {
10936
- this.#customDropdown.setAttribute("experimental", experimental);
10937
- }
10938
12199
  this.#button.append(this.#customDropdown);
10939
12200
  }
10940
12201
 
@@ -11082,13 +12343,6 @@ class FigComboInput extends HTMLElement {
11082
12343
  case "disabled":
11083
12344
  this.#applyDisabled(newValue !== null && newValue !== "false");
11084
12345
  break;
11085
- case "experimental":
11086
- if (this.#dropdown) {
11087
- if (newValue) this.#dropdown.setAttribute("experimental", newValue);
11088
- else if (!this.#usesCustomDropdown)
11089
- this.#dropdown.removeAttribute("experimental");
11090
- }
11091
- break;
11092
12346
  case "aria-label":
11093
12347
  case "aria-labelledby":
11094
12348
  case "aria-describedby":
@@ -11099,7 +12353,7 @@ class FigComboInput extends HTMLElement {
11099
12353
  }
11100
12354
  }
11101
12355
  }
11102
- customElements.define("fig-combo-input", FigComboInput);
12356
+ figDefineElement("fig-combo-input", FigComboInput);
11103
12357
 
11104
12358
  /* Swatch */
11105
12359
  /**
@@ -11315,7 +12569,7 @@ class FigSwatch extends HTMLElement {
11315
12569
  }
11316
12570
  }
11317
12571
  }
11318
- customElements.define("fig-swatch", FigSwatch);
12572
+ figDefineElement("fig-swatch", FigSwatch);
11319
12573
 
11320
12574
  /* Media */
11321
12575
  /**
@@ -12071,21 +13325,21 @@ class FigMedia extends HTMLElement {
12071
13325
  }
12072
13326
  }
12073
13327
 
12074
- customElements.define("fig-media", FigMedia);
13328
+ figDefineElement("fig-media", FigMedia);
12075
13329
 
12076
13330
  class FigImage extends FigMedia {
12077
13331
  get mediaKind() {
12078
13332
  return "image";
12079
13333
  }
12080
13334
  }
12081
- customElements.define("fig-image", FigImage);
13335
+ figDefineElement("fig-image", FigImage);
12082
13336
 
12083
13337
  class FigVideo extends FigMedia {
12084
13338
  get mediaKind() {
12085
13339
  return "video";
12086
13340
  }
12087
13341
  }
12088
- customElements.define("fig-video", FigVideo);
13342
+ figDefineElement("fig-video", FigVideo);
12089
13343
 
12090
13344
  /**
12091
13345
  * <fig-card> — Media card with optional link, selection chrome, and truncated label.
@@ -12358,7 +13612,7 @@ class FigCard extends HTMLElement {
12358
13612
  }
12359
13613
  }
12360
13614
  }
12361
- customElements.define("fig-card", FigCard);
13615
+ figDefineElement("fig-card", FigCard);
12362
13616
 
12363
13617
  /**
12364
13618
  * <fig-media-controls> — Standalone playback controls UI.
@@ -12601,7 +13855,7 @@ class FigMediaControls extends HTMLElement {
12601
13855
  this.toggle();
12602
13856
  }
12603
13857
  }
12604
- customElements.define("fig-media-controls", FigMediaControls);
13858
+ figDefineElement("fig-media-controls", FigMediaControls);
12605
13859
 
12606
13860
  /* File Upload Input */
12607
13861
  class FigInputFile extends HTMLElement {
@@ -12865,7 +14119,7 @@ class FigInputFile extends HTMLElement {
12865
14119
  }
12866
14120
  }
12867
14121
  }
12868
- customElements.define("fig-input-file", FigInputFile);
14122
+ figDefineElement("fig-input-file", FigInputFile);
12869
14123
 
12870
14124
  /**
12871
14125
  * A bezier / spring easing curve editor with draggable control points.
@@ -12886,9 +14140,7 @@ class FigEasingCurve extends HTMLElement {
12886
14140
  #line2 = null;
12887
14141
  #handle1 = null;
12888
14142
  #handle2 = null;
12889
- #bezierEndpointStart = null;
12890
- #bezierEndpointEnd = null;
12891
- #dropdown = null;
14143
+ #select = null;
12892
14144
  #valueInput = null;
12893
14145
  #presetName = null;
12894
14146
  #targetLine = null;
@@ -12896,10 +14148,10 @@ class FigEasingCurve extends HTMLElement {
12896
14148
  #drawWidth = 200;
12897
14149
  #drawHeight = 200;
12898
14150
  #bounds = null;
12899
- #diagonal = null;
14151
+ #boundaryTop = null;
14152
+ #boundaryBottom = null;
12900
14153
  #resizeObserver = null;
12901
14154
  #bezierHandleRadius = 5;
12902
- #bezierEndpointRadius = 2;
12903
14155
  #durationBarWidth = 10;
12904
14156
  #durationBarHeight = 10;
12905
14157
  #durationBarRadius = 3;
@@ -13017,7 +14269,7 @@ class FigEasingCurve extends HTMLElement {
13017
14269
  this.#render();
13018
14270
  } else {
13019
14271
  if (this.#svg) this.#updatePaths();
13020
- this.#syncDropdown();
14272
+ this.#syncSelect();
13021
14273
  this.#syncValueInput();
13022
14274
  }
13023
14275
  }
@@ -13240,14 +14492,15 @@ class FigEasingCurve extends HTMLElement {
13240
14492
  .replace(/>/g, "&gt;");
13241
14493
  }
13242
14494
 
13243
- #getDropdownHTML() {
14495
+ #getSelectHTML() {
13244
14496
  let optionsHTML = "";
13245
14497
  let currentGroup = undefined;
13246
14498
  for (const p of FigEasingCurve.PRESETS) {
13247
14499
  if (!this.#isEditEnabled() && !p.value && !p.spring) continue;
13248
14500
  if (p.group !== currentGroup) {
13249
- if (currentGroup !== undefined) optionsHTML += `</optgroup>`;
13250
- if (p.group) optionsHTML += `<optgroup label="${p.group}">`;
14501
+ if (p.group) {
14502
+ optionsHTML += `<fig-menu-separator label="${FigEasingCurve.#escapeAttribute(p.group)}"></fig-menu-separator>`;
14503
+ }
13251
14504
  currentGroup = p.group;
13252
14505
  }
13253
14506
  let icon;
@@ -13263,40 +14516,37 @@ class FigEasingCurve extends HTMLElement {
13263
14516
  ];
13264
14517
  icon = FigEasingCurve.curveIcon(...v);
13265
14518
  }
13266
- const selected = p.name === this.#presetName ? " selected" : "";
13267
- optionsHTML += `<option value="${p.name}"${selected}>${icon} ${p.name}</option>`;
14519
+ const name = FigEasingCurve.#escapeAttribute(p.name);
14520
+ optionsHTML += `<fig-select-option value="${name}" label="${name}"><span slot="prepend">${icon}</span><span>${name}</span></fig-select-option>`;
13268
14521
  }
13269
- if (currentGroup) optionsHTML += `</optgroup>`;
13270
- return `<fig-dropdown class="fig-easing-curve-dropdown" full experimental="modern">${optionsHTML}</fig-dropdown>`;
14522
+ const value = FigEasingCurve.#escapeAttribute(this.#presetName);
14523
+ return `<fig-select class="fig-easing-curve-select" label="Easing preset" value="${value}" full><fig-select-options>${optionsHTML}</fig-select-options></fig-select>`;
13271
14524
  }
13272
14525
 
13273
14526
  #getInnerHTML() {
13274
14527
  const size = 200;
13275
- const dropdown = this.#getDropdownHTML();
13276
- if (!this.#isEditEnabled()) return dropdown;
14528
+ const select = this.#getSelectHTML();
14529
+ if (!this.#isEditEnabled()) return select;
13277
14530
  const valueInput = `<fig-input-text class="fig-easing-curve-value-input" value="${FigEasingCurve.#escapeAttribute(this.value)}" full></fig-input-text>`;
13278
14531
 
13279
14532
  if (this.#mode === "spring") {
13280
14533
  const targetY = 40;
13281
- const startY = 180;
13282
- return `${dropdown}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
14534
+ return `${select}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
13283
14535
  <rect class="fig-easing-curve-bounds" x="0" y="0" width="${size}" height="${size}"/>
13284
14536
  <line class="fig-easing-curve-target" x1="0" y1="${targetY}" x2="${size}" y2="${targetY}"/>
13285
- <line class="fig-easing-curve-diagonal" x1="0" y1="${startY}" x2="0" y2="${startY}"/>
13286
14537
  <path class="fig-easing-curve-path"/>
13287
14538
  <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>
13288
14539
  <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>
13289
14540
  </svg></div>${valueInput}`;
13290
14541
  }
13291
14542
 
13292
- return `${dropdown}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
14543
+ return `${select}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
13293
14544
  <rect class="fig-easing-curve-bounds" x="0" y="0" width="${size}" height="${size}"/>
13294
- <line class="fig-easing-curve-diagonal" x1="0" y1="${size}" x2="${size}" y2="0"/>
14545
+ <line class="fig-easing-curve-boundary" data-boundary="top" x1="0" y1="0" x2="${size}" y2="0"/>
14546
+ <line class="fig-easing-curve-boundary" data-boundary="bottom" x1="0" y1="${size}" x2="${size}" y2="${size}"/>
14547
+ <path class="fig-easing-curve-path"/>
13295
14548
  <line class="fig-easing-curve-arm" data-arm="1"/>
13296
14549
  <line class="fig-easing-curve-arm" data-arm="2"/>
13297
- <path class="fig-easing-curve-path"/>
13298
- <circle class="fig-easing-curve-endpoint" data-endpoint="start" r="${this.#bezierEndpointRadius}"/>
13299
- <circle class="fig-easing-curve-endpoint" data-endpoint="end" r="${this.#bezierEndpointRadius}"/>
13300
14550
  <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>
13301
14551
  <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>
13302
14552
  </svg></div>${valueInput}`;
@@ -13310,10 +14560,6 @@ class FigEasingCurve extends HTMLElement {
13310
14560
  }
13311
14561
 
13312
14562
  #syncMetricsFromCSS() {
13313
- this.#bezierEndpointRadius = this.#readCssNumber(
13314
- "--easing-bezier-endpoint-radius",
13315
- this.#bezierEndpointRadius,
13316
- );
13317
14563
  this.#durationBarRadius = this.#readCssNumber(
13318
14564
  "--easing-duration-bar-radius",
13319
14565
  this.#durationBarRadius,
@@ -13331,13 +14577,12 @@ class FigEasingCurve extends HTMLElement {
13331
14577
  this.#handle2 =
13332
14578
  this.querySelector('[data-handle="2"]') ||
13333
14579
  this.querySelector('[data-handle="duration"]');
13334
- this.#bezierEndpointStart = this.querySelector('[data-endpoint="start"]');
13335
- this.#bezierEndpointEnd = this.querySelector('[data-endpoint="end"]');
13336
- this.#dropdown = this.querySelector(".fig-easing-curve-dropdown");
14580
+ this.#select = this.querySelector(".fig-easing-curve-select");
13337
14581
  this.#valueInput = this.querySelector(".fig-easing-curve-value-input");
13338
14582
  this.#targetLine = this.querySelector(".fig-easing-curve-target");
13339
14583
  this.#bounds = this.querySelector(".fig-easing-curve-bounds");
13340
- this.#diagonal = this.querySelector(".fig-easing-curve-diagonal");
14584
+ this.#boundaryTop = this.querySelector('[data-boundary="top"]');
14585
+ this.#boundaryBottom = this.querySelector('[data-boundary="bottom"]');
13341
14586
  }
13342
14587
 
13343
14588
  #syncHandleSizes() {
@@ -13386,12 +14631,37 @@ class FigEasingCurve extends HTMLElement {
13386
14631
 
13387
14632
  // --- Coordinate helpers ---
13388
14633
 
14634
+ #bezierDomain() {
14635
+ const minVal = Math.min(0, this.#cp1.y, this.#cp2.y);
14636
+ const maxVal = Math.max(1, this.#cp1.y, this.#cp2.y);
14637
+ const range = maxVal - minVal || 1;
14638
+ const pad = Math.min(
14639
+ this.#bezierHandleRadius,
14640
+ Math.max(0, (this.#drawHeight - 1) / 2),
14641
+ );
14642
+ return {
14643
+ minVal,
14644
+ maxVal,
14645
+ range,
14646
+ pad,
14647
+ draw: Math.max(1, this.#drawHeight - pad * 2),
14648
+ };
14649
+ }
14650
+
13389
14651
  #toSVG(nx, ny) {
13390
- return { x: nx * this.#drawWidth, y: (1 - ny) * this.#drawHeight };
14652
+ const { maxVal, range, pad, draw } = this.#bezierDomain();
14653
+ return {
14654
+ x: nx * this.#drawWidth,
14655
+ y: pad + ((maxVal - ny) / range) * draw,
14656
+ };
13391
14657
  }
13392
14658
 
13393
14659
  #fromSVG(sx, sy) {
13394
- return { x: sx / this.#drawWidth, y: 1 - sy / this.#drawHeight };
14660
+ const { maxVal, range, pad, draw } = this.#bezierDomain();
14661
+ return {
14662
+ x: sx / this.#drawWidth,
14663
+ y: maxVal - ((sy - pad) / draw) * range,
14664
+ };
13395
14665
  }
13396
14666
 
13397
14667
  #springScale = { minVal: 0, maxVal: 1.2, totalTime: 1 };
@@ -13436,17 +14706,20 @@ class FigEasingCurve extends HTMLElement {
13436
14706
  this.#bounds.setAttribute("width", this.#drawWidth);
13437
14707
  this.#bounds.setAttribute("height", this.#drawHeight);
13438
14708
  }
13439
- if (this.#diagonal) {
13440
- this.#diagonal.setAttribute("x1", "0");
13441
- this.#diagonal.setAttribute("y1", this.#drawHeight);
13442
- this.#diagonal.setAttribute("x2", this.#drawWidth);
13443
- this.#diagonal.setAttribute("y2", "0");
13444
- }
13445
-
13446
14709
  const p0 = this.#toSVG(0, 0);
13447
14710
  const p1 = this.#toSVG(this.#cp1.x, this.#cp1.y);
13448
14711
  const p2 = this.#toSVG(this.#cp2.x, this.#cp2.y);
13449
14712
  const p3 = this.#toSVG(1, 1);
14713
+ for (const [boundary, y] of [
14714
+ [this.#boundaryTop, p3.y],
14715
+ [this.#boundaryBottom, p0.y],
14716
+ ]) {
14717
+ if (!boundary) continue;
14718
+ boundary.setAttribute("x1", "0");
14719
+ boundary.setAttribute("y1", y);
14720
+ boundary.setAttribute("x2", this.#drawWidth);
14721
+ boundary.setAttribute("y2", y);
14722
+ }
13450
14723
 
13451
14724
  this.#curve.setAttribute(
13452
14725
  "d",
@@ -13465,14 +14738,6 @@ class FigEasingCurve extends HTMLElement {
13465
14738
  this.#handle1.setAttribute("y", p1.y - hr);
13466
14739
  this.#handle2.setAttribute("x", p2.x - hr);
13467
14740
  this.#handle2.setAttribute("y", p2.y - hr);
13468
- if (this.#bezierEndpointStart) {
13469
- this.#bezierEndpointStart.setAttribute("cx", p0.x);
13470
- this.#bezierEndpointStart.setAttribute("cy", p0.y);
13471
- }
13472
- if (this.#bezierEndpointEnd) {
13473
- this.#bezierEndpointEnd.setAttribute("cx", p3.x);
13474
- this.#bezierEndpointEnd.setAttribute("cy", p3.y);
13475
- }
13476
14741
  this.#syncBezierHandleTabOrder();
13477
14742
  }
13478
14743
 
@@ -13565,11 +14830,11 @@ class FigEasingCurve extends HTMLElement {
13565
14830
  return peak;
13566
14831
  }
13567
14832
 
13568
- // --- Dropdown ---
14833
+ // --- Select ---
13569
14834
 
13570
- #syncDropdown() {
13571
- if (!this.#dropdown) return;
13572
- this.#dropdown.value = this.#presetName;
14835
+ #syncSelect() {
14836
+ if (!this.#select) return;
14837
+ this.#select.value = this.#presetName;
13573
14838
  this.#refreshCustomPresetIcons();
13574
14839
  }
13575
14840
 
@@ -13608,23 +14873,24 @@ class FigEasingCurve extends HTMLElement {
13608
14873
  this.#render();
13609
14874
  } else {
13610
14875
  this.#updatePaths();
13611
- this.#syncDropdown();
14876
+ this.#syncSelect();
13612
14877
  if (eventType === "change") this.#syncValueInput();
13613
14878
  }
13614
14879
  this.#emit(eventType);
13615
14880
  }
13616
14881
 
13617
- #setOptionIconByValue(root, optionValue, icon) {
13618
- if (!root) return;
13619
- for (const option of root.querySelectorAll("option")) {
14882
+ #setOptionIconByValue(optionValue, icon) {
14883
+ if (!this.#select) return;
14884
+ for (const option of this.#select.querySelectorAll("fig-select-option")) {
13620
14885
  if (option.value === optionValue) {
13621
- option.innerHTML = `${icon} ${optionValue}`;
14886
+ const prepend = option.querySelector(':scope > [slot="prepend"]');
14887
+ if (prepend) prepend.innerHTML = icon;
13622
14888
  }
13623
14889
  }
13624
14890
  }
13625
14891
 
13626
14892
  #refreshCustomPresetIcons() {
13627
- if (!this.#dropdown) return;
14893
+ if (!this.#select) return;
13628
14894
  if (!this.#isEditEnabled()) return;
13629
14895
  const bezierIcon = FigEasingCurve.curveIcon(
13630
14896
  this.#cp1.x,
@@ -13634,25 +14900,14 @@ class FigEasingCurve extends HTMLElement {
13634
14900
  );
13635
14901
  const springIcon = FigEasingCurve.#springIcon(this.#spring);
13636
14902
 
13637
- // Update both slotted options and the cloned native select options.
13638
- this.#setOptionIconByValue(this.#dropdown, "Custom bezier", bezierIcon);
13639
- this.#setOptionIconByValue(this.#dropdown, "Custom spring", springIcon);
13640
- this.#setOptionIconByValue(
13641
- this.#dropdown.select,
13642
- "Custom bezier",
13643
- bezierIcon,
13644
- );
13645
- this.#setOptionIconByValue(
13646
- this.#dropdown.select,
13647
- "Custom spring",
13648
- springIcon,
13649
- );
14903
+ this.#setOptionIconByValue("Custom bezier", bezierIcon);
14904
+ this.#setOptionIconByValue("Custom spring", springIcon);
13650
14905
  }
13651
14906
 
13652
14907
  #syncAfterHandleInput(eventType) {
13653
14908
  this.#updatePaths();
13654
14909
  this.#presetName = this.#matchPreset();
13655
- this.#syncDropdown();
14910
+ this.#syncSelect();
13656
14911
  this.#syncValueInput();
13657
14912
  this.#emit(eventType);
13658
14913
  }
@@ -13841,8 +15096,8 @@ class FigEasingCurve extends HTMLElement {
13841
15096
  }
13842
15097
  }
13843
15098
 
13844
- if (this.#dropdown) {
13845
- this.#dropdown.addEventListener("change", (e) => {
15099
+ if (this.#select) {
15100
+ this.#select.addEventListener("change", (e) => {
13846
15101
  const name = e.detail;
13847
15102
  const preset = FigEasingCurve.PRESETS.find((p) => p.name === name);
13848
15103
  if (!preset) return;
@@ -13915,11 +15170,30 @@ class FigEasingCurve extends HTMLElement {
13915
15170
  e.preventDefault();
13916
15171
  this.#isDragging = handle;
13917
15172
  this.#syncActiveBezierArm();
15173
+ const svgRect = this.#svg.getBoundingClientRect();
15174
+ const startClientX = e.clientX;
15175
+ const startClientY = e.clientY;
15176
+ const fromHandle = e.target?.closest?.(
15177
+ ".fig-easing-curve-handle, fig-handle",
15178
+ );
15179
+ const currentPoint = handle === 1 ? this.#cp1 : this.#cp2;
15180
+ const svgPoint = this.#clientToSVG(e);
15181
+ const startPoint = fromHandle
15182
+ ? { ...currentPoint }
15183
+ : this.#fromSVG(svgPoint.x, svgPoint.y);
15184
+ const { range, draw } = this.#bezierDomain();
15185
+ const unitsPerClientY =
15186
+ range /
15187
+ Math.max(1, (draw / this.#drawHeight) * Math.max(1, svgRect.height));
13918
15188
 
13919
15189
  const onMove = (e) => {
13920
15190
  if (!this.#isDragging) return;
13921
- const svgPt = this.#clientToSVG(e);
13922
- const norm = this.#fromSVG(svgPt.x, svgPt.y);
15191
+ const norm = {
15192
+ x:
15193
+ startPoint.x +
15194
+ (e.clientX - startClientX) / Math.max(1, svgRect.width),
15195
+ y: startPoint.y - (e.clientY - startClientY) * unitsPerClientY,
15196
+ };
13923
15197
 
13924
15198
  norm.x = Math.round(norm.x * 100) / 100;
13925
15199
  norm.y = Math.round(norm.y * 100) / 100;
@@ -13934,7 +15208,7 @@ class FigEasingCurve extends HTMLElement {
13934
15208
  }
13935
15209
  this.#updatePaths();
13936
15210
  this.#presetName = this.#matchPreset();
13937
- this.#syncDropdown();
15211
+ this.#syncSelect();
13938
15212
  this.#syncValueInput();
13939
15213
  this.#emit("input");
13940
15214
  };
@@ -13985,7 +15259,7 @@ class FigEasingCurve extends HTMLElement {
13985
15259
 
13986
15260
  this.#updatePaths();
13987
15261
  this.#presetName = this.#matchPreset();
13988
- this.#syncDropdown();
15262
+ this.#syncSelect();
13989
15263
  this.#syncValueInput();
13990
15264
  this.#emit("input");
13991
15265
  };
@@ -14002,7 +15276,7 @@ class FigEasingCurve extends HTMLElement {
14002
15276
  document.addEventListener("pointerup", onUp);
14003
15277
  }
14004
15278
  }
14005
- customElements.define("fig-easing-curve", FigEasingCurve);
15279
+ figDefineElement("fig-easing-curve", FigEasingCurve);
14006
15280
 
14007
15281
  /**
14008
15282
  * A 3D rotation control with an interactive cube preview.
@@ -14372,7 +15646,7 @@ class Fig3DRotate extends HTMLElement {
14372
15646
  this.#container.addEventListener("lostpointercapture", onEnd);
14373
15647
  }
14374
15648
  }
14375
- customElements.define("fig-3d-rotate", Fig3DRotate);
15649
+ figDefineElement("fig-3d-rotate", Fig3DRotate);
14376
15650
 
14377
15651
  /**
14378
15652
  * A transform-origin grid control with draggable handle.
@@ -14922,7 +16196,7 @@ class FigOriginGrid extends HTMLElement {
14922
16196
  bindValueInput(this.#yInput, "y");
14923
16197
  }
14924
16198
  }
14925
- customElements.define("fig-origin-grid", FigOriginGrid);
16199
+ figDefineElement("fig-origin-grid", FigOriginGrid);
14926
16200
 
14927
16201
  /**
14928
16202
  * A custom joystick input element.
@@ -15347,7 +16621,7 @@ class FigInputJoystick extends HTMLElement {
15347
16621
  }
15348
16622
  }
15349
16623
 
15350
- customElements.define("fig-joystick", FigInputJoystick);
16624
+ figDefineElement("fig-joystick", FigInputJoystick);
15351
16625
 
15352
16626
 
15353
16627
  // FigInputAngle moved to fig-lab.js
@@ -15404,7 +16678,7 @@ class FigShimmer extends HTMLElement {
15404
16678
  }
15405
16679
  }
15406
16680
  }
15407
- customElements.define("fig-shimmer", FigShimmer);
16681
+ figDefineElement("fig-shimmer", FigShimmer);
15408
16682
 
15409
16683
  // FigSkeleton
15410
16684
  class FigSkeleton extends FigShimmer {
@@ -15414,7 +16688,7 @@ class FigSkeleton extends FigShimmer {
15414
16688
  this.setAttribute("inert", "");
15415
16689
  }
15416
16690
  }
15417
- customElements.define("fig-skeleton", FigSkeleton);
16691
+ figDefineElement("fig-skeleton", FigSkeleton);
15418
16692
 
15419
16693
  // FigGroup
15420
16694
  class FigGroup extends HTMLElement {
@@ -15560,7 +16834,7 @@ class FigGroup extends HTMLElement {
15560
16834
  }
15561
16835
  }
15562
16836
  }
15563
- customElements.define("fig-group", FigGroup);
16837
+ figDefineElement("fig-group", FigGroup);
15564
16838
 
15565
16839
  /**
15566
16840
  * A presentational header element used inside fig-dialog, fig-group, and other containers.
@@ -15570,7 +16844,7 @@ customElements.define("fig-group", FigGroup);
15570
16844
  * @attr {boolean} dialog-header - Marks this as a dialog header (auto-generated by fig-dialog)
15571
16845
  */
15572
16846
  class FigHeader extends HTMLElement {}
15573
- customElements.define("fig-header", FigHeader);
16847
+ figDefineElement("fig-header", FigHeader);
15574
16848
 
15575
16849
  /**
15576
16850
  * fig-footer
@@ -15579,7 +16853,7 @@ customElements.define("fig-header", FigHeader);
15579
16853
  * @attr {boolean} sticky - Pins the footer to the bottom of its scroll container
15580
16854
  */
15581
16855
  class FigFooter extends HTMLElement {}
15582
- customElements.define("fig-footer", FigFooter);
16856
+ figDefineElement("fig-footer", FigFooter);
15583
16857
 
15584
16858
  /* Presentational elements (CSS-only, no behavior) */
15585
16859
  class FigSpinner extends HTMLElement {
@@ -15590,7 +16864,7 @@ class FigSpinner extends HTMLElement {
15590
16864
  }
15591
16865
  }
15592
16866
  }
15593
- customElements.define("fig-spinner", FigSpinner);
16867
+ figDefineElement("fig-spinner", FigSpinner);
15594
16868
 
15595
16869
  /**
15596
16870
  * A styled visual preview layer for arbitrary content such as images, canvas,
@@ -15620,7 +16894,7 @@ class FigPreview extends HTMLElement {
15620
16894
  }
15621
16895
  }
15622
16896
  }
15623
- customElements.define("fig-preview", FigPreview);
16897
+ figDefineElement("fig-preview", FigPreview);
15624
16898
 
15625
16899
  /**
15626
16900
  * Compact swatch previewing gradient color-space interpolation.
@@ -15928,7 +17202,7 @@ class FigInterpolationSwatch extends HTMLElement {
15928
17202
  this.#fillEl.style.background = this.#previewBackground();
15929
17203
  }
15930
17204
  }
15931
- customElements.define("fig-interpolation-swatch", FigInterpolationSwatch);
17205
+ figDefineElement("fig-interpolation-swatch", FigInterpolationSwatch);
15932
17206
 
15933
17207
  /** @type {Record<string, string | { medium: string, small: string }>} */
15934
17208
  const FIG_ICON_TOKENS = {
@@ -16015,19 +17289,19 @@ class FigIcon extends HTMLElement {
16015
17289
  }
16016
17290
  }
16017
17291
  }
16018
- customElements.define("fig-icon", FigIcon);
17292
+ figDefineElement("fig-icon", FigIcon);
16019
17293
 
16020
17294
  class FigContent extends HTMLElement {}
16021
- customElements.define("fig-content", FigContent);
17295
+ figDefineElement("fig-content", FigContent);
16022
17296
 
16023
17297
  class FigTabContent extends HTMLElement {}
16024
- customElements.define("fig-tab-content", FigTabContent);
17298
+ figDefineElement("fig-tab-content", FigTabContent);
16025
17299
 
16026
17300
  class FigButtonCombo extends HTMLElement {}
16027
- customElements.define("fig-button-combo", FigButtonCombo);
17301
+ figDefineElement("fig-button-combo", FigButtonCombo);
16028
17302
 
16029
17303
  class FigInputCombo extends HTMLElement {}
16030
- customElements.define("fig-input-combo", FigInputCombo);
17304
+ figDefineElement("fig-input-combo", FigInputCombo);
16031
17305
 
16032
17306
 
16033
17307
 
@@ -16405,7 +17679,7 @@ class FigColorTip extends HTMLElement {
16405
17679
  this.toggleAttribute("disabled", Boolean(value));
16406
17680
  }
16407
17681
  }
16408
- customElements.define("fig-color-tip", FigColorTip);
17682
+ figDefineElement("fig-color-tip", FigColorTip);
16409
17683
 
16410
17684
  /* Choice */
16411
17685
  /**
@@ -16454,7 +17728,7 @@ class FigChoice extends HTMLElement {
16454
17728
  }
16455
17729
  }
16456
17730
  }
16457
- customElements.define("fig-choice", FigChoice);
17731
+ figDefineElement("fig-choice", FigChoice);
16458
17732
 
16459
17733
  /* Chooser */
16460
17734
  /**
@@ -16570,6 +17844,7 @@ class FigChooser extends HTMLElement {
16570
17844
  this.#setupDrag();
16571
17845
  this.#startObserver();
16572
17846
  this.#startResizeObserver();
17847
+ this.#syncDisabledChoices();
16573
17848
 
16574
17849
  figNextFrame(this, () => {
16575
17850
  this.#syncSelection();
@@ -16623,17 +17898,7 @@ class FigChooser extends HTMLElement {
16623
17898
  this.#selectByValue(newValue);
16624
17899
  }
16625
17900
  if (name === "disabled") {
16626
- const isDisabled = newValue !== null && newValue !== "false";
16627
- const choices = this.choices;
16628
- for (const choice of choices) {
16629
- if (isDisabled) {
16630
- choice.setAttribute("aria-disabled", "true");
16631
- choice.setAttribute("tabindex", "-1");
16632
- } else {
16633
- choice.removeAttribute("aria-disabled");
16634
- choice.setAttribute("tabindex", "0");
16635
- }
16636
- }
17901
+ this.#syncDisabledChoices();
16637
17902
  }
16638
17903
  if (name === "choice-element") {
16639
17904
  requestAnimationFrame(() => this.#syncSelection());
@@ -16685,6 +17950,23 @@ class FigChooser extends HTMLElement {
16685
17950
  this.selectedChoice = choices[0];
16686
17951
  }
16687
17952
 
17953
+ #syncDisabledChoices() {
17954
+ const chooserDisabled = figBooleanAttribute(this, "disabled");
17955
+ if (chooserDisabled) this.setAttribute("aria-disabled", "true");
17956
+ else this.removeAttribute("aria-disabled");
17957
+ for (const choice of this.choices) {
17958
+ const disabled =
17959
+ chooserDisabled || figBooleanAttribute(choice, "disabled");
17960
+ if (disabled) {
17961
+ choice.setAttribute("aria-disabled", "true");
17962
+ choice.setAttribute("tabindex", "-1");
17963
+ } else {
17964
+ choice.removeAttribute("aria-disabled");
17965
+ choice.setAttribute("tabindex", "0");
17966
+ }
17967
+ }
17968
+ }
17969
+
16688
17970
  #selectByValue(value) {
16689
17971
  const choices = this.choices;
16690
17972
  for (const choice of choices) {
@@ -17047,6 +18329,7 @@ class FigChooser extends HTMLElement {
17047
18329
  if (this.#isUnwrapping) return;
17048
18330
  this.#removeLegacyScroller();
17049
18331
  this.#applyOverflowMode();
18332
+ this.#syncDisabledChoices();
17050
18333
  const choices = this.choices;
17051
18334
  if (this.#selectedChoice && !choices.includes(this.#selectedChoice)) {
17052
18335
  this.#selectedChoice = null;
@@ -17059,7 +18342,7 @@ class FigChooser extends HTMLElement {
17059
18342
  this.#mutationObserver.observe(this, { childList: true, subtree: false });
17060
18343
  }
17061
18344
  }
17062
- customElements.define("fig-chooser", FigChooser);
18345
+ figDefineElement("fig-chooser", FigChooser);
17063
18346
 
17064
18347
  /* Handle */
17065
18348
  class FigHandle extends HTMLElement {
@@ -17083,6 +18366,7 @@ class FigHandle extends HTMLElement {
17083
18366
  #isDragging = false;
17084
18367
  #didDrag = false;
17085
18368
  #boundPointerDown = null;
18369
+ #activeDragCleanup = null;
17086
18370
  #applyingValue = false;
17087
18371
  #colorTip = null;
17088
18372
  #directColorPicker = null;
@@ -17521,11 +18805,14 @@ class FigHandle extends HTMLElement {
17521
18805
  }
17522
18806
 
17523
18807
  #teardownDrag() {
18808
+ this.#activeDragCleanup?.();
18809
+ this.#activeDragCleanup = null;
17524
18810
  if (this.#boundPointerDown) {
17525
18811
  this.removeEventListener("pointerdown", this.#boundPointerDown);
17526
18812
  this.#boundPointerDown = null;
17527
18813
  }
17528
18814
  this.#isDragging = false;
18815
+ this.#didDrag = false;
17529
18816
  }
17530
18817
 
17531
18818
  #onPointerDown(e) {
@@ -17534,6 +18821,7 @@ class FigHandle extends HTMLElement {
17534
18821
  const container = this.#getContainer();
17535
18822
  if (!container) return;
17536
18823
 
18824
+ this.#activeDragCleanup?.();
17537
18825
  this.#isDragging = true;
17538
18826
  const axes = this.#axes;
17539
18827
  let lastRect = null;
@@ -17611,11 +18899,7 @@ class FigHandle extends HTMLElement {
17611
18899
  };
17612
18900
 
17613
18901
  const onUp = (e) => {
17614
- this.#isDragging = false;
17615
- this.style.cursor = "";
17616
- this.classList.remove("dragging");
17617
- window.removeEventListener("pointermove", onMove);
17618
- window.removeEventListener("pointerup", onUp);
18902
+ cleanup();
17619
18903
  if (this.#didDrag) {
17620
18904
  clampAndApply(e.clientX, e.clientY, e.shiftKey);
17621
18905
  this.#syncValueAttribute();
@@ -17640,6 +18924,17 @@ class FigHandle extends HTMLElement {
17640
18924
  this.#didDrag = false;
17641
18925
  };
17642
18926
 
18927
+ const cleanup = () => {
18928
+ window.removeEventListener("pointermove", onMove);
18929
+ window.removeEventListener("pointerup", onUp);
18930
+ this.#isDragging = false;
18931
+ this.style.cursor = "";
18932
+ this.classList.remove("dragging");
18933
+ if (this.#activeDragCleanup === cleanup) {
18934
+ this.#activeDragCleanup = null;
18935
+ }
18936
+ };
18937
+ this.#activeDragCleanup = cleanup;
17643
18938
  window.addEventListener("pointermove", onMove);
17644
18939
  window.addEventListener("pointerup", onUp);
17645
18940
  }
@@ -17981,7 +19276,7 @@ class FigHandle extends HTMLElement {
17981
19276
  return { x, y, px, py };
17982
19277
  }
17983
19278
  }
17984
- customElements.define("fig-handle", FigHandle);
19279
+ figDefineElement("fig-handle", FigHandle);
17985
19280
 
17986
19281
  // ─── Menu ────────────────────────────────────────────────────────────────────
17987
19282
 
@@ -18029,7 +19324,7 @@ class FigMenuItem extends HTMLElement {
18029
19324
  }
18030
19325
  }
18031
19326
  }
18032
- customElements.define("fig-menu-item", FigMenuItem);
19327
+ figDefineElement("fig-menu-item", FigMenuItem);
18033
19328
 
18034
19329
  /**
18035
19330
  * Visual divider between menu item groups.
@@ -18067,7 +19362,7 @@ class FigMenuSeparator extends HTMLElement {
18067
19362
  else this.removeAttribute("aria-label");
18068
19363
  }
18069
19364
  }
18070
- customElements.define("fig-menu-separator", FigMenuSeparator);
19365
+ figDefineElement("fig-menu-separator", FigMenuSeparator);
18071
19366
 
18072
19367
  class FigMenu extends HTMLElement {
18073
19368
  #popup = null;
@@ -18108,6 +19403,7 @@ class FigMenu extends HTMLElement {
18108
19403
 
18109
19404
  set open(val) {
18110
19405
  if (val) {
19406
+ if (this.#isDisabled()) return;
18111
19407
  this.setAttribute("open", "");
18112
19408
  } else {
18113
19409
  this.removeAttribute("open");
@@ -18154,19 +19450,17 @@ class FigMenu extends HTMLElement {
18154
19450
  if (newValue === null || newValue === "false") {
18155
19451
  this.#closeMenu();
18156
19452
  } else {
19453
+ if (this.#isDisabled()) {
19454
+ this.removeAttribute("open");
19455
+ return;
19456
+ }
18157
19457
  this.#openMenu();
18158
19458
  }
18159
19459
  return;
18160
19460
  }
18161
19461
 
18162
19462
  if (name === "disabled") {
18163
- if (this.#trigger) {
18164
- if (newValue !== null && newValue !== "false") {
18165
- this.#trigger.setAttribute("disabled", "");
18166
- } else {
18167
- this.#trigger.removeAttribute("disabled");
18168
- }
18169
- }
19463
+ this.#syncDisabled();
18170
19464
  return;
18171
19465
  }
18172
19466
 
@@ -18308,8 +19602,12 @@ class FigMenu extends HTMLElement {
18308
19602
  }
18309
19603
 
18310
19604
  #syncDisabled() {
19605
+ const disabled = this.#isDisabled();
19606
+ if (disabled) {
19607
+ if (this.open) this.removeAttribute("open");
19608
+ else this.#closeMenu();
19609
+ }
18311
19610
  if (!this.#trigger) return;
18312
- const disabled = this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
18313
19611
  if (disabled) {
18314
19612
  this.#trigger.setAttribute("disabled", "");
18315
19613
  this.#trigger.setAttribute("aria-disabled", "true");
@@ -18320,9 +19618,15 @@ class FigMenu extends HTMLElement {
18320
19618
  }
18321
19619
  }
18322
19620
 
19621
+ #isDisabled() {
19622
+ return (
19623
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false"
19624
+ );
19625
+ }
19626
+
18323
19627
  #handleTriggerClick(e) {
18324
19628
  if (this.#usesContextMenuTrigger()) return;
18325
- if (this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") return;
19629
+ if (this.#isDisabled()) return;
18326
19630
  e.stopPropagation();
18327
19631
  const popupShowing = this.#popup?.matches?.(":open") ?? false;
18328
19632
  if (this.open && !popupShowing) {
@@ -18341,7 +19645,7 @@ class FigMenu extends HTMLElement {
18341
19645
 
18342
19646
  #handleTriggerContextMenu(e) {
18343
19647
  if (!this.#usesContextMenuTrigger()) return;
18344
- if (this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") return;
19648
+ if (this.#isDisabled()) return;
18345
19649
  e.preventDefault();
18346
19650
  e.stopPropagation();
18347
19651
  this.#showAtAfterPointerRelease(e.clientX, e.clientY);
@@ -18375,6 +19679,7 @@ class FigMenu extends HTMLElement {
18375
19679
  }
18376
19680
 
18377
19681
  #handlePopupClick(e) {
19682
+ if (this.#isDisabled()) return;
18378
19683
  const item = e.target.closest("fig-menu-item");
18379
19684
  if (!item) return;
18380
19685
  if (item.hasAttribute("disabled") && item.getAttribute("disabled") !== "false") return;
@@ -18383,6 +19688,7 @@ class FigMenu extends HTMLElement {
18383
19688
  }
18384
19689
 
18385
19690
  #handleMenuKeydown(e) {
19691
+ if (this.#isDisabled()) return;
18386
19692
  if (e.currentTarget === document && e.key !== "Escape") return;
18387
19693
  if (e.currentTarget === this && this.#popup?.contains(e.target)) return;
18388
19694
  if (!this.open || !this.#popup?.matches?.(":open")) {
@@ -18468,6 +19774,7 @@ class FigMenu extends HTMLElement {
18468
19774
  }
18469
19775
 
18470
19776
  showAt(x, y) {
19777
+ if (this.#isDisabled()) return;
18471
19778
  this.#virtualAnchor = {
18472
19779
  getBoundingClientRect: () => ({
18473
19780
  width: 0,
@@ -18485,12 +19792,16 @@ class FigMenu extends HTMLElement {
18485
19792
  }
18486
19793
  if (this.open) this.open = false;
18487
19794
  requestAnimationFrame(() => {
19795
+ if (this.#isDisabled()) return;
18488
19796
  this.open = true;
18489
19797
  });
18490
19798
  }
18491
19799
 
18492
19800
  #openMenu() {
18493
- if (!this.#popup) return;
19801
+ if (!this.#popup || this.#isDisabled()) {
19802
+ if (this.hasAttribute("open")) this.removeAttribute("open");
19803
+ return;
19804
+ }
18494
19805
  this.#popup.open = true;
18495
19806
  document.addEventListener("keydown", this.#boundMenuKeydown, true);
18496
19807
  if (this.#trigger) {
@@ -18510,4 +19821,4 @@ class FigMenu extends HTMLElement {
18510
19821
  this.#trigger?.setAttribute("aria-expanded", "false");
18511
19822
  }
18512
19823
  }
18513
- customElements.define("fig-menu", FigMenu);
19824
+ figDefineElement("fig-menu", FigMenu);