@rogieking/figui3 6.16.0 → 6.18.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-lab.js CHANGED
@@ -455,7 +455,7 @@ class PropskitSelect extends HTMLElement {
455
455
  #boundHandleClick = this.#handleClick.bind(this);
456
456
 
457
457
  static get observedAttributes() {
458
- return ["label", "direction", "aria-label"];
458
+ return ["label", "direction", "aria-label", "options", "value"];
459
459
  }
460
460
 
461
461
  connectedCallback() {
@@ -473,10 +473,11 @@ class PropskitSelect extends HTMLElement {
473
473
 
474
474
  for (const mutation of mutations) {
475
475
  if (mutation.type !== "attributes") continue;
476
+ const name = mutation.attributeName;
476
477
  if (
477
- mutation.attributeName === "label" ||
478
- mutation.attributeName === "direction" ||
479
- mutation.attributeName === "aria-label"
478
+ name === "label" ||
479
+ name === "direction" ||
480
+ name === "aria-label"
480
481
  ) {
481
482
  syncField = true;
482
483
  } else {
@@ -502,24 +503,18 @@ class PropskitSelect extends HTMLElement {
502
503
  if (oldValue === newValue || !this.#field) return;
503
504
  if (name === "label" || name === "direction" || name === "aria-label") {
504
505
  this.#syncField();
506
+ return;
507
+ }
508
+ if (name === "options" || name === "value") {
509
+ this.#syncSelectAttributes();
505
510
  }
506
511
  }
507
512
 
508
513
  #initialize() {
509
- const initialChildren = Array.from(this.childNodes).filter(
510
- (node) =>
511
- node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
512
- );
513
- const customLabel = initialChildren.find(
514
- (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
515
- );
514
+ const customLabel = this.querySelector(":scope > label");
516
515
  const field = document.createElement("fig-field");
517
516
  const label = customLabel || document.createElement("label");
518
- const select = document.createElement("fig-dropdown");
519
-
520
- for (const node of initialChildren) {
521
- if (node !== customLabel) select.appendChild(node);
522
- }
517
+ const select = document.createElement("fig-select");
523
518
  field.append(label, select);
524
519
  this.#field = field;
525
520
  this.#label = label;
@@ -576,7 +571,14 @@ class PropskitSelect extends HTMLElement {
576
571
 
577
572
  #syncSelectAttributes() {
578
573
  if (!this.#select) return;
579
- const selectAttrs = this.#getForwardedSelectAttrNames();
574
+ const selectAttrs = this.#getForwardedSelectAttrNames().sort((a, b) => {
575
+ // Build options before applying value so fig-select can resolve selection.
576
+ if (a === "options") return -1;
577
+ if (b === "options") return 1;
578
+ if (a === "value") return 1;
579
+ if (b === "value") return -1;
580
+ return 0;
581
+ });
580
582
  const nextManaged = new Set(selectAttrs);
581
583
 
582
584
  for (const attrName of this.#managedSelectAttrs) {
@@ -608,6 +610,7 @@ class PropskitSelect extends HTMLElement {
608
610
  }
609
611
 
610
612
  #forwardSelectEvent(type, event) {
613
+ if (event.target !== this.#select) return;
611
614
  event.stopImmediatePropagation();
612
615
  const value = this.#select?.value ?? "";
613
616
  this.setAttribute("value", String(value));
@@ -626,17 +629,12 @@ class PropskitSelect extends HTMLElement {
626
629
  }
627
630
 
628
631
  #handleClick(event) {
629
- if (event.target instanceof Element && event.target.closest("fig-dropdown")) {
632
+ if (event.target instanceof Element && event.target.closest("fig-select")) {
630
633
  return;
631
634
  }
632
- const select = this.#select?.querySelector("select");
633
- select?.focus();
634
- if (typeof select?.showPicker === "function") {
635
- try {
636
- select.showPicker();
637
- } catch {
638
- // Browser may reject showPicker when no user activation is available.
639
- }
635
+ this.#select?.focus();
636
+ if (this.#select && !figLabBooleanAttribute(this.#select, "disabled")) {
637
+ this.#select.open = true;
640
638
  }
641
639
  }
642
640
 
@@ -4729,3 +4727,1056 @@ class FigReorder extends HTMLElement {
4729
4727
  }
4730
4728
 
4731
4729
  customElements.define("fig-reorder", FigReorder);
4730
+
4731
+ /* Select — dropdown-styled trigger + fig-popup listbox */
4732
+ let figLabSelectId = 0;
4733
+ function figLabUniqueId(prefix = "fig-select") {
4734
+ figLabSelectId += 1;
4735
+ return `${prefix}-${figLabSelectId}`;
4736
+ }
4737
+
4738
+ /** Parse options attr — same formats as fig-options / propskit-select. */
4739
+ function figLabParseOptionsAttribute(raw) {
4740
+ const text = raw || "";
4741
+ if (text.startsWith("[")) {
4742
+ try {
4743
+ const parsed = JSON.parse(text);
4744
+ return Array.isArray(parsed) ? parsed : [];
4745
+ } catch {
4746
+ /* fall through */
4747
+ }
4748
+ }
4749
+ const delimiter = text.includes("\n") ? "\n" : ",";
4750
+ return text
4751
+ .split(delimiter)
4752
+ .map((s) => s.trim())
4753
+ .filter(Boolean);
4754
+ }
4755
+
4756
+ function figLabOptionEntryValue(opt) {
4757
+ if (opt && typeof opt === "object") {
4758
+ return String(opt.value ?? opt.label ?? "");
4759
+ }
4760
+ return String(opt ?? "");
4761
+ }
4762
+
4763
+ function figLabOptionEntryLabel(opt) {
4764
+ if (opt && typeof opt === "object") {
4765
+ return String(opt.label ?? opt.value ?? "");
4766
+ }
4767
+ return String(opt ?? "");
4768
+ }
4769
+
4770
+ class FigSelectOption extends HTMLElement {
4771
+ static get observedAttributes() {
4772
+ return ["value", "disabled", "selected"];
4773
+ }
4774
+
4775
+ get value() {
4776
+ const attr = this.getAttribute("value");
4777
+ if (attr !== null) return attr;
4778
+ return (this.textContent || "").trim();
4779
+ }
4780
+
4781
+ set value(val) {
4782
+ if (val === null || val === undefined) {
4783
+ this.removeAttribute("value");
4784
+ } else {
4785
+ this.setAttribute("value", String(val));
4786
+ }
4787
+ }
4788
+
4789
+ get disabled() {
4790
+ return figLabBooleanAttribute(this, "disabled");
4791
+ }
4792
+
4793
+ set disabled(val) {
4794
+ if (val) this.setAttribute("disabled", "");
4795
+ else this.removeAttribute("disabled");
4796
+ }
4797
+
4798
+ get selected() {
4799
+ return figLabBooleanAttribute(this, "selected");
4800
+ }
4801
+
4802
+ set selected(val) {
4803
+ if (val) this.setAttribute("selected", "");
4804
+ else this.removeAttribute("selected");
4805
+ }
4806
+
4807
+ connectedCallback() {
4808
+ if (!this.hasAttribute("role")) this.setAttribute("role", "option");
4809
+ if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
4810
+ this.#syncDisabled();
4811
+ }
4812
+
4813
+ attributeChangedCallback(name, oldValue, newValue) {
4814
+ if (oldValue === newValue) return;
4815
+ if (name === "disabled") this.#syncDisabled();
4816
+ }
4817
+
4818
+ #syncDisabled() {
4819
+ const disabled = this.disabled;
4820
+ if (disabled) {
4821
+ this.setAttribute("aria-disabled", "true");
4822
+ this.setAttribute("tabindex", "-1");
4823
+ } else {
4824
+ this.removeAttribute("aria-disabled");
4825
+ if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1");
4826
+ }
4827
+ }
4828
+ }
4829
+ customElements.define("fig-select-option", FigSelectOption);
4830
+
4831
+ function figLabSyncOverflowState(host, scrollEl, threshold = 2) {
4832
+ if (!host || !scrollEl) return false;
4833
+ const scrollable = scrollEl.scrollHeight - scrollEl.clientHeight > threshold;
4834
+ const atStart = !scrollable || scrollEl.scrollTop <= threshold;
4835
+ const atEnd =
4836
+ !scrollable ||
4837
+ scrollEl.scrollTop + scrollEl.clientHeight >=
4838
+ scrollEl.scrollHeight - threshold;
4839
+ host.classList.toggle("overflow-start", !atStart);
4840
+ host.classList.toggle("overflow-end", !atEnd);
4841
+ return scrollable;
4842
+ }
4843
+
4844
+ function figLabScrollOverflowPage(scrollEl, direction = 1) {
4845
+ if (!scrollEl) return;
4846
+ scrollEl.scrollBy({
4847
+ top: scrollEl.clientHeight * 0.8 * direction,
4848
+ behavior: "smooth",
4849
+ });
4850
+ }
4851
+
4852
+ function figLabCreateOverflowButtons({ onStart, onEnd } = {}) {
4853
+ const makeButton = (direction, onClick) => {
4854
+ const button = document.createElement("button");
4855
+ button.type = "button";
4856
+ button.className = `fig-overflow fig-overflow-${direction}`;
4857
+ button.dataset.figOverflow = direction;
4858
+ button.setAttribute("data-fig-select-nav", direction);
4859
+ button.setAttribute("tabindex", "-1");
4860
+ button.setAttribute(
4861
+ "aria-label",
4862
+ direction === "start" ? "Scroll up" : "Scroll down",
4863
+ );
4864
+ const icon = document.createElement("fig-icon");
4865
+ icon.setAttribute("name", "chevron");
4866
+ icon.setAttribute("size", "small");
4867
+ icon.className = "fig-overflow-chevron";
4868
+ button.appendChild(icon);
4869
+ button.addEventListener("click", (event) => {
4870
+ event.preventDefault();
4871
+ event.stopPropagation();
4872
+ onClick?.(event);
4873
+ });
4874
+ return button;
4875
+ };
4876
+ return {
4877
+ start: makeButton("start", onStart),
4878
+ end: makeButton("end", onEnd),
4879
+ };
4880
+ }
4881
+
4882
+ /** Light-DOM panel wrapper projected into fig-select's popup; owns overflow buttons. */
4883
+ class FigSelectOptions extends HTMLElement {
4884
+ #navStart = null;
4885
+ #navEnd = null;
4886
+ #resizeObserver = null;
4887
+ #boundSyncOverflow = this.syncOverflow.bind(this);
4888
+
4889
+ connectedCallback() {
4890
+ if (!this.hasAttribute("slot")) this.setAttribute("slot", "panel");
4891
+ this.#unwrapLegacyChooser();
4892
+ this.#ensureNavButtons();
4893
+ this.addEventListener("scroll", this.#boundSyncOverflow, { passive: true });
4894
+ this.#resizeObserver?.disconnect();
4895
+ this.#resizeObserver = new ResizeObserver(() => this.syncOverflow());
4896
+ this.#resizeObserver.observe(this);
4897
+ requestAnimationFrame(() => this.syncOverflow());
4898
+ }
4899
+
4900
+ disconnectedCallback() {
4901
+ this.removeEventListener("scroll", this.#boundSyncOverflow);
4902
+ this.#resizeObserver?.disconnect();
4903
+ this.#resizeObserver = null;
4904
+ this.#removeNavButtons();
4905
+ }
4906
+
4907
+ syncOverflow() {
4908
+ return figLabSyncOverflowState(this, this);
4909
+ }
4910
+
4911
+ scrollToOption(option, behavior = "auto") {
4912
+ if (!option || !this.contains(option)) return;
4913
+ requestAnimationFrame(() => {
4914
+ if (!option.isConnected) return;
4915
+ if (this.scrollHeight <= this.clientHeight + 1) {
4916
+ this.syncOverflow();
4917
+ return;
4918
+ }
4919
+ const optionRect = option.getBoundingClientRect();
4920
+ const hostRect = this.getBoundingClientRect();
4921
+ const optionTop = optionRect.top - hostRect.top + this.scrollTop;
4922
+ const maxScroll = this.scrollHeight - this.clientHeight;
4923
+ const top = Math.max(
4924
+ 0,
4925
+ Math.min(
4926
+ optionTop + optionRect.height / 2 - this.clientHeight / 2,
4927
+ maxScroll,
4928
+ ),
4929
+ );
4930
+ this.scrollTo({ top, behavior });
4931
+ this.syncOverflow();
4932
+ });
4933
+ }
4934
+
4935
+ #unwrapLegacyChooser() {
4936
+ const chooser = this.querySelector(":scope > fig-chooser");
4937
+ if (!chooser) return;
4938
+ while (chooser.firstChild) {
4939
+ this.insertBefore(chooser.firstChild, chooser);
4940
+ }
4941
+ chooser.remove();
4942
+ }
4943
+
4944
+ #ensureNavButtons() {
4945
+ if (
4946
+ this.#navStart &&
4947
+ this.#navEnd &&
4948
+ this.contains(this.#navStart) &&
4949
+ this.contains(this.#navEnd)
4950
+ ) {
4951
+ return;
4952
+ }
4953
+ this.#removeNavButtons();
4954
+ const buttons = figLabCreateOverflowButtons({
4955
+ onStart: () => figLabScrollOverflowPage(this, -1),
4956
+ onEnd: () => figLabScrollOverflowPage(this, 1),
4957
+ });
4958
+ this.#navStart = buttons.start;
4959
+ this.#navEnd = buttons.end;
4960
+ this.prepend(this.#navStart);
4961
+ this.append(this.#navEnd);
4962
+ }
4963
+
4964
+ #removeNavButtons() {
4965
+ this.#navStart?.remove();
4966
+ this.#navEnd?.remove();
4967
+ this.#navStart = null;
4968
+ this.#navEnd = null;
4969
+ this.classList.remove("overflow-start", "overflow-end");
4970
+ }
4971
+ }
4972
+ customElements.define("fig-select-options", FigSelectOptions);
4973
+
4974
+ class FigSelect extends HTMLElement {
4975
+ #button = null;
4976
+ #popup = null;
4977
+ #labelEl = null;
4978
+ #panelSlot = null;
4979
+ #observer = null;
4980
+ #initialized = false;
4981
+ #focusedIndex = -1;
4982
+ #syncingValue = false;
4983
+ #popupPositionPatched = false;
4984
+ #originalPositionPopup = null;
4985
+ /** After open align, stop repositioning so overflow scroll isn't yanked back. */
4986
+ #freezeMenuPosition = false;
4987
+ #syncingOptions = false;
4988
+ #boundTriggerClick = this.#handleTriggerClick.bind(this);
4989
+ #boundOptionClick = this.#handleOptionClick.bind(this);
4990
+ #boundKeydown = this.#handleKeydown.bind(this);
4991
+ #boundPopupClose = this.#handlePopupClose.bind(this);
4992
+ #boundSlotChange = this.#handleSlotChange.bind(this);
4993
+
4994
+ static get observedAttributes() {
4995
+ return [
4996
+ "value",
4997
+ "disabled",
4998
+ "label",
4999
+ "options",
5000
+ "position",
5001
+ "offset",
5002
+ "closedby",
5003
+ "open",
5004
+ ];
5005
+ }
5006
+
5007
+ get value() {
5008
+ return this.getAttribute("value") ?? "";
5009
+ }
5010
+
5011
+ set value(val) {
5012
+ if (val === null || val === undefined) this.removeAttribute("value");
5013
+ else this.setAttribute("value", String(val));
5014
+ }
5015
+
5016
+ get open() {
5017
+ return figLabBooleanAttribute(this, "open");
5018
+ }
5019
+
5020
+ set open(val) {
5021
+ if (val) this.setAttribute("open", "");
5022
+ else this.removeAttribute("open");
5023
+ }
5024
+
5025
+ connectedCallback() {
5026
+ if (!this.#initialized) this.#initialize();
5027
+ this.#ensurePanelSlotAttrs();
5028
+ this.#syncOptionsFromAttribute();
5029
+ this.#syncDisabled();
5030
+ this.#syncPopupAttrs();
5031
+ this.#syncValue();
5032
+ this.#setupObserver();
5033
+ if (this.open) this.#openList();
5034
+ }
5035
+
5036
+ disconnectedCallback() {
5037
+ this.#teardownListeners();
5038
+ document.removeEventListener("keydown", this.#boundKeydown, true);
5039
+ this.#observer?.disconnect();
5040
+ this.#observer = null;
5041
+ }
5042
+
5043
+ attributeChangedCallback(name, oldValue, newValue) {
5044
+ if (oldValue === newValue || !this.#initialized) return;
5045
+ if (name === "options") {
5046
+ this.#syncOptionsFromAttribute();
5047
+ this.#syncValue();
5048
+ return;
5049
+ }
5050
+ if (name === "value" || name === "label") {
5051
+ this.#syncValue();
5052
+ return;
5053
+ }
5054
+ if (name === "disabled") {
5055
+ this.#syncDisabled();
5056
+ return;
5057
+ }
5058
+ if (name === "open") {
5059
+ if (newValue === null || newValue === "false") this.#closeList();
5060
+ else this.#openList();
5061
+ return;
5062
+ }
5063
+ if (name === "position" || name === "offset" || name === "closedby") {
5064
+ this.#syncPopupAttrs();
5065
+ }
5066
+ }
5067
+
5068
+ focus(options) {
5069
+ this.#button?.focus(options);
5070
+ }
5071
+
5072
+ blur() {
5073
+ this.#button?.blur();
5074
+ }
5075
+
5076
+ #isMenuChild(node) {
5077
+ return (
5078
+ node?.nodeType === 1 &&
5079
+ (node.tagName === "FIG-SELECT-OPTION" ||
5080
+ node.tagName === "FIG-MENU-SEPARATOR" ||
5081
+ node.tagName === "FIG-SELECT-OPTIONS")
5082
+ );
5083
+ }
5084
+
5085
+ #ensurePanelSlotAttrs() {
5086
+ for (const panel of this.querySelectorAll(":scope > fig-select-options")) {
5087
+ if (!panel.hasAttribute("slot")) panel.setAttribute("slot", "panel");
5088
+ }
5089
+ }
5090
+
5091
+ #getPanel() {
5092
+ const assigned = this.#panelSlot?.assignedElements({ flatten: true }) ?? [];
5093
+ const fromSlot = assigned.find(
5094
+ (el) => el.tagName === "FIG-SELECT-OPTIONS",
5095
+ );
5096
+ if (fromSlot) return fromSlot;
5097
+ return this.querySelector(":scope > fig-select-options");
5098
+ }
5099
+
5100
+ #hasAuthoredOptions() {
5101
+ return Boolean(
5102
+ this.querySelector(
5103
+ ":scope > fig-select-option:not([data-fig-generated]), :scope > fig-select-options > fig-select-option:not([data-fig-generated])",
5104
+ ),
5105
+ );
5106
+ }
5107
+
5108
+ #ensureOptionsPanel() {
5109
+ let panel = this.#getPanel();
5110
+ if (panel) {
5111
+ if (!panel.hasAttribute("slot")) panel.setAttribute("slot", "panel");
5112
+ return panel;
5113
+ }
5114
+ panel = document.createElement("fig-select-options");
5115
+ panel.setAttribute("slot", "panel");
5116
+ panel.setAttribute("data-fig-generated", "");
5117
+ this.appendChild(panel);
5118
+ return panel;
5119
+ }
5120
+
5121
+ /**
5122
+ * When no authored fig-select-option exists, build panel/options from the
5123
+ * options attribute (comma / newline / JSON — same as fig-options).
5124
+ */
5125
+ #syncOptionsFromAttribute() {
5126
+ if (this.#hasAuthoredOptions()) return;
5127
+
5128
+ const hasOptionsAttr = this.hasAttribute("options");
5129
+ const panel = hasOptionsAttr
5130
+ ? this.#ensureOptionsPanel()
5131
+ : this.#getPanel();
5132
+ if (!panel) return;
5133
+
5134
+ this.#syncingOptions = true;
5135
+ try {
5136
+ for (const opt of panel.querySelectorAll(
5137
+ ":scope > fig-select-option[data-fig-generated]",
5138
+ )) {
5139
+ opt.remove();
5140
+ }
5141
+
5142
+ if (!hasOptionsAttr) return;
5143
+
5144
+ const parsed = figLabParseOptionsAttribute(this.getAttribute("options"));
5145
+ const endBtn = panel.querySelector(":scope > .fig-overflow-end");
5146
+ for (const entry of parsed) {
5147
+ const el = document.createElement("fig-select-option");
5148
+ el.setAttribute("data-fig-generated", "");
5149
+ el.setAttribute("value", figLabOptionEntryValue(entry));
5150
+ el.textContent = figLabOptionEntryLabel(entry);
5151
+ if (endBtn) panel.insertBefore(el, endBtn);
5152
+ else panel.appendChild(el);
5153
+ }
5154
+ } finally {
5155
+ this.#syncingOptions = false;
5156
+ }
5157
+ }
5158
+
5159
+ #initialize() {
5160
+ this.#initialized = true;
5161
+ const shadow = this.attachShadow({ mode: "open" });
5162
+ shadow.innerHTML = `
5163
+ <style>
5164
+ :host {
5165
+ display: inline-flex;
5166
+ position: relative;
5167
+ align-items: center;
5168
+ min-width: 0;
5169
+ }
5170
+ :host([full]:not([full="false"])) {
5171
+ display: flex;
5172
+ width: 100%;
5173
+ }
5174
+ .fig-select-trigger {
5175
+ display: flex;
5176
+ align-items: center;
5177
+ justify-content: flex-start;
5178
+ flex: 1;
5179
+ min-width: 0;
5180
+ width: var(--fig-select-trigger-width, 100%);
5181
+ height: 100%;
5182
+ margin: 0;
5183
+ padding: 0 var(--spacer-4, 1rem) 0 var(--spacer-2, 0.5rem);
5184
+ border: 0;
5185
+ border-radius: inherit;
5186
+ background: transparent;
5187
+ box-shadow: none;
5188
+ color: inherit;
5189
+ font: inherit;
5190
+ font-weight: inherit;
5191
+ text-align: left;
5192
+ white-space: nowrap;
5193
+ overflow: hidden;
5194
+ text-overflow: ellipsis;
5195
+ cursor: default;
5196
+ }
5197
+ .fig-select-trigger:hover,
5198
+ .fig-select-trigger:active,
5199
+ .fig-select-trigger:active:hover {
5200
+ background: transparent;
5201
+ box-shadow: none;
5202
+ color: inherit;
5203
+ }
5204
+ .fig-select-trigger:focus-visible,
5205
+ .fig-select-trigger[data-focus-visible] {
5206
+ outline: var(--figma-focus-outline);
5207
+ outline-offset: var(--figma-focus-outline-offset);
5208
+ }
5209
+ :host([disabled]:not([disabled="false"])) .fig-select-trigger,
5210
+ :host([disabled]:not([disabled="false"])) .fig-select-label {
5211
+ color: var(--figma-color-text-tertiary);
5212
+ }
5213
+ .fig-select-label {
5214
+ display: block;
5215
+ width: 100%;
5216
+ min-width: 0;
5217
+ overflow: hidden;
5218
+ text-overflow: ellipsis;
5219
+ white-space: nowrap;
5220
+ text-align: left;
5221
+ }
5222
+ /* Listbox chrome from document fig-select::part(listbox).
5223
+ Overflow UI lives on slotted fig-select-options.
5224
+ Never set display except when open — closed <dialog> must stay display:none. */
5225
+ dialog[is="fig-popup"] {
5226
+ flex-direction: column;
5227
+ overflow: hidden;
5228
+ }
5229
+ dialog[is="fig-popup"][open] {
5230
+ display: flex;
5231
+ }
5232
+ ::slotted(fig-select-options) {
5233
+ flex: 1 1 auto;
5234
+ min-height: 0;
5235
+ max-height: inherit;
5236
+ }
5237
+ </style>
5238
+ `;
5239
+
5240
+ const button = document.createElement("fig-button");
5241
+ button.className = "fig-select-trigger";
5242
+ button.setAttribute("part", "trigger");
5243
+ button.setAttribute("variant", "ghost");
5244
+ button.setAttribute("aria-haspopup", "listbox");
5245
+ button.setAttribute("aria-expanded", "false");
5246
+
5247
+ const labelEl = document.createElement("span");
5248
+ labelEl.className = "fig-select-label";
5249
+ labelEl.setAttribute("part", "label");
5250
+ button.appendChild(labelEl);
5251
+
5252
+ const popup = document.createElement("dialog", { is: "fig-popup" });
5253
+ popup.setAttribute("is", "fig-popup");
5254
+ popup.setAttribute("part", "listbox");
5255
+ popup.setAttribute("theme", "menu");
5256
+ popup.setAttribute("role", "listbox");
5257
+ popup.id = figLabUniqueId("fig-select-list");
5258
+ button.setAttribute("aria-controls", popup.id);
5259
+
5260
+ const panelSlot = document.createElement("slot");
5261
+ panelSlot.setAttribute("name", "panel");
5262
+ popup.appendChild(panelSlot);
5263
+
5264
+ shadow.append(button, popup);
5265
+
5266
+ this.#button = button;
5267
+ this.#labelEl = labelEl;
5268
+ this.#popup = popup;
5269
+ this.#panelSlot = panelSlot;
5270
+ popup.anchor = button;
5271
+
5272
+ this.#ensurePanelSlotAttrs();
5273
+ this.#setupListeners();
5274
+ this.#installPopupPositioning();
5275
+
5276
+ if (!this.hasAttribute("value")) {
5277
+ const selected = this.#getOptions().find((opt) =>
5278
+ figLabBooleanAttribute(opt, "selected"),
5279
+ );
5280
+ if (selected) this.setAttribute("value", selected.value);
5281
+ }
5282
+ }
5283
+
5284
+ #installPopupPositioning() {
5285
+ if (!this.#popup || this.#popupPositionPatched) return;
5286
+ if (typeof this.#popup.positionPopup !== "function") return;
5287
+ this.#originalPositionPopup = this.#popup.positionPopup.bind(this.#popup);
5288
+ this.#popup.positionPopup = () => {
5289
+ if (!this.open) {
5290
+ this.#originalPositionPopup?.();
5291
+ return;
5292
+ }
5293
+ this.#positionPopupOverSelected();
5294
+ };
5295
+ this.#popupPositionPatched = true;
5296
+ }
5297
+
5298
+ #getOptionTextRect(option) {
5299
+ if (!option) return null;
5300
+ const range = document.createRange();
5301
+ range.selectNodeContents(option);
5302
+ const rects = [...range.getClientRects()].filter(
5303
+ (rect) => rect.width > 0 && rect.height > 0,
5304
+ );
5305
+ if (rects.length) return rects[0];
5306
+ return option.getBoundingClientRect();
5307
+ }
5308
+
5309
+ #getViewportMargins() {
5310
+ if (typeof this.#popup?.parseViewportMargins === "function") {
5311
+ return this.#popup.parseViewportMargins();
5312
+ }
5313
+ return { top: 8, right: 8, bottom: 8, left: 8 };
5314
+ }
5315
+
5316
+ #positionPopupOverSelected() {
5317
+ // Content ResizeObserver / anchor tracking re-enter here after overflow
5318
+ // scroll; keep the open-time alignment instead of fighting the scroller.
5319
+ if (this.#freezeMenuPosition) return;
5320
+
5321
+ const popup = this.#popup;
5322
+ const label = this.#labelEl;
5323
+ if (!popup || !label) {
5324
+ this.#originalPositionPopup?.();
5325
+ return;
5326
+ }
5327
+
5328
+ const options = this.#getOptions();
5329
+ const selected =
5330
+ options.find((opt) => this.#optionValue(opt) === this.value) ||
5331
+ options[0];
5332
+ if (!selected) {
5333
+ this.#originalPositionPopup?.();
5334
+ return;
5335
+ }
5336
+
5337
+ // Lay out with the default positioning first so option metrics are valid.
5338
+ this.#originalPositionPopup?.();
5339
+
5340
+ const popupRect = popup.getBoundingClientRect();
5341
+ const labelRect = label.getBoundingClientRect();
5342
+ const optionTextRect = this.#getOptionTextRect(selected);
5343
+ if (
5344
+ !popupRect.width ||
5345
+ !popupRect.height ||
5346
+ !labelRect.width ||
5347
+ !optionTextRect
5348
+ ) {
5349
+ return;
5350
+ }
5351
+
5352
+ const selectedOffsetX = optionTextRect.left - popupRect.left;
5353
+ const selectedOffsetY = optionTextRect.top - popupRect.top;
5354
+ let left = labelRect.left - selectedOffsetX;
5355
+ let top = labelRect.top - selectedOffsetY;
5356
+
5357
+ const margins = this.#getViewportMargins();
5358
+ const minLeft = margins.left;
5359
+ const minTop = margins.top;
5360
+ const maxLeft = window.innerWidth - popupRect.width - margins.right;
5361
+ const maxTop = window.innerHeight - popupRect.height - margins.bottom;
5362
+ left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft));
5363
+ top = Math.min(Math.max(top, minTop), Math.max(minTop, maxTop));
5364
+
5365
+ popup.style.left = `${Math.round(left)}px`;
5366
+ popup.style.top = `${Math.round(top)}px`;
5367
+
5368
+ // Nudge the panel scroller so the selected label stays over the trigger.
5369
+ const panel = this.#getPanel();
5370
+ const alignedTextRect = this.#getOptionTextRect(selected);
5371
+ if (
5372
+ alignedTextRect &&
5373
+ panel &&
5374
+ panel.scrollHeight > panel.clientHeight + 1
5375
+ ) {
5376
+ const deltaY = alignedTextRect.top - labelRect.top;
5377
+ if (Math.abs(deltaY) > 0.5) {
5378
+ panel.scrollTop += deltaY;
5379
+ }
5380
+ panel.syncOverflow?.();
5381
+ }
5382
+ }
5383
+
5384
+ #setupListeners() {
5385
+ this.#button?.addEventListener("click", this.#boundTriggerClick);
5386
+ this.#button?.addEventListener("keydown", this.#boundKeydown);
5387
+ // Host click: slotted options stay in light DOM (not dialog.contains).
5388
+ this.addEventListener("click", this.#boundOptionClick);
5389
+ this.#popup?.addEventListener("keydown", this.#boundKeydown);
5390
+ this.#popup?.addEventListener("close", this.#boundPopupClose);
5391
+ this.#panelSlot?.addEventListener("slotchange", this.#boundSlotChange);
5392
+ }
5393
+
5394
+ #teardownListeners() {
5395
+ this.#button?.removeEventListener("click", this.#boundTriggerClick);
5396
+ this.#button?.removeEventListener("keydown", this.#boundKeydown);
5397
+ this.removeEventListener("click", this.#boundOptionClick);
5398
+ this.#popup?.removeEventListener("keydown", this.#boundKeydown);
5399
+ this.#popup?.removeEventListener("close", this.#boundPopupClose);
5400
+ this.#panelSlot?.removeEventListener("slotchange", this.#boundSlotChange);
5401
+ }
5402
+
5403
+ #handleSlotChange() {
5404
+ this.#ensurePanelSlotAttrs();
5405
+ this.#syncValue();
5406
+ }
5407
+
5408
+ #setupObserver() {
5409
+ if (this.#observer) return;
5410
+ this.#observer = new MutationObserver((mutations) => {
5411
+ if (this.#syncingValue || this.#syncingOptions) return;
5412
+ let needsSync = false;
5413
+ for (const mutation of mutations) {
5414
+ if (mutation.type === "childList") {
5415
+ if (
5416
+ [...mutation.addedNodes].some((node) => this.#isMenuChild(node)) ||
5417
+ [...mutation.removedNodes].some((node) => this.#isMenuChild(node))
5418
+ ) {
5419
+ needsSync = true;
5420
+ }
5421
+ }
5422
+ if (
5423
+ mutation.type === "attributes" &&
5424
+ mutation.target?.tagName === "FIG-SELECT-OPTION" &&
5425
+ (mutation.attributeName === "value" ||
5426
+ mutation.attributeName === "disabled")
5427
+ ) {
5428
+ needsSync = true;
5429
+ }
5430
+ if (
5431
+ mutation.type === "characterData" &&
5432
+ mutation.target?.parentElement?.tagName === "FIG-SELECT-OPTION"
5433
+ ) {
5434
+ needsSync = true;
5435
+ }
5436
+ }
5437
+ if (needsSync) this.#syncValue();
5438
+ });
5439
+ this.#observer.observe(this, {
5440
+ childList: true,
5441
+ subtree: true,
5442
+ characterData: true,
5443
+ attributes: true,
5444
+ attributeFilter: ["value", "disabled", "selected"],
5445
+ });
5446
+ }
5447
+
5448
+ #getOptions({ enabledOnly = false } = {}) {
5449
+ const panel = this.#getPanel();
5450
+ const options = panel
5451
+ ? Array.from(panel.querySelectorAll(":scope > fig-select-option"))
5452
+ : [];
5453
+ if (!enabledOnly) return options;
5454
+ return options.filter((opt) => !figLabBooleanAttribute(opt, "disabled"));
5455
+ }
5456
+
5457
+ #optionValue(option) {
5458
+ if (!option) return "";
5459
+ if (typeof option.value === "string") return option.value;
5460
+ const attr = option.getAttribute?.("value");
5461
+ if (attr != null) return attr;
5462
+ return (option.textContent || "").trim();
5463
+ }
5464
+
5465
+ #optionLabel(option) {
5466
+ return (option?.textContent || "").trim();
5467
+ }
5468
+
5469
+ #syncPopupAttrs() {
5470
+ if (!this.#popup) return;
5471
+ this.#popup.setAttribute(
5472
+ "position",
5473
+ this.getAttribute("position") || "bottom left",
5474
+ );
5475
+ const offset = this.getAttribute("offset");
5476
+ if (offset) this.#popup.setAttribute("offset", offset);
5477
+ else this.#popup.removeAttribute("offset");
5478
+ const closedby = this.getAttribute("closedby");
5479
+ if (closedby) this.#popup.setAttribute("closedby", closedby);
5480
+ else this.#popup.removeAttribute("closedby");
5481
+ }
5482
+
5483
+ #syncDisabled() {
5484
+ const disabled = figLabBooleanAttribute(this, "disabled");
5485
+ if (this.#button) {
5486
+ if (disabled) this.#button.setAttribute("disabled", "");
5487
+ else this.#button.removeAttribute("disabled");
5488
+ }
5489
+ if (disabled && this.open) this.open = false;
5490
+ }
5491
+
5492
+ #pickFallbackOption(options) {
5493
+ if (!options.length) return null;
5494
+ const selected = options.find((opt) =>
5495
+ figLabBooleanAttribute(opt, "selected"),
5496
+ );
5497
+ if (selected && !figLabBooleanAttribute(selected, "disabled")) {
5498
+ return selected;
5499
+ }
5500
+ return (
5501
+ options.find((opt) => !figLabBooleanAttribute(opt, "disabled")) ||
5502
+ options[0] ||
5503
+ null
5504
+ );
5505
+ }
5506
+
5507
+ #emitValueEvents(value) {
5508
+ this.dispatchEvent(
5509
+ new CustomEvent("input", {
5510
+ detail: value,
5511
+ bubbles: true,
5512
+ composed: true,
5513
+ }),
5514
+ );
5515
+ this.dispatchEvent(
5516
+ new CustomEvent("change", {
5517
+ detail: value,
5518
+ bubbles: true,
5519
+ composed: true,
5520
+ }),
5521
+ );
5522
+ }
5523
+
5524
+ #syncValue() {
5525
+ if (this.#syncingValue) return;
5526
+ this.#syncingValue = true;
5527
+ try {
5528
+ const options = this.#getOptions();
5529
+ const hasValueAttr = this.hasAttribute("value");
5530
+ const previousValue = hasValueAttr ? this.getAttribute("value") : null;
5531
+ let match = hasValueAttr
5532
+ ? options.find((opt) => this.#optionValue(opt) === previousValue)
5533
+ : null;
5534
+ let valueCorrected = false;
5535
+
5536
+ if (!match) {
5537
+ if (hasValueAttr) {
5538
+ // Options may not be built yet (options attr sync). Keep value until then.
5539
+ if (!options.length) {
5540
+ if (this.#labelEl) {
5541
+ this.#labelEl.textContent =
5542
+ previousValue || this.getAttribute("label") || "";
5543
+ }
5544
+ return;
5545
+ }
5546
+ // Value orphaned (option removed / value attr changed) — clamp or clear.
5547
+ match = this.#pickFallbackOption(options);
5548
+ if (match) {
5549
+ const nextValue = this.#optionValue(match);
5550
+ if (previousValue !== nextValue) {
5551
+ this.setAttribute("value", nextValue);
5552
+ valueCorrected = true;
5553
+ }
5554
+ } else {
5555
+ this.removeAttribute("value");
5556
+ valueCorrected = true;
5557
+ }
5558
+ } else {
5559
+ // No host value yet — honor a selected option if present.
5560
+ match = options.find((opt) =>
5561
+ figLabBooleanAttribute(opt, "selected"),
5562
+ );
5563
+ if (match) {
5564
+ this.setAttribute("value", this.#optionValue(match));
5565
+ valueCorrected = true;
5566
+ }
5567
+ }
5568
+ }
5569
+
5570
+ for (const opt of options) {
5571
+ const selected = opt === match;
5572
+ opt.setAttribute("aria-selected", selected ? "true" : "false");
5573
+ if (selected) opt.setAttribute("selected", "");
5574
+ else opt.removeAttribute("selected");
5575
+ }
5576
+
5577
+ const label =
5578
+ (match && this.#optionLabel(match)) || this.getAttribute("label") || "";
5579
+ if (this.#labelEl) this.#labelEl.textContent = label;
5580
+
5581
+ const ariaLabel = this.getAttribute("label") || "Select";
5582
+ this.#button?.setAttribute("aria-label", ariaLabel);
5583
+
5584
+ // Don't scrollToOption while open — reposition/sync would fight overflow paging.
5585
+ this.#getPanel()?.syncOverflow?.();
5586
+
5587
+ if (valueCorrected) {
5588
+ this.#emitValueEvents(this.getAttribute("value") ?? "");
5589
+ }
5590
+ } finally {
5591
+ this.#syncingValue = false;
5592
+ }
5593
+ }
5594
+
5595
+ #handleTriggerClick(e) {
5596
+ if (figLabBooleanAttribute(this, "disabled")) return;
5597
+ e.preventDefault();
5598
+ e.stopPropagation();
5599
+ const popupShowing = this.#popup?.matches?.(":open") ?? false;
5600
+ if (this.open && popupShowing) this.open = false;
5601
+ else {
5602
+ if (this.#popup && this.#button) this.#popup.anchor = this.#button;
5603
+ this.open = true;
5604
+ }
5605
+ }
5606
+
5607
+ #handleOptionClick(e) {
5608
+ const path = typeof e.composedPath === "function" ? e.composedPath() : [];
5609
+ const option = path.find(
5610
+ (node) => node?.tagName === "FIG-SELECT-OPTION",
5611
+ );
5612
+ if (!option || !this.contains(option)) return;
5613
+ if (figLabBooleanAttribute(option, "disabled")) return;
5614
+ // Do not stopPropagation — React light-DOM onClick must still fire.
5615
+ this.#selectOption(option);
5616
+ }
5617
+
5618
+ #handleKeydown(e) {
5619
+ if (e.currentTarget === document && e.key !== "Escape") return;
5620
+
5621
+ const listOpen = this.open && (this.#popup?.matches?.(":open") ?? false);
5622
+ if (!listOpen) {
5623
+ if (
5624
+ this.#button?.contains(e.target) &&
5625
+ (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ")
5626
+ ) {
5627
+ e.preventDefault();
5628
+ if (this.#popup && this.#button) this.#popup.anchor = this.#button;
5629
+ this.open = true;
5630
+ requestAnimationFrame(() => {
5631
+ const options = this.#getOptions({ enabledOnly: true });
5632
+ const selectedIndex = options.findIndex(
5633
+ (opt) => this.#optionValue(opt) === this.value,
5634
+ );
5635
+ this.#focusOptionAt(selectedIndex >= 0 ? selectedIndex : 0);
5636
+ });
5637
+ }
5638
+ return;
5639
+ }
5640
+
5641
+ const options = this.#getOptions({ enabledOnly: true });
5642
+ if (!options.length) return;
5643
+
5644
+ switch (e.key) {
5645
+ case "ArrowDown":
5646
+ e.preventDefault();
5647
+ this.#syncFocusedIndex();
5648
+ this.#focusOptionAt(this.#focusedIndex + 1);
5649
+ break;
5650
+ case "ArrowUp":
5651
+ e.preventDefault();
5652
+ this.#syncFocusedIndex();
5653
+ this.#focusOptionAt(this.#focusedIndex - 1);
5654
+ break;
5655
+ case "Home":
5656
+ e.preventDefault();
5657
+ this.#focusOptionAt(0);
5658
+ break;
5659
+ case "End":
5660
+ e.preventDefault();
5661
+ this.#focusOptionAt(options.length - 1);
5662
+ break;
5663
+ case "Escape":
5664
+ e.preventDefault();
5665
+ this.open = false;
5666
+ this.#button?.focus();
5667
+ break;
5668
+ case "Enter":
5669
+ case " ": {
5670
+ this.#syncFocusedIndex();
5671
+ const focused = options[this.#focusedIndex];
5672
+ if (!focused) return;
5673
+ e.preventDefault();
5674
+ this.#selectOption(focused);
5675
+ break;
5676
+ }
5677
+ }
5678
+ }
5679
+
5680
+ #handlePopupClose() {
5681
+ if (this.hasAttribute("open")) this.removeAttribute("open");
5682
+ this.#button?.setAttribute("aria-expanded", "false");
5683
+ this.#button?.focus();
5684
+ this.#focusedIndex = -1;
5685
+ }
5686
+
5687
+ #selectOption(option) {
5688
+ const value = this.#optionValue(option);
5689
+ this.setAttribute("value", value);
5690
+ this.#syncValue();
5691
+ this.#emitValueEvents(value);
5692
+ this.open = false;
5693
+ }
5694
+
5695
+ #getEnabledOptions() {
5696
+ return this.#getOptions({ enabledOnly: true });
5697
+ }
5698
+
5699
+ #syncFocusedIndex() {
5700
+ const options = this.#getEnabledOptions();
5701
+ if (!options.length) {
5702
+ this.#focusedIndex = -1;
5703
+ return;
5704
+ }
5705
+ const active = options.find((opt) => opt === document.activeElement);
5706
+ const index = active ? options.indexOf(active) : -1;
5707
+ this.#focusedIndex = index >= 0 ? index : this.#focusedIndex;
5708
+ }
5709
+
5710
+ #focusOptionAt(index) {
5711
+ const options = this.#getEnabledOptions();
5712
+ if (!options.length) return;
5713
+ const next = ((index % options.length) + options.length) % options.length;
5714
+ this.#focusedIndex = next;
5715
+ options[next]?.focus();
5716
+ }
5717
+
5718
+ #syncPopupWidth() {
5719
+ if (!this.#popup || !this.#button) return;
5720
+ const hostWidth = Math.ceil(this.getBoundingClientRect().width);
5721
+ const triggerWidth = Math.ceil(this.#button.getBoundingClientRect().width);
5722
+ const anchorWidth = Math.max(hostWidth, triggerWidth, 96);
5723
+ const full = figLabBooleanAttribute(this, "full");
5724
+
5725
+ // Use !important — fig-select::part(listbox) width rules beat element.style.
5726
+ if (full) {
5727
+ this.#popup.style.setProperty("width", `${anchorWidth}px`, "important");
5728
+ this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
5729
+ this.#popup.style.setProperty("max-width", `${anchorWidth}px`, "important");
5730
+ } else {
5731
+ this.#popup.style.setProperty("width", "max-content", "important");
5732
+ this.#popup.style.setProperty("min-width", `${anchorWidth}px`, "important");
5733
+ this.#popup.style.setProperty(
5734
+ "max-width",
5735
+ "min(20rem, calc(100vw - 1rem))",
5736
+ "important",
5737
+ );
5738
+ }
5739
+ }
5740
+
5741
+ #openList() {
5742
+ if (!this.#popup || figLabBooleanAttribute(this, "disabled")) return;
5743
+ if (this.#button) this.#popup.anchor = this.#button;
5744
+ this.#installPopupPositioning();
5745
+ this.#freezeMenuPosition = false;
5746
+ this.#syncValue();
5747
+ this.#syncPopupWidth();
5748
+ this.#popup.open = true;
5749
+ document.addEventListener("keydown", this.#boundKeydown, true);
5750
+ this.#button?.setAttribute("aria-expanded", "true");
5751
+ this.#focusedIndex = -1;
5752
+ requestAnimationFrame(() => {
5753
+ this.#syncPopupWidth();
5754
+ this.#positionPopupOverSelected();
5755
+ const panel = this.#getPanel();
5756
+ const options = this.#getEnabledOptions();
5757
+ const selectedIndex = options.findIndex(
5758
+ (opt) => this.#optionValue(opt) === this.value,
5759
+ );
5760
+ if (selectedIndex >= 0) {
5761
+ this.#focusOptionAt(selectedIndex);
5762
+ } else if (
5763
+ this.#button?.hasAttribute("data-focus-visible") ||
5764
+ this.#button?.matches?.(":focus-visible")
5765
+ ) {
5766
+ this.#focusOptionAt(0);
5767
+ }
5768
+ panel?.syncOverflow?.();
5769
+ // Freeze after open align so later positionPopup passes don't undo scroll.
5770
+ this.#freezeMenuPosition = true;
5771
+ });
5772
+ }
5773
+
5774
+ #closeList() {
5775
+ if (!this.#popup) return;
5776
+ this.#freezeMenuPosition = false;
5777
+ document.removeEventListener("keydown", this.#boundKeydown, true);
5778
+ this.#popup.open = false;
5779
+ this.#button?.setAttribute("aria-expanded", "false");
5780
+ }
5781
+ }
5782
+ customElements.define("fig-select", FigSelect);