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