@rogieking/figui3 11.2.2 → 11.3.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
@@ -14864,9 +14864,354 @@ class FigMediaControls extends HTMLElement {
14864
14864
  }
14865
14865
  figDefineElement("fig-media-controls", FigMediaControls);
14866
14866
 
14867
+ /**
14868
+ * Responsive waveform generated from an audio URL.
14869
+ *
14870
+ * @attr {string} url - Audio URL to decode when CORS permits.
14871
+ * @attr {number} polarization - Waveform contrast around half-height. Defaults to 1.5.
14872
+ * @attr {number} progress - Completed fraction from 0 to 1. Defaults to 0.
14873
+ * @attr {boolean|string} full - Makes the waveform fill its container.
14874
+ */
14875
+ class FigAudioWaveform extends HTMLElement {
14876
+ static #instanceCount = 0;
14877
+ static observedAttributes = ["url", "polarization", "progress"];
14878
+
14879
+ #surface = null;
14880
+ #svg = null;
14881
+ #waveformPath = null;
14882
+ #progressPath = null;
14883
+ #progressClipRect = null;
14884
+ #resizeObserver = null;
14885
+ #abortController = null;
14886
+ #audioContext = null;
14887
+ #levels = [];
14888
+ #generation = 0;
14889
+ #layoutFrame = 0;
14890
+
14891
+ connectedCallback() {
14892
+ if (!this.#surface) this.#render();
14893
+ this.#observeWaveform();
14894
+ this.#syncSource();
14895
+ }
14896
+
14897
+ disconnectedCallback() {
14898
+ this.#cancelDecode();
14899
+ this.#resizeObserver?.disconnect();
14900
+ this.#resizeObserver = null;
14901
+ if (this.#layoutFrame) cancelAnimationFrame(this.#layoutFrame);
14902
+ this.#layoutFrame = 0;
14903
+ }
14904
+
14905
+ attributeChangedCallback(name, oldValue, newValue) {
14906
+ if (oldValue === newValue || !this.#surface) return;
14907
+ if (name === "url") this.#syncSource();
14908
+ if (name === "polarization") this.#queueWaveformLayout();
14909
+ if (name === "progress") this.#syncProgress();
14910
+ }
14911
+
14912
+ #render() {
14913
+ this.#surface = document.createElement("div");
14914
+ this.#surface.className = "fig-audio-waveform-surface";
14915
+ this.#surface.setAttribute("aria-hidden", "true");
14916
+
14917
+ this.#svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
14918
+ this.#svg.setAttribute("aria-hidden", "true");
14919
+ this.#svg.setAttribute("preserveAspectRatio", "none");
14920
+ const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
14921
+ const progressClip = document.createElementNS(
14922
+ "http://www.w3.org/2000/svg",
14923
+ "clipPath",
14924
+ );
14925
+ const progressClipId = `fig-audio-waveform-progress-${++FigAudioWaveform.#instanceCount}`;
14926
+ progressClip.id = progressClipId;
14927
+ progressClip.setAttribute("clipPathUnits", "userSpaceOnUse");
14928
+ this.#progressClipRect = document.createElementNS(
14929
+ "http://www.w3.org/2000/svg",
14930
+ "rect",
14931
+ );
14932
+ progressClip.append(this.#progressClipRect);
14933
+ defs.append(progressClip);
14934
+
14935
+ this.#waveformPath = document.createElementNS(
14936
+ "http://www.w3.org/2000/svg",
14937
+ "path",
14938
+ );
14939
+ this.#waveformPath.classList.add("fig-audio-waveform-path");
14940
+ this.#progressPath = document.createElementNS(
14941
+ "http://www.w3.org/2000/svg",
14942
+ "path",
14943
+ );
14944
+ this.#progressPath.classList.add("fig-audio-waveform-progress");
14945
+ this.#progressPath.setAttribute("clip-path", `url(#${progressClipId})`);
14946
+ this.#svg.append(defs, this.#waveformPath, this.#progressPath);
14947
+ this.#surface.appendChild(this.#svg);
14948
+ this.replaceChildren(this.#surface);
14949
+
14950
+ this.#observeWaveform();
14951
+ }
14952
+
14953
+ #observeWaveform() {
14954
+ if (this.#resizeObserver || !this.#svg) return;
14955
+ this.#resizeObserver = new ResizeObserver(() => this.#queueWaveformLayout());
14956
+ this.#resizeObserver.observe(this.#svg);
14957
+ }
14958
+
14959
+ #syncSource() {
14960
+ const url = this.getAttribute("url");
14961
+ if (!url) {
14962
+ this.#clearWaveform();
14963
+ return;
14964
+ }
14965
+
14966
+ this.#decodeSource(async (signal) => {
14967
+ const response = await fetch(url, { signal });
14968
+ if (!response.ok) throw new Error(`Audio request failed: ${response.status}`);
14969
+ return response.arrayBuffer();
14970
+ });
14971
+ }
14972
+
14973
+ async #decodeSource(loadArrayBuffer) {
14974
+ this.#cancelDecode();
14975
+ const generation = ++this.#generation;
14976
+ const controller = new AbortController();
14977
+ this.#abortController = controller;
14978
+ this.setAttribute("data-waveform-state", "loading");
14979
+ this.setAttribute("aria-busy", "true");
14980
+ this.#levels = [];
14981
+ this.#queueWaveformLayout();
14982
+
14983
+ let context = null;
14984
+ try {
14985
+ const arrayBuffer = await loadArrayBuffer(controller.signal);
14986
+ if (generation !== this.#generation || !this.isConnected) return;
14987
+
14988
+ const AudioContextConstructor =
14989
+ window.AudioContext || window.webkitAudioContext;
14990
+ if (!AudioContextConstructor) throw new Error("Web Audio is unavailable");
14991
+ context = new AudioContextConstructor();
14992
+ this.#audioContext = context;
14993
+ const audioBuffer = await context.decodeAudioData(arrayBuffer);
14994
+ if (generation !== this.#generation || !this.isConnected) return;
14995
+
14996
+ this.#levels = this.#extractLevels(audioBuffer);
14997
+ this.setAttribute("data-waveform-state", "ready");
14998
+ this.#queueWaveformLayout();
14999
+ } catch (error) {
15000
+ if (generation !== this.#generation || error?.name === "AbortError") return;
15001
+ this.#levels = new Array(256).fill(0);
15002
+ this.setAttribute("data-waveform-state", "unavailable");
15003
+ this.#queueWaveformLayout();
15004
+ } finally {
15005
+ if (context) {
15006
+ try {
15007
+ await context.close();
15008
+ } catch {
15009
+ // Closing is best-effort in browsers with suspended audio contexts.
15010
+ }
15011
+ }
15012
+ if (this.#audioContext === context) this.#audioContext = null;
15013
+ if (this.#abortController === controller) this.#abortController = null;
15014
+ if (generation === this.#generation) this.removeAttribute("aria-busy");
15015
+ }
15016
+ }
15017
+
15018
+ #extractLevels(audioBuffer) {
15019
+ const binCount = 256;
15020
+ const levels = new Array(binCount).fill(0);
15021
+ const channelCount = audioBuffer.numberOfChannels;
15022
+ const frameCount = audioBuffer.length;
15023
+ const sampleStride = Math.max(1, Math.ceil(frameCount / 500000));
15024
+ let maximum = 0;
15025
+
15026
+ for (let bin = 0; bin < binCount; bin += 1) {
15027
+ const start = Math.floor((bin / binCount) * frameCount);
15028
+ const end = Math.max(
15029
+ start + 1,
15030
+ Math.floor(((bin + 1) / binCount) * frameCount),
15031
+ );
15032
+ let sumSquares = 0;
15033
+ let sampleCount = 0;
15034
+ for (let channel = 0; channel < channelCount; channel += 1) {
15035
+ const samples = audioBuffer.getChannelData(channel);
15036
+ for (let index = start; index < end; index += sampleStride) {
15037
+ const sample = samples[index] || 0;
15038
+ if (!Number.isFinite(sample)) continue;
15039
+ sumSquares += sample * sample;
15040
+ sampleCount += 1;
15041
+ }
15042
+ }
15043
+ const level = sampleCount > 0 ? Math.sqrt(sumSquares / sampleCount) : 0;
15044
+ levels[bin] = level;
15045
+ maximum = Math.max(maximum, level);
15046
+ }
15047
+
15048
+ if (!Number.isFinite(maximum) || maximum <= 0) return levels.fill(0);
15049
+ const minimum = Math.min(...levels);
15050
+ const range = maximum - minimum;
15051
+ if (!Number.isFinite(range) || range <= Number.EPSILON) {
15052
+ return levels.fill(0);
15053
+ }
15054
+ return levels.map((level) => {
15055
+ const normalized = (level - minimum) / range;
15056
+ return Number.isFinite(normalized) ? normalized : 0;
15057
+ });
15058
+ }
15059
+
15060
+ #clearWaveform() {
15061
+ this.#cancelDecode();
15062
+ this.#levels = [];
15063
+ this.removeAttribute("data-waveform-state");
15064
+ this.removeAttribute("aria-busy");
15065
+ this.#queueWaveformLayout();
15066
+ }
15067
+
15068
+ #polarizeLevel(level) {
15069
+ const configured = Number.parseFloat(this.getAttribute("polarization"));
15070
+ const factor = Number.isFinite(configured)
15071
+ ? Math.max(0, Math.min(configured, 4))
15072
+ : 1.5;
15073
+ return Math.max(0, Math.min(1, 0.5 + (level - 0.5) * factor));
15074
+ }
15075
+
15076
+ #progress() {
15077
+ const configured = Number.parseFloat(this.getAttribute("progress"));
15078
+ return Number.isFinite(configured)
15079
+ ? Math.max(0, Math.min(configured, 1))
15080
+ : 0;
15081
+ }
15082
+
15083
+ #syncProgress() {
15084
+ if (!this.#svg || !this.#progressClipRect) return;
15085
+ const viewBox = this.#svg.viewBox.baseVal;
15086
+ const bounds = this.#svg.getBoundingClientRect();
15087
+ const width = viewBox.width || Math.max(1, Math.round(bounds.width));
15088
+ const height = viewBox.height || Math.max(1, Math.round(bounds.height));
15089
+ this.#progressClipRect.setAttribute("x", "0");
15090
+ this.#progressClipRect.setAttribute("y", "0");
15091
+ this.#progressClipRect.setAttribute("width", String(width * this.#progress()));
15092
+ this.#progressClipRect.setAttribute("height", String(height));
15093
+ }
15094
+
15095
+ #cancelDecode() {
15096
+ this.#generation += 1;
15097
+ this.#abortController?.abort();
15098
+ this.#abortController = null;
15099
+ if (this.#audioContext) {
15100
+ try {
15101
+ this.#audioContext.close();
15102
+ } catch {
15103
+ // Closing is best-effort during teardown and source replacement.
15104
+ }
15105
+ this.#audioContext = null;
15106
+ }
15107
+ }
15108
+
15109
+ #queueWaveformLayout() {
15110
+ if (this.#layoutFrame) return;
15111
+ this.#layoutFrame = requestAnimationFrame(() => {
15112
+ this.#layoutFrame = 0;
15113
+ this.#layoutWaveform();
15114
+ });
15115
+ }
15116
+
15117
+ #layoutWaveform() {
15118
+ if (
15119
+ !this.#svg ||
15120
+ !this.#waveformPath ||
15121
+ !this.#progressPath ||
15122
+ !this.#progressClipRect
15123
+ ) {
15124
+ return;
15125
+ }
15126
+ const bounds = this.#svg.getBoundingClientRect();
15127
+ const width = Math.max(1, Math.round(bounds.width));
15128
+ const height = Math.max(1, Math.round(bounds.height));
15129
+ this.#svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
15130
+ this.#syncProgress();
15131
+
15132
+ if (this.#levels.length === 0) {
15133
+ this.#waveformPath.setAttribute("d", "");
15134
+ this.#progressPath.setAttribute("d", "");
15135
+ return;
15136
+ }
15137
+
15138
+ const barCount = Math.min(
15139
+ this.#levels.length,
15140
+ Math.max(16, Math.floor(width / 3)),
15141
+ );
15142
+ const cellWidth = width / barCount;
15143
+ const configuredStrokeWidth = Number.parseFloat(
15144
+ getComputedStyle(this).getPropertyValue(
15145
+ "--fig-audio-waveform-stroke-width",
15146
+ ),
15147
+ );
15148
+ const strokeWidth =
15149
+ Number.isFinite(configuredStrokeWidth) && configuredStrokeWidth > 0
15150
+ ? configuredStrokeWidth
15151
+ : 2;
15152
+ const barWidth = Math.max(0.5, Math.min(strokeWidth, cellWidth * 0.75));
15153
+ const halfWidth = barWidth / 2;
15154
+ const centerY = height / 2;
15155
+ const maxBarHeight = Math.max(2, height - 12);
15156
+ const commands = [];
15157
+
15158
+ for (let index = 0; index < barCount; index += 1) {
15159
+ const levelIndex = Math.min(
15160
+ this.#levels.length - 1,
15161
+ Math.floor((index / barCount) * this.#levels.length),
15162
+ );
15163
+ const polarizedLevel = this.#polarizeLevel(this.#levels[levelIndex]);
15164
+ const barHeight = Math.max(barWidth, polarizedLevel * maxBarHeight);
15165
+ const x = (index + 0.5) * cellWidth;
15166
+ const top = centerY - barHeight / 2;
15167
+ const bottom = centerY + barHeight / 2;
15168
+ const left = x - halfWidth;
15169
+ const right = x + halfWidth;
15170
+ commands.push(
15171
+ `M ${x} ${top}` +
15172
+ ` Q ${right} ${top} ${right} ${top + halfWidth}` +
15173
+ ` V ${bottom - halfWidth}` +
15174
+ ` Q ${right} ${bottom} ${x} ${bottom}` +
15175
+ ` Q ${left} ${bottom} ${left} ${bottom - halfWidth}` +
15176
+ ` V ${top + halfWidth}` +
15177
+ ` Q ${left} ${top} ${x} ${top} Z`,
15178
+ );
15179
+ }
15180
+
15181
+ const path = commands.join(" ");
15182
+ this.#waveformPath.setAttribute("d", path);
15183
+ this.#progressPath.setAttribute("d", path);
15184
+ }
15185
+ }
15186
+ figDefineElement("fig-audio-waveform", FigAudioWaveform);
15187
+
14867
15188
  /* File Upload Input */
15189
+ const FIG_INPUT_FILE_IMAGE_EXTENSIONS = [
15190
+ ".png",
15191
+ ".jpg",
15192
+ ".jpeg",
15193
+ ".gif",
15194
+ ".webp",
15195
+ ".svg",
15196
+ ".avif",
15197
+ ];
15198
+ const FIG_INPUT_FILE_AUDIO_EXTENSIONS = [
15199
+ ".mp3",
15200
+ ".wav",
15201
+ ".aac",
15202
+ ".m4a",
15203
+ ".ogg",
15204
+ ".flac",
15205
+ ];
15206
+
15207
+ /**
15208
+ * File upload input with file-aware icons, image previews, and audio playback waveforms.
15209
+ * @attr {string} accepts - Native file chooser filter.
15210
+ * @attr {boolean} preview - Preview selected files with a supported image extension.
15211
+ * @attr {string} label - Upload button label. Defaults by known file type.
15212
+ */
14868
15213
  class FigInputFile extends HTMLElement {
14869
- static observedAttributes = ["accepts", "label", "disabled", "multiple", "variant", "url", "filename"];
15214
+ static observedAttributes = ["accepts", "preview", "label", "disabled", "multiple", "variant", "url", "filename"];
14870
15215
 
14871
15216
  #fileInput = null;
14872
15217
  #filenameEl = null;
@@ -14874,6 +15219,14 @@ class FigInputFile extends HTMLElement {
14874
15219
  #tooltipEl = null;
14875
15220
  #uploadBtn = null;
14876
15221
  #files = null;
15222
+ #previewImage = null;
15223
+ #audioPreview = null;
15224
+ #audioIcon = null;
15225
+ #audioWaveform = null;
15226
+ #audioPlaying = false;
15227
+ #audioBlobUrl = null;
15228
+ #syncingPreview = false;
15229
+ #suppressAttributeRender = false;
14877
15230
 
14878
15231
  get files() {
14879
15232
  return this.#files;
@@ -14901,6 +15254,180 @@ class FigInputFile extends HTMLElement {
14901
15254
  return this.#urlFilename;
14902
15255
  }
14903
15256
 
15257
+ get #effectiveAccepts() {
15258
+ return this.getAttribute("accepts") || "";
15259
+ }
15260
+
15261
+ get #previewEnabled() {
15262
+ return (
15263
+ this.hasAttribute("preview") &&
15264
+ this.getAttribute("preview") !== "false"
15265
+ );
15266
+ }
15267
+
15268
+ #hasKnownExtension(filename, extensions) {
15269
+ const lowerName = filename.toLowerCase();
15270
+ return extensions.some((extension) => lowerName.endsWith(extension));
15271
+ }
15272
+
15273
+ #isKnownFile(file, extensions, mimePrefix) {
15274
+ return (
15275
+ this.#hasKnownExtension(file.name, extensions) ||
15276
+ file.type.toLowerCase().startsWith(mimePrefix)
15277
+ );
15278
+ }
15279
+
15280
+ #generatedLabel() {
15281
+ if (this.#files?.[0]) {
15282
+ if (
15283
+ this.#isKnownFile(
15284
+ this.#files[0],
15285
+ FIG_INPUT_FILE_AUDIO_EXTENSIONS,
15286
+ "audio/",
15287
+ )
15288
+ ) {
15289
+ return "Upload audio";
15290
+ }
15291
+ if (
15292
+ this.#isKnownFile(
15293
+ this.#files[0],
15294
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15295
+ "image/",
15296
+ )
15297
+ ) {
15298
+ return "Upload image";
15299
+ }
15300
+ }
15301
+
15302
+ if (
15303
+ this.#hasKnownExtension(
15304
+ this.#urlFilename,
15305
+ FIG_INPUT_FILE_AUDIO_EXTENSIONS,
15306
+ )
15307
+ ) {
15308
+ return "Upload audio";
15309
+ }
15310
+ if (
15311
+ this.#hasKnownExtension(
15312
+ this.#urlFilename,
15313
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15314
+ )
15315
+ ) {
15316
+ return "Upload image";
15317
+ }
15318
+
15319
+ const acceptedTypes = this.#effectiveAccepts
15320
+ .split(",")
15321
+ .map((value) => value.trim().toLowerCase())
15322
+ .filter(Boolean)
15323
+ .map((value) => {
15324
+ if (
15325
+ value.startsWith("audio/") ||
15326
+ FIG_INPUT_FILE_AUDIO_EXTENSIONS.includes(value)
15327
+ ) {
15328
+ return "audio";
15329
+ }
15330
+ if (
15331
+ value.startsWith("image/") ||
15332
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS.includes(value)
15333
+ ) {
15334
+ return "image";
15335
+ }
15336
+ return "file";
15337
+ });
15338
+ if (
15339
+ acceptedTypes.length > 0 &&
15340
+ acceptedTypes.every((type) => type === "audio")
15341
+ ) {
15342
+ return "Upload audio";
15343
+ }
15344
+ if (
15345
+ acceptedTypes.length > 0 &&
15346
+ acceptedTypes.every((type) => type === "image")
15347
+ ) {
15348
+ return "Upload image";
15349
+ }
15350
+ return "Upload file";
15351
+ }
15352
+
15353
+ #hasPreviewableImage() {
15354
+ if (this.#files?.length) {
15355
+ return this.#hasKnownExtension(
15356
+ this.#files[0].name,
15357
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15358
+ );
15359
+ }
15360
+ return (
15361
+ !!this.getAttribute("url") &&
15362
+ this.#hasKnownExtension(
15363
+ this.#urlFilename,
15364
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15365
+ )
15366
+ );
15367
+ }
15368
+
15369
+ #hasPreviewableAudio() {
15370
+ if (this.#files?.length) {
15371
+ return this.#isKnownFile(
15372
+ this.#files[0],
15373
+ FIG_INPUT_FILE_AUDIO_EXTENSIONS,
15374
+ "audio/",
15375
+ );
15376
+ }
15377
+ return (
15378
+ !!this.getAttribute("url") &&
15379
+ this.#hasKnownExtension(
15380
+ this.#urlFilename,
15381
+ FIG_INPUT_FILE_AUDIO_EXTENSIONS,
15382
+ )
15383
+ );
15384
+ }
15385
+
15386
+ #selectedFileIcon() {
15387
+ if (this.#files && this.#files.length > 0) {
15388
+ const files = Array.from(this.#files);
15389
+ if (
15390
+ files.every((file) =>
15391
+ this.#isKnownFile(
15392
+ file,
15393
+ FIG_INPUT_FILE_AUDIO_EXTENSIONS,
15394
+ "audio/",
15395
+ ),
15396
+ )
15397
+ ) {
15398
+ return "sound";
15399
+ }
15400
+ if (
15401
+ files.every((file) =>
15402
+ this.#isKnownFile(
15403
+ file,
15404
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15405
+ "image/",
15406
+ ),
15407
+ )
15408
+ ) {
15409
+ return "image";
15410
+ }
15411
+ }
15412
+ if (
15413
+ this.#hasKnownExtension(
15414
+ this.#urlFilename,
15415
+ FIG_INPUT_FILE_AUDIO_EXTENSIONS,
15416
+ )
15417
+ ) {
15418
+ return "sound";
15419
+ }
15420
+ if (
15421
+ this.#hasKnownExtension(
15422
+ this.#urlFilename,
15423
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15424
+ )
15425
+ ) {
15426
+ return "image";
15427
+ }
15428
+ return "upload";
15429
+ }
15430
+
14904
15431
  connectedCallback() {
14905
15432
  this.#render();
14906
15433
  this.addEventListener("dragover", this.#onDragOver);
@@ -14911,6 +15438,8 @@ class FigInputFile extends HTMLElement {
14911
15438
  disconnectedCallback() {
14912
15439
  this.#fileInput?.removeEventListener("change", this.#onFileChange);
14913
15440
  this.#clearBtn?.removeEventListener("click", this.#onClear);
15441
+ this.#disconnectPreview();
15442
+ this.#disconnectAudioPreview();
14914
15443
  this.removeEventListener("dragover", this.#onDragOver);
14915
15444
  this.removeEventListener("dragleave", this.#onDragLeave);
14916
15445
  this.removeEventListener("drop", this.#onDrop);
@@ -14918,6 +15447,7 @@ class FigInputFile extends HTMLElement {
14918
15447
 
14919
15448
  attributeChangedCallback(name, oldValue, newValue) {
14920
15449
  if (oldValue === newValue) return;
15450
+ if (this.#suppressAttributeRender) return;
14921
15451
  this.#render();
14922
15452
  }
14923
15453
 
@@ -14951,6 +15481,7 @@ class FigInputFile extends HTMLElement {
14951
15481
  };
14952
15482
 
14953
15483
  #onDragOver = (e) => {
15484
+ if (this.#previewImage) return;
14954
15485
  if (
14955
15486
  this.hasAttribute("disabled") &&
14956
15487
  this.getAttribute("disabled") !== "false"
@@ -14967,6 +15498,7 @@ class FigInputFile extends HTMLElement {
14967
15498
  };
14968
15499
 
14969
15500
  #onDragLeave = () => {
15501
+ if (this.#previewImage) return;
14970
15502
  this.removeAttribute("dragover");
14971
15503
  if (this.#uploadBtn && this.#uploadBtn.dataset.prevText !== undefined) {
14972
15504
  this.#uploadBtn.textContent = this.#uploadBtn.dataset.prevText;
@@ -14975,6 +15507,7 @@ class FigInputFile extends HTMLElement {
14975
15507
  };
14976
15508
 
14977
15509
  #onDrop = (e) => {
15510
+ if (this.#previewImage) return;
14978
15511
  e.preventDefault();
14979
15512
  this.removeAttribute("dragover");
14980
15513
  if (this.#uploadBtn && this.#uploadBtn.dataset.prevText !== undefined) {
@@ -14987,7 +15520,7 @@ class FigInputFile extends HTMLElement {
14987
15520
  )
14988
15521
  return;
14989
15522
 
14990
- const accepts = this.getAttribute("accepts");
15523
+ const accepts = this.#effectiveAccepts;
14991
15524
  let dropped = Array.from(e.dataTransfer.files);
14992
15525
  if (accepts) {
14993
15526
  const allowed = accepts.split(",").map((s) => s.trim().toLowerCase());
@@ -15021,24 +15554,210 @@ class FigInputFile extends HTMLElement {
15021
15554
  this.#emitEvents();
15022
15555
  };
15023
15556
 
15557
+ #stopPreviewInputEvent = (e) => {
15558
+ e.stopPropagation();
15559
+ };
15560
+
15561
+ #removeExternalFileAttributes() {
15562
+ this.#suppressAttributeRender = true;
15563
+ this.removeAttribute("url");
15564
+ this.removeAttribute("filename");
15565
+ this.#suppressAttributeRender = false;
15566
+ }
15567
+
15568
+ #onPreviewLoaded = (e) => {
15569
+ if (e.currentTarget !== this.#previewImage) return;
15570
+ e.stopPropagation();
15571
+ const file = e.detail?.file;
15572
+ if (!file || this.#files?.[0] === file) return;
15573
+
15574
+ const dt = new DataTransfer();
15575
+ const selectedFiles =
15576
+ this.#previewImage.querySelector("fig-input-file[data-generated]")?.files;
15577
+ if (selectedFiles?.length) {
15578
+ Array.from(selectedFiles).forEach((selectedFile) =>
15579
+ dt.items.add(selectedFile),
15580
+ );
15581
+ } else {
15582
+ dt.items.add(file);
15583
+ }
15584
+ this.#files = dt.files;
15585
+ this.#removeExternalFileAttributes();
15586
+ if (!this.#syncingPreview) this.#emitEvents();
15587
+ };
15588
+
15589
+ #onPreviewChange = (e) => {
15590
+ if (e.currentTarget !== this.#previewImage) return;
15591
+ e.stopPropagation();
15592
+ if (this.#previewImage.file || (!this.#files && !this.#urlFilename)) return;
15593
+
15594
+ this.#files = null;
15595
+ this.#removeExternalFileAttributes();
15596
+ this.#render();
15597
+ if (!this.#syncingPreview) this.#emitEvents({ cleared: true });
15598
+ };
15599
+
15600
+ #disconnectPreview() {
15601
+ if (!this.#previewImage) return;
15602
+ this.#previewImage.removeEventListener("loaded", this.#onPreviewLoaded);
15603
+ this.#previewImage.removeEventListener("change", this.#onPreviewChange);
15604
+ this.#previewImage = null;
15605
+ }
15606
+
15607
+ #disconnectAudioPreview() {
15608
+ this.#audioPreview?.pause();
15609
+ if (this.#audioBlobUrl) {
15610
+ URL.revokeObjectURL(this.#audioBlobUrl);
15611
+ this.#audioBlobUrl = null;
15612
+ }
15613
+ this.#audioPreview = null;
15614
+ this.#audioIcon = null;
15615
+ this.#audioWaveform = null;
15616
+ this.#audioPlaying = false;
15617
+ }
15618
+
15619
+ #setAudioPlaybackUi(playing) {
15620
+ this.#audioPlaying = playing;
15621
+ this.#audioIcon?.setAttribute("name", playing ? "pause" : "play");
15622
+ this.#uploadBtn?.toggleAttribute("selected", playing);
15623
+ this.#uploadBtn?.setAttribute(
15624
+ "aria-label",
15625
+ playing ? "Pause audio" : "Play audio",
15626
+ );
15627
+ }
15628
+
15629
+ #syncAudioWaveformProgress() {
15630
+ if (!this.#audioPreview || !this.#audioWaveform) return;
15631
+ const duration = this.#audioPreview.duration;
15632
+ const currentTime = this.#audioPreview.currentTime;
15633
+ const progress =
15634
+ Number.isFinite(duration) && duration > 0 && Number.isFinite(currentTime)
15635
+ ? Math.max(0, Math.min(currentTime / duration, 1))
15636
+ : 0;
15637
+ this.#audioWaveform.setAttribute("progress", String(progress));
15638
+ }
15639
+
15640
+ #onAudioPlaybackToggle = (event) => {
15641
+ event.preventDefault();
15642
+ if (!this.#audioPreview || this.#uploadBtn?.hasAttribute("disabled")) return;
15643
+ if (this.#audioPlaying) {
15644
+ this.#audioPreview.pause();
15645
+ return;
15646
+ }
15647
+ const playback = this.#audioPreview.play();
15648
+ if (playback && typeof playback.catch === "function") {
15649
+ playback.catch(() => this.#setAudioPlaybackUi(false));
15650
+ }
15651
+ };
15652
+
15653
+ #renderAudioPreview() {
15654
+ const audio = document.createElement("audio");
15655
+ audio.hidden = true;
15656
+ audio.preload = "metadata";
15657
+ if (this.#files?.[0]) {
15658
+ this.#audioBlobUrl = URL.createObjectURL(this.#files[0]);
15659
+ audio.src = this.#audioBlobUrl;
15660
+ } else {
15661
+ audio.src = this.getAttribute("url") || "";
15662
+ }
15663
+
15664
+ audio.addEventListener("play", () => this.#setAudioPlaybackUi(true));
15665
+ audio.addEventListener("pause", () => this.#setAudioPlaybackUi(false));
15666
+ audio.addEventListener("ended", () => {
15667
+ this.#setAudioPlaybackUi(false);
15668
+ this.#audioWaveform?.setAttribute("progress", "0");
15669
+ });
15670
+ audio.addEventListener("timeupdate", () =>
15671
+ this.#syncAudioWaveformProgress(),
15672
+ );
15673
+ audio.addEventListener("durationchange", () =>
15674
+ this.#syncAudioWaveformProgress(),
15675
+ );
15676
+
15677
+ this.#audioPreview = audio;
15678
+ this.#audioWaveform = document.createElement("fig-audio-waveform");
15679
+ this.#audioWaveform.className = "fig-input-file-audio-waveform";
15680
+ this.#audioWaveform.setAttribute("url", audio.src);
15681
+ this.#audioWaveform.setAttribute("polarization", "3");
15682
+ this.#audioWaveform.setAttribute("progress", "0");
15683
+ this.#uploadBtn?.append(this.#audioWaveform);
15684
+ this.#uploadBtn?.addEventListener("click", this.#onAudioPlaybackToggle);
15685
+ this.#setAudioPlaybackUi(false);
15686
+ this.append(audio);
15687
+ }
15688
+
15689
+ #renderImagePreview({ accepts, label, disabled, multiple }) {
15690
+ const image = document.createElement("fig-image");
15691
+ image.setAttribute("full", "");
15692
+ image.setAttribute("checkerboard", "");
15693
+ image.setAttribute("upload", "");
15694
+ image.setAttribute("aspect-ratio", "4/3");
15695
+ image.setAttribute("label", label);
15696
+ const url = this.getAttribute("url");
15697
+ if (url) image.setAttribute("src", url);
15698
+
15699
+ this.#previewImage = image;
15700
+ image.addEventListener("loaded", this.#onPreviewLoaded);
15701
+ image.addEventListener("change", this.#onPreviewChange);
15702
+ this.appendChild(image);
15703
+
15704
+ const nestedInput = image.querySelector("fig-input-file[data-generated]");
15705
+ if (!nestedInput) return;
15706
+ nestedInput.addEventListener("input", this.#stopPreviewInputEvent);
15707
+ nestedInput.addEventListener("change", this.#stopPreviewInputEvent);
15708
+ nestedInput.setAttribute("accepts", accepts);
15709
+ if (disabled) nestedInput.setAttribute("disabled", "");
15710
+ if (multiple) nestedInput.setAttribute("multiple", "");
15711
+
15712
+ if (this.#files?.length) {
15713
+ const nativeInput = nestedInput.querySelector('input[type="file"]');
15714
+ if (nativeInput) {
15715
+ this.#syncingPreview = true;
15716
+ nativeInput.files = this.#files;
15717
+ nativeInput.dispatchEvent(new Event("change", { bubbles: true }));
15718
+ this.#syncingPreview = false;
15719
+ }
15720
+ }
15721
+ }
15722
+
15024
15723
  #render() {
15025
- const accepts = this.getAttribute("accepts") || "";
15026
- const label = this.getAttribute("label") || "Upload";
15724
+ const accepts = this.#effectiveAccepts;
15725
+ const customLabel = this.getAttribute("label");
15726
+ const label = customLabel?.trim() ? customLabel : this.#generatedLabel();
15027
15727
  const disabled =
15028
15728
  this.hasAttribute("disabled") &&
15029
15729
  this.getAttribute("disabled") !== "false";
15030
15730
  const multiple =
15031
15731
  this.hasAttribute("multiple") && this.getAttribute("multiple") !== "false";
15032
- const variant = this.getAttribute("variant") || "input";
15732
+ const variant = this.getAttribute("variant") || "property";
15033
15733
  const hasFile =
15034
15734
  (this.#files && this.#files.length > 0) ||
15035
15735
  !!this.getAttribute("url") ||
15036
15736
  !!this.getAttribute("filename");
15737
+ const showsImagePreview =
15738
+ this.#previewEnabled && this.#hasPreviewableImage();
15739
+ const showsAudioPreview =
15740
+ this.#previewEnabled && this.#hasPreviewableAudio();
15037
15741
 
15742
+ this.#disconnectPreview();
15743
+ this.#disconnectAudioPreview();
15038
15744
  this.replaceChildren();
15745
+ this.#fileInput = null;
15746
+ this.#uploadBtn = null;
15747
+ this.#clearBtn = null;
15748
+ this.#tooltipEl = null;
15749
+ this.toggleAttribute("data-previewing", showsImagePreview);
15750
+
15751
+ if (showsImagePreview) {
15752
+ this.#renderImagePreview({ accepts, label, disabled, multiple });
15753
+ return;
15754
+ }
15039
15755
 
15040
15756
  if (hasFile) {
15041
- const tooltipText = accepts
15757
+ const filename = this.value;
15758
+ const tooltipText = showsAudioPreview
15759
+ ? filename
15760
+ : accepts
15042
15761
  ? `Accepts ${accepts
15043
15762
  .split(",")
15044
15763
  .map((s) => s.trim())
@@ -15047,26 +15766,38 @@ class FigInputFile extends HTMLElement {
15047
15766
 
15048
15767
  this.#uploadBtn = document.createElement("fig-button");
15049
15768
  this.#uploadBtn.setAttribute("variant", variant);
15050
- this.#uploadBtn.setAttribute("type", "upload");
15769
+ if (variant === "property") this.#uploadBtn.setAttribute("full", "");
15051
15770
  this.#uploadBtn.className = "fig-input-file-filename";
15052
15771
  if (disabled) this.#uploadBtn.setAttribute("disabled", "");
15053
- const truncEl = document.createElement("fig-truncate");
15054
- truncEl.setAttribute("position", "middle");
15055
- truncEl.setAttribute("tooltip", "");
15056
- const filename = this.value;
15057
- const dotIdx = filename.lastIndexOf(".");
15058
- if (dotIdx > 0) truncEl.setAttribute("tail", filename.slice(dotIdx));
15059
- truncEl.textContent = filename;
15060
- this.#uploadBtn.appendChild(truncEl);
15061
-
15062
- this.#fileInput = document.createElement("input");
15063
- this.#fileInput.type = "file";
15064
- this.#fileInput.title = "";
15065
- this.#fileInput.disabled = disabled;
15066
- if (accepts) this.#fileInput.setAttribute("accept", accepts);
15067
- if (multiple) this.#fileInput.setAttribute("multiple", "");
15068
- this.#fileInput.addEventListener("change", this.#onFileChange);
15069
- this.#uploadBtn.appendChild(this.#fileInput);
15772
+ const fileIcon = createFigIcon(
15773
+ showsAudioPreview ? "play" : this.#selectedFileIcon(),
15774
+ );
15775
+ if (showsAudioPreview) {
15776
+ this.#audioIcon = fileIcon;
15777
+ fileIcon.setAttribute("slot", "prepend");
15778
+ this.#uploadBtn.append(fileIcon);
15779
+ } else {
15780
+ const truncEl = document.createElement("fig-truncate");
15781
+ truncEl.setAttribute("position", "middle");
15782
+ truncEl.setAttribute("tooltip", "");
15783
+ const dotIdx = filename.lastIndexOf(".");
15784
+ if (dotIdx > 0) truncEl.setAttribute("tail", filename.slice(dotIdx));
15785
+ truncEl.textContent = filename;
15786
+ fileIcon.setAttribute("slot", "prepend");
15787
+ this.#uploadBtn.append(fileIcon, truncEl);
15788
+ }
15789
+
15790
+ if (!showsAudioPreview) {
15791
+ this.#uploadBtn.setAttribute("type", "upload");
15792
+ this.#fileInput = document.createElement("input");
15793
+ this.#fileInput.type = "file";
15794
+ this.#fileInput.title = "";
15795
+ this.#fileInput.disabled = disabled;
15796
+ if (accepts) this.#fileInput.setAttribute("accept", accepts);
15797
+ if (multiple) this.#fileInput.setAttribute("multiple", "");
15798
+ this.#fileInput.addEventListener("change", this.#onFileChange);
15799
+ this.#uploadBtn.appendChild(this.#fileInput);
15800
+ }
15070
15801
 
15071
15802
  if (tooltipText) {
15072
15803
  this.#tooltipEl = document.createElement("fig-tooltip");
@@ -15089,6 +15820,7 @@ class FigInputFile extends HTMLElement {
15089
15820
  this.#clearBtn.addEventListener("click", this.#onClear);
15090
15821
  clearTooltip.appendChild(this.#clearBtn);
15091
15822
  this.appendChild(clearTooltip);
15823
+ if (showsAudioPreview) this.#renderAudioPreview();
15092
15824
  } else {
15093
15825
  const tooltipText = accepts
15094
15826
  ? `Accepts ${accepts
@@ -15104,8 +15836,12 @@ class FigInputFile extends HTMLElement {
15104
15836
 
15105
15837
  this.#uploadBtn = document.createElement("fig-button");
15106
15838
  this.#uploadBtn.setAttribute("variant", variant);
15839
+ if (variant === "property") this.#uploadBtn.setAttribute("full", "");
15107
15840
  this.#uploadBtn.setAttribute("type", "upload");
15108
15841
  this.#uploadBtn.textContent = label;
15842
+ const uploadIcon = createFigIcon("upload");
15843
+ uploadIcon.setAttribute("slot", "prepend");
15844
+ this.#uploadBtn.prepend(uploadIcon);
15109
15845
  if (disabled) this.#uploadBtn.setAttribute("disabled", "");
15110
15846
 
15111
15847
  this.#fileInput = document.createElement("input");
@@ -19458,6 +20194,12 @@ const FIG_ICON_TOKENS = {
19458
20194
  search: "--icon-24-search",
19459
20195
  download: "--icon-24-download",
19460
20196
  upload: "--icon-24-upload",
20197
+ image: "--icon-24-image",
20198
+ sound: { medium: "--icon-24-sound", small: "--icon-16-sound" },
20199
+ "sound-off": {
20200
+ medium: "--icon-24-sound-off",
20201
+ small: "--icon-16-sound-off",
20202
+ },
19461
20203
  sun: "--icon-24-sun",
19462
20204
  moon: "--icon-24-moon",
19463
20205
  edit: { medium: "--icon-24-edit", small: "--icon-16-edit" },