@rogieking/figui3 6.9.3 → 6.9.5

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
@@ -13,12 +13,34 @@ class FigFieldSlider extends HTMLElement {
13
13
  #field = null;
14
14
  #label = null;
15
15
  #slider = null;
16
+ #hasCustomLabel = false;
16
17
  #observer = null;
17
18
  #managedSliderAttrs = new Set();
18
19
  #steppersSyncFrame = 0;
20
+ #focusSyncFrame = 0;
21
+ #rangeInput = null;
22
+ #contextMenu = null;
23
+ #pendingClickTimer = 0;
24
+ #pendingClickValue = null;
25
+ #isElasticTracking = false;
26
+ #elasticMaxPx = 0;
27
+ #elasticRangeRect = null;
19
28
  #boundHandleSliderInput = null;
20
29
  #boundHandleSliderChange = null;
21
- #ignoredSliderAttrs = new Set(["variant", "color", "text", "full"]);
30
+ #boundHandleElasticPointerDown = this.#handleElasticPointerDown.bind(this);
31
+ #boundHandleElasticPointerMove = this.#handleElasticPointerMove.bind(this);
32
+ #boundHandleElasticPointerEnd = this.#handleElasticPointerEnd.bind(this);
33
+ #boundHandleRangeDoubleClick = this.#handleRangeDoubleClick.bind(this);
34
+ #boundHandleContextMenu = this.#handleContextMenu.bind(this);
35
+ #boundHandleContextMenuChange = this.#handleContextMenuChange.bind(this);
36
+ #ignoredSliderAttrs = new Set([
37
+ "variant",
38
+ "color",
39
+ "text",
40
+ "full",
41
+ "data-elastic-dragging",
42
+ "style",
43
+ ]);
22
44
 
23
45
  static get observedAttributes() {
24
46
  return ["label", "direction"];
@@ -32,6 +54,16 @@ class FigFieldSlider extends HTMLElement {
32
54
  this.#syncField();
33
55
  this.#syncSliderAttributes();
34
56
  this.#bindSliderEvents();
57
+ this.#queueFocusDelegationSync();
58
+ this.removeEventListener("pointerdown", this.#boundHandleElasticPointerDown, {
59
+ capture: true,
60
+ });
61
+ this.addEventListener("pointerdown", this.#boundHandleElasticPointerDown, {
62
+ capture: true,
63
+ passive: true,
64
+ });
65
+ this.removeEventListener("contextmenu", this.#boundHandleContextMenu);
66
+ this.addEventListener("contextmenu", this.#boundHandleContextMenu);
35
67
 
36
68
  if (!this.#observer) {
37
69
  this.#observer = new MutationObserver((mutations) => {
@@ -58,7 +90,10 @@ class FigFieldSlider extends HTMLElement {
58
90
  }
59
91
 
60
92
  if (syncField) this.#syncField();
61
- if (syncSlider) this.#syncSliderAttributes();
93
+ if (syncSlider) {
94
+ this.#syncSliderAttributes();
95
+ this.#queueFocusDelegationSync();
96
+ }
62
97
  });
63
98
  }
64
99
 
@@ -71,7 +106,19 @@ class FigFieldSlider extends HTMLElement {
71
106
  cancelAnimationFrame(this.#steppersSyncFrame);
72
107
  this.#steppersSyncFrame = 0;
73
108
  }
109
+ if (this.#focusSyncFrame) {
110
+ cancelAnimationFrame(this.#focusSyncFrame);
111
+ this.#focusSyncFrame = 0;
112
+ }
113
+ this.#clearPendingClick();
114
+ this.#resetElasticPull();
115
+ this.#unbindRangeInput();
74
116
  this.#unbindSliderEvents();
117
+ this.removeEventListener("pointerdown", this.#boundHandleElasticPointerDown, {
118
+ capture: true,
119
+ });
120
+ this.removeEventListener("contextmenu", this.#boundHandleContextMenu);
121
+ this.#contextMenu?.removeEventListener("change", this.#boundHandleContextMenuChange);
75
122
  }
76
123
 
77
124
  attributeChangedCallback(name, oldValue, newValue) {
@@ -88,8 +135,11 @@ class FigFieldSlider extends HTMLElement {
88
135
  );
89
136
  });
90
137
 
138
+ const customLabel = initialChildren.find(
139
+ (node) => node.nodeType === Node.ELEMENT_NODE && node.matches("label"),
140
+ );
91
141
  const field = document.createElement("fig-field");
92
- const label = document.createElement("label");
142
+ const label = customLabel || document.createElement("label");
93
143
  const slider = document.createElement("fig-slider");
94
144
  slider.setAttribute("text", "true");
95
145
  for (const attrName of this.#getForwardedSliderAttrNames()) {
@@ -102,14 +152,32 @@ class FigFieldSlider extends HTMLElement {
102
152
  this.#field = field;
103
153
  this.#label = label;
104
154
  this.#slider = slider;
155
+ this.#hasCustomLabel = Boolean(customLabel);
105
156
 
106
157
  this.replaceChildren(field);
158
+ this.#setupContextMenu();
107
159
 
108
160
  for (const node of initialChildren) {
161
+ if (node === customLabel) continue;
109
162
  this.#slider.appendChild(node);
110
163
  }
111
164
  }
112
165
 
166
+ #setupContextMenu() {
167
+ const menu = document.createElement("fig-menu");
168
+ menu.setAttribute("position", "bottom left");
169
+ menu.setAttribute("offset", "0 0");
170
+
171
+ const resetItem = document.createElement("fig-menu-item");
172
+ resetItem.setAttribute("value", "reset-default");
173
+ resetItem.textContent = "Reset to default";
174
+ menu.appendChild(resetItem);
175
+ menu.addEventListener("change", this.#boundHandleContextMenuChange);
176
+
177
+ this.#contextMenu = menu;
178
+ this.appendChild(menu);
179
+ }
180
+
113
181
  #syncField() {
114
182
  if (!this.#field || !this.#label) return;
115
183
  const hasLabelAttr = this.hasAttribute("label");
@@ -121,7 +189,9 @@ class FigFieldSlider extends HTMLElement {
121
189
  this.#label.remove();
122
190
  }
123
191
  } else {
124
- this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
192
+ if (!this.#hasCustomLabel) {
193
+ this.#label.textContent = hasLabelAttr ? (rawLabel ?? "") : "Label";
194
+ }
125
195
  if (this.#label.parentElement !== this.#field) {
126
196
  this.#field.prepend(this.#label);
127
197
  }
@@ -174,7 +244,7 @@ class FigFieldSlider extends HTMLElement {
174
244
  if (sliderType === "opacity") {
175
245
  this.#slider.style.setProperty(
176
246
  "--color",
177
- "var(--figma-color-bg-tertiary)",
247
+ "light-dark(#444444, #e6e6e6)",
178
248
  );
179
249
  } else {
180
250
  this.#slider.style.removeProperty("--color");
@@ -204,7 +274,242 @@ class FigFieldSlider extends HTMLElement {
204
274
  this.#steppersSyncFrame = requestAnimationFrame(() => {
205
275
  this.#steppersSyncFrame = 0;
206
276
  this.#syncSteppersToNumberInput();
277
+ this.#syncFocusDelegation();
278
+ });
279
+ }
280
+
281
+ #queueFocusDelegationSync() {
282
+ if (this.#focusSyncFrame) {
283
+ cancelAnimationFrame(this.#focusSyncFrame);
284
+ }
285
+ this.#focusSyncFrame = requestAnimationFrame(() => {
286
+ this.#focusSyncFrame = 0;
287
+ this.#syncFocusDelegation();
288
+ });
289
+ }
290
+
291
+ #syncFocusDelegation() {
292
+ const rangeInput = this.#slider?.querySelector('input[type="range"]');
293
+ const numberInput = this.#slider?.querySelector("fig-input-number input");
294
+ if (rangeInput !== this.#rangeInput) {
295
+ this.#bindRangeInput(rangeInput);
296
+ }
297
+ rangeInput?.removeAttribute("tabindex");
298
+ numberInput?.setAttribute("tabindex", "-1");
299
+ }
300
+
301
+ #bindRangeInput(rangeInput) {
302
+ this.#unbindRangeInput();
303
+ this.#rangeInput = rangeInput;
304
+ if (!this.#rangeInput) return;
305
+ this.#rangeInput.addEventListener("dblclick", this.#boundHandleRangeDoubleClick, {
306
+ capture: true,
307
+ });
308
+ }
309
+
310
+ #unbindRangeInput() {
311
+ if (!this.#rangeInput) return;
312
+ this.#rangeInput.removeEventListener("dblclick", this.#boundHandleRangeDoubleClick, {
313
+ capture: true,
314
+ });
315
+ this.#rangeInput = null;
316
+ }
317
+
318
+ #handleRangeDoubleClick(event) {
319
+ event.preventDefault();
320
+ event.stopImmediatePropagation();
321
+ this.#resetToDefault();
322
+ }
323
+
324
+ #handleElasticPointerDown(event) {
325
+ if (event.button !== 0 || this.hasAttribute("disabled")) return;
326
+ if (event.target?.closest?.("fig-input-number")) return;
327
+ const rangeInput =
328
+ this.#slider?.querySelector('input[type="range"]') ?? this.#rangeInput;
329
+ if (!rangeInput) return;
330
+ this.#rangeInput = rangeInput;
331
+ this.#isElasticTracking = true;
332
+ this.#elasticMaxPx = this.#readElasticDistance();
333
+ const rect = rangeInput.getBoundingClientRect();
334
+ this.#elasticRangeRect = {
335
+ left: rect.left,
336
+ right: rect.right,
337
+ width: rect.width,
338
+ };
339
+ window.addEventListener("pointermove", this.#boundHandleElasticPointerMove, {
340
+ passive: true,
207
341
  });
342
+ window.addEventListener("pointerup", this.#boundHandleElasticPointerEnd, {
343
+ once: true,
344
+ });
345
+ window.addEventListener("pointercancel", this.#boundHandleElasticPointerEnd, {
346
+ once: true,
347
+ });
348
+ }
349
+
350
+ #handleElasticPointerMove(event) {
351
+ if (!this.#isElasticTracking) return;
352
+ this.#updateElasticPull(event.clientX);
353
+ }
354
+
355
+ #handleElasticPointerEnd() {
356
+ window.removeEventListener("pointermove", this.#boundHandleElasticPointerMove);
357
+ window.removeEventListener("pointerup", this.#boundHandleElasticPointerEnd);
358
+ window.removeEventListener("pointercancel", this.#boundHandleElasticPointerEnd);
359
+ this.#isElasticTracking = false;
360
+ this.#resetElasticPull();
361
+ }
362
+
363
+ #handleContextMenu(event) {
364
+ if (this.hasAttribute("disabled")) return;
365
+ event.preventDefault();
366
+ event.stopPropagation();
367
+ event.stopImmediatePropagation();
368
+ this.#clearPendingClick();
369
+ this.#showContextMenuAfterPointerRelease(event.clientX, event.clientY);
370
+ }
371
+
372
+ #showContextMenuAfterPointerRelease(x, y) {
373
+ let opened = false;
374
+ let fallbackTimer = 0;
375
+ const openMenu = () => {
376
+ if (opened) return;
377
+ opened = true;
378
+ window.clearTimeout(fallbackTimer);
379
+ window.removeEventListener("pointerup", openMenu, true);
380
+ window.removeEventListener("pointercancel", openMenu, true);
381
+ requestAnimationFrame(() => {
382
+ this.#contextMenu?.showAt?.(x, y);
383
+ });
384
+ };
385
+ window.addEventListener("pointerup", openMenu, { once: true, capture: true });
386
+ window.addEventListener("pointercancel", openMenu, {
387
+ once: true,
388
+ capture: true,
389
+ });
390
+ fallbackTimer = window.setTimeout(openMenu, 180);
391
+ }
392
+
393
+ #handleContextMenuChange(event) {
394
+ event.stopPropagation();
395
+ if (event.detail?.value !== "reset-default") return;
396
+ this.#resetToDefault();
397
+ }
398
+
399
+ #clearPendingClick() {
400
+ if (this.#pendingClickTimer) {
401
+ clearTimeout(this.#pendingClickTimer);
402
+ this.#pendingClickTimer = 0;
403
+ }
404
+ this.#pendingClickValue = null;
405
+ }
406
+
407
+ #readElasticDistance() {
408
+ let raw = getComputedStyle(this)
409
+ .getPropertyValue("--fig-field-slider-elastic-distance")
410
+ .trim();
411
+ if (raw.includes("var(") || !raw.endsWith("px")) {
412
+ const probe = document.createElement("div");
413
+ Object.assign(probe.style, {
414
+ position: "absolute",
415
+ visibility: "hidden",
416
+ pointerEvents: "none",
417
+ width: "var(--fig-field-slider-elastic-distance)",
418
+ });
419
+ this.appendChild(probe);
420
+ raw = getComputedStyle(probe).width;
421
+ probe.remove();
422
+ }
423
+ const value = Number.parseFloat(raw);
424
+ return Number.isFinite(value) ? Math.max(0, value) : 0;
425
+ }
426
+
427
+ #updateElasticPull(pointerX) {
428
+ const rect = this.#elasticRangeRect;
429
+ if (!rect || !this.#elasticMaxPx) {
430
+ this.#resetElasticPull();
431
+ return;
432
+ }
433
+ const overshoot =
434
+ pointerX < rect.left
435
+ ? pointerX - rect.left
436
+ : pointerX > rect.right
437
+ ? pointerX - rect.right
438
+ : 0;
439
+ if (!overshoot) {
440
+ this.#clearElasticPull();
441
+ return;
442
+ }
443
+ const offset = Math.max(
444
+ -this.#elasticMaxPx,
445
+ Math.min(this.#elasticMaxPx, overshoot * 0.5),
446
+ );
447
+ const stretch = Math.abs(offset);
448
+ this.dataset.elasticDragging = "true";
449
+ this.style.setProperty("--fig-field-slider-elastic-size", `${stretch}px`);
450
+ this.style.setProperty(
451
+ "--fig-field-slider-elastic-position-offset",
452
+ offset < 0 ? `${-stretch / 2}px` : `${stretch / 2}px`,
453
+ );
454
+ }
455
+
456
+ #resetElasticPull() {
457
+ this.#clearElasticPull();
458
+ this.#elasticMaxPx = 0;
459
+ this.#elasticRangeRect = null;
460
+ }
461
+
462
+ #clearElasticPull() {
463
+ this.removeAttribute("data-elastic-dragging");
464
+ this.style.removeProperty("--fig-field-slider-elastic-size");
465
+ this.style.removeProperty("--fig-field-slider-elastic-position-offset");
466
+ }
467
+
468
+ #valueFromPointer(event) {
469
+ const input = this.#rangeInput;
470
+ if (!input) return this.#slider?.value ?? "";
471
+ const rect = input.getBoundingClientRect();
472
+ const percent = rect.width
473
+ ? Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width))
474
+ : 0;
475
+ const min = Number(input.min || 0);
476
+ const max = Number(input.max || 100);
477
+ const step = input.step === "any" ? 0 : Number(input.step || 1);
478
+ const raw = min + (max - min) * percent;
479
+ if (!step) return String(raw);
480
+ const snapped = Math.round((raw - min) / step) * step + min;
481
+ const decimals = Math.max(0, `${step}`.split(".")[1]?.length || 0);
482
+ return String(Number(snapped.toFixed(decimals)));
483
+ }
484
+
485
+ #defaultValue() {
486
+ return (
487
+ this.getAttribute("default") ??
488
+ this.#slider?.getAttribute("default") ??
489
+ this.getAttribute("value") ??
490
+ this.#slider?.getAttribute("value") ??
491
+ this.#rangeInput?.min ??
492
+ "0"
493
+ );
494
+ }
495
+
496
+ #resetToDefault() {
497
+ this.#clearPendingClick();
498
+ this.#setSliderValue(this.#defaultValue(), "input");
499
+ this.#setSliderValue(this.#defaultValue(), "change");
500
+ }
501
+
502
+ #setSliderValue(value, eventType) {
503
+ if (!this.#slider || value === null || value === undefined) return;
504
+ this.#slider.value = value;
505
+ this.dispatchEvent(
506
+ new CustomEvent(eventType, {
507
+ detail: this.#slider.value,
508
+ bubbles: true,
509
+ cancelable: true,
510
+ composed: true,
511
+ }),
512
+ );
208
513
  }
209
514
 
210
515
  #syncSteppersToNumberInput() {
@@ -254,6 +559,9 @@ class FigFieldSlider extends HTMLElement {
254
559
 
255
560
  #forwardSliderEvent(type, event) {
256
561
  event.stopPropagation();
562
+ if (type === "change") {
563
+ this.#resetElasticPull();
564
+ }
257
565
  const detail =
258
566
  event instanceof CustomEvent && event.detail !== undefined
259
567
  ? event.detail
@@ -267,6 +575,14 @@ class FigFieldSlider extends HTMLElement {
267
575
  }),
268
576
  );
269
577
  }
578
+
579
+ focus(options) {
580
+ this.#slider?.querySelector('input[type="range"]')?.focus(options);
581
+ }
582
+
583
+ resetToDefault() {
584
+ this.#resetToDefault();
585
+ }
270
586
  }
271
587
  customElements.define("fig-field-slider", FigFieldSlider);
272
588