@rogieking/figui3 6.15.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
@@ -22,6 +22,7 @@ class PropskitSwitch extends HTMLElement {
22
22
  #managedSwitchAttrs = new Set();
23
23
  #boundHandleInput = null;
24
24
  #boundHandleChange = null;
25
+ #boundHandleClick = this.#handleClick.bind(this);
25
26
 
26
27
  static get observedAttributes() {
27
28
  return ["label", "direction"];
@@ -32,6 +33,8 @@ class PropskitSwitch extends HTMLElement {
32
33
  this.#syncField();
33
34
  this.#syncSwitchAttributes();
34
35
  this.#bindSwitchEvents();
36
+ this.removeEventListener("click", this.#boundHandleClick);
37
+ this.addEventListener("click", this.#boundHandleClick);
35
38
 
36
39
  if (!this.#observer) {
37
40
  this.#observer = new MutationObserver((mutations) => {
@@ -61,6 +64,7 @@ class PropskitSwitch extends HTMLElement {
61
64
  disconnectedCallback() {
62
65
  this.#observer?.disconnect();
63
66
  this.#unbindSwitchEvents();
67
+ this.removeEventListener("click", this.#boundHandleClick);
64
68
  }
65
69
 
66
70
  attributeChangedCallback(name, oldValue, newValue) {
@@ -193,6 +197,14 @@ class PropskitSwitch extends HTMLElement {
193
197
  );
194
198
  }
195
199
 
200
+ #handleClick(event) {
201
+ if (event.target instanceof Element && event.target.closest("fig-segmented-control")) {
202
+ return;
203
+ }
204
+ const value = this.#switch?.value === "on" ? "off" : "on";
205
+ this.#switch?.querySelector(`fig-segment[value="${value}"]`)?.click();
206
+ }
207
+
196
208
  get checked() {
197
209
  return this.#switch
198
210
  ? this.#switch.value === "on"
@@ -220,6 +232,216 @@ class PropskitSwitch extends HTMLElement {
220
232
  }
221
233
  customElements.define("propskit-switch", PropskitSwitch);
222
234
 
235
+ /* Field + Color wrapper */
236
+ class PropskitColor extends HTMLElement {
237
+ #field = null;
238
+ #label = null;
239
+ #input = null;
240
+ #hasCustomLabel = false;
241
+ #observer = null;
242
+ #managedInputAttrs = new Set();
243
+ #boundHandleInput = null;
244
+ #boundHandleChange = null;
245
+ #boundHandleClick = this.#handleClick.bind(this);
246
+
247
+ static get observedAttributes() {
248
+ return ["label", "direction", "aria-label"];
249
+ }
250
+
251
+ connectedCallback() {
252
+ if (!this.#field) this.#initialize();
253
+ this.#syncField();
254
+ this.#syncInputAttributes();
255
+ this.#bindInputEvents();
256
+ this.removeEventListener("click", this.#boundHandleClick);
257
+ this.addEventListener("click", this.#boundHandleClick);
258
+
259
+ if (!this.#observer) {
260
+ this.#observer = new MutationObserver((mutations) => {
261
+ let syncField = false;
262
+ let syncInput = false;
263
+
264
+ for (const mutation of mutations) {
265
+ if (mutation.type !== "attributes") continue;
266
+ if (
267
+ mutation.attributeName === "label" ||
268
+ mutation.attributeName === "direction" ||
269
+ mutation.attributeName === "aria-label"
270
+ ) {
271
+ syncField = true;
272
+ } else {
273
+ syncInput = true;
274
+ }
275
+ }
276
+
277
+ if (syncField) this.#syncField();
278
+ if (syncInput) this.#syncInputAttributes();
279
+ });
280
+ }
281
+
282
+ this.#observer.observe(this, { attributes: true });
283
+ }
284
+
285
+ disconnectedCallback() {
286
+ this.#observer?.disconnect();
287
+ this.#unbindInputEvents();
288
+ this.removeEventListener("click", this.#boundHandleClick);
289
+ }
290
+
291
+ attributeChangedCallback(name, oldValue, newValue) {
292
+ if (oldValue === newValue || !this.#field) return;
293
+ if (name === "label" || name === "direction" || name === "aria-label") {
294
+ this.#syncField();
295
+ }
296
+ }
297
+
298
+ #initialize() {
299
+ const initialChildren = Array.from(this.childNodes).filter(
300
+ (node) =>
301
+ node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()),
302
+ );
303
+ const customLabel = initialChildren.find(
304
+ (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
305
+ );
306
+ const field = document.createElement("fig-field");
307
+ const label = customLabel || document.createElement("label");
308
+ const input = document.createElement("fig-input-color");
309
+
310
+ for (const node of initialChildren) {
311
+ if (node !== customLabel) input.appendChild(node);
312
+ }
313
+ field.append(label, input);
314
+ this.#field = field;
315
+ this.#label = label;
316
+ this.#input = input;
317
+ this.#hasCustomLabel = Boolean(customLabel);
318
+ this.replaceChildren(field);
319
+ }
320
+
321
+ #syncField() {
322
+ if (!this.#field || !this.#label || !this.#input) return;
323
+ const hasLabelAttr = this.hasAttribute("label");
324
+ const rawLabel = this.getAttribute("label");
325
+ const isBlankLabel = hasLabelAttr && (rawLabel ?? "").trim() === "";
326
+
327
+ if (isBlankLabel) {
328
+ this.#label.remove();
329
+ } else {
330
+ if (!this.#hasCustomLabel) {
331
+ this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
332
+ }
333
+ if (this.#label.parentElement !== this.#field) {
334
+ this.#field.prepend(this.#label);
335
+ }
336
+ }
337
+
338
+ this.#field.setAttribute(
339
+ "direction",
340
+ this.getAttribute("direction") || "horizontal",
341
+ );
342
+ this.#input.setAttribute(
343
+ "aria-label",
344
+ this.getAttribute("aria-label") ||
345
+ this.#label.textContent?.trim() ||
346
+ "Color",
347
+ );
348
+ }
349
+
350
+ #getForwardedInputAttrNames() {
351
+ const reserved = new Set([
352
+ "label",
353
+ "direction",
354
+ "oninput",
355
+ "onchange",
356
+ "class",
357
+ "style",
358
+ "id",
359
+ "size",
360
+ "aria-label",
361
+ "text",
362
+ ]);
363
+ return this.getAttributeNames().filter(
364
+ (name) => !reserved.has(name) && !name.startsWith("data-"),
365
+ );
366
+ }
367
+
368
+ #syncInputAttributes() {
369
+ if (!this.#input) return;
370
+ const inputAttrs = this.#getForwardedInputAttrNames();
371
+ const nextManaged = new Set(inputAttrs);
372
+
373
+ for (const attrName of this.#managedInputAttrs) {
374
+ if (!nextManaged.has(attrName)) this.#input.removeAttribute(attrName);
375
+ }
376
+ for (const attrName of inputAttrs) {
377
+ this.#input.setAttribute(attrName, this.getAttribute(attrName) ?? "");
378
+ }
379
+
380
+ this.#input.setAttribute("text", "true");
381
+ this.#managedInputAttrs = nextManaged;
382
+ }
383
+
384
+ #bindInputEvents() {
385
+ if (!this.#input) return;
386
+ this.#boundHandleInput ??= this.#forwardInputEvent.bind(this, "input");
387
+ this.#boundHandleChange ??= this.#forwardInputEvent.bind(this, "change");
388
+ this.#input.addEventListener("input", this.#boundHandleInput);
389
+ this.#input.addEventListener("change", this.#boundHandleChange);
390
+ }
391
+
392
+ #unbindInputEvents() {
393
+ if (!this.#input) return;
394
+ if (this.#boundHandleInput) {
395
+ this.#input.removeEventListener("input", this.#boundHandleInput);
396
+ }
397
+ if (this.#boundHandleChange) {
398
+ this.#input.removeEventListener("change", this.#boundHandleChange);
399
+ }
400
+ }
401
+
402
+ #forwardInputEvent(type, event) {
403
+ event.stopImmediatePropagation();
404
+ const value = this.#input?.getAttribute("value") ?? "";
405
+ this.setAttribute("value", value);
406
+ const detail =
407
+ event instanceof CustomEvent && event.detail !== undefined
408
+ ? event.detail
409
+ : value;
410
+ this.dispatchEvent(
411
+ new CustomEvent(type, {
412
+ detail,
413
+ bubbles: true,
414
+ cancelable: true,
415
+ composed: true,
416
+ }),
417
+ );
418
+ }
419
+
420
+ #handleClick(event) {
421
+ if (event.target instanceof Element && event.target.closest("fig-input-color")) {
422
+ return;
423
+ }
424
+ this.focus();
425
+ }
426
+
427
+ get value() {
428
+ return this.#input?.getAttribute("value") ?? this.getAttribute("value") ?? "";
429
+ }
430
+
431
+ set value(nextValue) {
432
+ if (nextValue === null || nextValue === undefined || nextValue === "") {
433
+ this.removeAttribute("value");
434
+ } else {
435
+ this.setAttribute("value", String(nextValue));
436
+ }
437
+ }
438
+
439
+ focus(options) {
440
+ this.#input?.querySelector("input:not([tabindex='-1'])")?.focus(options);
441
+ }
442
+ }
443
+ customElements.define("propskit-color", PropskitColor);
444
+
223
445
  /* Field + Select wrapper */
224
446
  class PropskitSelect extends HTMLElement {
225
447
  #field = null;
@@ -230,6 +452,7 @@ class PropskitSelect extends HTMLElement {
230
452
  #managedSelectAttrs = new Set();
231
453
  #boundHandleInput = null;
232
454
  #boundHandleChange = null;
455
+ #boundHandleClick = this.#handleClick.bind(this);
233
456
 
234
457
  static get observedAttributes() {
235
458
  return ["label", "direction", "aria-label"];
@@ -240,6 +463,8 @@ class PropskitSelect extends HTMLElement {
240
463
  this.#syncField();
241
464
  this.#syncSelectAttributes();
242
465
  this.#bindSelectEvents();
466
+ this.removeEventListener("click", this.#boundHandleClick);
467
+ this.addEventListener("click", this.#boundHandleClick);
243
468
 
244
469
  if (!this.#observer) {
245
470
  this.#observer = new MutationObserver((mutations) => {
@@ -270,6 +495,7 @@ class PropskitSelect extends HTMLElement {
270
495
  disconnectedCallback() {
271
496
  this.#observer?.disconnect();
272
497
  this.#unbindSelectEvents();
498
+ this.removeEventListener("click", this.#boundHandleClick);
273
499
  }
274
500
 
275
501
  attributeChangedCallback(name, oldValue, newValue) {
@@ -289,11 +515,21 @@ class PropskitSelect extends HTMLElement {
289
515
  );
290
516
  const field = document.createElement("fig-field");
291
517
  const label = customLabel || document.createElement("label");
292
- 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");
293
527
 
294
528
  for (const node of initialChildren) {
295
- if (node !== customLabel) select.appendChild(node);
529
+ if (node === customLabel || node === existingPanel) continue;
530
+ panel.appendChild(node);
296
531
  }
532
+ select.append(panel);
297
533
  field.append(label, select);
298
534
  this.#field = field;
299
535
  this.#label = label;
@@ -382,6 +618,7 @@ class PropskitSelect extends HTMLElement {
382
618
  }
383
619
 
384
620
  #forwardSelectEvent(type, event) {
621
+ if (event.target !== this.#select) return;
385
622
  event.stopImmediatePropagation();
386
623
  const value = this.#select?.value ?? "";
387
624
  this.setAttribute("value", String(value));
@@ -399,6 +636,16 @@ class PropskitSelect extends HTMLElement {
399
636
  );
400
637
  }
401
638
 
639
+ #handleClick(event) {
640
+ if (event.target instanceof Element && event.target.closest("fig-select")) {
641
+ return;
642
+ }
643
+ this.#select?.focus();
644
+ if (this.#select && !figLabBooleanAttribute(this.#select, "disabled")) {
645
+ this.#select.open = true;
646
+ }
647
+ }
648
+
402
649
  get value() {
403
650
  return this.#select?.value ?? this.getAttribute("value") ?? "";
404
651
  }
@@ -427,6 +674,7 @@ class PropskitText extends HTMLElement {
427
674
  #managedInputAttrs = new Set();
428
675
  #boundHandleInput = null;
429
676
  #boundHandleChange = null;
677
+ #boundHandleClick = this.#handleClick.bind(this);
430
678
 
431
679
  static get observedAttributes() {
432
680
  return ["label", "direction", "aria-label"];
@@ -437,6 +685,8 @@ class PropskitText extends HTMLElement {
437
685
  this.#syncField();
438
686
  this.#syncInputAttributes();
439
687
  this.#bindInputEvents();
688
+ this.removeEventListener("click", this.#boundHandleClick);
689
+ this.addEventListener("click", this.#boundHandleClick);
440
690
 
441
691
  if (!this.#observer) {
442
692
  this.#observer = new MutationObserver((mutations) => {
@@ -467,6 +717,7 @@ class PropskitText extends HTMLElement {
467
717
  disconnectedCallback() {
468
718
  this.#observer?.disconnect();
469
719
  this.#unbindInputEvents();
720
+ this.removeEventListener("click", this.#boundHandleClick);
470
721
  }
471
722
 
472
723
  attributeChangedCallback(name, oldValue, newValue) {
@@ -598,6 +849,13 @@ class PropskitText extends HTMLElement {
598
849
  );
599
850
  }
600
851
 
852
+ #handleClick(event) {
853
+ if (event.target instanceof Element && event.target.closest("fig-input-text")) {
854
+ return;
855
+ }
856
+ this.focus();
857
+ }
858
+
601
859
  get value() {
602
860
  return this.#input?.value ?? this.getAttribute("value") ?? "";
603
861
  }
@@ -626,6 +884,7 @@ class PropskitNumber extends HTMLElement {
626
884
  #managedInputAttrs = new Set();
627
885
  #boundHandleInput = null;
628
886
  #boundHandleChange = null;
887
+ #boundHandleClick = this.#handleClick.bind(this);
629
888
 
630
889
  static get observedAttributes() {
631
890
  return ["label", "direction"];
@@ -636,6 +895,8 @@ class PropskitNumber extends HTMLElement {
636
895
  this.#syncField();
637
896
  this.#syncInputAttributes();
638
897
  this.#bindInputEvents();
898
+ this.removeEventListener("click", this.#boundHandleClick);
899
+ this.addEventListener("click", this.#boundHandleClick);
639
900
 
640
901
  if (!this.#observer) {
641
902
  this.#observer = new MutationObserver((mutations) => {
@@ -665,6 +926,7 @@ class PropskitNumber extends HTMLElement {
665
926
  disconnectedCallback() {
666
927
  this.#observer?.disconnect();
667
928
  this.#unbindInputEvents();
929
+ this.removeEventListener("click", this.#boundHandleClick);
668
930
  }
669
931
 
670
932
  attributeChangedCallback(name, oldValue, newValue) {
@@ -786,6 +1048,13 @@ class PropskitNumber extends HTMLElement {
786
1048
  );
787
1049
  }
788
1050
 
1051
+ #handleClick(event) {
1052
+ if (event.target instanceof Element && event.target.closest("fig-input-number")) {
1053
+ return;
1054
+ }
1055
+ this.focus();
1056
+ }
1057
+
789
1058
  get value() {
790
1059
  return this.#input?.value ?? this.getAttribute("value") ?? "";
791
1060
  }
@@ -4051,6 +4320,7 @@ class FigReorder extends HTMLElement {
4051
4320
  "fig-slider",
4052
4321
  "fig-input-number",
4053
4322
  "fig-input-text",
4323
+ "propskit-color",
4054
4324
  "propskit-number",
4055
4325
  "propskit-select",
4056
4326
  "propskit-slider",
@@ -4465,3 +4735,937 @@ class FigReorder extends HTMLElement {
4465
4735
  }
4466
4736
 
4467
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);