@rogieking/figui3 11.3.3 → 11.4.1

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
@@ -11181,9 +11181,9 @@ class FigInputPalette extends HTMLElement {
11181
11181
 
11182
11182
  const popup = document.createElement("dialog", { is: "fig-popup" });
11183
11183
  popup.setAttribute("is", "fig-popup");
11184
- popup.setAttribute("title", "Edit palette");
11185
- popup.setAttribute("position", "center left");
11186
- popup.setAttribute("offset", "8 0");
11184
+ popup.setAttribute("aria-label", "Edit palette");
11185
+ popup.setAttribute("position", "bottom left");
11186
+ popup.setAttribute("offset", "0 8");
11187
11187
  popup.setAttribute("viewport-margin", "8");
11188
11188
  popup.setAttribute("popover", "manual");
11189
11189
  popup.className = "fig-palette-editor-popup";
@@ -11224,9 +11224,6 @@ class FigInputPalette extends HTMLElement {
11224
11224
  popup.addEventListener("toggle", this.#boundPickerClosed);
11225
11225
 
11226
11226
  (figGetOverlayRoot() ?? document.body).append(popup);
11227
- popup
11228
- .querySelector(":scope > fig-header")
11229
- ?.setAttribute("borderless", "");
11230
11227
  this.#pickerPopup = popup;
11231
11228
  this.#pickerEditor = editor;
11232
11229
  this.querySelector(".palette-colors-inline")?.setAttribute(
@@ -15300,6 +15297,351 @@ class FigAudioWaveform extends HTMLElement {
15300
15297
  }
15301
15298
  figDefineElement("fig-audio-waveform", FigAudioWaveform);
15302
15299
 
15300
+ /**
15301
+ * Audio player composed from a waveform and shared media controls.
15302
+ *
15303
+ * @attr {string} src - Audio source URL
15304
+ * @attr {boolean} autoplay - Audio autoplay
15305
+ * @attr {boolean} controls - Show playback controls (default false)
15306
+ * @attr {boolean} muted - Mute audio playback
15307
+ * @attr {boolean} loop - Loop audio playback
15308
+ */
15309
+ class FigAudio extends HTMLElement {
15310
+ #src = "";
15311
+ #audioEl = null;
15312
+ #waveformEl = null;
15313
+ #controlsEl = null;
15314
+ #controlsWiredFor = null;
15315
+ #controlsWiredControls = null;
15316
+ #controlsSync = null;
15317
+ #controlsOnPlay = null;
15318
+ #controlsOnPause = null;
15319
+ #controlsOnSeek = null;
15320
+
15321
+ static get observedAttributes() {
15322
+ return ["src", "autoplay", "controls", "muted", "loop"];
15323
+ }
15324
+
15325
+ connectedCallback() {
15326
+ this.#src = this.getAttribute("src") || "";
15327
+ this.#ensureAudioElement();
15328
+ this.#ensureWaveform();
15329
+ this.#syncAudioElement();
15330
+ this.#syncControlsVisibility();
15331
+ this.#syncPlaybackUi();
15332
+ }
15333
+
15334
+ disconnectedCallback() {
15335
+ this.#unwireControls();
15336
+ }
15337
+
15338
+ get src() {
15339
+ return this.#src;
15340
+ }
15341
+
15342
+ set src(value) {
15343
+ if (value === null || value === undefined || value === "") {
15344
+ this.removeAttribute("src");
15345
+ } else {
15346
+ this.setAttribute("src", String(value));
15347
+ }
15348
+ }
15349
+
15350
+ get audioEl() {
15351
+ return this.#audioEl;
15352
+ }
15353
+
15354
+ #isEnabledAttr(name) {
15355
+ return this.hasAttribute(name) && this.getAttribute(name) !== "false";
15356
+ }
15357
+
15358
+ #ensureAudioElement() {
15359
+ const authored = this.querySelector(":scope > audio:not([data-generated])");
15360
+ if (authored) {
15361
+ this.#audioEl = authored;
15362
+ return;
15363
+ }
15364
+
15365
+ const generated = Array.from(
15366
+ this.querySelectorAll(":scope > audio[data-generated]"),
15367
+ );
15368
+ const audio = generated[0] || document.createElement("audio");
15369
+ generated.slice(1).forEach((duplicate) => duplicate.remove());
15370
+ if (!audio.hasAttribute("data-generated")) {
15371
+ audio.setAttribute("data-generated", "");
15372
+ audio.preload = "metadata";
15373
+ this.append(audio);
15374
+ }
15375
+ this.#audioEl = audio;
15376
+ }
15377
+
15378
+ #ensureWaveform() {
15379
+ const authored = this.querySelector(
15380
+ ":scope > fig-audio-waveform:not([data-generated])",
15381
+ );
15382
+ if (authored) {
15383
+ this.querySelectorAll(
15384
+ ":scope > fig-audio-waveform[data-generated]",
15385
+ ).forEach((waveform) => waveform.remove());
15386
+ this.#waveformEl = authored;
15387
+ } else {
15388
+ const generated = Array.from(
15389
+ this.querySelectorAll(":scope > fig-audio-waveform[data-generated]"),
15390
+ );
15391
+ const waveform = generated[0] || document.createElement("fig-audio-waveform");
15392
+ generated.slice(1).forEach((duplicate) => duplicate.remove());
15393
+ if (!waveform.hasAttribute("data-generated")) {
15394
+ waveform.setAttribute("data-generated", "");
15395
+ waveform.setAttribute("full", "");
15396
+ this.prepend(waveform);
15397
+ }
15398
+ this.#waveformEl = waveform;
15399
+ }
15400
+ this.#syncWaveformSource();
15401
+ }
15402
+
15403
+ #syncWaveformSource() {
15404
+ if (!this.#waveformEl) return;
15405
+ if (this.#src) this.#waveformEl.setAttribute("url", this.#src);
15406
+ else this.#waveformEl.removeAttribute("url");
15407
+ }
15408
+
15409
+ #syncAudioElement() {
15410
+ if (!this.#audioEl) return;
15411
+ if (this.#audioEl.getAttribute("src") !== this.#src) {
15412
+ if (this.#src) this.#audioEl.setAttribute("src", this.#src);
15413
+ else {
15414
+ this.#audioEl.removeAttribute("src");
15415
+ this.#audioEl.load?.();
15416
+ }
15417
+ }
15418
+ this.#audioEl.controls = false;
15419
+ this.#audioEl.removeAttribute("controls");
15420
+ this.#audioEl.autoplay = this.#isEnabledAttr("autoplay");
15421
+ this.#audioEl.muted = this.#isEnabledAttr("muted");
15422
+ this.#audioEl.loop = this.#isEnabledAttr("loop");
15423
+ this.#syncWaveformSource();
15424
+ this.#syncControlsDisabled();
15425
+ }
15426
+
15427
+ #hasPlayableAudio() {
15428
+ return Boolean(this.#src);
15429
+ }
15430
+
15431
+ #syncControlsVisibility() {
15432
+ const authored = this.querySelector(
15433
+ ":scope > fig-media-controls:not([data-generated])",
15434
+ );
15435
+ if (authored) {
15436
+ if (this.#controlsEl !== authored) {
15437
+ this.#removeControls();
15438
+ this.#controlsEl = authored;
15439
+ }
15440
+ this.#wireControlsToAudio();
15441
+ this.#syncControlsDisabled();
15442
+ return;
15443
+ }
15444
+
15445
+ if (this.#isEnabledAttr("controls")) {
15446
+ this.#ensureControls();
15447
+ } else {
15448
+ this.#removeControls();
15449
+ }
15450
+ }
15451
+
15452
+ #ensureControls() {
15453
+ const generated = Array.from(
15454
+ this.querySelectorAll(":scope > fig-media-controls[data-generated]"),
15455
+ );
15456
+ const controls = generated[0] || document.createElement("fig-media-controls");
15457
+ generated.slice(1).forEach((duplicate) => duplicate.remove());
15458
+ if (!controls.hasAttribute("data-generated")) {
15459
+ controls.setAttribute("data-generated", "");
15460
+ this.append(controls);
15461
+ }
15462
+ this.#controlsEl = controls;
15463
+ this.#wireControlsToAudio();
15464
+ this.#syncControlsDisabled();
15465
+ }
15466
+
15467
+ #syncControlsDisabled() {
15468
+ if (!this.#controlsEl) return;
15469
+ if (this.#hasPlayableAudio()) {
15470
+ if (this.#controlsEl.hasAttribute("data-fig-empty-disabled")) {
15471
+ this.#controlsEl.removeAttribute("disabled");
15472
+ this.#controlsEl.removeAttribute("data-fig-empty-disabled");
15473
+ }
15474
+ return;
15475
+ }
15476
+ this.#controlsEl.setAttribute("disabled", "");
15477
+ this.#controlsEl.setAttribute("data-fig-empty-disabled", "");
15478
+ this.#controlsEl.removeAttribute("playing");
15479
+ this.#controlsEl.setAttribute("time", "0");
15480
+ this.#controlsEl.setAttribute("duration", "0");
15481
+ this.#syncWaveformProgress(0, 0);
15482
+ }
15483
+
15484
+ #syncWaveformProgress(time, duration) {
15485
+ if (!this.#waveformEl) return;
15486
+ const progress =
15487
+ Number.isFinite(time) && Number.isFinite(duration) && duration > 0
15488
+ ? Math.max(0, Math.min(time / duration, 1))
15489
+ : 0;
15490
+ this.#waveformEl.setAttribute("progress", String(progress));
15491
+ }
15492
+
15493
+ #syncPlaybackUi() {
15494
+ const audio = this.#audioEl;
15495
+ if (!audio) return;
15496
+ const time = Number.isFinite(audio.currentTime) ? audio.currentTime : 0;
15497
+ const duration = Number.isFinite(audio.duration) ? audio.duration : 0;
15498
+ if (this.#controlsEl) {
15499
+ this.#controlsEl.toggleAttribute("playing", !audio.paused && !audio.ended);
15500
+ this.#controlsEl.setAttribute("time", String(time));
15501
+ this.#controlsEl.setAttribute("duration", String(duration));
15502
+ }
15503
+ this.#syncWaveformProgress(time, duration);
15504
+ }
15505
+
15506
+ #wireControlsToAudio() {
15507
+ if (!this.#controlsEl || !this.#audioEl) return;
15508
+ if (
15509
+ this.#controlsWiredFor === this.#audioEl &&
15510
+ this.#controlsWiredControls === this.#controlsEl
15511
+ ) {
15512
+ this.#syncControlsDisabled();
15513
+ this.#syncPlaybackUi();
15514
+ return;
15515
+ }
15516
+ this.#unwireControls();
15517
+
15518
+ const controls = this.#controlsEl;
15519
+ const audio = this.#audioEl;
15520
+ let pendingSeekTime = null;
15521
+ const syncFromAudio = () => {
15522
+ this.#syncControlsDisabled();
15523
+ if (!this.#hasPlayableAudio()) return;
15524
+ const duration = Number.isFinite(audio.duration) ? audio.duration : 0;
15525
+ const time = Number.isFinite(audio.currentTime) ? audio.currentTime : 0;
15526
+ controls.toggleAttribute("playing", !audio.paused && !audio.ended);
15527
+ controls.setAttribute("duration", String(duration));
15528
+ if (pendingSeekTime !== null) {
15529
+ if (Math.abs(time - pendingSeekTime) < 0.25) pendingSeekTime = null;
15530
+ else {
15531
+ this.#syncWaveformProgress(time, duration);
15532
+ return;
15533
+ }
15534
+ }
15535
+ controls.setAttribute("time", String(time));
15536
+ this.#syncWaveformProgress(time, duration);
15537
+ };
15538
+ const onPlay = () => this.play();
15539
+ const onPause = () => this.pause();
15540
+ const onSeek = (event) => {
15541
+ if (!this.#hasPlayableAudio()) return;
15542
+ const next = Number(event?.detail?.time);
15543
+ if (!Number.isFinite(next)) return;
15544
+ pendingSeekTime = next;
15545
+ try {
15546
+ audio.currentTime = next;
15547
+ } catch {}
15548
+ this.#syncWaveformProgress(next, audio.duration);
15549
+ };
15550
+
15551
+ this.#controlsWiredFor = audio;
15552
+ this.#controlsWiredControls = controls;
15553
+ this.#controlsSync = syncFromAudio;
15554
+ this.#controlsOnPlay = onPlay;
15555
+ this.#controlsOnPause = onPause;
15556
+ this.#controlsOnSeek = onSeek;
15557
+
15558
+ [
15559
+ "play",
15560
+ "pause",
15561
+ "ended",
15562
+ "timeupdate",
15563
+ "loadedmetadata",
15564
+ "durationchange",
15565
+ "seeked",
15566
+ "emptied",
15567
+ ].forEach((type) => audio.addEventListener(type, syncFromAudio));
15568
+ controls.addEventListener("play", onPlay);
15569
+ controls.addEventListener("pause", onPause);
15570
+ controls.addEventListener("seek", onSeek);
15571
+ syncFromAudio();
15572
+ }
15573
+
15574
+ #unwireControls() {
15575
+ const audio = this.#controlsWiredFor;
15576
+ const controls = this.#controlsWiredControls;
15577
+ if (audio && this.#controlsSync) {
15578
+ [
15579
+ "play",
15580
+ "pause",
15581
+ "ended",
15582
+ "timeupdate",
15583
+ "loadedmetadata",
15584
+ "durationchange",
15585
+ "seeked",
15586
+ "emptied",
15587
+ ].forEach((type) => audio.removeEventListener(type, this.#controlsSync));
15588
+ }
15589
+ if (controls) {
15590
+ if (this.#controlsOnPlay) {
15591
+ controls.removeEventListener("play", this.#controlsOnPlay);
15592
+ }
15593
+ if (this.#controlsOnPause) {
15594
+ controls.removeEventListener("pause", this.#controlsOnPause);
15595
+ }
15596
+ if (this.#controlsOnSeek) {
15597
+ controls.removeEventListener("seek", this.#controlsOnSeek);
15598
+ }
15599
+ }
15600
+ this.#controlsWiredFor = null;
15601
+ this.#controlsWiredControls = null;
15602
+ this.#controlsSync = null;
15603
+ this.#controlsOnPlay = null;
15604
+ this.#controlsOnPause = null;
15605
+ this.#controlsOnSeek = null;
15606
+ }
15607
+
15608
+ #removeControls() {
15609
+ this.#unwireControls();
15610
+ this.querySelectorAll(
15611
+ ":scope > fig-media-controls[data-generated]",
15612
+ ).forEach((controls) => controls.remove());
15613
+ this.#controlsEl = null;
15614
+ }
15615
+
15616
+ play() {
15617
+ if (!this.#audioEl || !this.#hasPlayableAudio()) return;
15618
+ const promise = this.#audioEl.play?.();
15619
+ if (promise && typeof promise.catch === "function") promise.catch(() => {});
15620
+ }
15621
+
15622
+ pause() {
15623
+ this.#audioEl?.pause?.();
15624
+ }
15625
+
15626
+ toggle() {
15627
+ if (!this.#audioEl || !this.#hasPlayableAudio()) return;
15628
+ if (this.#audioEl.paused || this.#audioEl.ended) this.play();
15629
+ else this.pause();
15630
+ }
15631
+
15632
+ attributeChangedCallback(name, oldValue, newValue) {
15633
+ if (oldValue === newValue) return;
15634
+ if (name === "src") this.#src = newValue || "";
15635
+ if (!this.isConnected) return;
15636
+ if (name === "controls") this.#syncControlsVisibility();
15637
+ if (["src", "autoplay", "muted", "loop"].includes(name)) {
15638
+ this.#syncAudioElement();
15639
+ this.#syncPlaybackUi();
15640
+ }
15641
+ }
15642
+ }
15643
+ figDefineElement("fig-audio", FigAudio);
15644
+
15303
15645
  /* File Upload Input */
15304
15646
  const FIG_INPUT_FILE_IMAGE_EXTENSIONS = [
15305
15647
  ".png",
@@ -15318,11 +15660,23 @@ const FIG_INPUT_FILE_AUDIO_EXTENSIONS = [
15318
15660
  ".ogg",
15319
15661
  ".flac",
15320
15662
  ];
15663
+ const FIG_INPUT_FILE_VIDEO_EXTENSIONS = [
15664
+ ".mp4",
15665
+ ".webm",
15666
+ ".mov",
15667
+ ".m4v",
15668
+ ".avi",
15669
+ ".mkv",
15670
+ ".ogv",
15671
+ ".mpeg",
15672
+ ".mpg",
15673
+ ".3gp",
15674
+ ];
15321
15675
 
15322
15676
  /**
15323
15677
  * File upload input with file-aware icons, image previews, and audio playback waveforms.
15324
15678
  * @attr {string} accepts - Native file chooser filter.
15325
- * @attr {boolean} preview - Preview selected files with a supported image extension.
15679
+ * @attr {boolean} preview - Preview supported files or show their media-specific icon.
15326
15680
  * @attr {string} label - Upload button label. Defaults by known file type.
15327
15681
  */
15328
15682
  class FigInputFile extends HTMLElement {
@@ -15334,13 +15688,12 @@ class FigInputFile extends HTMLElement {
15334
15688
  #tooltipEl = null;
15335
15689
  #uploadBtn = null;
15336
15690
  #files = null;
15337
- #previewImage = null;
15691
+ #imageBlobUrl = null;
15338
15692
  #audioPreview = null;
15339
15693
  #audioIcon = null;
15340
15694
  #audioWaveform = null;
15341
15695
  #audioPlaying = false;
15342
15696
  #audioBlobUrl = null;
15343
- #syncingPreview = false;
15344
15697
  #suppressAttributeRender = false;
15345
15698
 
15346
15699
  get files() {
@@ -15380,6 +15733,10 @@ class FigInputFile extends HTMLElement {
15380
15733
  );
15381
15734
  }
15382
15735
 
15736
+ get #largePreviewEnabled() {
15737
+ return this.getAttribute("preview")?.toLowerCase() === "large";
15738
+ }
15739
+
15383
15740
  #hasKnownExtension(filename, extensions) {
15384
15741
  const lowerName = filename.toLowerCase();
15385
15742
  return extensions.some((extension) => lowerName.endsWith(extension));
@@ -15467,9 +15824,10 @@ class FigInputFile extends HTMLElement {
15467
15824
 
15468
15825
  #hasPreviewableImage() {
15469
15826
  if (this.#files?.length) {
15470
- return this.#hasKnownExtension(
15471
- this.#files[0].name,
15827
+ return this.#isKnownFile(
15828
+ this.#files[0],
15472
15829
  FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15830
+ "image/",
15473
15831
  );
15474
15832
  }
15475
15833
  return (
@@ -15498,9 +15856,90 @@ class FigInputFile extends HTMLElement {
15498
15856
  );
15499
15857
  }
15500
15858
 
15859
+ #largePreviewKind() {
15860
+ const file = this.#files?.[0];
15861
+ if (file) {
15862
+ if (
15863
+ this.#isKnownFile(
15864
+ file,
15865
+ FIG_INPUT_FILE_VIDEO_EXTENSIONS,
15866
+ "video/",
15867
+ )
15868
+ ) {
15869
+ return "video";
15870
+ }
15871
+ if (
15872
+ this.#isKnownFile(
15873
+ file,
15874
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15875
+ "image/",
15876
+ )
15877
+ ) {
15878
+ return "image";
15879
+ }
15880
+ return null;
15881
+ }
15882
+
15883
+ if (
15884
+ this.#hasKnownExtension(
15885
+ this.#urlFilename,
15886
+ FIG_INPUT_FILE_VIDEO_EXTENSIONS,
15887
+ )
15888
+ ) {
15889
+ return "video";
15890
+ }
15891
+ if (
15892
+ this.#hasKnownExtension(
15893
+ this.#urlFilename,
15894
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15895
+ )
15896
+ ) {
15897
+ return "image";
15898
+ }
15899
+
15900
+ const acceptedKinds = this.#effectiveAccepts
15901
+ .split(",")
15902
+ .map((value) => value.trim().toLowerCase())
15903
+ .filter(Boolean)
15904
+ .map((value) => {
15905
+ if (
15906
+ value.startsWith("video/") ||
15907
+ FIG_INPUT_FILE_VIDEO_EXTENSIONS.includes(value)
15908
+ ) {
15909
+ return "video";
15910
+ }
15911
+ if (
15912
+ value.startsWith("image/") ||
15913
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS.includes(value)
15914
+ ) {
15915
+ return "image";
15916
+ }
15917
+ return null;
15918
+ });
15919
+ if (
15920
+ acceptedKinds.length > 0 &&
15921
+ acceptedKinds.every((kind) => kind === acceptedKinds[0])
15922
+ ) {
15923
+ return acceptedKinds[0];
15924
+ }
15925
+ return null;
15926
+ }
15927
+
15501
15928
  #selectedFileIcon() {
15502
15929
  if (this.#files && this.#files.length > 0) {
15503
15930
  const files = Array.from(this.#files);
15931
+ if (
15932
+ this.#previewEnabled &&
15933
+ files.every((file) =>
15934
+ this.#isKnownFile(
15935
+ file,
15936
+ FIG_INPUT_FILE_VIDEO_EXTENSIONS,
15937
+ "video/",
15938
+ ),
15939
+ )
15940
+ ) {
15941
+ return "video";
15942
+ }
15504
15943
  if (
15505
15944
  files.every((file) =>
15506
15945
  this.#isKnownFile(
@@ -15524,6 +15963,15 @@ class FigInputFile extends HTMLElement {
15524
15963
  return "image";
15525
15964
  }
15526
15965
  }
15966
+ if (
15967
+ this.#previewEnabled &&
15968
+ this.#hasKnownExtension(
15969
+ this.#urlFilename,
15970
+ FIG_INPUT_FILE_VIDEO_EXTENSIONS,
15971
+ )
15972
+ ) {
15973
+ return "video";
15974
+ }
15527
15975
  if (
15528
15976
  this.#hasKnownExtension(
15529
15977
  this.#urlFilename,
@@ -15553,7 +16001,7 @@ class FigInputFile extends HTMLElement {
15553
16001
  disconnectedCallback() {
15554
16002
  this.#fileInput?.removeEventListener("change", this.#onFileChange);
15555
16003
  this.#clearBtn?.removeEventListener("click", this.#onClear);
15556
- this.#disconnectPreview();
16004
+ this.#disconnectImagePreview();
15557
16005
  this.#disconnectAudioPreview();
15558
16006
  this.removeEventListener("dragover", this.#onDragOver);
15559
16007
  this.removeEventListener("dragleave", this.#onDragLeave);
@@ -15581,10 +16029,17 @@ class FigInputFile extends HTMLElement {
15581
16029
  this.dispatchEvent(new CustomEvent("change", { detail, bubbles: true }));
15582
16030
  }
15583
16031
 
16032
+ #removeExternalFileAttributes() {
16033
+ this.#suppressAttributeRender = true;
16034
+ this.removeAttribute("url");
16035
+ this.removeAttribute("filename");
16036
+ this.#suppressAttributeRender = false;
16037
+ }
16038
+
15584
16039
  #onFileChange = () => {
15585
16040
  if (this.#fileInput.files.length > 0) {
15586
16041
  this.#files = this.#fileInput.files;
15587
- this.removeAttribute("url");
16042
+ this.#removeExternalFileAttributes();
15588
16043
  this.#render();
15589
16044
  this.#emitEvents();
15590
16045
  }
@@ -15596,7 +16051,6 @@ class FigInputFile extends HTMLElement {
15596
16051
  };
15597
16052
 
15598
16053
  #onDragOver = (e) => {
15599
- if (this.#previewImage) return;
15600
16054
  if (
15601
16055
  this.hasAttribute("disabled") &&
15602
16056
  this.getAttribute("disabled") !== "false"
@@ -15613,7 +16067,6 @@ class FigInputFile extends HTMLElement {
15613
16067
  };
15614
16068
 
15615
16069
  #onDragLeave = () => {
15616
- if (this.#previewImage) return;
15617
16070
  this.removeAttribute("dragover");
15618
16071
  if (this.#uploadBtn && this.#uploadBtn.dataset.prevText !== undefined) {
15619
16072
  this.#uploadBtn.textContent = this.#uploadBtn.dataset.prevText;
@@ -15622,7 +16075,6 @@ class FigInputFile extends HTMLElement {
15622
16075
  };
15623
16076
 
15624
16077
  #onDrop = (e) => {
15625
- if (this.#previewImage) return;
15626
16078
  e.preventDefault();
15627
16079
  this.removeAttribute("dragover");
15628
16080
  if (this.#uploadBtn && this.#uploadBtn.dataset.prevText !== undefined) {
@@ -15664,59 +16116,15 @@ class FigInputFile extends HTMLElement {
15664
16116
  if (this.#fileInput) {
15665
16117
  this.#fileInput.files = dt.files;
15666
16118
  }
15667
- this.removeAttribute("url");
15668
- this.#render();
15669
- this.#emitEvents();
15670
- };
15671
-
15672
- #stopPreviewInputEvent = (e) => {
15673
- e.stopPropagation();
15674
- };
15675
-
15676
- #removeExternalFileAttributes() {
15677
- this.#suppressAttributeRender = true;
15678
- this.removeAttribute("url");
15679
- this.removeAttribute("filename");
15680
- this.#suppressAttributeRender = false;
15681
- }
15682
-
15683
- #onPreviewLoaded = (e) => {
15684
- if (e.currentTarget !== this.#previewImage) return;
15685
- e.stopPropagation();
15686
- const file = e.detail?.file;
15687
- if (!file || this.#files?.[0] === file) return;
15688
-
15689
- const dt = new DataTransfer();
15690
- const selectedFiles =
15691
- this.#previewImage.querySelector("fig-input-file[data-generated]")?.files;
15692
- if (selectedFiles?.length) {
15693
- Array.from(selectedFiles).forEach((selectedFile) =>
15694
- dt.items.add(selectedFile),
15695
- );
15696
- } else {
15697
- dt.items.add(file);
15698
- }
15699
- this.#files = dt.files;
15700
- this.#removeExternalFileAttributes();
15701
- if (!this.#syncingPreview) this.#emitEvents();
15702
- };
15703
-
15704
- #onPreviewChange = (e) => {
15705
- if (e.currentTarget !== this.#previewImage) return;
15706
- e.stopPropagation();
15707
- if (this.#previewImage.file || (!this.#files && !this.#urlFilename)) return;
15708
-
15709
- this.#files = null;
15710
16119
  this.#removeExternalFileAttributes();
15711
16120
  this.#render();
15712
- if (!this.#syncingPreview) this.#emitEvents({ cleared: true });
16121
+ this.#emitEvents();
15713
16122
  };
15714
16123
 
15715
- #disconnectPreview() {
15716
- if (!this.#previewImage) return;
15717
- this.#previewImage.removeEventListener("loaded", this.#onPreviewLoaded);
15718
- this.#previewImage.removeEventListener("change", this.#onPreviewChange);
15719
- this.#previewImage = null;
16124
+ #disconnectImagePreview() {
16125
+ if (!this.#imageBlobUrl) return;
16126
+ URL.revokeObjectURL(this.#imageBlobUrl);
16127
+ this.#imageBlobUrl = null;
15720
16128
  }
15721
16129
 
15722
16130
  #disconnectAudioPreview() {
@@ -15801,38 +16209,69 @@ class FigInputFile extends HTMLElement {
15801
16209
  this.append(audio);
15802
16210
  }
15803
16211
 
15804
- #renderImagePreview({ accepts, label, disabled, multiple }) {
15805
- const image = document.createElement("fig-image");
15806
- image.setAttribute("full", "");
15807
- image.setAttribute("checkerboard", "");
15808
- image.setAttribute("upload", "");
15809
- image.setAttribute("aspect-ratio", "4/3");
15810
- image.setAttribute("label", label);
15811
- const url = this.getAttribute("url");
15812
- if (url) image.setAttribute("src", url);
15813
-
15814
- this.#previewImage = image;
15815
- image.addEventListener("loaded", this.#onPreviewLoaded);
15816
- image.addEventListener("change", this.#onPreviewChange);
15817
- this.appendChild(image);
16212
+ #createImagePreviewSwatch() {
16213
+ const swatch = document.createElement("fig-swatch");
16214
+ const file = this.#files?.[0];
16215
+ const url = file
16216
+ ? (this.#imageBlobUrl = URL.createObjectURL(file))
16217
+ : this.getAttribute("url") || "";
16218
+ swatch.setAttribute("background", `url(${JSON.stringify(url)})`);
16219
+ swatch.setAttribute("slot", "prepend");
16220
+ return swatch;
16221
+ }
15818
16222
 
15819
- const nestedInput = image.querySelector("fig-input-file[data-generated]");
15820
- if (!nestedInput) return;
15821
- nestedInput.addEventListener("input", this.#stopPreviewInputEvent);
15822
- nestedInput.addEventListener("change", this.#stopPreviewInputEvent);
15823
- nestedInput.setAttribute("accepts", accepts);
15824
- if (disabled) nestedInput.setAttribute("disabled", "");
15825
- if (multiple) nestedInput.setAttribute("multiple", "");
16223
+ #createMultipleFileIcon(count) {
16224
+ const icon = document.createElement("fig-icon");
16225
+ icon.setAttribute("slot", "prepend");
16226
+ const label = document.createElement("label");
16227
+ label.textContent = String(count);
16228
+ icon.append(label);
16229
+ return icon;
16230
+ }
15826
16231
 
15827
- if (this.#files?.length) {
15828
- const nativeInput = nestedInput.querySelector('input[type="file"]');
15829
- if (nativeInput) {
15830
- this.#syncingPreview = true;
15831
- nativeInput.files = this.#files;
15832
- nativeInput.dispatchEvent(new Event("change", { bubbles: true }));
15833
- this.#syncingPreview = false;
15834
- }
16232
+ #onLargePreviewChange = (event) => {
16233
+ if (event.target !== event.currentTarget) {
16234
+ event.stopPropagation();
16235
+ return;
16236
+ }
16237
+ event.stopPropagation();
16238
+ const media = event.currentTarget;
16239
+ const file = media.file;
16240
+ if (file) {
16241
+ const dataTransfer = new DataTransfer();
16242
+ dataTransfer.items.add(file);
16243
+ this.#files = dataTransfer.files;
16244
+ this.#removeExternalFileAttributes();
16245
+ this.#render();
16246
+ this.#emitEvents();
16247
+ return;
15835
16248
  }
16249
+
16250
+ this.#files = null;
16251
+ this.#removeExternalFileAttributes();
16252
+ this.#render();
16253
+ this.#emitEvents({ cleared: true });
16254
+ };
16255
+
16256
+ #renderLargePreview(kind, disabled) {
16257
+ const media = document.createElement(
16258
+ kind === "video" ? "fig-video" : "fig-image",
16259
+ );
16260
+ media.setAttribute("upload", "");
16261
+ media.setAttribute("full", "");
16262
+ media.setAttribute("checkerboard", "");
16263
+ const file = this.#files?.[0];
16264
+ const url = file
16265
+ ? (this.#imageBlobUrl = URL.createObjectURL(file))
16266
+ : this.getAttribute("url") || "";
16267
+ if (url) media.setAttribute("src", url);
16268
+ media.addEventListener("change", this.#onLargePreviewChange);
16269
+ this.append(media);
16270
+ const nestedInput = media.querySelector(
16271
+ ":scope > fig-preview > fig-input-file[data-generated]",
16272
+ );
16273
+ if (disabled) nestedInput?.setAttribute("disabled", "");
16274
+ this.setAttribute("data-previewing", "large");
15836
16275
  }
15837
16276
 
15838
16277
  #render() {
@@ -15849,28 +16288,46 @@ class FigInputFile extends HTMLElement {
15849
16288
  (this.#files && this.#files.length > 0) ||
15850
16289
  !!this.getAttribute("url") ||
15851
16290
  !!this.getAttribute("filename");
16291
+ const showsMultiplePreview =
16292
+ this.#previewEnabled &&
16293
+ multiple &&
16294
+ (this.#files?.length ?? 0) > 1;
15852
16295
  const showsImagePreview =
15853
- this.#previewEnabled && this.#hasPreviewableImage();
16296
+ !showsMultiplePreview &&
16297
+ this.#previewEnabled &&
16298
+ this.#hasPreviewableImage();
15854
16299
  const showsAudioPreview =
15855
- this.#previewEnabled && this.#hasPreviewableAudio();
16300
+ !showsMultiplePreview &&
16301
+ this.#previewEnabled &&
16302
+ this.#hasPreviewableAudio();
15856
16303
 
15857
- this.#disconnectPreview();
16304
+ this.#disconnectImagePreview();
15858
16305
  this.#disconnectAudioPreview();
15859
16306
  this.replaceChildren();
15860
16307
  this.#fileInput = null;
15861
16308
  this.#uploadBtn = null;
15862
16309
  this.#clearBtn = null;
15863
16310
  this.#tooltipEl = null;
15864
- this.toggleAttribute("data-previewing", showsImagePreview);
16311
+ this.removeAttribute("data-previewing");
15865
16312
 
15866
- if (showsImagePreview) {
15867
- this.#renderImagePreview({ accepts, label, disabled, multiple });
16313
+ const largePreviewKind =
16314
+ this.#largePreviewEnabled && !showsMultiplePreview
16315
+ ? this.#largePreviewKind()
16316
+ : null;
16317
+ if (largePreviewKind) {
16318
+ this.#renderLargePreview(largePreviewKind, disabled);
15868
16319
  return;
15869
16320
  }
15870
16321
 
15871
16322
  if (hasFile) {
15872
16323
  const filename = this.value;
15873
- const tooltipText = showsAudioPreview
16324
+ const selectedFileNames =
16325
+ multiple && this.#files?.length
16326
+ ? Array.from(this.#files, (file) => file.name).join(", ")
16327
+ : "";
16328
+ const tooltipText = selectedFileNames
16329
+ ? selectedFileNames
16330
+ : showsAudioPreview
15874
16331
  ? filename
15875
16332
  : accepts
15876
16333
  ? `Accepts ${accepts
@@ -15884,9 +16341,13 @@ class FigInputFile extends HTMLElement {
15884
16341
  if (variant === "property") this.#uploadBtn.setAttribute("full", "");
15885
16342
  this.#uploadBtn.className = "fig-input-file-filename";
15886
16343
  if (disabled) this.#uploadBtn.setAttribute("disabled", "");
15887
- const fileIcon = createFigIcon(
15888
- showsAudioPreview ? "play" : this.#selectedFileIcon(),
15889
- );
16344
+ const fileIcon = showsMultiplePreview
16345
+ ? this.#createMultipleFileIcon(this.#files.length)
16346
+ : showsImagePreview
16347
+ ? this.#createImagePreviewSwatch()
16348
+ : createFigIcon(
16349
+ showsAudioPreview ? "play" : this.#selectedFileIcon(),
16350
+ );
15890
16351
  if (showsAudioPreview) {
15891
16352
  this.#audioIcon = fileIcon;
15892
16353
  fileIcon.setAttribute("slot", "prepend");
@@ -15895,9 +16356,14 @@ class FigInputFile extends HTMLElement {
15895
16356
  const truncEl = document.createElement("fig-truncate");
15896
16357
  truncEl.setAttribute("position", "middle");
15897
16358
  truncEl.setAttribute("tooltip", "");
15898
- const dotIdx = filename.lastIndexOf(".");
15899
- if (dotIdx > 0) truncEl.setAttribute("tail", filename.slice(dotIdx));
15900
- truncEl.textContent = filename;
16359
+ const buttonLabel = showsMultiplePreview
16360
+ ? "Multiple files"
16361
+ : filename;
16362
+ const dotIdx = buttonLabel.lastIndexOf(".");
16363
+ if (!showsMultiplePreview && dotIdx > 0) {
16364
+ truncEl.setAttribute("tail", buttonLabel.slice(dotIdx));
16365
+ }
16366
+ truncEl.textContent = buttonLabel;
15901
16367
  fileIcon.setAttribute("slot", "prepend");
15902
16368
  this.#uploadBtn.append(fileIcon, truncEl);
15903
16369
  }
@@ -20310,6 +20776,7 @@ const FIG_ICON_TOKENS = {
20310
20776
  download: "--icon-24-download",
20311
20777
  upload: "--icon-24-upload",
20312
20778
  image: "--icon-24-image",
20779
+ video: { medium: "--icon-24-video", small: "--icon-16-video" },
20313
20780
  sound: { medium: "--icon-24-sound", small: "--icon-16-sound" },
20314
20781
  "sound-off": {
20315
20782
  medium: "--icon-24-sound-off",