@rogieking/figui3 6.20.2 → 6.21.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.css CHANGED
@@ -137,27 +137,33 @@ fig-content:has(> propskit-group[name]:last-child) {
137
137
  padding-bottom: 0;
138
138
  }
139
139
 
140
- /* Large size: more left field padding; nudge trailing controls in from the right.
141
- propskit-select applies left inset on label + trigger button instead (below). */
140
+ /* Default/large size: more left field padding; nudge trailing controls in from
141
+ the right. propskit-select applies left inset on label + trigger instead. */
142
142
  :is(
143
143
  propskit-switch,
144
144
  propskit-color,
145
145
  propskit-text,
146
146
  propskit-number,
147
147
  propskit-slider
148
- )[size="large"] {
148
+ ):is(:not([size]), [size="large"]) {
149
149
  fig-field:not([direction="vertical"]) {
150
150
  padding-left: var(--spacer-2-5);
151
151
  }
152
152
  }
153
153
 
154
154
  /* Flex-positioned numbers — margin works. Absolute numbers use right: calc(...) below. */
155
- :is(propskit-color, propskit-number)[size="large"] {
155
+ :is(propskit-color, propskit-number):is(:not([size]), [size="large"]) {
156
156
  fig-field fig-input-number {
157
157
  margin-right: var(--spacer-1);
158
158
  }
159
159
  }
160
160
 
161
+ propskit-text:is(:not([size]), [size="large"]) {
162
+ fig-field > fig-input-text {
163
+ margin-right: var(--spacer-1);
164
+ }
165
+ }
166
+
161
167
  propskit-switch {
162
168
  --propskit-switch-height: 2rem;
163
169
  --propskit-switch-vertical-padding: var(--spacer-1);
@@ -169,6 +175,7 @@ propskit-switch {
169
175
  padding: var(--propskit-switch-vertical-padding)
170
176
  var(--propskit-switch-horizontal-padding);
171
177
 
178
+ &:not([size]),
172
179
  &[size="large"] {
173
180
  --propskit-switch-vertical-padding: 0px;
174
181
 
@@ -245,6 +252,7 @@ propskit-color {
245
252
  padding: var(--propskit-color-vertical-padding)
246
253
  var(--propskit-color-horizontal-padding);
247
254
 
255
+ &:not([size]),
248
256
  &[size="large"] {
249
257
  --propskit-color-vertical-padding: 0px;
250
258
  }
@@ -368,6 +376,7 @@ propskit-select {
368
376
  padding: var(--propskit-select-vertical-padding)
369
377
  var(--propskit-select-horizontal-padding);
370
378
 
379
+ &:not([size]),
371
380
  &[size="large"] {
372
381
  --propskit-select-vertical-padding: 0px;
373
382
 
@@ -477,6 +486,7 @@ propskit-text {
477
486
  padding: var(--propskit-text-vertical-padding)
478
487
  var(--propskit-text-horizontal-padding);
479
488
 
489
+ &:not([size]),
480
490
  &[size="large"] {
481
491
  --propskit-text-vertical-padding: 0px;
482
492
  }
@@ -586,6 +596,7 @@ propskit-number {
586
596
  padding: var(--propskit-number-vertical-padding)
587
597
  var(--propskit-number-horizontal-padding);
588
598
 
599
+ &:not([size]),
589
600
  &[size="large"] {
590
601
  --propskit-number-vertical-padding: 0px;
591
602
 
@@ -714,6 +725,7 @@ propskit-slider {
714
725
  padding-left: var(--propskit-slider-horizontal-padding);
715
726
  padding-right: var(--propskit-slider-horizontal-padding);
716
727
 
728
+ &:not([size]),
717
729
  &[size="large"] {
718
730
  --propskit-slider-vertical-padding: 0px;
719
731
  --propskit-slider-fig-slider-thumb-height: calc(
@@ -1496,9 +1508,9 @@ fig-select::part(listbox) {
1496
1508
  background-color: var(--fig-popup-bg-color);
1497
1509
  color: var(--figma-color-text-menu);
1498
1510
  color-scheme: dark;
1499
- box-shadow: var(--figma-elevation-100);
1511
+ box-shadow: var(--figma-elevation-400-menu-panel);
1500
1512
  /* width/min/max set in JS — ::part width rules override element.style. */
1501
- max-height: min(20rem, calc(100vh - 1rem));
1513
+ max-height: calc(100vh - 1rem);
1502
1514
  height: max-content;
1503
1515
  }
1504
1516
 
@@ -1512,7 +1524,7 @@ fig-select-options {
1512
1524
  gap: 0;
1513
1525
  width: 100%;
1514
1526
  max-width: 100%;
1515
- max-height: min(20rem, calc(100vh - 1rem));
1527
+ max-height: calc(100vh - 1rem);
1516
1528
  /* No block padding — sticky overflow buttons must sit flush to the popup edges. */
1517
1529
  padding: 0;
1518
1530
  overflow: hidden auto;
@@ -1595,56 +1607,35 @@ fig-select-options {
1595
1607
  & > fig-menu-separator {
1596
1608
  --fig-menu-separator-color: var(--figma-color-border-menu);
1597
1609
  --fig-menu-separator-label-color: var(--figma-color-text-menu-secondary);
1598
- /* Align label with option text (padding + checkmark column). */
1599
- --fig-menu-item-padding: calc(var(--spacer-1) * 2 + var(--spacer-1) + 1rem);
1610
+
1611
+ /* Align label with option text (padding + checkmark column).
1612
+ Negative margins on ::before must match this padding exactly so the
1613
+ rule bleeds to the listbox edges (base rule uses --fig-menu-item-padding). */
1600
1614
  &[label]:not([label=""]) {
1601
1615
  padding-left: var(--spacer-5);
1602
1616
  padding-right: var(--spacer-2);
1617
+
1618
+ &::before {
1619
+ margin-inline: unset;
1620
+ margin-left: calc(-1 * var(--spacer-5));
1621
+ margin-right: calc(-1 * var(--spacer-2));
1622
+ }
1603
1623
  }
1604
1624
  }
1605
1625
  }
1606
1626
 
1607
- /*
1608
- [label]:not([label=""]) {
1609
- display: flex;
1610
- flex-direction: column;
1611
- gap: var(--spacer-2);
1612
- height: auto;
1613
- margin: var(--spacer-2) 0;
1614
- margin-bottom: var(--spacer-1);
1615
- padding: 0 var(--spacer-2) 0 var(--fig-menu-item-padding, var(--spacer-4));
1616
- background: transparent;
1617
-
1618
- &::before {
1619
- content: "";
1620
- display: block;
1621
- height: 1px;
1622
- margin-left: calc(-1 * var(--fig-menu-item-padding, var(--spacer-4)));
1623
- margin-right: calc(-1 * var(--spacer-2));
1624
- background: var(--fig-menu-separator-color);
1625
- }
1626
-
1627
- &::after {
1628
- content: attr(label);
1629
- display: block;
1630
- color: var(--fig-menu-separator-label-color);
1631
- line-height: var(--spacer-4);
1632
- min-height: var(--spacer-4);
1633
- }
1634
- }*/
1635
-
1636
1627
  fig-select-option {
1637
1628
  /* Match fig-dropdown[experimental~="modern"] option + option::checkmark */
1638
1629
  display: flex;
1639
1630
  align-items: center;
1640
- gap: var(--spacer-1);
1631
+ gap: 0;
1641
1632
  position: relative;
1642
- height: var(--spacer-4);
1643
- padding: 0 var(--spacer-2);
1633
+ min-height: var(--spacer-4);
1634
+ height: auto;
1635
+ padding: var(--spacer-1) var(--spacer-2);
1644
1636
  padding-right: var(--spacer-3);
1645
1637
  border-radius: var(--radius-medium);
1646
1638
  color: var(--figma-color-text-menu);
1647
- font-weight: var(--body-medium-fontWeight);
1648
1639
  cursor: default;
1649
1640
  user-select: none;
1650
1641
 
@@ -1678,6 +1669,24 @@ fig-select-option {
1678
1669
  visibility: hidden;
1679
1670
  }
1680
1671
 
1672
+ & > [slot="prepend"] {
1673
+ margin-top: calc(var(--spacer-1) * -1);
1674
+ margin-bottom: calc(var(--spacer-1) * -1);
1675
+ margin-right: var(--spacer-1);
1676
+ }
1677
+
1678
+
1679
+ & > :not([slot="prepend"]):not([slot="append"]) {
1680
+ flex: 1 1 auto;
1681
+ min-width: 0;
1682
+ }
1683
+
1684
+ & > [slot="append"] {
1685
+ flex: 0 0 auto;
1686
+ margin-inline-start: auto;
1687
+ color: var(--figma-color-text-tertiary);
1688
+ }
1689
+
1681
1690
  &:hover,
1682
1691
  &:focus-visible {
1683
1692
  outline: none;
package/fig-lab.js CHANGED
@@ -128,12 +128,12 @@ class PropskitSwitch extends HTMLElement {
128
128
  const reserved = new Set([
129
129
  "label",
130
130
  "direction",
131
+ "size",
131
132
  "oninput",
132
133
  "onchange",
133
134
  "class",
134
135
  "style",
135
136
  "id",
136
- "size",
137
137
  "checked",
138
138
  "value",
139
139
  ]);
@@ -1094,6 +1094,7 @@ class PropskitNumber extends HTMLElement {
1094
1094
  const reserved = new Set([
1095
1095
  "label",
1096
1096
  "direction",
1097
+ "size",
1097
1098
  "oninput",
1098
1099
  "onchange",
1099
1100
  "class",
@@ -5384,9 +5385,19 @@ function figLabOptionEntryLabel(opt) {
5384
5385
  return String(opt ?? "");
5385
5386
  }
5386
5387
 
5388
+ /**
5389
+ * A selectable option for fig-select.
5390
+ * Supports light-DOM slots: `slot="prepend"` (leading) and `slot="append"` (trailing).
5391
+ * Use the `label` attribute for the closed-trigger label when option content is rich.
5392
+ *
5393
+ * @attr {string} value - Option value
5394
+ * @attr {string} label - Optional display label for the select trigger
5395
+ * @attr {boolean} disabled - Whether the option is disabled
5396
+ * @attr {boolean} selected - Whether the option is selected
5397
+ */
5387
5398
  class FigSelectOption extends HTMLElement {
5388
5399
  static get observedAttributes() {
5389
- return ["value", "disabled", "selected"];
5400
+ return ["value", "disabled", "selected", "label"];
5390
5401
  }
5391
5402
 
5392
5403
  get value() {
@@ -5599,8 +5610,14 @@ class FigSelect extends HTMLElement {
5599
5610
  #syncingValue = false;
5600
5611
  #popupPositionPatched = false;
5601
5612
  #originalPositionPopup = null;
5602
- /** After open align, stop repositioning so overflow scroll isn't yanked back. */
5613
+ /**
5614
+ * After open align, ignore content/scroll-driven positionPopup passes so
5615
+ * overflow paging isn't yanked back. Still realign when the trigger moves
5616
+ * or the viewport size changes (window resize, layout shift, page scroll).
5617
+ */
5603
5618
  #freezeMenuPosition = false;
5619
+ #frozenLabelRect = null;
5620
+ #frozenViewport = null;
5604
5621
  #syncingOptions = false;
5605
5622
  #boundTriggerClick = this.#handleTriggerClick.bind(this);
5606
5623
  #boundOptionClick = this.#handleOptionClick.bind(this);
@@ -5871,6 +5888,12 @@ class FigSelect extends HTMLElement {
5871
5888
  popup.setAttribute("part", "listbox");
5872
5889
  popup.setAttribute("theme", "menu");
5873
5890
  popup.setAttribute("role", "listbox");
5891
+ // Top-layer via popover so the menu escapes ancestor contain/overflow
5892
+ // (e.g. fig-fill-picker-dialog). Stays in shadow so option slots still work —
5893
+ // unlike tooltips, we cannot portal this popup to the overlay root.
5894
+ if ("popover" in HTMLElement.prototype) {
5895
+ popup.setAttribute("popover", "manual");
5896
+ }
5874
5897
  popup.id = figLabUniqueId("fig-select-list");
5875
5898
  button.setAttribute("aria-controls", popup.id);
5876
5899
 
@@ -5930,10 +5953,73 @@ class FigSelect extends HTMLElement {
5930
5953
  return { top: 8, right: 8, bottom: 8, left: 8 };
5931
5954
  }
5932
5955
 
5956
+ #readLabelRectSnapshot() {
5957
+ const rect = this.#labelEl?.getBoundingClientRect();
5958
+ if (!rect) return null;
5959
+ return {
5960
+ x: rect.x,
5961
+ y: rect.y,
5962
+ width: rect.width,
5963
+ height: rect.height,
5964
+ };
5965
+ }
5966
+
5967
+ #readViewportSnapshot() {
5968
+ const vv = window.visualViewport;
5969
+ return {
5970
+ width: vv?.width ?? window.innerWidth,
5971
+ height: vv?.height ?? window.innerHeight,
5972
+ offsetLeft: vv?.offsetLeft ?? 0,
5973
+ offsetTop: vv?.offsetTop ?? 0,
5974
+ };
5975
+ }
5976
+
5977
+ #rectSnapshotChanged(prev, next, epsilon = 0.25) {
5978
+ if (!prev && !next) return false;
5979
+ if (!prev || !next) return true;
5980
+ return (
5981
+ Math.abs(prev.x - next.x) > epsilon ||
5982
+ Math.abs(prev.y - next.y) > epsilon ||
5983
+ Math.abs(prev.width - next.width) > epsilon ||
5984
+ Math.abs(prev.height - next.height) > epsilon
5985
+ );
5986
+ }
5987
+
5988
+ #viewportSnapshotChanged(prev, next, epsilon = 0.25) {
5989
+ if (!prev && !next) return false;
5990
+ if (!prev || !next) return true;
5991
+ return (
5992
+ Math.abs(prev.width - next.width) > epsilon ||
5993
+ Math.abs(prev.height - next.height) > epsilon ||
5994
+ Math.abs(prev.offsetLeft - next.offsetLeft) > epsilon ||
5995
+ Math.abs(prev.offsetTop - next.offsetTop) > epsilon
5996
+ );
5997
+ }
5998
+
5999
+ #shouldSkipFrozenPositionPass() {
6000
+ if (!this.#freezeMenuPosition) return false;
6001
+ const labelMoved = this.#rectSnapshotChanged(
6002
+ this.#frozenLabelRect,
6003
+ this.#readLabelRectSnapshot(),
6004
+ );
6005
+ const viewportChanged = this.#viewportSnapshotChanged(
6006
+ this.#frozenViewport,
6007
+ this.#readViewportSnapshot(),
6008
+ );
6009
+ // Skip only when neither the trigger nor the viewport moved — typical of
6010
+ // overflow scroll / content sync fighting the open-time alignment.
6011
+ return !labelMoved && !viewportChanged;
6012
+ }
6013
+
6014
+ #rememberFrozenGeometry() {
6015
+ this.#frozenLabelRect = this.#readLabelRectSnapshot();
6016
+ this.#frozenViewport = this.#readViewportSnapshot();
6017
+ }
6018
+
5933
6019
  #positionPopupOverSelected() {
5934
- // Content ResizeObserver / anchor tracking re-enter here after overflow
5935
- // scroll; keep the open-time alignment instead of fighting the scroller.
5936
- if (this.#freezeMenuPosition) return;
6020
+ // Content ResizeObserver / overflow scroll re-enter here; keep the
6021
+ // open-time alignment unless the trigger or viewport actually changed.
6022
+ if (this.#shouldSkipFrozenPositionPass()) return;
5937
6023
 
5938
6024
  const popup = this.#popup;
5939
6025
  const label = this.#labelEl;
@@ -6011,6 +6097,10 @@ class FigSelect extends HTMLElement {
6011
6097
  }
6012
6098
  panel.syncOverflow?.();
6013
6099
  }
6100
+
6101
+ if (this.#freezeMenuPosition || this.open) {
6102
+ this.#rememberFrozenGeometry();
6103
+ }
6014
6104
  }
6015
6105
 
6016
6106
  #setupListeners() {
@@ -6055,7 +6145,8 @@ class FigSelect extends HTMLElement {
6055
6145
  mutation.type === "attributes" &&
6056
6146
  mutation.target?.tagName === "FIG-SELECT-OPTION" &&
6057
6147
  (mutation.attributeName === "value" ||
6058
- mutation.attributeName === "disabled")
6148
+ mutation.attributeName === "disabled" ||
6149
+ mutation.attributeName === "label")
6059
6150
  ) {
6060
6151
  needsSync = true;
6061
6152
  }
@@ -6073,7 +6164,7 @@ class FigSelect extends HTMLElement {
6073
6164
  subtree: true,
6074
6165
  characterData: true,
6075
6166
  attributes: true,
6076
- attributeFilter: ["value", "disabled", "selected"],
6167
+ attributeFilter: ["value", "disabled", "selected", "label"],
6077
6168
  });
6078
6169
  }
6079
6170
 
@@ -6095,7 +6186,26 @@ class FigSelect extends HTMLElement {
6095
6186
  }
6096
6187
 
6097
6188
  #optionLabel(option) {
6098
- return (option?.textContent || "").trim();
6189
+ if (!option) return "";
6190
+ const labelAttr = option.getAttribute?.("label");
6191
+ if (labelAttr != null && labelAttr !== "") return labelAttr.trim();
6192
+
6193
+ // Ignore prepend/append slot content when deriving a label from children.
6194
+ const parts = [];
6195
+ for (const node of option.childNodes) {
6196
+ if (node.nodeType === Node.TEXT_NODE) {
6197
+ const text = node.textContent?.trim();
6198
+ if (text) parts.push(text);
6199
+ continue;
6200
+ }
6201
+ if (!(node instanceof Element)) continue;
6202
+ const slot = node.getAttribute("slot");
6203
+ if (slot === "prepend" || slot === "append") continue;
6204
+ const text = node.textContent?.trim();
6205
+ if (text) parts.push(text);
6206
+ }
6207
+ if (parts.length) return parts.join(" ").trim();
6208
+ return (option.textContent || "").trim();
6099
6209
  }
6100
6210
 
6101
6211
  #syncPopupAttrs() {
@@ -6375,6 +6485,8 @@ class FigSelect extends HTMLElement {
6375
6485
  if (this.#button) this.#popup.anchor = this.#button;
6376
6486
  this.#installPopupPositioning();
6377
6487
  this.#freezeMenuPosition = false;
6488
+ this.#frozenLabelRect = null;
6489
+ this.#frozenViewport = null;
6378
6490
  this.#syncValue();
6379
6491
  this.#syncPopupWidth();
6380
6492
  this.#popup.open = true;
@@ -6399,13 +6511,17 @@ class FigSelect extends HTMLElement {
6399
6511
  }
6400
6512
  panel?.syncOverflow?.();
6401
6513
  // Freeze after open align so later positionPopup passes don't undo scroll.
6514
+ // Window resize / trigger movement still realigns via geometry checks.
6402
6515
  this.#freezeMenuPosition = true;
6516
+ this.#rememberFrozenGeometry();
6403
6517
  });
6404
6518
  }
6405
6519
 
6406
6520
  #closeList() {
6407
6521
  if (!this.#popup) return;
6408
6522
  this.#freezeMenuPosition = false;
6523
+ this.#frozenLabelRect = null;
6524
+ this.#frozenViewport = null;
6409
6525
  document.removeEventListener("keydown", this.#boundKeydown, true);
6410
6526
  this.#popup.open = false;
6411
6527
  this.#button?.setAttribute("aria-expanded", "false");