@rogieking/figui3 6.22.0 → 6.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/fig.js CHANGED
@@ -105,9 +105,12 @@ function createFigOverflowButtons({
105
105
  startClass = "",
106
106
  endClass = "",
107
107
  chevronClass = "",
108
+ startLabel = "Scroll back",
109
+ endLabel = "Scroll forward",
108
110
  } = {}) {
109
111
  const makeButton = (direction, onPointerDown) => {
110
112
  const button = document.createElement("button");
113
+ button.type = "button";
111
114
  button.className = [
112
115
  "fig-overflow",
113
116
  `fig-overflow-${direction}`,
@@ -118,7 +121,10 @@ function createFigOverflowButtons({
118
121
  button.dataset.figOverflow = direction;
119
122
  if (owner) button.setAttribute(`data-fig-${owner}-nav`, direction);
120
123
  button.setAttribute("tabindex", "-1");
121
- button.setAttribute("aria-label", direction === "start" ? "Scroll back" : "Scroll forward");
124
+ button.setAttribute(
125
+ "aria-label",
126
+ direction === "start" ? startLabel : endLabel,
127
+ );
122
128
  button.appendChild(
123
129
  createFigIcon("chevron", {
124
130
  size: "small",
@@ -164,6 +170,48 @@ function figScrollOverflowPage(scrollEl, axis = "x", direction = 1) {
164
170
  });
165
171
  }
166
172
 
173
+ function figScrollElementToCenter(
174
+ scrollEl,
175
+ element,
176
+ axis = "y",
177
+ behavior = "auto",
178
+ ) {
179
+ if (!scrollEl || !element || !scrollEl.contains(element)) return;
180
+ requestAnimationFrame(() => {
181
+ if (!scrollEl.isConnected || !element.isConnected) return;
182
+ const isHorizontal = axis === "x";
183
+ const scrollSize = isHorizontal
184
+ ? scrollEl.scrollWidth
185
+ : scrollEl.scrollHeight;
186
+ const clientSize = isHorizontal
187
+ ? scrollEl.clientWidth
188
+ : scrollEl.clientHeight;
189
+ if (scrollSize <= clientSize + 1) {
190
+ figSyncOverflowState(scrollEl, scrollEl, axis);
191
+ return;
192
+ }
193
+ const elementRect = element.getBoundingClientRect();
194
+ const hostRect = scrollEl.getBoundingClientRect();
195
+ const currentScroll = isHorizontal
196
+ ? scrollEl.scrollLeft
197
+ : scrollEl.scrollTop;
198
+ const elementStart =
199
+ (isHorizontal ? elementRect.left - hostRect.left : elementRect.top - hostRect.top) +
200
+ currentScroll;
201
+ const elementSize = isHorizontal ? elementRect.width : elementRect.height;
202
+ const maxScroll = scrollSize - clientSize;
203
+ const nextScroll = Math.max(
204
+ 0,
205
+ Math.min(elementStart + elementSize / 2 - clientSize / 2, maxScroll),
206
+ );
207
+ scrollEl.scrollTo({
208
+ [isHorizontal ? "left" : "top"]: nextScroll,
209
+ behavior,
210
+ });
211
+ figSyncOverflowState(scrollEl, scrollEl, axis);
212
+ });
213
+ }
214
+
167
215
  function hasFigFillPicker() {
168
216
  return typeof customElements !== "undefined" && !!customElements.get("fig-fill-picker");
169
217
  }
@@ -5220,57 +5268,6 @@ class FigSelectOption extends HTMLElement {
5220
5268
  }
5221
5269
  figDefineElement("fig-select-option", FigSelectOption);
5222
5270
 
5223
- function figSelectSyncOverflowState(host, scrollEl, threshold = 2) {
5224
- if (!host || !scrollEl) return false;
5225
- const scrollable = scrollEl.scrollHeight - scrollEl.clientHeight > threshold;
5226
- const atStart = !scrollable || scrollEl.scrollTop <= threshold;
5227
- const atEnd =
5228
- !scrollable ||
5229
- scrollEl.scrollTop + scrollEl.clientHeight >=
5230
- scrollEl.scrollHeight - threshold;
5231
- host.classList.toggle("overflow-start", !atStart);
5232
- host.classList.toggle("overflow-end", !atEnd);
5233
- return scrollable;
5234
- }
5235
-
5236
- function figSelectScrollOverflowPage(scrollEl, direction = 1) {
5237
- if (!scrollEl) return;
5238
- scrollEl.scrollBy({
5239
- top: scrollEl.clientHeight * 0.8 * direction,
5240
- behavior: "smooth",
5241
- });
5242
- }
5243
-
5244
- function figSelectCreateOverflowButtons({ onStart, onEnd } = {}) {
5245
- const makeButton = (direction, onClick) => {
5246
- const button = document.createElement("button");
5247
- button.type = "button";
5248
- button.className = `fig-overflow fig-overflow-${direction}`;
5249
- button.dataset.figOverflow = direction;
5250
- button.setAttribute("data-fig-select-nav", direction);
5251
- button.setAttribute("tabindex", "-1");
5252
- button.setAttribute(
5253
- "aria-label",
5254
- direction === "start" ? "Scroll up" : "Scroll down",
5255
- );
5256
- const icon = document.createElement("fig-icon");
5257
- icon.setAttribute("name", "chevron");
5258
- icon.setAttribute("size", "small");
5259
- icon.className = "fig-overflow-chevron";
5260
- button.appendChild(icon);
5261
- button.addEventListener("click", (event) => {
5262
- event.preventDefault();
5263
- event.stopPropagation();
5264
- onClick?.(event);
5265
- });
5266
- return button;
5267
- };
5268
- return {
5269
- start: makeButton("start", onStart),
5270
- end: makeButton("end", onEnd),
5271
- };
5272
- }
5273
-
5274
5271
  /** Light-DOM panel wrapper projected into fig-select's popup; owns overflow buttons. */
5275
5272
  class FigSelectOptions extends HTMLElement {
5276
5273
  #navStart = null;
@@ -5297,31 +5294,11 @@ class FigSelectOptions extends HTMLElement {
5297
5294
  }
5298
5295
 
5299
5296
  syncOverflow() {
5300
- return figSelectSyncOverflowState(this, this);
5297
+ return figSyncOverflowState(this, this, "y");
5301
5298
  }
5302
5299
 
5303
5300
  scrollToOption(option, behavior = "auto") {
5304
- if (!option || !this.contains(option)) return;
5305
- requestAnimationFrame(() => {
5306
- if (!option.isConnected) return;
5307
- if (this.scrollHeight <= this.clientHeight + 1) {
5308
- this.syncOverflow();
5309
- return;
5310
- }
5311
- const optionRect = option.getBoundingClientRect();
5312
- const hostRect = this.getBoundingClientRect();
5313
- const optionTop = optionRect.top - hostRect.top + this.scrollTop;
5314
- const maxScroll = this.scrollHeight - this.clientHeight;
5315
- const top = Math.max(
5316
- 0,
5317
- Math.min(
5318
- optionTop + optionRect.height / 2 - this.clientHeight / 2,
5319
- maxScroll,
5320
- ),
5321
- );
5322
- this.scrollTo({ top, behavior });
5323
- this.syncOverflow();
5324
- });
5301
+ figScrollElementToCenter(this, option, "y", behavior);
5325
5302
  }
5326
5303
 
5327
5304
  #unwrapLegacyChooser() {
@@ -5343,9 +5320,12 @@ class FigSelectOptions extends HTMLElement {
5343
5320
  return;
5344
5321
  }
5345
5322
  this.#removeNavButtons();
5346
- const buttons = figSelectCreateOverflowButtons({
5347
- onStart: () => figSelectScrollOverflowPage(this, -1),
5348
- onEnd: () => figSelectScrollOverflowPage(this, 1),
5323
+ const buttons = createFigOverflowButtons({
5324
+ owner: "select",
5325
+ startLabel: "Scroll up",
5326
+ endLabel: "Scroll down",
5327
+ onStart: () => figScrollOverflowPage(this, "y", -1),
5328
+ onEnd: () => figScrollOverflowPage(this, "y", 1),
5349
5329
  });
5350
5330
  this.#navStart = buttons.start;
5351
5331
  this.#navEnd = buttons.end;
@@ -19366,14 +19346,19 @@ figDefineElement("fig-menu-separator", FigMenuSeparator);
19366
19346
 
19367
19347
  class FigMenu extends HTMLElement {
19368
19348
  #popup = null;
19349
+ #panel = null;
19369
19350
  #trigger = null;
19370
19351
  #virtualAnchor = null;
19371
19352
  #observer = null;
19353
+ #resizeObserver = null;
19354
+ #navStart = null;
19355
+ #navEnd = null;
19372
19356
  #boundTriggerClick;
19373
19357
  #boundTriggerContextMenu;
19374
19358
  #boundPopupClick;
19375
19359
  #boundMenuKeydown;
19376
19360
  #boundPopupClose;
19361
+ #boundSyncOverflow = this.#syncOverflow.bind(this);
19377
19362
  #focusedIndex = -1;
19378
19363
 
19379
19364
  static get observedAttributes() {
@@ -19415,6 +19400,7 @@ class FigMenu extends HTMLElement {
19415
19400
  this.#createPopup();
19416
19401
  this.#moveItemsToPopup();
19417
19402
  this.#setupListeners();
19403
+ this.#setupOverflow();
19418
19404
  this.#setupObserver();
19419
19405
  this.#syncDisabled();
19420
19406
 
@@ -19425,6 +19411,7 @@ class FigMenu extends HTMLElement {
19425
19411
 
19426
19412
  disconnectedCallback() {
19427
19413
  this.#teardownListeners();
19414
+ this.#teardownOverflow();
19428
19415
  document.removeEventListener("keydown", this.#boundMenuKeydown, true);
19429
19416
  if (this.#observer) {
19430
19417
  this.#observer.disconnect();
@@ -19433,13 +19420,14 @@ class FigMenu extends HTMLElement {
19433
19420
  if (this.#popup) {
19434
19421
  this.#popup.removeEventListener("close", this.#boundPopupClose);
19435
19422
  const items = Array.from(
19436
- this.#popup.querySelectorAll(
19423
+ this.#panel?.querySelectorAll(
19437
19424
  ":scope > fig-menu-item, :scope > fig-menu-separator",
19438
- ),
19425
+ ) ?? [],
19439
19426
  );
19440
19427
  for (const item of items) this.insertBefore(item, this.#popup);
19441
19428
  this.#popup.remove();
19442
19429
  this.#popup = null;
19430
+ this.#panel = null;
19443
19431
  }
19444
19432
  }
19445
19433
 
@@ -19487,6 +19475,7 @@ class FigMenu extends HTMLElement {
19487
19475
  #createPopup() {
19488
19476
  this.#popup = document.createElement("dialog", { is: "fig-popup" });
19489
19477
  this.#popup.setAttribute("is", "fig-popup");
19478
+ this.#popup.classList.add("fig-menu-popup");
19490
19479
  this.#popup.setAttribute("theme", "menu");
19491
19480
  this.#popup.setAttribute("role", "menu");
19492
19481
  this.#popup.setAttribute("id", this.#popup.getAttribute("id") || figUniqueId());
@@ -19504,16 +19493,21 @@ class FigMenu extends HTMLElement {
19504
19493
  this.#popup.anchor = this.#trigger;
19505
19494
  }
19506
19495
 
19496
+ this.#panel = document.createElement("div");
19497
+ this.#panel.className = "fig-menu-options";
19498
+ this.#panel.setAttribute("role", "presentation");
19499
+ this.#popup.appendChild(this.#panel);
19507
19500
  this.#popup.addEventListener("close", this.#boundPopupClose);
19508
19501
  this.appendChild(this.#popup);
19509
19502
  }
19510
19503
 
19511
19504
  #moveItemsToPopup() {
19505
+ if (!this.#panel) return;
19512
19506
  const items = Array.from(this.querySelectorAll(
19513
19507
  ":scope > fig-menu-item, :scope > fig-menu-separator"
19514
19508
  ));
19515
19509
  for (const item of items) {
19516
- this.#popup.appendChild(item);
19510
+ this.#panel.appendChild(item);
19517
19511
  }
19518
19512
  }
19519
19513
 
@@ -19553,7 +19547,7 @@ class FigMenu extends HTMLElement {
19553
19547
  (node.tagName === "FIG-MENU-ITEM" || node.tagName === "FIG-MENU-SEPARATOR") &&
19554
19548
  node.parentElement === this
19555
19549
  ) {
19556
- this.#popup.appendChild(node);
19550
+ this.#panel?.appendChild(node);
19557
19551
  } else if (!this.#trigger && node.parentElement === this) {
19558
19552
  this.#detectTrigger();
19559
19553
  if (this.#trigger) {
@@ -19568,13 +19562,68 @@ class FigMenu extends HTMLElement {
19568
19562
  }
19569
19563
  }
19570
19564
  }
19565
+ this.#syncOverflow();
19571
19566
  });
19572
19567
  this.#observer.observe(this, { childList: true });
19573
19568
  }
19574
19569
 
19570
+ #setupOverflow() {
19571
+ if (!this.#panel) return;
19572
+ this.#ensureNavButtons();
19573
+ this.#panel.addEventListener("scroll", this.#boundSyncOverflow, {
19574
+ passive: true,
19575
+ });
19576
+ this.#resizeObserver?.disconnect();
19577
+ this.#resizeObserver = new ResizeObserver(() => this.#syncOverflow());
19578
+ this.#resizeObserver.observe(this.#panel);
19579
+ requestAnimationFrame(() => this.#syncOverflow());
19580
+ }
19581
+
19582
+ #teardownOverflow() {
19583
+ this.#panel?.removeEventListener("scroll", this.#boundSyncOverflow);
19584
+ this.#resizeObserver?.disconnect();
19585
+ this.#resizeObserver = null;
19586
+ this.#removeNavButtons();
19587
+ }
19588
+
19589
+ #syncOverflow() {
19590
+ return figSyncOverflowState(this.#panel, this.#panel, "y");
19591
+ }
19592
+
19593
+ #ensureNavButtons() {
19594
+ if (
19595
+ this.#navStart &&
19596
+ this.#navEnd &&
19597
+ this.#panel?.contains(this.#navStart) &&
19598
+ this.#panel?.contains(this.#navEnd)
19599
+ ) {
19600
+ return;
19601
+ }
19602
+ this.#removeNavButtons();
19603
+ const buttons = createFigOverflowButtons({
19604
+ owner: "menu",
19605
+ startLabel: "Scroll up",
19606
+ endLabel: "Scroll down",
19607
+ onStart: () => figScrollOverflowPage(this.#panel, "y", -1),
19608
+ onEnd: () => figScrollOverflowPage(this.#panel, "y", 1),
19609
+ });
19610
+ this.#navStart = buttons.start;
19611
+ this.#navEnd = buttons.end;
19612
+ this.#panel.prepend(this.#navStart);
19613
+ this.#panel.append(this.#navEnd);
19614
+ }
19615
+
19616
+ #removeNavButtons() {
19617
+ this.#navStart?.remove();
19618
+ this.#navEnd?.remove();
19619
+ this.#navStart = null;
19620
+ this.#navEnd = null;
19621
+ this.#panel?.classList.remove("overflow-start", "overflow-end");
19622
+ }
19623
+
19575
19624
  #getItems() {
19576
- if (!this.#popup) return [];
19577
- return Array.from(this.#popup.querySelectorAll("fig-menu-item")).filter(
19625
+ if (!this.#panel) return [];
19626
+ return Array.from(this.#panel.querySelectorAll("fig-menu-item")).filter(
19578
19627
  (item) =>
19579
19628
  !item.hasAttribute("disabled") || item.getAttribute("disabled") === "false",
19580
19629
  );
@@ -19598,7 +19647,9 @@ class FigMenu extends HTMLElement {
19598
19647
  if (!items.length) return;
19599
19648
  const wrapped = (index + items.length) % items.length;
19600
19649
  this.#focusedIndex = wrapped;
19601
- items[wrapped].focus();
19650
+ const item = items[wrapped];
19651
+ item.focus();
19652
+ figScrollElementToCenter(this.#panel, item, "y");
19602
19653
  }
19603
19654
 
19604
19655
  #syncDisabled() {
@@ -19809,6 +19860,7 @@ class FigMenu extends HTMLElement {
19809
19860
  }
19810
19861
  this.#focusedIndex = -1;
19811
19862
  requestAnimationFrame(() => {
19863
+ this.#syncOverflow();
19812
19864
  if (!this.#trigger?.matches?.(":focus-visible")) return;
19813
19865
  this.#focusItemAt(0);
19814
19866
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "6.22.0",
3
+ "version": "6.23.0",
4
4
  "description": "A lightweight web components library for building Figma plugin and widget UIs with native look and feel",
5
5
  "author": "Rogie King",
6
6
  "license": "MIT",