@rogieking/figui3 6.21.1 → 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
@@ -330,9 +330,11 @@ function figSupportsPopover() {
330
330
  class FigButton extends HTMLElement {
331
331
  type;
332
332
  #selected;
333
+ #slottedDisabledStates = new WeakMap();
333
334
  #a11yAttributes = ["aria-label", "aria-labelledby", "aria-describedby", "title"];
334
335
  #boundHandleControlKeydown = this.#handleControlKeydown.bind(this);
335
336
  #boundHandleClick = this.#handleClick.bind(this);
337
+ #boundHandleSlotChange = () => this.#syncSlottedControlDisabled();
336
338
  #boundHandleFocus = () => {
337
339
  if (this.button?.matches(":focus-visible")) {
338
340
  this.setAttribute("data-focus-visible", "");
@@ -398,6 +400,9 @@ class FigButton extends HTMLElement {
398
400
  this.button.addEventListener("blur", this.#boundHandleBlur);
399
401
  }
400
402
 
403
+ const slot = this.shadowRoot.querySelector("slot");
404
+ slot?.removeEventListener("slotchange", this.#boundHandleSlotChange);
405
+ slot?.addEventListener("slotchange", this.#boundHandleSlotChange);
401
406
  this.removeEventListener("keydown", this.#boundHandleControlKeydown);
402
407
  this.addEventListener("keydown", this.#boundHandleControlKeydown);
403
408
 
@@ -514,9 +519,30 @@ class FigButton extends HTMLElement {
514
519
  this.button.type = "button";
515
520
  this.button.setAttribute("type", "button");
516
521
  }
522
+ this.#syncSlottedControlDisabled();
517
523
  this.#syncA11yAttributes();
518
524
  this.#syncPressedState();
519
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
+ }
520
546
  static get observedAttributes() {
521
547
  return [
522
548
  "disabled",
@@ -555,6 +581,9 @@ class FigButton extends HTMLElement {
555
581
  }
556
582
  disconnectedCallback() {
557
583
  this.removeEventListener("keydown", this.#boundHandleControlKeydown);
584
+ this.shadowRoot
585
+ ?.querySelector("slot")
586
+ ?.removeEventListener("slotchange", this.#boundHandleSlotChange);
558
587
  }
559
588
  }
560
589
  figDefineElement("fig-button", FigButton);
@@ -570,8 +599,6 @@ class FigDropdown extends HTMLElement {
570
599
  #boundHandleSelectInput;
571
600
  #boundHandleSelectChange;
572
601
  #boundHandleSelectKeydown;
573
- #selectedContentEnabled = false;
574
- #selectedContentEl = null;
575
602
 
576
603
  get label() {
577
604
  return this.#label;
@@ -591,54 +618,6 @@ class FigDropdown extends HTMLElement {
591
618
  this.#boundSlotChange = this.slotChange.bind(this);
592
619
  }
593
620
 
594
- #supportsSelectedContent() {
595
- if (typeof CSS === "undefined" || typeof CSS.supports !== "function")
596
- return false;
597
- try {
598
- return (
599
- CSS.supports("appearance: base-select") &&
600
- CSS.supports("selector(::picker(select))")
601
- );
602
- } catch {
603
- return false;
604
- }
605
- }
606
-
607
- #enableSelectedContentIfNeeded() {
608
- const experimental = this.getAttribute("experimental") || "";
609
- const wantsModern = experimental
610
- .split(/\s+/)
611
- .filter(Boolean)
612
- .includes("modern");
613
-
614
- if (!wantsModern || !this.#supportsSelectedContent()) {
615
- this.#selectedContentEnabled = false;
616
- return;
617
- }
618
-
619
- const button = document.createElement("button");
620
- button.setAttribute("type", "button");
621
- button.setAttribute("aria-hidden", "true");
622
- const selected = document.createElement("selectedcontent");
623
- button.appendChild(selected);
624
- this.select.appendChild(button);
625
- this.#selectedContentEnabled = true;
626
- this.#selectedContentEl = selected;
627
- }
628
-
629
- #syncSelectedContent() {
630
- if (!this.#selectedContentEl) return;
631
- const selectedOption = this.select.selectedOptions?.[0];
632
- if (!selectedOption) {
633
- this.#selectedContentEl.textContent = "";
634
- return;
635
- }
636
- // Fallback mirror for browsers that don't auto-project selectedcontent reliably.
637
- this.#selectedContentEl.replaceChildren(
638
- ...Array.from(selectedOption.childNodes, (node) => node.cloneNode(true)),
639
- );
640
- }
641
-
642
621
  #addEventListeners() {
643
622
  this.select.addEventListener("input", this.#boundHandleSelectInput);
644
623
  this.select.addEventListener("change", this.#boundHandleSelectChange);
@@ -703,8 +682,6 @@ class FigDropdown extends HTMLElement {
703
682
  this.select.firstChild.remove();
704
683
  }
705
684
 
706
- this.#enableSelectedContentIfNeeded();
707
-
708
685
  if (this.type === "dropdown") {
709
686
  const hiddenOption = document.createElement("option");
710
687
  hiddenOption.setAttribute("hidden", "true");
@@ -720,7 +697,6 @@ class FigDropdown extends HTMLElement {
720
697
  if (selectedValue !== null) {
721
698
  this.#syncSelectedValue(selectedValue);
722
699
  }
723
- this.#syncSelectedContent();
724
700
  if (this.type === "dropdown") {
725
701
  this.select.selectedIndex = -1;
726
702
  }
@@ -743,7 +719,6 @@ class FigDropdown extends HTMLElement {
743
719
  this.#selectedValue = selectedValue;
744
720
  }
745
721
  this.setAttribute("value", selectedValue);
746
- this.#syncSelectedContent();
747
722
  this.dispatchEvent(
748
723
  new CustomEvent("input", {
749
724
  detail: selectedValue,
@@ -771,7 +746,6 @@ class FigDropdown extends HTMLElement {
771
746
  if (this.type === "dropdown") {
772
747
  this.select.selectedIndex = -1;
773
748
  }
774
- this.#syncSelectedContent();
775
749
  this.dispatchEvent(
776
750
  new CustomEvent("change", {
777
751
  detail: selectedValue,
@@ -784,7 +758,6 @@ class FigDropdown extends HTMLElement {
784
758
  #handleSelectKeydown(e) {
785
759
  if (this.closest('fig-button[type="select"]')) return;
786
760
  if (e.key !== "Enter" || e.defaultPrevented) return;
787
- if (this.#selectedContentEnabled && this.select.matches(":open")) return;
788
761
  if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
789
762
  if (this.select.disabled || this.select.multiple) return;
790
763
  if (typeof this.select.showPicker !== "function") return;
@@ -817,7 +790,7 @@ class FigDropdown extends HTMLElement {
817
790
  this.setAttribute("value", value);
818
791
  }
819
792
  static get observedAttributes() {
820
- return ["value", "type", "experimental", "label", "disabled"];
793
+ return ["value", "type", "label", "disabled"];
821
794
  }
822
795
  #syncDisabled() {
823
796
  const disabled =
@@ -830,7 +803,6 @@ class FigDropdown extends HTMLElement {
830
803
  return;
831
804
  }
832
805
  if (this.select) this.select.value = value ?? "";
833
- this.#syncSelectedContent();
834
806
  }
835
807
  attributeChangedCallback(name, oldValue, newValue) {
836
808
  if (name === "value") {
@@ -840,9 +812,6 @@ class FigDropdown extends HTMLElement {
840
812
  this.type = newValue || "select";
841
813
  if (this.isConnected) this.slotChange();
842
814
  }
843
- if (name === "experimental") {
844
- this.slotChange();
845
- }
846
815
  if (name === "label") {
847
816
  this.#label = newValue || "Menu";
848
817
  this.select.setAttribute("aria-label", this.#label);
@@ -887,6 +856,8 @@ class FigTooltip extends HTMLElement {
887
856
  #boundHidePopupOutsideClick;
888
857
  #boundShowDelayedPopup;
889
858
  #boundHandlePointerLeave;
859
+ #boundHandleFocus;
860
+ #boundHandleBlur;
890
861
  #boundHandleTouchStart;
891
862
  #boundHandleTouchMove;
892
863
  #boundHandleTouchEnd;
@@ -896,6 +867,7 @@ class FigTooltip extends HTMLElement {
896
867
  #parentDialog = null;
897
868
  #triggerEl = null;
898
869
  #childObserver = null;
870
+ #suppressFocusOpen = false;
899
871
  #touchTimeout;
900
872
  #isTouching = false;
901
873
  constructor() {
@@ -908,6 +880,13 @@ class FigTooltip extends HTMLElement {
908
880
  this.#boundHidePopupOutsideClick = this.hidePopupOutsideClick.bind(this);
909
881
  this.#boundShowDelayedPopup = this.showDelayedPopup.bind(this);
910
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
+ };
911
890
  this.#boundHandleTouchStart = this.#handleTouchStart.bind(this);
912
891
  this.#boundHandleTouchMove = this.#handleTouchMove.bind(this);
913
892
  this.#boundHandleTouchEnd = this.#handleTouchEnd.bind(this);
@@ -1002,6 +981,8 @@ class FigTooltip extends HTMLElement {
1002
981
  trigger.addEventListener("touchcancel", this.#boundHandleTouchCancel, {
1003
982
  passive: true,
1004
983
  });
984
+ trigger.addEventListener("focus", this.#boundHandleFocus);
985
+ trigger.addEventListener("blur", this.#boundHandleBlur);
1005
986
  } else if (this.action === "click") {
1006
987
  trigger.addEventListener("click", this.#boundShowDelayedPopup);
1007
988
  trigger.addEventListener("touchstart", this.#boundShowDelayedPopup, {
@@ -1020,6 +1001,8 @@ class FigTooltip extends HTMLElement {
1020
1001
  trigger.removeEventListener("touchmove", this.#boundHandleTouchMove);
1021
1002
  trigger.removeEventListener("touchend", this.#boundHandleTouchEnd);
1022
1003
  trigger.removeEventListener("touchcancel", this.#boundHandleTouchCancel);
1004
+ trigger.removeEventListener("focus", this.#boundHandleFocus);
1005
+ trigger.removeEventListener("blur", this.#boundHandleBlur);
1023
1006
  } else if (this.action === "click") {
1024
1007
  trigger.removeEventListener("click", this.#boundShowDelayedPopup);
1025
1008
  trigger.removeEventListener("touchstart", this.#boundShowDelayedPopup);
@@ -1421,6 +1404,16 @@ class FigTooltip extends HTMLElement {
1421
1404
  if (!(node instanceof FigTooltip)) continue;
1422
1405
  if (node.action !== "hover") continue;
1423
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);
1424
1417
  if (node.isOpen || node.timeout) node.hidePopup();
1425
1418
  }
1426
1419
  for (const anchor of Array.from(FigTooltip.#programmaticAnchors)) {
@@ -5051,77 +5044,1277 @@ class FigOptions extends HTMLElement {
5051
5044
  }
5052
5045
  }
5053
5046
 
5054
- #syncValueToChild() {
5055
- if (!this.#childControl || this.#suppressEvents) return;
5056
- const val = this.getAttribute("value") || "";
5057
- this.#childControl.value = val;
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;
5058
6140
  }
5059
6141
 
5060
- #syncAttrToChild(attr) {
5061
- if (!this.#childControl) return;
5062
- if (this.hasAttribute(attr)) {
5063
- this.#childControl.setAttribute(attr, this.getAttribute(attr) || "");
5064
- } else {
5065
- this.#childControl.removeAttribute(attr);
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);
6151
+ }
6152
+
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;
6174
+ }
6175
+
6176
+ const options = this.#getOptions({ enabledOnly: true });
6177
+ if (!options.length) return;
6178
+
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
+ }
5066
6212
  }
5067
6213
  }
5068
6214
 
5069
- #startResizeObserver() {
5070
- this.#resizeObserver?.disconnect();
5071
- this.#resizeObserver = new ResizeObserver(() => {
5072
- this.#checkOverflow();
5073
- });
5074
- 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;
5075
6220
  }
5076
6221
 
5077
- #isSegmentTruncated(seg) {
5078
- const range = document.createRange();
5079
- range.selectNodeContents(seg);
5080
- const textWidth = range.getBoundingClientRect().width;
5081
- const segRect = seg.getBoundingClientRect();
5082
- const segWidth = segRect.width;
5083
- const cs = getComputedStyle(seg);
5084
- const padL = parseFloat(cs.paddingLeft) || 0;
5085
- const padR = parseFloat(cs.paddingRight) || 0;
5086
- const contentWidth = segWidth - padL - padR;
5087
- return textWidth > contentWidth + 0.5;
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;
5088
6228
  }
5089
6229
 
5090
- #anySegmentTruncated() {
5091
- const segments = this.querySelectorAll("fig-segment");
5092
- for (const seg of segments) {
5093
- if (this.#isSegmentTruncated(seg)) return true;
5094
- }
5095
- return false;
6230
+ #getEnabledOptions() {
6231
+ return this.#getOptions({ enabledOnly: true });
5096
6232
  }
5097
6233
 
5098
- #checkOverflow() {
5099
- 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
+ }
5100
6269
 
5101
- if (this.#currentMode === "segments") {
5102
- const sc = this.#childControl;
5103
- const containerOverflow = sc && sc.scrollWidth > sc.clientWidth + 1;
5104
- if (containerOverflow || this.#anySegmentTruncated()) {
5105
- this.#naturalWidth = this.clientWidth;
5106
- this.#renderDropdown();
5107
- }
5108
- } else {
5109
- if (this.#naturalWidth > 0 && this.clientWidth >= this.#naturalWidth) {
5110
- this.#renderSegments();
5111
- requestAnimationFrame(() => {
5112
- requestAnimationFrame(() => {
5113
- const sc = this.#childControl;
5114
- const containerOverflow = sc && sc.scrollWidth > sc.clientWidth + 1;
5115
- if (containerOverflow || this.#anySegmentTruncated()) {
5116
- this.#renderDropdown();
5117
- }
5118
- });
5119
- });
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);
5120
6298
  }
5121
- }
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");
5122
6315
  }
5123
6316
  }
5124
- figDefineElement("fig-options", FigOptions);
6317
+ figDefineElement("fig-select", FigSelect);
5125
6318
 
5126
6319
  /* Slider */
5127
6320
  /**
@@ -5823,6 +7016,8 @@ figDefineElement("fig-slider", FigSlider);
5823
7016
  class FigInputText extends HTMLElement {
5824
7017
  #isInteracting = false;
5825
7018
  #passwordVisible = false;
7019
+ #value = "";
7020
+ #reflectingValue = false;
5826
7021
  #boundMouseMove;
5827
7022
  #boundMouseUp;
5828
7023
  #boundWindowBlur;
@@ -5866,7 +7061,9 @@ class FigInputText extends HTMLElement {
5866
7061
  new CustomEvent("input", { detail: this.value, bubbles: true }),
5867
7062
  );
5868
7063
  };
5869
- this.#boundFocusControl = this.focus.bind(this);
7064
+ this.#boundFocusControl = () => {
7065
+ if (!this.disabled) this.focus();
7066
+ };
5870
7067
  this.#boundAdornmentClick = this.#handleAdornmentClick.bind(this);
5871
7068
  }
5872
7069
 
@@ -5907,6 +7104,7 @@ class FigInputText extends HTMLElement {
5907
7104
  this.#syncSearchClear();
5908
7105
  this.#syncSearchClearVisibility();
5909
7106
  this.#syncPasswordToggle();
7107
+ this.#syncGeneratedAdornmentDisabled();
5910
7108
  figNormalizeTextOnlyInputSlots(this);
5911
7109
  this.#startObserver();
5912
7110
 
@@ -5982,6 +7180,7 @@ class FigInputText extends HTMLElement {
5982
7180
  #handleAdornmentClick(event) {
5983
7181
  const adornment = event.target?.closest?.("[slot]");
5984
7182
  if (!adornment || adornment.parentElement !== this) return;
7183
+ if (this.disabled) return;
5985
7184
  this.focus();
5986
7185
  }
5987
7186
  #startObserver() {
@@ -6013,6 +7212,7 @@ class FigInputText extends HTMLElement {
6013
7212
  this.#syncSearchClear();
6014
7213
  this.#syncSearchClearVisibility();
6015
7214
  this.#syncPasswordToggle();
7215
+ this.#syncGeneratedAdornmentDisabled();
6016
7216
  figNormalizeTextOnlyInputSlots(this);
6017
7217
  }
6018
7218
  #syncInputA11yAttributes() {
@@ -6097,6 +7297,7 @@ class FigInputText extends HTMLElement {
6097
7297
  button.addEventListener("click", (e) => {
6098
7298
  e.preventDefault();
6099
7299
  e.stopPropagation();
7300
+ if (this.disabled) return;
6100
7301
  if (!this.input || this.input.value === "") {
6101
7302
  this.focus();
6102
7303
  return;
@@ -6155,6 +7356,7 @@ class FigInputText extends HTMLElement {
6155
7356
  button.addEventListener("click", (e) => {
6156
7357
  e.preventDefault();
6157
7358
  e.stopPropagation();
7359
+ if (this.disabled) return;
6158
7360
  this.#passwordVisible = !this.#passwordVisible;
6159
7361
  if (this.input) {
6160
7362
  this.input.type = this.#passwordVisible ? "text" : "password";
@@ -6172,6 +7374,14 @@ class FigInputText extends HTMLElement {
6172
7374
  button?.setAttribute("aria-label", label);
6173
7375
  icon?.setAttribute("name", this.#passwordVisible ? "visible" : "hidden");
6174
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
+ }
6175
7385
  #transformNumber(value) {
6176
7386
  if (value === "") return "";
6177
7387
  let transformed = Number(value) * (this.transform || 1);
@@ -6275,15 +7485,29 @@ class FigInputText extends HTMLElement {
6275
7485
  return Number.isInteger(rounded) ? rounded : rounded.toFixed(precision);
6276
7486
  }
6277
7487
 
6278
- /*
6279
7488
  get value() {
6280
- return this.value;
7489
+ return this.#value;
6281
7490
  }
6282
7491
 
6283
7492
  set value(val) {
6284
- this.value = val;
6285
- this.setAttribute("value", val);
6286
- }*/
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
+ }
6287
7511
 
6288
7512
  static get observedAttributes() {
6289
7513
  return [
@@ -6313,6 +7537,7 @@ class FigInputText extends HTMLElement {
6313
7537
  case "disabled":
6314
7538
  this.disabled = this.input.disabled =
6315
7539
  newValue !== null && newValue !== "false";
7540
+ this.#syncGeneratedAdornmentDisabled();
6316
7541
  break;
6317
7542
  case "readonly":
6318
7543
  this.readonly = newValue !== null && newValue !== "false";
@@ -6329,13 +7554,9 @@ class FigInputText extends HTMLElement {
6329
7554
  let value = newValue;
6330
7555
  if (this.type === "number") {
6331
7556
  value = this.#sanitizeInput(value, false);
6332
- this.value = value;
6333
- this.input.value = this.#transformNumber(value);
6334
- } else {
6335
- this.value = value;
6336
- this.input.value = value;
6337
7557
  }
6338
- this.#syncSearchClearVisibility();
7558
+ this.#value = value ?? "";
7559
+ if (!this.#reflectingValue) this.#syncRenderedValue(this.#value);
6339
7560
  break;
6340
7561
  case "min":
6341
7562
  case "max":
@@ -6378,6 +7599,7 @@ class FigInputText extends HTMLElement {
6378
7599
  this.#syncSearchClear();
6379
7600
  this.#syncSearchClearVisibility();
6380
7601
  this.#syncPasswordToggle();
7602
+ this.#syncGeneratedAdornmentDisabled();
6381
7603
  break;
6382
7604
  case "multiline": {
6383
7605
  const next = newValue !== null && newValue !== "false";
@@ -7405,10 +8627,12 @@ class FigInputColor extends HTMLElement {
7405
8627
 
7406
8628
  #fillPickerAttrs() {
7407
8629
  const attrs = {};
7408
- const experimental = this.getAttribute("experimental");
7409
- if (experimental) attrs["experimental"] = experimental;
7410
8630
  for (const { name, value } of this.attributes) {
7411
- if (name.startsWith("picker-") && name !== "picker-anchor") {
8631
+ if (
8632
+ name.startsWith("picker-") &&
8633
+ name !== "picker-anchor" &&
8634
+ name !== "picker-experimental"
8635
+ ) {
7412
8636
  attrs[name.slice(7)] = value;
7413
8637
  }
7414
8638
  }
@@ -7615,8 +8839,8 @@ class FigInputColor extends HTMLElement {
7615
8839
 
7616
8840
  const picker = document.createElement("fig-fill-picker");
7617
8841
  picker.innerHTML = "<span hidden></span>";
7618
- picker.addEventListener("input", this.#handleFillPickerInput.bind(this));
7619
- picker.addEventListener("change", this.#handleChange.bind(this));
8842
+ picker.addEventListener("input", this.#boundFillPickerInput);
8843
+ picker.addEventListener("change", this.#boundChange);
7620
8844
  this.appendChild(picker);
7621
8845
  this.#fillPicker = picker;
7622
8846
  this.#syncFillPicker();
@@ -7681,8 +8905,22 @@ class FigInputColor extends HTMLElement {
7681
8905
  }
7682
8906
 
7683
8907
  #setValues(hexValue) {
7684
- const colorValue = hexValue || "#D9D9D9";
7685
- 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;
7686
8924
  this.value = this.rgbAlphaToHex(
7687
8925
  {
7688
8926
  r: isNaN(this.rgba.r) ? 0 : this.rgba.r,
@@ -7873,7 +9111,6 @@ class FigInputColor extends HTMLElement {
7873
9111
  "value",
7874
9112
  "style",
7875
9113
  "mode",
7876
- "experimental",
7877
9114
  "alpha",
7878
9115
  "text",
7879
9116
  "disabled",
@@ -7991,6 +9228,7 @@ class FigInputColor extends HTMLElement {
7991
9228
  }
7992
9229
 
7993
9230
  convertToRGBA(color) {
9231
+ if (typeof color !== "string") return null;
7994
9232
  let r,
7995
9233
  g,
7996
9234
  b,
@@ -8415,7 +9653,6 @@ class FigInputFill extends HTMLElement {
8415
9653
  "value",
8416
9654
  "disabled",
8417
9655
  "mode",
8418
- "experimental",
8419
9656
  "alpha",
8420
9657
  "aria-label",
8421
9658
  "aria-describedby",
@@ -8499,13 +9736,15 @@ class FigInputFill extends HTMLElement {
8499
9736
  // Backward-compat: direct attributes forwarded to fill picker
8500
9737
  const mode = this.getAttribute("mode");
8501
9738
  if (mode) attrs["mode"] = mode;
8502
- const experimental = this.getAttribute("experimental");
8503
- if (experimental) attrs["experimental"] = experimental;
8504
9739
  const alpha = this.getAttribute("alpha");
8505
9740
  if (alpha) attrs["alpha"] = alpha;
8506
9741
  // picker-* overrides (except anchor, handled programmatically)
8507
9742
  for (const { name, value } of this.attributes) {
8508
- if (name.startsWith("picker-") && name !== "picker-anchor") {
9743
+ if (
9744
+ name.startsWith("picker-") &&
9745
+ name !== "picker-anchor" &&
9746
+ name !== "picker-experimental"
9747
+ ) {
8509
9748
  attrs[name.slice(7)] = value;
8510
9749
  }
8511
9750
  }
@@ -8741,7 +9980,6 @@ class FigInputFill extends HTMLElement {
8741
9980
  if (detail.video) this.#video = detail.video;
8742
9981
  break;
8743
9982
  }
8744
-
8745
9983
  // Update controls (don't re-render to keep dialog open)
8746
9984
  if (typeChanged) {
8747
9985
  this.#updateControlsForType();
@@ -9141,7 +10379,6 @@ class FigInputFill extends HTMLElement {
9141
10379
  this.#syncDisabled();
9142
10380
  break;
9143
10381
  case "mode":
9144
- case "experimental":
9145
10382
  // Pass through to internal fill picker
9146
10383
  if (this.#fillPicker) {
9147
10384
  if (newValue) {
@@ -9183,6 +10420,7 @@ class FigInputPalette extends HTMLElement {
9183
10420
  #expandedPickers = [];
9184
10421
  #renderRAF = null;
9185
10422
  #boundHandleKeyDown = this.#handleKeyDown.bind(this);
10423
+ #boundHandleHostFocus = () => this.focus();
9186
10424
 
9187
10425
  static get observedAttributes() {
9188
10426
  return ["value", "disabled", "min", "max", "open", "fixed"];
@@ -9226,7 +10464,9 @@ class FigInputPalette extends HTMLElement {
9226
10464
  }
9227
10465
 
9228
10466
  connectedCallback() {
9229
- 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);
9230
10470
  this.removeEventListener("keydown", this.#boundHandleKeyDown);
9231
10471
  this.addEventListener("keydown", this.#boundHandleKeyDown);
9232
10472
  if (this.#renderRAF) cancelAnimationFrame(this.#renderRAF);
@@ -9244,13 +10484,14 @@ class FigInputPalette extends HTMLElement {
9244
10484
  this.#renderRAF = null;
9245
10485
  }
9246
10486
  this.removeEventListener("keydown", this.#boundHandleKeyDown);
10487
+ this.removeEventListener("focus", this.#boundHandleHostFocus);
9247
10488
  this.#inlinePickers = [];
9248
10489
  this.#expandedPickers = [];
9249
10490
  }
9250
10491
 
9251
10492
  #handleKeyDown(event) {
9252
10493
  if (event.key !== "Enter" && event.key !== " ") return;
9253
- if (event.target !== this && !event.target?.closest?.(".palette-colors-inline")) return;
10494
+ if (event.target !== this.querySelector(".palette-colors-inline")) return;
9254
10495
  if (this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") return;
9255
10496
  event.preventDefault();
9256
10497
  event.stopPropagation();
@@ -9275,7 +10516,7 @@ class FigInputPalette extends HTMLElement {
9275
10516
  this.#render();
9276
10517
  break;
9277
10518
  case "open":
9278
- // CSS handles visibility; no re-render needed
10519
+ this.#syncTriggerState();
9279
10520
  break;
9280
10521
  }
9281
10522
  }
@@ -9371,8 +10612,13 @@ class FigInputPalette extends HTMLElement {
9371
10612
  const inlineWrap = document.createElement("div");
9372
10613
  inlineWrap.className = "palette-colors-inline";
9373
10614
  inlineWrap.setAttribute("role", "button");
10615
+ inlineWrap.setAttribute("tabindex", disabled ? "-1" : "+0");
9374
10616
  inlineWrap.setAttribute("aria-expanded", String(this.open));
9375
10617
  inlineWrap.setAttribute("aria-label", "Edit palette colors");
10618
+ inlineWrap.addEventListener("blur", () => {
10619
+ inlineWrap.style.removeProperty("outline");
10620
+ inlineWrap.style.removeProperty("outline-offset");
10621
+ });
9376
10622
  const openPalette = () => {
9377
10623
  if (
9378
10624
  this.hasAttribute("disabled") &&
@@ -9383,12 +10629,6 @@ class FigInputPalette extends HTMLElement {
9383
10629
  inlineWrap.setAttribute("aria-expanded", "true");
9384
10630
  };
9385
10631
  inlineWrap.addEventListener("click", openPalette);
9386
- inlineWrap.addEventListener("keydown", (event) => {
9387
- if (event.key !== "Enter" && event.key !== " ") return;
9388
- event.preventDefault();
9389
- event.stopPropagation();
9390
- openPalette();
9391
- });
9392
10632
 
9393
10633
  const wrap = document.createElement("div");
9394
10634
  wrap.className = "palette-colors";
@@ -9399,6 +10639,7 @@ class FigInputPalette extends HTMLElement {
9399
10639
  });
9400
10640
  inlineWrap.appendChild(wrap);
9401
10641
  this.appendChild(inlineWrap);
10642
+ this.#syncTriggerState();
9402
10643
 
9403
10644
  if (!this.#isFixed) this.#createAddButton(disabled, this);
9404
10645
 
@@ -9595,6 +10836,31 @@ class FigInputPalette extends HTMLElement {
9595
10836
  else addBtn.removeAttribute("disabled");
9596
10837
  }
9597
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();
9598
10864
  }
9599
10865
 
9600
10866
  #syncRemoveButtons(disabled = this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") {
@@ -9923,11 +11189,9 @@ class FigInputGradient extends HTMLElement {
9923
11189
  const mode = this.#editMode;
9924
11190
 
9925
11191
  if (mode === "picker" && hasFigFillPicker()) {
9926
- const experimental = this.getAttribute("experimental");
9927
- const expAttr = experimental ? ` experimental="${experimental}"` : "";
9928
11192
  const gradientValue = JSON.stringify(this.value);
9929
11193
  this.innerHTML = `
9930
- <fig-fill-picker mode="gradient"${expAttr} value='${gradientValue}'${disabled ? " disabled" : ""}>
11194
+ <fig-fill-picker mode="gradient" value='${gradientValue}'${disabled ? " disabled" : ""}>
9931
11195
  <fig-swatch background="${this.#buildGradientCSS()}"${this.#swatchSizeAttr()}${disabled ? " disabled" : ""}></fig-swatch>
9932
11196
  </fig-fill-picker>`;
9933
11197
  this.#swatch = this.querySelector("fig-swatch");
@@ -10819,7 +12083,6 @@ figDefineElement("fig-switch", FigSwitch);
10819
12083
  * @attr {string} placeholder - Placeholder text for the input
10820
12084
  * @attr {string} value - The current input value
10821
12085
  * @attr {boolean} disabled - Disables the input and dropdown button
10822
- * @attr {string} experimental - Feature flag passed to internal fig-dropdown
10823
12086
  */
10824
12087
  class FigComboInput extends HTMLElement {
10825
12088
  static observedAttributes = [
@@ -10827,7 +12090,6 @@ class FigComboInput extends HTMLElement {
10827
12090
  "placeholder",
10828
12091
  "value",
10829
12092
  "disabled",
10830
- "experimental",
10831
12093
  "aria-label",
10832
12094
  "aria-labelledby",
10833
12095
  "aria-describedby",
@@ -10908,15 +12170,11 @@ class FigComboInput extends HTMLElement {
10908
12170
  const options = this.#getOptions();
10909
12171
  const placeholder = this.getAttribute("placeholder") || "";
10910
12172
  const currentValue = this.value;
10911
- const experimental = this.getAttribute("experimental");
10912
- const expAttr = experimental
10913
- ? ` experimental="${figEscapeAttribute(experimental)}"`
10914
- : "";
10915
12173
  const dropdownLabel = this.#dropdownLabel();
10916
12174
 
10917
12175
  const dropdownHTML = this.#usesCustomDropdown
10918
12176
  ? ""
10919
- : `<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>`;
10920
12178
 
10921
12179
  this.innerHTML = `<div class="input-combo">
10922
12180
  <fig-input-text placeholder="${figEscapeAttribute(placeholder)}" value="${figEscapeAttribute(currentValue)}"></fig-input-text>
@@ -10938,9 +12196,6 @@ class FigComboInput extends HTMLElement {
10938
12196
  if (!this.#customDropdown.hasAttribute("label")) {
10939
12197
  this.#customDropdown.setAttribute("label", dropdownLabel);
10940
12198
  }
10941
- if (experimental) {
10942
- this.#customDropdown.setAttribute("experimental", experimental);
10943
- }
10944
12199
  this.#button.append(this.#customDropdown);
10945
12200
  }
10946
12201
 
@@ -11088,13 +12343,6 @@ class FigComboInput extends HTMLElement {
11088
12343
  case "disabled":
11089
12344
  this.#applyDisabled(newValue !== null && newValue !== "false");
11090
12345
  break;
11091
- case "experimental":
11092
- if (this.#dropdown) {
11093
- if (newValue) this.#dropdown.setAttribute("experimental", newValue);
11094
- else if (!this.#usesCustomDropdown)
11095
- this.#dropdown.removeAttribute("experimental");
11096
- }
11097
- break;
11098
12346
  case "aria-label":
11099
12347
  case "aria-labelledby":
11100
12348
  case "aria-describedby":
@@ -12892,9 +14140,7 @@ class FigEasingCurve extends HTMLElement {
12892
14140
  #line2 = null;
12893
14141
  #handle1 = null;
12894
14142
  #handle2 = null;
12895
- #bezierEndpointStart = null;
12896
- #bezierEndpointEnd = null;
12897
- #dropdown = null;
14143
+ #select = null;
12898
14144
  #valueInput = null;
12899
14145
  #presetName = null;
12900
14146
  #targetLine = null;
@@ -12902,10 +14148,10 @@ class FigEasingCurve extends HTMLElement {
12902
14148
  #drawWidth = 200;
12903
14149
  #drawHeight = 200;
12904
14150
  #bounds = null;
12905
- #diagonal = null;
14151
+ #boundaryTop = null;
14152
+ #boundaryBottom = null;
12906
14153
  #resizeObserver = null;
12907
14154
  #bezierHandleRadius = 5;
12908
- #bezierEndpointRadius = 2;
12909
14155
  #durationBarWidth = 10;
12910
14156
  #durationBarHeight = 10;
12911
14157
  #durationBarRadius = 3;
@@ -13023,7 +14269,7 @@ class FigEasingCurve extends HTMLElement {
13023
14269
  this.#render();
13024
14270
  } else {
13025
14271
  if (this.#svg) this.#updatePaths();
13026
- this.#syncDropdown();
14272
+ this.#syncSelect();
13027
14273
  this.#syncValueInput();
13028
14274
  }
13029
14275
  }
@@ -13246,14 +14492,15 @@ class FigEasingCurve extends HTMLElement {
13246
14492
  .replace(/>/g, "&gt;");
13247
14493
  }
13248
14494
 
13249
- #getDropdownHTML() {
14495
+ #getSelectHTML() {
13250
14496
  let optionsHTML = "";
13251
14497
  let currentGroup = undefined;
13252
14498
  for (const p of FigEasingCurve.PRESETS) {
13253
14499
  if (!this.#isEditEnabled() && !p.value && !p.spring) continue;
13254
14500
  if (p.group !== currentGroup) {
13255
- if (currentGroup !== undefined) optionsHTML += `</optgroup>`;
13256
- 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
+ }
13257
14504
  currentGroup = p.group;
13258
14505
  }
13259
14506
  let icon;
@@ -13269,40 +14516,37 @@ class FigEasingCurve extends HTMLElement {
13269
14516
  ];
13270
14517
  icon = FigEasingCurve.curveIcon(...v);
13271
14518
  }
13272
- const selected = p.name === this.#presetName ? " selected" : "";
13273
- 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>`;
13274
14521
  }
13275
- if (currentGroup) optionsHTML += `</optgroup>`;
13276
- 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>`;
13277
14524
  }
13278
14525
 
13279
14526
  #getInnerHTML() {
13280
14527
  const size = 200;
13281
- const dropdown = this.#getDropdownHTML();
13282
- if (!this.#isEditEnabled()) return dropdown;
14528
+ const select = this.#getSelectHTML();
14529
+ if (!this.#isEditEnabled()) return select;
13283
14530
  const valueInput = `<fig-input-text class="fig-easing-curve-value-input" value="${FigEasingCurve.#escapeAttribute(this.value)}" full></fig-input-text>`;
13284
14531
 
13285
14532
  if (this.#mode === "spring") {
13286
14533
  const targetY = 40;
13287
- const startY = 180;
13288
- return `${dropdown}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
14534
+ return `${select}<div class="fig-easing-curve-svg-container"><svg viewBox="0 0 ${size} ${size}" class="fig-easing-curve-svg">
13289
14535
  <rect class="fig-easing-curve-bounds" x="0" y="0" width="${size}" height="${size}"/>
13290
14536
  <line class="fig-easing-curve-target" x1="0" y1="${targetY}" x2="${size}" y2="${targetY}"/>
13291
- <line class="fig-easing-curve-diagonal" x1="0" y1="${startY}" x2="0" y2="${startY}"/>
13292
14537
  <path class="fig-easing-curve-path"/>
13293
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>
13294
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>
13295
14540
  </svg></div>${valueInput}`;
13296
14541
  }
13297
14542
 
13298
- 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">
13299
14544
  <rect class="fig-easing-curve-bounds" x="0" y="0" width="${size}" height="${size}"/>
13300
- <line class="fig-easing-curve-diagonal" x1="0" y1="${size}" x2="${size}" y2="0"/>
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"/>
13301
14548
  <line class="fig-easing-curve-arm" data-arm="1"/>
13302
14549
  <line class="fig-easing-curve-arm" data-arm="2"/>
13303
- <path class="fig-easing-curve-path"/>
13304
- <circle class="fig-easing-curve-endpoint" data-endpoint="start" r="${this.#bezierEndpointRadius}"/>
13305
- <circle class="fig-easing-curve-endpoint" data-endpoint="end" r="${this.#bezierEndpointRadius}"/>
13306
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>
13307
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>
13308
14552
  </svg></div>${valueInput}`;
@@ -13316,10 +14560,6 @@ class FigEasingCurve extends HTMLElement {
13316
14560
  }
13317
14561
 
13318
14562
  #syncMetricsFromCSS() {
13319
- this.#bezierEndpointRadius = this.#readCssNumber(
13320
- "--easing-bezier-endpoint-radius",
13321
- this.#bezierEndpointRadius,
13322
- );
13323
14563
  this.#durationBarRadius = this.#readCssNumber(
13324
14564
  "--easing-duration-bar-radius",
13325
14565
  this.#durationBarRadius,
@@ -13337,13 +14577,12 @@ class FigEasingCurve extends HTMLElement {
13337
14577
  this.#handle2 =
13338
14578
  this.querySelector('[data-handle="2"]') ||
13339
14579
  this.querySelector('[data-handle="duration"]');
13340
- this.#bezierEndpointStart = this.querySelector('[data-endpoint="start"]');
13341
- this.#bezierEndpointEnd = this.querySelector('[data-endpoint="end"]');
13342
- this.#dropdown = this.querySelector(".fig-easing-curve-dropdown");
14580
+ this.#select = this.querySelector(".fig-easing-curve-select");
13343
14581
  this.#valueInput = this.querySelector(".fig-easing-curve-value-input");
13344
14582
  this.#targetLine = this.querySelector(".fig-easing-curve-target");
13345
14583
  this.#bounds = this.querySelector(".fig-easing-curve-bounds");
13346
- this.#diagonal = this.querySelector(".fig-easing-curve-diagonal");
14584
+ this.#boundaryTop = this.querySelector('[data-boundary="top"]');
14585
+ this.#boundaryBottom = this.querySelector('[data-boundary="bottom"]');
13347
14586
  }
13348
14587
 
13349
14588
  #syncHandleSizes() {
@@ -13392,12 +14631,37 @@ class FigEasingCurve extends HTMLElement {
13392
14631
 
13393
14632
  // --- Coordinate helpers ---
13394
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
+
13395
14651
  #toSVG(nx, ny) {
13396
- 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
+ };
13397
14657
  }
13398
14658
 
13399
14659
  #fromSVG(sx, sy) {
13400
- 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
+ };
13401
14665
  }
13402
14666
 
13403
14667
  #springScale = { minVal: 0, maxVal: 1.2, totalTime: 1 };
@@ -13442,17 +14706,20 @@ class FigEasingCurve extends HTMLElement {
13442
14706
  this.#bounds.setAttribute("width", this.#drawWidth);
13443
14707
  this.#bounds.setAttribute("height", this.#drawHeight);
13444
14708
  }
13445
- if (this.#diagonal) {
13446
- this.#diagonal.setAttribute("x1", "0");
13447
- this.#diagonal.setAttribute("y1", this.#drawHeight);
13448
- this.#diagonal.setAttribute("x2", this.#drawWidth);
13449
- this.#diagonal.setAttribute("y2", "0");
13450
- }
13451
-
13452
14709
  const p0 = this.#toSVG(0, 0);
13453
14710
  const p1 = this.#toSVG(this.#cp1.x, this.#cp1.y);
13454
14711
  const p2 = this.#toSVG(this.#cp2.x, this.#cp2.y);
13455
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
+ }
13456
14723
 
13457
14724
  this.#curve.setAttribute(
13458
14725
  "d",
@@ -13471,14 +14738,6 @@ class FigEasingCurve extends HTMLElement {
13471
14738
  this.#handle1.setAttribute("y", p1.y - hr);
13472
14739
  this.#handle2.setAttribute("x", p2.x - hr);
13473
14740
  this.#handle2.setAttribute("y", p2.y - hr);
13474
- if (this.#bezierEndpointStart) {
13475
- this.#bezierEndpointStart.setAttribute("cx", p0.x);
13476
- this.#bezierEndpointStart.setAttribute("cy", p0.y);
13477
- }
13478
- if (this.#bezierEndpointEnd) {
13479
- this.#bezierEndpointEnd.setAttribute("cx", p3.x);
13480
- this.#bezierEndpointEnd.setAttribute("cy", p3.y);
13481
- }
13482
14741
  this.#syncBezierHandleTabOrder();
13483
14742
  }
13484
14743
 
@@ -13571,11 +14830,11 @@ class FigEasingCurve extends HTMLElement {
13571
14830
  return peak;
13572
14831
  }
13573
14832
 
13574
- // --- Dropdown ---
14833
+ // --- Select ---
13575
14834
 
13576
- #syncDropdown() {
13577
- if (!this.#dropdown) return;
13578
- this.#dropdown.value = this.#presetName;
14835
+ #syncSelect() {
14836
+ if (!this.#select) return;
14837
+ this.#select.value = this.#presetName;
13579
14838
  this.#refreshCustomPresetIcons();
13580
14839
  }
13581
14840
 
@@ -13614,23 +14873,24 @@ class FigEasingCurve extends HTMLElement {
13614
14873
  this.#render();
13615
14874
  } else {
13616
14875
  this.#updatePaths();
13617
- this.#syncDropdown();
14876
+ this.#syncSelect();
13618
14877
  if (eventType === "change") this.#syncValueInput();
13619
14878
  }
13620
14879
  this.#emit(eventType);
13621
14880
  }
13622
14881
 
13623
- #setOptionIconByValue(root, optionValue, icon) {
13624
- if (!root) return;
13625
- 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")) {
13626
14885
  if (option.value === optionValue) {
13627
- option.innerHTML = `${icon} ${optionValue}`;
14886
+ const prepend = option.querySelector(':scope > [slot="prepend"]');
14887
+ if (prepend) prepend.innerHTML = icon;
13628
14888
  }
13629
14889
  }
13630
14890
  }
13631
14891
 
13632
14892
  #refreshCustomPresetIcons() {
13633
- if (!this.#dropdown) return;
14893
+ if (!this.#select) return;
13634
14894
  if (!this.#isEditEnabled()) return;
13635
14895
  const bezierIcon = FigEasingCurve.curveIcon(
13636
14896
  this.#cp1.x,
@@ -13640,25 +14900,14 @@ class FigEasingCurve extends HTMLElement {
13640
14900
  );
13641
14901
  const springIcon = FigEasingCurve.#springIcon(this.#spring);
13642
14902
 
13643
- // Update both slotted options and the cloned native select options.
13644
- this.#setOptionIconByValue(this.#dropdown, "Custom bezier", bezierIcon);
13645
- this.#setOptionIconByValue(this.#dropdown, "Custom spring", springIcon);
13646
- this.#setOptionIconByValue(
13647
- this.#dropdown.select,
13648
- "Custom bezier",
13649
- bezierIcon,
13650
- );
13651
- this.#setOptionIconByValue(
13652
- this.#dropdown.select,
13653
- "Custom spring",
13654
- springIcon,
13655
- );
14903
+ this.#setOptionIconByValue("Custom bezier", bezierIcon);
14904
+ this.#setOptionIconByValue("Custom spring", springIcon);
13656
14905
  }
13657
14906
 
13658
14907
  #syncAfterHandleInput(eventType) {
13659
14908
  this.#updatePaths();
13660
14909
  this.#presetName = this.#matchPreset();
13661
- this.#syncDropdown();
14910
+ this.#syncSelect();
13662
14911
  this.#syncValueInput();
13663
14912
  this.#emit(eventType);
13664
14913
  }
@@ -13847,8 +15096,8 @@ class FigEasingCurve extends HTMLElement {
13847
15096
  }
13848
15097
  }
13849
15098
 
13850
- if (this.#dropdown) {
13851
- this.#dropdown.addEventListener("change", (e) => {
15099
+ if (this.#select) {
15100
+ this.#select.addEventListener("change", (e) => {
13852
15101
  const name = e.detail;
13853
15102
  const preset = FigEasingCurve.PRESETS.find((p) => p.name === name);
13854
15103
  if (!preset) return;
@@ -13921,11 +15170,30 @@ class FigEasingCurve extends HTMLElement {
13921
15170
  e.preventDefault();
13922
15171
  this.#isDragging = handle;
13923
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));
13924
15188
 
13925
15189
  const onMove = (e) => {
13926
15190
  if (!this.#isDragging) return;
13927
- const svgPt = this.#clientToSVG(e);
13928
- 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
+ };
13929
15197
 
13930
15198
  norm.x = Math.round(norm.x * 100) / 100;
13931
15199
  norm.y = Math.round(norm.y * 100) / 100;
@@ -13940,7 +15208,7 @@ class FigEasingCurve extends HTMLElement {
13940
15208
  }
13941
15209
  this.#updatePaths();
13942
15210
  this.#presetName = this.#matchPreset();
13943
- this.#syncDropdown();
15211
+ this.#syncSelect();
13944
15212
  this.#syncValueInput();
13945
15213
  this.#emit("input");
13946
15214
  };
@@ -13991,7 +15259,7 @@ class FigEasingCurve extends HTMLElement {
13991
15259
 
13992
15260
  this.#updatePaths();
13993
15261
  this.#presetName = this.#matchPreset();
13994
- this.#syncDropdown();
15262
+ this.#syncSelect();
13995
15263
  this.#syncValueInput();
13996
15264
  this.#emit("input");
13997
15265
  };
@@ -16576,6 +17844,7 @@ class FigChooser extends HTMLElement {
16576
17844
  this.#setupDrag();
16577
17845
  this.#startObserver();
16578
17846
  this.#startResizeObserver();
17847
+ this.#syncDisabledChoices();
16579
17848
 
16580
17849
  figNextFrame(this, () => {
16581
17850
  this.#syncSelection();
@@ -16629,17 +17898,7 @@ class FigChooser extends HTMLElement {
16629
17898
  this.#selectByValue(newValue);
16630
17899
  }
16631
17900
  if (name === "disabled") {
16632
- const isDisabled = newValue !== null && newValue !== "false";
16633
- const choices = this.choices;
16634
- for (const choice of choices) {
16635
- if (isDisabled) {
16636
- choice.setAttribute("aria-disabled", "true");
16637
- choice.setAttribute("tabindex", "-1");
16638
- } else {
16639
- choice.removeAttribute("aria-disabled");
16640
- choice.setAttribute("tabindex", "0");
16641
- }
16642
- }
17901
+ this.#syncDisabledChoices();
16643
17902
  }
16644
17903
  if (name === "choice-element") {
16645
17904
  requestAnimationFrame(() => this.#syncSelection());
@@ -16691,6 +17950,23 @@ class FigChooser extends HTMLElement {
16691
17950
  this.selectedChoice = choices[0];
16692
17951
  }
16693
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
+
16694
17970
  #selectByValue(value) {
16695
17971
  const choices = this.choices;
16696
17972
  for (const choice of choices) {
@@ -17053,6 +18329,7 @@ class FigChooser extends HTMLElement {
17053
18329
  if (this.#isUnwrapping) return;
17054
18330
  this.#removeLegacyScroller();
17055
18331
  this.#applyOverflowMode();
18332
+ this.#syncDisabledChoices();
17056
18333
  const choices = this.choices;
17057
18334
  if (this.#selectedChoice && !choices.includes(this.#selectedChoice)) {
17058
18335
  this.#selectedChoice = null;
@@ -17089,6 +18366,7 @@ class FigHandle extends HTMLElement {
17089
18366
  #isDragging = false;
17090
18367
  #didDrag = false;
17091
18368
  #boundPointerDown = null;
18369
+ #activeDragCleanup = null;
17092
18370
  #applyingValue = false;
17093
18371
  #colorTip = null;
17094
18372
  #directColorPicker = null;
@@ -17527,11 +18805,14 @@ class FigHandle extends HTMLElement {
17527
18805
  }
17528
18806
 
17529
18807
  #teardownDrag() {
18808
+ this.#activeDragCleanup?.();
18809
+ this.#activeDragCleanup = null;
17530
18810
  if (this.#boundPointerDown) {
17531
18811
  this.removeEventListener("pointerdown", this.#boundPointerDown);
17532
18812
  this.#boundPointerDown = null;
17533
18813
  }
17534
18814
  this.#isDragging = false;
18815
+ this.#didDrag = false;
17535
18816
  }
17536
18817
 
17537
18818
  #onPointerDown(e) {
@@ -17540,6 +18821,7 @@ class FigHandle extends HTMLElement {
17540
18821
  const container = this.#getContainer();
17541
18822
  if (!container) return;
17542
18823
 
18824
+ this.#activeDragCleanup?.();
17543
18825
  this.#isDragging = true;
17544
18826
  const axes = this.#axes;
17545
18827
  let lastRect = null;
@@ -17617,11 +18899,7 @@ class FigHandle extends HTMLElement {
17617
18899
  };
17618
18900
 
17619
18901
  const onUp = (e) => {
17620
- this.#isDragging = false;
17621
- this.style.cursor = "";
17622
- this.classList.remove("dragging");
17623
- window.removeEventListener("pointermove", onMove);
17624
- window.removeEventListener("pointerup", onUp);
18902
+ cleanup();
17625
18903
  if (this.#didDrag) {
17626
18904
  clampAndApply(e.clientX, e.clientY, e.shiftKey);
17627
18905
  this.#syncValueAttribute();
@@ -17646,6 +18924,17 @@ class FigHandle extends HTMLElement {
17646
18924
  this.#didDrag = false;
17647
18925
  };
17648
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;
17649
18938
  window.addEventListener("pointermove", onMove);
17650
18939
  window.addEventListener("pointerup", onUp);
17651
18940
  }
@@ -18114,6 +19403,7 @@ class FigMenu extends HTMLElement {
18114
19403
 
18115
19404
  set open(val) {
18116
19405
  if (val) {
19406
+ if (this.#isDisabled()) return;
18117
19407
  this.setAttribute("open", "");
18118
19408
  } else {
18119
19409
  this.removeAttribute("open");
@@ -18160,19 +19450,17 @@ class FigMenu extends HTMLElement {
18160
19450
  if (newValue === null || newValue === "false") {
18161
19451
  this.#closeMenu();
18162
19452
  } else {
19453
+ if (this.#isDisabled()) {
19454
+ this.removeAttribute("open");
19455
+ return;
19456
+ }
18163
19457
  this.#openMenu();
18164
19458
  }
18165
19459
  return;
18166
19460
  }
18167
19461
 
18168
19462
  if (name === "disabled") {
18169
- if (this.#trigger) {
18170
- if (newValue !== null && newValue !== "false") {
18171
- this.#trigger.setAttribute("disabled", "");
18172
- } else {
18173
- this.#trigger.removeAttribute("disabled");
18174
- }
18175
- }
19463
+ this.#syncDisabled();
18176
19464
  return;
18177
19465
  }
18178
19466
 
@@ -18314,8 +19602,12 @@ class FigMenu extends HTMLElement {
18314
19602
  }
18315
19603
 
18316
19604
  #syncDisabled() {
19605
+ const disabled = this.#isDisabled();
19606
+ if (disabled) {
19607
+ if (this.open) this.removeAttribute("open");
19608
+ else this.#closeMenu();
19609
+ }
18317
19610
  if (!this.#trigger) return;
18318
- const disabled = this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false";
18319
19611
  if (disabled) {
18320
19612
  this.#trigger.setAttribute("disabled", "");
18321
19613
  this.#trigger.setAttribute("aria-disabled", "true");
@@ -18326,9 +19618,15 @@ class FigMenu extends HTMLElement {
18326
19618
  }
18327
19619
  }
18328
19620
 
19621
+ #isDisabled() {
19622
+ return (
19623
+ this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false"
19624
+ );
19625
+ }
19626
+
18329
19627
  #handleTriggerClick(e) {
18330
19628
  if (this.#usesContextMenuTrigger()) return;
18331
- if (this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") return;
19629
+ if (this.#isDisabled()) return;
18332
19630
  e.stopPropagation();
18333
19631
  const popupShowing = this.#popup?.matches?.(":open") ?? false;
18334
19632
  if (this.open && !popupShowing) {
@@ -18347,7 +19645,7 @@ class FigMenu extends HTMLElement {
18347
19645
 
18348
19646
  #handleTriggerContextMenu(e) {
18349
19647
  if (!this.#usesContextMenuTrigger()) return;
18350
- if (this.hasAttribute("disabled") && this.getAttribute("disabled") !== "false") return;
19648
+ if (this.#isDisabled()) return;
18351
19649
  e.preventDefault();
18352
19650
  e.stopPropagation();
18353
19651
  this.#showAtAfterPointerRelease(e.clientX, e.clientY);
@@ -18381,6 +19679,7 @@ class FigMenu extends HTMLElement {
18381
19679
  }
18382
19680
 
18383
19681
  #handlePopupClick(e) {
19682
+ if (this.#isDisabled()) return;
18384
19683
  const item = e.target.closest("fig-menu-item");
18385
19684
  if (!item) return;
18386
19685
  if (item.hasAttribute("disabled") && item.getAttribute("disabled") !== "false") return;
@@ -18389,6 +19688,7 @@ class FigMenu extends HTMLElement {
18389
19688
  }
18390
19689
 
18391
19690
  #handleMenuKeydown(e) {
19691
+ if (this.#isDisabled()) return;
18392
19692
  if (e.currentTarget === document && e.key !== "Escape") return;
18393
19693
  if (e.currentTarget === this && this.#popup?.contains(e.target)) return;
18394
19694
  if (!this.open || !this.#popup?.matches?.(":open")) {
@@ -18474,6 +19774,7 @@ class FigMenu extends HTMLElement {
18474
19774
  }
18475
19775
 
18476
19776
  showAt(x, y) {
19777
+ if (this.#isDisabled()) return;
18477
19778
  this.#virtualAnchor = {
18478
19779
  getBoundingClientRect: () => ({
18479
19780
  width: 0,
@@ -18491,12 +19792,16 @@ class FigMenu extends HTMLElement {
18491
19792
  }
18492
19793
  if (this.open) this.open = false;
18493
19794
  requestAnimationFrame(() => {
19795
+ if (this.#isDisabled()) return;
18494
19796
  this.open = true;
18495
19797
  });
18496
19798
  }
18497
19799
 
18498
19800
  #openMenu() {
18499
- if (!this.#popup) return;
19801
+ if (!this.#popup || this.#isDisabled()) {
19802
+ if (this.hasAttribute("open")) this.removeAttribute("open");
19803
+ return;
19804
+ }
18500
19805
  this.#popup.open = true;
18501
19806
  document.addEventListener("keydown", this.#boundMenuKeydown, true);
18502
19807
  if (this.#trigger) {