@rogieking/figui3 4.9.0 → 4.10.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
@@ -8380,7 +8380,6 @@ customElements.define("fig-swatch", FigSwatch);
8380
8380
  */
8381
8381
  class FigMedia extends HTMLElement {
8382
8382
  #src = null;
8383
- #chit = null;
8384
8383
  #mediaEl = null;
8385
8384
  #fileInput = null;
8386
8385
  #blobUrl = null;
@@ -8389,6 +8388,13 @@ class FigMedia extends HTMLElement {
8389
8388
  #boundHandleMediaPlay = this.#handleMediaPlay.bind(this);
8390
8389
  #boundHandleMediaPause = this.#handleMediaPause.bind(this);
8391
8390
  #boundHandleMediaEnded = this.#handleMediaEnded.bind(this);
8391
+ #controlsEl = null;
8392
+ #controlsWiredFor = null;
8393
+ #controlsWiredControls = null;
8394
+ #controlsSync = null;
8395
+ #controlsOnPlay = null;
8396
+ #controlsOnPause = null;
8397
+ #controlsOnSeek = null;
8392
8398
 
8393
8399
  static get observedAttributes() {
8394
8400
  return [
@@ -8465,27 +8471,16 @@ class FigMedia extends HTMLElement {
8465
8471
 
8466
8472
  const ar = this.getAttribute("aspect-ratio");
8467
8473
  if (ar) {
8468
- this.style.setProperty("--aspect-ratio", ar);
8474
+ this.style.setProperty("--fig-media-aspect-ratio", ar);
8475
+ } else {
8476
+ this.style.setProperty("--fig-media-aspect-ratio", "4/3");
8469
8477
  }
8470
8478
  const fit = this.getAttribute("fit");
8471
8479
  if (fit) {
8472
- this.style.setProperty("--fit", fit);
8480
+ this.style.setProperty("--fig-media-fit", fit);
8473
8481
  }
8474
8482
 
8475
- if (!this.querySelector("fig-chit")) {
8476
- const chit = document.createElement("fig-chit");
8477
- chit.setAttribute("data-generated", "");
8478
- chit.setAttribute("size", "large");
8479
- chit.setAttribute("data-type", this.mediaKind);
8480
- chit.setAttribute("disabled", "");
8481
- this.#applyChitBackground(chit);
8482
- if (this.hasAttribute("checkerboard") && this.getAttribute("checkerboard") !== "false") {
8483
- chit.setAttribute("checkerboard", "");
8484
- }
8485
- this.prepend(chit);
8486
- }
8487
- this.#chit = this.querySelector("fig-chit");
8488
- this.#syncChitType();
8483
+ this.querySelectorAll("fig-chit[data-generated]").forEach((el) => el.remove());
8489
8484
  this.#ensureMediaElement();
8490
8485
  this.#syncGeneratedMediaElement();
8491
8486
 
@@ -8498,22 +8493,13 @@ class FigMedia extends HTMLElement {
8498
8493
  disconnectedCallback() {
8499
8494
  this.#fileInput?.removeEventListener("change", this.#boundHandleFileInput);
8500
8495
  this.#removeMediaElementListeners();
8496
+ this.#removeControls();
8501
8497
  if (this.#blobUrl) {
8502
8498
  URL.revokeObjectURL(this.#blobUrl);
8503
8499
  this.#blobUrl = null;
8504
8500
  }
8505
8501
  }
8506
8502
 
8507
- #applyChitBackground(chit) {
8508
- const cb = this.hasAttribute("checkerboard") && this.getAttribute("checkerboard") !== "false";
8509
- chit.setAttribute("background", cb ? "url()" : "var(--figma-color-bg-secondary)");
8510
- }
8511
-
8512
- #syncChitType() {
8513
- if (!this.#chit) return;
8514
- this.#chit.setAttribute("data-type", this.mediaKind);
8515
- }
8516
-
8517
8503
  #removeMediaElementListeners() {
8518
8504
  if (!this.#mediaEl) return;
8519
8505
  if (this.#mediaEl.tagName === "VIDEO") {
@@ -8556,12 +8542,19 @@ class FigMedia extends HTMLElement {
8556
8542
  video.setAttribute("data-generated", "");
8557
8543
  video.className = "fig-media-element";
8558
8544
  video.setAttribute("playsinline", "");
8559
- video.preload = "metadata";
8545
+ video.preload = "auto";
8560
8546
  this.prepend(video);
8561
8547
  this.#mediaEl = video;
8562
8548
  this.#mediaEl.addEventListener("play", this.#boundHandleMediaPlay);
8563
8549
  this.#mediaEl.addEventListener("pause", this.#boundHandleMediaPause);
8564
8550
  this.#mediaEl.addEventListener("ended", this.#boundHandleMediaEnded);
8551
+ const seekToFirstFrame = () => {
8552
+ if (this.#mediaEl?.autoplay) return;
8553
+ try {
8554
+ this.#mediaEl.currentTime = 0.001;
8555
+ } catch {}
8556
+ };
8557
+ this.#mediaEl.addEventListener("loadedmetadata", seekToFirstFrame, { once: true });
8565
8558
  } else {
8566
8559
  const img = document.createElement("img");
8567
8560
  img.setAttribute("data-generated", "");
@@ -8601,11 +8594,163 @@ class FigMedia extends HTMLElement {
8601
8594
  } else {
8602
8595
  this.#mediaEl.removeAttribute("poster");
8603
8596
  }
8604
- this.#mediaEl.controls = this.#isEnabledAttr("controls", false);
8597
+ this.#mediaEl.controls = false;
8598
+ this.#mediaEl.removeAttribute("controls");
8605
8599
  this.#mediaEl.autoplay = this.#isEnabledAttr("autoplay", false);
8606
8600
  this.#mediaEl.loop = this.#isEnabledAttr("loop", false);
8607
8601
  this.#mediaEl.muted = this.#isEnabledAttr("muted", false);
8608
8602
  this.#mediaEl.playsInline = true;
8603
+ this.#syncControlsVisibility();
8604
+ }
8605
+
8606
+ get mediaEl() {
8607
+ return this.#mediaEl;
8608
+ }
8609
+
8610
+ #syncControlsVisibility() {
8611
+ if (this.mediaKind !== "video") {
8612
+ this.#removeControls();
8613
+ return;
8614
+ }
8615
+ const userControls = this.querySelector(
8616
+ ":scope > fig-media-controls:not([data-generated])",
8617
+ );
8618
+ if (userControls) {
8619
+ if (this.#controlsEl !== userControls) {
8620
+ this.#removeControls();
8621
+ this.#controlsEl = userControls;
8622
+ }
8623
+ this.#wireControlsToMedia();
8624
+ return;
8625
+ }
8626
+ if (this.#isEnabledAttr("controls", false)) {
8627
+ this.#ensureControls();
8628
+ } else {
8629
+ this.#removeControls();
8630
+ }
8631
+ }
8632
+
8633
+ #ensureControls() {
8634
+ if (this.#controlsEl && this.#controlsEl.isConnected) {
8635
+ this.#wireControlsToMedia();
8636
+ return;
8637
+ }
8638
+ const controls = document.createElement("fig-media-controls");
8639
+ controls.setAttribute("data-generated", "");
8640
+ controls.setAttribute("overlay", "");
8641
+ this.append(controls);
8642
+ this.#controlsEl = controls;
8643
+ this.#wireControlsToMedia();
8644
+ }
8645
+
8646
+ #wireControlsToMedia() {
8647
+ if (!this.#controlsEl || !this.#mediaEl) return;
8648
+ if (
8649
+ this.#controlsWiredFor === this.#mediaEl &&
8650
+ this.#controlsWiredControls === this.#controlsEl
8651
+ ) {
8652
+ return;
8653
+ }
8654
+ this.#unwireControls();
8655
+
8656
+ const controls = this.#controlsEl;
8657
+ const video = this.#mediaEl;
8658
+ this.#controlsWiredFor = video;
8659
+ this.#controlsWiredControls = controls;
8660
+
8661
+ let pendingSeekTime = null;
8662
+ const syncFromVideo = () => {
8663
+ controls.playing = !video.paused && !video.ended;
8664
+ if (Number.isFinite(video.duration)) controls.duration = video.duration;
8665
+ if (pendingSeekTime !== null) {
8666
+ if (Math.abs(video.currentTime - pendingSeekTime) < 0.25) {
8667
+ pendingSeekTime = null;
8668
+ } else {
8669
+ return;
8670
+ }
8671
+ }
8672
+ controls.time = video.currentTime || 0;
8673
+ };
8674
+ const onPlay = () => {
8675
+ const p = video.play?.();
8676
+ if (p && typeof p.catch === "function") p.catch(() => {});
8677
+ };
8678
+ const onPause = () => video.pause?.();
8679
+ const onSeek = (e) => {
8680
+ const next = Number(e?.detail?.time);
8681
+ if (!Number.isFinite(next)) return;
8682
+ pendingSeekTime = next;
8683
+ try { video.currentTime = next; } catch {}
8684
+ };
8685
+
8686
+ this.#controlsSync = syncFromVideo;
8687
+ this.#controlsOnPlay = onPlay;
8688
+ this.#controlsOnPause = onPause;
8689
+ this.#controlsOnSeek = onSeek;
8690
+
8691
+ video.addEventListener("play", syncFromVideo);
8692
+ video.addEventListener("pause", syncFromVideo);
8693
+ video.addEventListener("ended", syncFromVideo);
8694
+ video.addEventListener("timeupdate", syncFromVideo);
8695
+ video.addEventListener("loadedmetadata", syncFromVideo);
8696
+ video.addEventListener("durationchange", syncFromVideo);
8697
+ video.addEventListener("seeked", syncFromVideo);
8698
+ controls.addEventListener("play", onPlay);
8699
+ controls.addEventListener("pause", onPause);
8700
+ controls.addEventListener("seek", onSeek);
8701
+
8702
+ syncFromVideo();
8703
+ }
8704
+
8705
+ #unwireControls() {
8706
+ const video = this.#controlsWiredFor;
8707
+ const controls = this.#controlsWiredControls;
8708
+ if (video && this.#controlsSync) {
8709
+ video.removeEventListener("play", this.#controlsSync);
8710
+ video.removeEventListener("pause", this.#controlsSync);
8711
+ video.removeEventListener("ended", this.#controlsSync);
8712
+ video.removeEventListener("timeupdate", this.#controlsSync);
8713
+ video.removeEventListener("loadedmetadata", this.#controlsSync);
8714
+ video.removeEventListener("durationchange", this.#controlsSync);
8715
+ video.removeEventListener("seeked", this.#controlsSync);
8716
+ }
8717
+ if (controls) {
8718
+ if (this.#controlsOnPlay) controls.removeEventListener("play", this.#controlsOnPlay);
8719
+ if (this.#controlsOnPause) controls.removeEventListener("pause", this.#controlsOnPause);
8720
+ if (this.#controlsOnSeek) controls.removeEventListener("seek", this.#controlsOnSeek);
8721
+ }
8722
+ this.#controlsWiredFor = null;
8723
+ this.#controlsWiredControls = null;
8724
+ this.#controlsSync = null;
8725
+ this.#controlsOnPlay = null;
8726
+ this.#controlsOnPause = null;
8727
+ this.#controlsOnSeek = null;
8728
+ }
8729
+
8730
+ #removeControls() {
8731
+ this.#unwireControls();
8732
+ if (!this.#controlsEl) return;
8733
+ if (this.#controlsEl.hasAttribute("data-generated")) {
8734
+ this.#controlsEl.remove();
8735
+ }
8736
+ this.#controlsEl = null;
8737
+ }
8738
+
8739
+ toggle() {
8740
+ if (!this.#mediaEl || this.mediaKind !== "video") return;
8741
+ if (this.#mediaEl.paused || this.#mediaEl.ended) this.play();
8742
+ else this.pause();
8743
+ }
8744
+
8745
+ play() {
8746
+ if (this.mediaKind !== "video" || !this.#mediaEl) return;
8747
+ const p = this.#mediaEl.play();
8748
+ if (p && typeof p.catch === "function") p.catch(() => {});
8749
+ }
8750
+
8751
+ pause() {
8752
+ if (this.mediaKind !== "video" || !this.#mediaEl) return;
8753
+ this.#mediaEl.pause();
8609
8754
  }
8610
8755
 
8611
8756
  #createFileInput() {
@@ -8724,7 +8869,6 @@ class FigMedia extends HTMLElement {
8724
8869
  }
8725
8870
 
8726
8871
  if (name === "type") {
8727
- this.#syncChitType();
8728
8872
  this.#ensureMediaElement();
8729
8873
  this.#syncGeneratedMediaElement();
8730
8874
  if (this.#fileInput) {
@@ -8750,28 +8894,17 @@ class FigMedia extends HTMLElement {
8750
8894
 
8751
8895
  if (name === "aspect-ratio") {
8752
8896
  if (newValue) {
8753
- this.style.setProperty("--aspect-ratio", newValue);
8897
+ this.style.setProperty("--fig-media-aspect-ratio", newValue);
8754
8898
  } else {
8755
- this.style.removeProperty("--aspect-ratio");
8899
+ this.style.removeProperty("--fig-media-aspect-ratio");
8756
8900
  }
8757
8901
  }
8758
8902
 
8759
8903
  if (name === "fit") {
8760
8904
  if (newValue) {
8761
- this.style.setProperty("--fit", newValue);
8905
+ this.style.setProperty("--fig-media-fit", newValue);
8762
8906
  } else {
8763
- this.style.removeProperty("--fit");
8764
- }
8765
- }
8766
-
8767
- if (name === "checkerboard") {
8768
- if (this.#chit) {
8769
- if (newValue !== null && newValue !== "false") {
8770
- this.#chit.setAttribute("checkerboard", "");
8771
- } else {
8772
- this.#chit.removeAttribute("checkerboard");
8773
- }
8774
- this.#applyChitBackground(this.#chit);
8907
+ this.style.removeProperty("--fig-media-fit");
8775
8908
  }
8776
8909
  }
8777
8910
 
@@ -8802,6 +8935,203 @@ class FigVideo extends FigMedia {
8802
8935
  }
8803
8936
  customElements.define("fig-video", FigVideo);
8804
8937
 
8938
+ /**
8939
+ * <fig-media-controls> — Standalone playback controls UI.
8940
+ *
8941
+ * Renders a play/pause button, a scrubber slider, and a MM:SS time display.
8942
+ * Holds its own state via attributes — no media element required.
8943
+ *
8944
+ * Attributes:
8945
+ * - `playing` (boolean presence) — current play/pause state
8946
+ * - `duration` (number, seconds) — total track length
8947
+ * - `time` (number, seconds) — current playhead position
8948
+ *
8949
+ * Events:
8950
+ * - `play` — emitted when the user toggles playback on (detail: { playing: true })
8951
+ * - `pause` — emitted when the user toggles playback off (detail: { playing: false })
8952
+ * - `seek` — emitted when the user drags the scrubber (detail: { time })
8953
+ *
8954
+ * Properties: `playing`, `duration`, `time` mirror the attributes.
8955
+ */
8956
+ class FigMediaControls extends HTMLElement {
8957
+ #playBtn = null;
8958
+ #playTooltip = null;
8959
+ #timeSlider = null;
8960
+ #timeEl = null;
8961
+ #userSeeking = false;
8962
+ #rendered = false;
8963
+
8964
+ static get observedAttributes() {
8965
+ return ["playing", "duration", "time"];
8966
+ }
8967
+
8968
+ connectedCallback() {
8969
+ this.#render();
8970
+ this.#syncPlayingUi();
8971
+ this.#syncTimeUi();
8972
+ }
8973
+
8974
+ get playing() {
8975
+ return this.hasAttribute("playing") && this.getAttribute("playing") !== "false";
8976
+ }
8977
+ set playing(value) {
8978
+ if (value) this.setAttribute("playing", "");
8979
+ else this.removeAttribute("playing");
8980
+ }
8981
+
8982
+ get duration() {
8983
+ const n = Number(this.getAttribute("duration"));
8984
+ return Number.isFinite(n) && n >= 0 ? n : 0;
8985
+ }
8986
+ set duration(value) {
8987
+ const n = Number(value);
8988
+ if (!Number.isFinite(n) || n < 0) {
8989
+ this.removeAttribute("duration");
8990
+ return;
8991
+ }
8992
+ this.setAttribute("duration", String(n));
8993
+ }
8994
+
8995
+ get time() {
8996
+ const n = Number(this.getAttribute("time"));
8997
+ return Number.isFinite(n) && n >= 0 ? n : 0;
8998
+ }
8999
+ set time(value) {
9000
+ const n = Number(value);
9001
+ if (!Number.isFinite(n) || n < 0) {
9002
+ this.removeAttribute("time");
9003
+ return;
9004
+ }
9005
+ this.setAttribute("time", String(n));
9006
+ }
9007
+
9008
+ attributeChangedCallback(name) {
9009
+ if (!this.#rendered) return;
9010
+ if (name === "playing") this.#syncPlayingUi();
9011
+ if (name === "duration" || name === "time") this.#syncTimeUi();
9012
+ }
9013
+
9014
+ #render() {
9015
+ if (this.#rendered) return;
9016
+ this.#rendered = true;
9017
+
9018
+ const tooltip = document.createElement("fig-tooltip");
9019
+ tooltip.setAttribute("text", "Play");
9020
+ const btn = document.createElement("fig-button");
9021
+ btn.setAttribute("variant", "ghost");
9022
+ btn.setAttribute("size", "small");
9023
+ btn.setAttribute("icon", "true");
9024
+ btn.setAttribute("aria-label", "Play");
9025
+ const icon = document.createElement("span");
9026
+ icon.className = "fig-mask-icon fig-media-controls-play-icon";
9027
+ icon.style.setProperty("--icon", "var(--icon-play)");
9028
+ icon.style.setProperty("--size", "1.5rem");
9029
+ btn.append(icon);
9030
+ tooltip.append(btn);
9031
+ btn.addEventListener("click", (e) => {
9032
+ e.preventDefault();
9033
+ e.stopPropagation();
9034
+ this.toggle();
9035
+ });
9036
+
9037
+ const slider = document.createElement("fig-slider");
9038
+ slider.setAttribute("variant", "neue");
9039
+ slider.setAttribute("min", "0");
9040
+ slider.setAttribute("max", String(this.duration));
9041
+ slider.setAttribute("step", "0.1");
9042
+ slider.setAttribute("value", String(this.time));
9043
+ slider.setAttribute("full", "");
9044
+ const timeEl = document.createElement("label");
9045
+ timeEl.className = "fig-media-controls-time";
9046
+ timeEl.textContent = this.#formatTime(this.time);
9047
+
9048
+ const handleSeek = (e) => {
9049
+ const host = e.currentTarget;
9050
+ const next = Number(host?.value);
9051
+ if (!Number.isFinite(next)) return;
9052
+ this.#userSeeking = true;
9053
+ this.setAttribute("time", String(next));
9054
+ this.dispatchEvent(
9055
+ new CustomEvent("seek", {
9056
+ bubbles: true,
9057
+ composed: true,
9058
+ detail: { time: next },
9059
+ }),
9060
+ );
9061
+ requestAnimationFrame(() => {
9062
+ this.#userSeeking = false;
9063
+ });
9064
+ };
9065
+ slider.addEventListener("input", handleSeek);
9066
+ slider.addEventListener("change", handleSeek);
9067
+
9068
+ this.append(tooltip, slider, timeEl);
9069
+
9070
+ this.#playBtn = btn;
9071
+ this.#playTooltip = tooltip;
9072
+ this.#timeSlider = slider;
9073
+ this.#timeEl = timeEl;
9074
+ }
9075
+
9076
+ #formatTime(seconds) {
9077
+ if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
9078
+ const total = Math.floor(seconds);
9079
+ const m = Math.floor(total / 60);
9080
+ const s = total % 60;
9081
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
9082
+ }
9083
+
9084
+ #syncPlayingUi() {
9085
+ if (!this.#playBtn) return;
9086
+ const playing = this.playing;
9087
+ this.#playBtn.setAttribute("aria-label", playing ? "Pause" : "Play");
9088
+ this.#playTooltip?.setAttribute("text", playing ? "Pause" : "Play");
9089
+ const icon = this.#playBtn.querySelector(".fig-media-controls-play-icon");
9090
+ if (icon) {
9091
+ icon.style.setProperty(
9092
+ "--icon",
9093
+ playing ? "var(--icon-pause)" : "var(--icon-play)",
9094
+ );
9095
+ }
9096
+ }
9097
+
9098
+ #syncTimeUi() {
9099
+ if (!this.#timeSlider) return;
9100
+ const duration = this.duration;
9101
+ if (Number(this.#timeSlider.getAttribute("max")) !== duration) {
9102
+ this.#timeSlider.setAttribute("max", String(duration));
9103
+ }
9104
+ const t = this.time;
9105
+ if (!this.#userSeeking) {
9106
+ this.#timeSlider.setAttribute("value", String(t));
9107
+ }
9108
+ if (this.#timeEl) this.#timeEl.textContent = this.#formatTime(t);
9109
+ }
9110
+
9111
+ toggle() {
9112
+ const next = !this.playing;
9113
+ this.playing = next;
9114
+ this.dispatchEvent(
9115
+ new CustomEvent(next ? "play" : "pause", {
9116
+ bubbles: true,
9117
+ composed: true,
9118
+ detail: { playing: next },
9119
+ }),
9120
+ );
9121
+ }
9122
+
9123
+ play() {
9124
+ if (this.playing) return;
9125
+ this.toggle();
9126
+ }
9127
+
9128
+ pause() {
9129
+ if (!this.playing) return;
9130
+ this.toggle();
9131
+ }
9132
+ }
9133
+ customElements.define("fig-media-controls", FigMediaControls);
9134
+
8805
9135
  /* File Upload Input */
8806
9136
  class FigInputFile extends HTMLElement {
8807
9137
  static observedAttributes = ["accepts", "label", "disabled", "multiple", "variant", "url"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "4.9.0",
3
+ "version": "4.10.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",