@rogieking/figui3 11.2.2 → 11.3.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
@@ -6342,6 +6342,7 @@ figDefineElement("fig-options", FigOptions);
6342
6342
  * @attr {number} min - The minimum value
6343
6343
  * @attr {number} max - The maximum value
6344
6344
  * @attr {number} step - The step increment
6345
+ * @attr {number} default - Explicit tick and native snap target
6345
6346
  * @attr {boolean} text - Whether to show a text input alongside the slider (default true)
6346
6347
  * @attr {string} placeholder - Placeholder for the number input when text is enabled
6347
6348
  * @attr {string} units - The units to display after the value
@@ -6634,6 +6635,8 @@ class FigSlider extends HTMLElement {
6634
6635
  this.datalist.append(option);
6635
6636
  this.inputContainer.append(this.datalist);
6636
6637
  this.input.setAttribute("list", this.datalist.getAttribute("id"));
6638
+ } else if (this.#hasExplicitValidDefault()) {
6639
+ this.#createDefaultDatalist();
6637
6640
  }
6638
6641
  this.#syncDefaultDatalist();
6639
6642
  this.#syncValue();
@@ -6754,6 +6757,25 @@ class FigSlider extends HTMLElement {
6754
6757
  const tolerance = Number.EPSILON * Math.max(1, Math.abs(steps)) * 16;
6755
6758
  return Math.abs(steps - nearest) <= tolerance;
6756
6759
  }
6760
+ #supportsGeneratedDefaultDatalist() {
6761
+ return ["range", "hue", "opacity"].includes(this.type);
6762
+ }
6763
+ #hasExplicitValidDefault() {
6764
+ if (
6765
+ !this.hasAttribute("default") ||
6766
+ !this.#supportsGeneratedDefaultDatalist()
6767
+ ) {
6768
+ return false;
6769
+ }
6770
+ const { min, max } = this.#getBounds();
6771
+ const parsed = this.#toFiniteNumber(this.getAttribute("default"));
6772
+ return (
6773
+ parsed !== null &&
6774
+ parsed >= min &&
6775
+ parsed <= max &&
6776
+ this.#isStepAligned(parsed)
6777
+ );
6778
+ }
6757
6779
  #resolveDefaultValue(rawDefault = this.getAttribute("default")) {
6758
6780
  const { min, max } = this.#getBounds();
6759
6781
  const parsed = this.#toFiniteNumber(rawDefault);
@@ -6959,8 +6981,24 @@ class FigSlider extends HTMLElement {
6959
6981
  }
6960
6982
 
6961
6983
  #syncDefaultDatalist() {
6984
+ const generatedDefault =
6985
+ this.datalist?.getAttribute("data-generated") === "slider-default";
6986
+ const shouldGenerateDefault = this.#hasExplicitValidDefault();
6987
+ if (generatedDefault && !shouldGenerateDefault) {
6988
+ this.datalist.remove();
6989
+ this.datalist = null;
6990
+ this.input?.removeAttribute("list");
6991
+ } else if (!this.datalist && shouldGenerateDefault) {
6992
+ this.#createDefaultDatalist();
6993
+ }
6962
6994
  if (!this.datalist) return;
6963
6995
  const defaultValue = String(this.default);
6996
+ if (generatedDefault) {
6997
+ const option =
6998
+ this.datalist.querySelector("option") ?? document.createElement("option");
6999
+ option.value = defaultValue;
7000
+ this.datalist.replaceChildren(option);
7001
+ }
6964
7002
  const options = [...this.datalist.querySelectorAll("option")];
6965
7003
  options.forEach((option) => option.removeAttribute("default"));
6966
7004
  let defaultOption = options.find((option) => option.value === defaultValue);
@@ -6976,6 +7014,17 @@ class FigSlider extends HTMLElement {
6976
7014
  defaultOption?.setAttribute("default", "true");
6977
7015
  }
6978
7016
 
7017
+ #createDefaultDatalist() {
7018
+ this.datalist = document.createElement("datalist");
7019
+ this.datalist.setAttribute("data-generated", "slider-default");
7020
+ this.datalist.setAttribute("id", figUniqueId());
7021
+ const option = document.createElement("option");
7022
+ option.value = String(this.default);
7023
+ this.datalist.append(option);
7024
+ this.inputContainer.append(this.datalist);
7025
+ this.input.setAttribute("list", this.datalist.id);
7026
+ }
7027
+
6979
7028
  #regenerateWhenIdle() {
6980
7029
  if (this.#isInteracting) {
6981
7030
  this.#pendingRegeneration = true;
@@ -7779,6 +7828,12 @@ class FigInputNumber extends HTMLElement {
7779
7828
  #precision;
7780
7829
  #isInteracting = false;
7781
7830
  #stepperEl = null;
7831
+ #stepperPointerId = null;
7832
+ #stepperRepeatTimeout = 0;
7833
+ #stepperRepeatInterval = 0;
7834
+ #stepperDidChange = false;
7835
+ #boundStepperPointerEnd;
7836
+ #boundStepperWindowBlur;
7782
7837
  #mutationObserver = null;
7783
7838
  #syncRaf = 0;
7784
7839
  #previousUserSelect = "";
@@ -7791,6 +7846,8 @@ class FigInputNumber extends HTMLElement {
7791
7846
  "aria-required",
7792
7847
  ];
7793
7848
  static #DEFAULT_UNITS_DISALLOW = "px";
7849
+ static #STEPPER_REPEAT_DELAY = 350;
7850
+ static #STEPPER_REPEAT_INTERVAL = 60;
7794
7851
 
7795
7852
  #parseUnitsDisallowList(value) {
7796
7853
  return (value || "")
@@ -7832,11 +7889,12 @@ class FigInputNumber extends HTMLElement {
7832
7889
  const btn = e.target.closest("button");
7833
7890
  if (!btn || this.disabled || btn.disabled) return;
7834
7891
  const dir = btn.classList.contains("fig-stepper-up") ? 1 : -1;
7835
- this.#stepValue(dir);
7892
+ this.#startStepperRepeat(e, dir);
7836
7893
  this.input.focus();
7837
7894
  });
7838
7895
  this.append(this.#stepperEl);
7839
7896
  } else if (!hasSteppers && this.#stepperEl) {
7897
+ this.#stopStepperRepeat();
7840
7898
  this.#stepperEl.remove();
7841
7899
  this.#stepperEl = null;
7842
7900
  }
@@ -7867,13 +7925,60 @@ class FigInputNumber extends HTMLElement {
7867
7925
  down.disabled = disabled || atMin;
7868
7926
  }
7869
7927
 
7928
+ #startStepperRepeat(event, direction) {
7929
+ this.#stopStepperRepeat();
7930
+ this.#stepperPointerId = event.pointerId;
7931
+ this.#stepperDidChange = this.#stepValue(direction);
7932
+ window.addEventListener("pointerup", this.#boundStepperPointerEnd);
7933
+ window.addEventListener("pointercancel", this.#boundStepperPointerEnd);
7934
+ window.addEventListener("blur", this.#boundStepperWindowBlur);
7935
+ if (!this.#stepperDidChange) return;
7936
+
7937
+ this.#stepperRepeatTimeout = window.setTimeout(() => {
7938
+ this.#stepperRepeatTimeout = 0;
7939
+ this.#stepperRepeatInterval = window.setInterval(() => {
7940
+ const changed = this.#stepValue(direction);
7941
+ this.#stepperDidChange ||= changed;
7942
+ if (!changed) this.#clearStepperRepeatTimers();
7943
+ }, FigInputNumber.#STEPPER_REPEAT_INTERVAL);
7944
+ }, FigInputNumber.#STEPPER_REPEAT_DELAY);
7945
+ }
7946
+
7947
+ #clearStepperRepeatTimers() {
7948
+ if (this.#stepperRepeatTimeout) {
7949
+ clearTimeout(this.#stepperRepeatTimeout);
7950
+ this.#stepperRepeatTimeout = 0;
7951
+ }
7952
+ if (this.#stepperRepeatInterval) {
7953
+ clearInterval(this.#stepperRepeatInterval);
7954
+ this.#stepperRepeatInterval = 0;
7955
+ }
7956
+ }
7957
+
7958
+ #stopStepperRepeat(commit = true) {
7959
+ this.#clearStepperRepeatTimers();
7960
+ window.removeEventListener("pointerup", this.#boundStepperPointerEnd);
7961
+ window.removeEventListener("pointercancel", this.#boundStepperPointerEnd);
7962
+ window.removeEventListener("blur", this.#boundStepperWindowBlur);
7963
+ this.#stepperPointerId = null;
7964
+ if (commit && this.#stepperDidChange) {
7965
+ this.dispatchEvent(
7966
+ new CustomEvent("change", { detail: this.value, bubbles: true }),
7967
+ );
7968
+ }
7969
+ this.#stepperDidChange = false;
7970
+ }
7971
+
7870
7972
  #stepValue(direction) {
7973
+ if (this.disabled) return false;
7871
7974
  const step = this.step || 1;
7975
+ const previousValue = this.value;
7872
7976
  let numericValue = this.#getNumericValue(this.input.value);
7873
7977
  let value =
7874
7978
  (numericValue !== "" ? Number(numericValue) / (this.transform || 1) : 0) +
7875
7979
  step * direction;
7876
7980
  value = this.#sanitizeInput(value, false);
7981
+ if (value === previousValue) return false;
7877
7982
  this.value = value;
7878
7983
  this.input.value = this.#formatWithUnit(this.value);
7879
7984
  this.#syncStepperState();
@@ -7881,9 +7986,7 @@ class FigInputNumber extends HTMLElement {
7881
7986
  this.dispatchEvent(
7882
7987
  new CustomEvent("input", { detail: this.value, bubbles: true }),
7883
7988
  );
7884
- this.dispatchEvent(
7885
- new CustomEvent("change", { detail: this.value, bubbles: true }),
7886
- );
7989
+ return true;
7887
7990
  }
7888
7991
 
7889
7992
  constructor() {
@@ -7910,6 +8013,16 @@ class FigInputNumber extends HTMLElement {
7910
8013
  this.#boundKeyDown = (e) => {
7911
8014
  this.#handleKeyDown(e);
7912
8015
  };
8016
+ this.#boundStepperPointerEnd = (e) => {
8017
+ if (
8018
+ this.#stepperPointerId !== null &&
8019
+ e.pointerId !== this.#stepperPointerId
8020
+ ) {
8021
+ return;
8022
+ }
8023
+ this.#stopStepperRepeat();
8024
+ };
8025
+ this.#boundStepperWindowBlur = () => this.#stopStepperRepeat();
7913
8026
  this.#boundFocusControl = this.focus.bind(this);
7914
8027
  this.#boundAdornmentClick = this.#handleAdornmentClick.bind(this);
7915
8028
  }
@@ -8001,6 +8114,7 @@ class FigInputNumber extends HTMLElement {
8001
8114
  window.removeEventListener("pointermove", this.#boundMouseMove);
8002
8115
  window.removeEventListener("pointerup", this.#boundMouseUp);
8003
8116
  window.removeEventListener("blur", this.#boundWindowBlur);
8117
+ this.#stopStepperRepeat(false);
8004
8118
  this.#mutationObserver?.disconnect();
8005
8119
  this.#mutationObserver = null;
8006
8120
  if (this.#syncRaf) {
@@ -8375,6 +8489,7 @@ class FigInputNumber extends HTMLElement {
8375
8489
  case "disabled":
8376
8490
  this.disabled = this.input.disabled =
8377
8491
  newValue !== null && newValue !== "false";
8492
+ if (this.disabled) this.#stopStepperRepeat();
8378
8493
  this.#syncStepperState();
8379
8494
  break;
8380
8495
  case "units":
@@ -14864,9 +14979,354 @@ class FigMediaControls extends HTMLElement {
14864
14979
  }
14865
14980
  figDefineElement("fig-media-controls", FigMediaControls);
14866
14981
 
14982
+ /**
14983
+ * Responsive waveform generated from an audio URL.
14984
+ *
14985
+ * @attr {string} url - Audio URL to decode when CORS permits.
14986
+ * @attr {number} polarization - Waveform contrast around half-height. Defaults to 1.5.
14987
+ * @attr {number} progress - Completed fraction from 0 to 1. Defaults to 0.
14988
+ * @attr {boolean|string} full - Makes the waveform fill its container.
14989
+ */
14990
+ class FigAudioWaveform extends HTMLElement {
14991
+ static #instanceCount = 0;
14992
+ static observedAttributes = ["url", "polarization", "progress"];
14993
+
14994
+ #surface = null;
14995
+ #svg = null;
14996
+ #waveformPath = null;
14997
+ #progressPath = null;
14998
+ #progressClipRect = null;
14999
+ #resizeObserver = null;
15000
+ #abortController = null;
15001
+ #audioContext = null;
15002
+ #levels = [];
15003
+ #generation = 0;
15004
+ #layoutFrame = 0;
15005
+
15006
+ connectedCallback() {
15007
+ if (!this.#surface) this.#render();
15008
+ this.#observeWaveform();
15009
+ this.#syncSource();
15010
+ }
15011
+
15012
+ disconnectedCallback() {
15013
+ this.#cancelDecode();
15014
+ this.#resizeObserver?.disconnect();
15015
+ this.#resizeObserver = null;
15016
+ if (this.#layoutFrame) cancelAnimationFrame(this.#layoutFrame);
15017
+ this.#layoutFrame = 0;
15018
+ }
15019
+
15020
+ attributeChangedCallback(name, oldValue, newValue) {
15021
+ if (oldValue === newValue || !this.#surface) return;
15022
+ if (name === "url") this.#syncSource();
15023
+ if (name === "polarization") this.#queueWaveformLayout();
15024
+ if (name === "progress") this.#syncProgress();
15025
+ }
15026
+
15027
+ #render() {
15028
+ this.#surface = document.createElement("div");
15029
+ this.#surface.className = "fig-audio-waveform-surface";
15030
+ this.#surface.setAttribute("aria-hidden", "true");
15031
+
15032
+ this.#svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
15033
+ this.#svg.setAttribute("aria-hidden", "true");
15034
+ this.#svg.setAttribute("preserveAspectRatio", "none");
15035
+ const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
15036
+ const progressClip = document.createElementNS(
15037
+ "http://www.w3.org/2000/svg",
15038
+ "clipPath",
15039
+ );
15040
+ const progressClipId = `fig-audio-waveform-progress-${++FigAudioWaveform.#instanceCount}`;
15041
+ progressClip.id = progressClipId;
15042
+ progressClip.setAttribute("clipPathUnits", "userSpaceOnUse");
15043
+ this.#progressClipRect = document.createElementNS(
15044
+ "http://www.w3.org/2000/svg",
15045
+ "rect",
15046
+ );
15047
+ progressClip.append(this.#progressClipRect);
15048
+ defs.append(progressClip);
15049
+
15050
+ this.#waveformPath = document.createElementNS(
15051
+ "http://www.w3.org/2000/svg",
15052
+ "path",
15053
+ );
15054
+ this.#waveformPath.classList.add("fig-audio-waveform-path");
15055
+ this.#progressPath = document.createElementNS(
15056
+ "http://www.w3.org/2000/svg",
15057
+ "path",
15058
+ );
15059
+ this.#progressPath.classList.add("fig-audio-waveform-progress");
15060
+ this.#progressPath.setAttribute("clip-path", `url(#${progressClipId})`);
15061
+ this.#svg.append(defs, this.#waveformPath, this.#progressPath);
15062
+ this.#surface.appendChild(this.#svg);
15063
+ this.replaceChildren(this.#surface);
15064
+
15065
+ this.#observeWaveform();
15066
+ }
15067
+
15068
+ #observeWaveform() {
15069
+ if (this.#resizeObserver || !this.#svg) return;
15070
+ this.#resizeObserver = new ResizeObserver(() => this.#queueWaveformLayout());
15071
+ this.#resizeObserver.observe(this.#svg);
15072
+ }
15073
+
15074
+ #syncSource() {
15075
+ const url = this.getAttribute("url");
15076
+ if (!url) {
15077
+ this.#clearWaveform();
15078
+ return;
15079
+ }
15080
+
15081
+ this.#decodeSource(async (signal) => {
15082
+ const response = await fetch(url, { signal });
15083
+ if (!response.ok) throw new Error(`Audio request failed: ${response.status}`);
15084
+ return response.arrayBuffer();
15085
+ });
15086
+ }
15087
+
15088
+ async #decodeSource(loadArrayBuffer) {
15089
+ this.#cancelDecode();
15090
+ const generation = ++this.#generation;
15091
+ const controller = new AbortController();
15092
+ this.#abortController = controller;
15093
+ this.setAttribute("data-waveform-state", "loading");
15094
+ this.setAttribute("aria-busy", "true");
15095
+ this.#levels = [];
15096
+ this.#queueWaveformLayout();
15097
+
15098
+ let context = null;
15099
+ try {
15100
+ const arrayBuffer = await loadArrayBuffer(controller.signal);
15101
+ if (generation !== this.#generation || !this.isConnected) return;
15102
+
15103
+ const AudioContextConstructor =
15104
+ window.AudioContext || window.webkitAudioContext;
15105
+ if (!AudioContextConstructor) throw new Error("Web Audio is unavailable");
15106
+ context = new AudioContextConstructor();
15107
+ this.#audioContext = context;
15108
+ const audioBuffer = await context.decodeAudioData(arrayBuffer);
15109
+ if (generation !== this.#generation || !this.isConnected) return;
15110
+
15111
+ this.#levels = this.#extractLevels(audioBuffer);
15112
+ this.setAttribute("data-waveform-state", "ready");
15113
+ this.#queueWaveformLayout();
15114
+ } catch (error) {
15115
+ if (generation !== this.#generation || error?.name === "AbortError") return;
15116
+ this.#levels = new Array(256).fill(0);
15117
+ this.setAttribute("data-waveform-state", "unavailable");
15118
+ this.#queueWaveformLayout();
15119
+ } finally {
15120
+ if (context) {
15121
+ try {
15122
+ await context.close();
15123
+ } catch {
15124
+ // Closing is best-effort in browsers with suspended audio contexts.
15125
+ }
15126
+ }
15127
+ if (this.#audioContext === context) this.#audioContext = null;
15128
+ if (this.#abortController === controller) this.#abortController = null;
15129
+ if (generation === this.#generation) this.removeAttribute("aria-busy");
15130
+ }
15131
+ }
15132
+
15133
+ #extractLevels(audioBuffer) {
15134
+ const binCount = 256;
15135
+ const levels = new Array(binCount).fill(0);
15136
+ const channelCount = audioBuffer.numberOfChannels;
15137
+ const frameCount = audioBuffer.length;
15138
+ const sampleStride = Math.max(1, Math.ceil(frameCount / 500000));
15139
+ let maximum = 0;
15140
+
15141
+ for (let bin = 0; bin < binCount; bin += 1) {
15142
+ const start = Math.floor((bin / binCount) * frameCount);
15143
+ const end = Math.max(
15144
+ start + 1,
15145
+ Math.floor(((bin + 1) / binCount) * frameCount),
15146
+ );
15147
+ let sumSquares = 0;
15148
+ let sampleCount = 0;
15149
+ for (let channel = 0; channel < channelCount; channel += 1) {
15150
+ const samples = audioBuffer.getChannelData(channel);
15151
+ for (let index = start; index < end; index += sampleStride) {
15152
+ const sample = samples[index] || 0;
15153
+ if (!Number.isFinite(sample)) continue;
15154
+ sumSquares += sample * sample;
15155
+ sampleCount += 1;
15156
+ }
15157
+ }
15158
+ const level = sampleCount > 0 ? Math.sqrt(sumSquares / sampleCount) : 0;
15159
+ levels[bin] = level;
15160
+ maximum = Math.max(maximum, level);
15161
+ }
15162
+
15163
+ if (!Number.isFinite(maximum) || maximum <= 0) return levels.fill(0);
15164
+ const minimum = Math.min(...levels);
15165
+ const range = maximum - minimum;
15166
+ if (!Number.isFinite(range) || range <= Number.EPSILON) {
15167
+ return levels.fill(0);
15168
+ }
15169
+ return levels.map((level) => {
15170
+ const normalized = (level - minimum) / range;
15171
+ return Number.isFinite(normalized) ? normalized : 0;
15172
+ });
15173
+ }
15174
+
15175
+ #clearWaveform() {
15176
+ this.#cancelDecode();
15177
+ this.#levels = [];
15178
+ this.removeAttribute("data-waveform-state");
15179
+ this.removeAttribute("aria-busy");
15180
+ this.#queueWaveformLayout();
15181
+ }
15182
+
15183
+ #polarizeLevel(level) {
15184
+ const configured = Number.parseFloat(this.getAttribute("polarization"));
15185
+ const factor = Number.isFinite(configured)
15186
+ ? Math.max(0, Math.min(configured, 4))
15187
+ : 1.5;
15188
+ return Math.max(0, Math.min(1, 0.5 + (level - 0.5) * factor));
15189
+ }
15190
+
15191
+ #progress() {
15192
+ const configured = Number.parseFloat(this.getAttribute("progress"));
15193
+ return Number.isFinite(configured)
15194
+ ? Math.max(0, Math.min(configured, 1))
15195
+ : 0;
15196
+ }
15197
+
15198
+ #syncProgress() {
15199
+ if (!this.#svg || !this.#progressClipRect) return;
15200
+ const viewBox = this.#svg.viewBox.baseVal;
15201
+ const bounds = this.#svg.getBoundingClientRect();
15202
+ const width = viewBox.width || Math.max(1, Math.round(bounds.width));
15203
+ const height = viewBox.height || Math.max(1, Math.round(bounds.height));
15204
+ this.#progressClipRect.setAttribute("x", "0");
15205
+ this.#progressClipRect.setAttribute("y", "0");
15206
+ this.#progressClipRect.setAttribute("width", String(width * this.#progress()));
15207
+ this.#progressClipRect.setAttribute("height", String(height));
15208
+ }
15209
+
15210
+ #cancelDecode() {
15211
+ this.#generation += 1;
15212
+ this.#abortController?.abort();
15213
+ this.#abortController = null;
15214
+ if (this.#audioContext) {
15215
+ try {
15216
+ this.#audioContext.close();
15217
+ } catch {
15218
+ // Closing is best-effort during teardown and source replacement.
15219
+ }
15220
+ this.#audioContext = null;
15221
+ }
15222
+ }
15223
+
15224
+ #queueWaveformLayout() {
15225
+ if (this.#layoutFrame) return;
15226
+ this.#layoutFrame = requestAnimationFrame(() => {
15227
+ this.#layoutFrame = 0;
15228
+ this.#layoutWaveform();
15229
+ });
15230
+ }
15231
+
15232
+ #layoutWaveform() {
15233
+ if (
15234
+ !this.#svg ||
15235
+ !this.#waveformPath ||
15236
+ !this.#progressPath ||
15237
+ !this.#progressClipRect
15238
+ ) {
15239
+ return;
15240
+ }
15241
+ const bounds = this.#svg.getBoundingClientRect();
15242
+ const width = Math.max(1, Math.round(bounds.width));
15243
+ const height = Math.max(1, Math.round(bounds.height));
15244
+ this.#svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
15245
+ this.#syncProgress();
15246
+
15247
+ if (this.#levels.length === 0) {
15248
+ this.#waveformPath.setAttribute("d", "");
15249
+ this.#progressPath.setAttribute("d", "");
15250
+ return;
15251
+ }
15252
+
15253
+ const barCount = Math.min(
15254
+ this.#levels.length,
15255
+ Math.max(16, Math.floor(width / 3)),
15256
+ );
15257
+ const cellWidth = width / barCount;
15258
+ const configuredStrokeWidth = Number.parseFloat(
15259
+ getComputedStyle(this).getPropertyValue(
15260
+ "--fig-audio-waveform-stroke-width",
15261
+ ),
15262
+ );
15263
+ const strokeWidth =
15264
+ Number.isFinite(configuredStrokeWidth) && configuredStrokeWidth > 0
15265
+ ? configuredStrokeWidth
15266
+ : 2;
15267
+ const barWidth = Math.max(0.5, Math.min(strokeWidth, cellWidth * 0.75));
15268
+ const halfWidth = barWidth / 2;
15269
+ const centerY = height / 2;
15270
+ const maxBarHeight = Math.max(2, height - 12);
15271
+ const commands = [];
15272
+
15273
+ for (let index = 0; index < barCount; index += 1) {
15274
+ const levelIndex = Math.min(
15275
+ this.#levels.length - 1,
15276
+ Math.floor((index / barCount) * this.#levels.length),
15277
+ );
15278
+ const polarizedLevel = this.#polarizeLevel(this.#levels[levelIndex]);
15279
+ const barHeight = Math.max(barWidth, polarizedLevel * maxBarHeight);
15280
+ const x = (index + 0.5) * cellWidth;
15281
+ const top = centerY - barHeight / 2;
15282
+ const bottom = centerY + barHeight / 2;
15283
+ const left = x - halfWidth;
15284
+ const right = x + halfWidth;
15285
+ commands.push(
15286
+ `M ${x} ${top}` +
15287
+ ` Q ${right} ${top} ${right} ${top + halfWidth}` +
15288
+ ` V ${bottom - halfWidth}` +
15289
+ ` Q ${right} ${bottom} ${x} ${bottom}` +
15290
+ ` Q ${left} ${bottom} ${left} ${bottom - halfWidth}` +
15291
+ ` V ${top + halfWidth}` +
15292
+ ` Q ${left} ${top} ${x} ${top} Z`,
15293
+ );
15294
+ }
15295
+
15296
+ const path = commands.join(" ");
15297
+ this.#waveformPath.setAttribute("d", path);
15298
+ this.#progressPath.setAttribute("d", path);
15299
+ }
15300
+ }
15301
+ figDefineElement("fig-audio-waveform", FigAudioWaveform);
15302
+
14867
15303
  /* File Upload Input */
15304
+ const FIG_INPUT_FILE_IMAGE_EXTENSIONS = [
15305
+ ".png",
15306
+ ".jpg",
15307
+ ".jpeg",
15308
+ ".gif",
15309
+ ".webp",
15310
+ ".svg",
15311
+ ".avif",
15312
+ ];
15313
+ const FIG_INPUT_FILE_AUDIO_EXTENSIONS = [
15314
+ ".mp3",
15315
+ ".wav",
15316
+ ".aac",
15317
+ ".m4a",
15318
+ ".ogg",
15319
+ ".flac",
15320
+ ];
15321
+
15322
+ /**
15323
+ * File upload input with file-aware icons, image previews, and audio playback waveforms.
15324
+ * @attr {string} accepts - Native file chooser filter.
15325
+ * @attr {boolean} preview - Preview selected files with a supported image extension.
15326
+ * @attr {string} label - Upload button label. Defaults by known file type.
15327
+ */
14868
15328
  class FigInputFile extends HTMLElement {
14869
- static observedAttributes = ["accepts", "label", "disabled", "multiple", "variant", "url", "filename"];
15329
+ static observedAttributes = ["accepts", "preview", "label", "disabled", "multiple", "variant", "url", "filename"];
14870
15330
 
14871
15331
  #fileInput = null;
14872
15332
  #filenameEl = null;
@@ -14874,6 +15334,14 @@ class FigInputFile extends HTMLElement {
14874
15334
  #tooltipEl = null;
14875
15335
  #uploadBtn = null;
14876
15336
  #files = null;
15337
+ #previewImage = null;
15338
+ #audioPreview = null;
15339
+ #audioIcon = null;
15340
+ #audioWaveform = null;
15341
+ #audioPlaying = false;
15342
+ #audioBlobUrl = null;
15343
+ #syncingPreview = false;
15344
+ #suppressAttributeRender = false;
14877
15345
 
14878
15346
  get files() {
14879
15347
  return this.#files;
@@ -14901,6 +15369,180 @@ class FigInputFile extends HTMLElement {
14901
15369
  return this.#urlFilename;
14902
15370
  }
14903
15371
 
15372
+ get #effectiveAccepts() {
15373
+ return this.getAttribute("accepts") || "";
15374
+ }
15375
+
15376
+ get #previewEnabled() {
15377
+ return (
15378
+ this.hasAttribute("preview") &&
15379
+ this.getAttribute("preview") !== "false"
15380
+ );
15381
+ }
15382
+
15383
+ #hasKnownExtension(filename, extensions) {
15384
+ const lowerName = filename.toLowerCase();
15385
+ return extensions.some((extension) => lowerName.endsWith(extension));
15386
+ }
15387
+
15388
+ #isKnownFile(file, extensions, mimePrefix) {
15389
+ return (
15390
+ this.#hasKnownExtension(file.name, extensions) ||
15391
+ file.type.toLowerCase().startsWith(mimePrefix)
15392
+ );
15393
+ }
15394
+
15395
+ #generatedLabel() {
15396
+ if (this.#files?.[0]) {
15397
+ if (
15398
+ this.#isKnownFile(
15399
+ this.#files[0],
15400
+ FIG_INPUT_FILE_AUDIO_EXTENSIONS,
15401
+ "audio/",
15402
+ )
15403
+ ) {
15404
+ return "Upload audio";
15405
+ }
15406
+ if (
15407
+ this.#isKnownFile(
15408
+ this.#files[0],
15409
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15410
+ "image/",
15411
+ )
15412
+ ) {
15413
+ return "Upload image";
15414
+ }
15415
+ }
15416
+
15417
+ if (
15418
+ this.#hasKnownExtension(
15419
+ this.#urlFilename,
15420
+ FIG_INPUT_FILE_AUDIO_EXTENSIONS,
15421
+ )
15422
+ ) {
15423
+ return "Upload audio";
15424
+ }
15425
+ if (
15426
+ this.#hasKnownExtension(
15427
+ this.#urlFilename,
15428
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15429
+ )
15430
+ ) {
15431
+ return "Upload image";
15432
+ }
15433
+
15434
+ const acceptedTypes = this.#effectiveAccepts
15435
+ .split(",")
15436
+ .map((value) => value.trim().toLowerCase())
15437
+ .filter(Boolean)
15438
+ .map((value) => {
15439
+ if (
15440
+ value.startsWith("audio/") ||
15441
+ FIG_INPUT_FILE_AUDIO_EXTENSIONS.includes(value)
15442
+ ) {
15443
+ return "audio";
15444
+ }
15445
+ if (
15446
+ value.startsWith("image/") ||
15447
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS.includes(value)
15448
+ ) {
15449
+ return "image";
15450
+ }
15451
+ return "file";
15452
+ });
15453
+ if (
15454
+ acceptedTypes.length > 0 &&
15455
+ acceptedTypes.every((type) => type === "audio")
15456
+ ) {
15457
+ return "Upload audio";
15458
+ }
15459
+ if (
15460
+ acceptedTypes.length > 0 &&
15461
+ acceptedTypes.every((type) => type === "image")
15462
+ ) {
15463
+ return "Upload image";
15464
+ }
15465
+ return "Upload file";
15466
+ }
15467
+
15468
+ #hasPreviewableImage() {
15469
+ if (this.#files?.length) {
15470
+ return this.#hasKnownExtension(
15471
+ this.#files[0].name,
15472
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15473
+ );
15474
+ }
15475
+ return (
15476
+ !!this.getAttribute("url") &&
15477
+ this.#hasKnownExtension(
15478
+ this.#urlFilename,
15479
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15480
+ )
15481
+ );
15482
+ }
15483
+
15484
+ #hasPreviewableAudio() {
15485
+ if (this.#files?.length) {
15486
+ return this.#isKnownFile(
15487
+ this.#files[0],
15488
+ FIG_INPUT_FILE_AUDIO_EXTENSIONS,
15489
+ "audio/",
15490
+ );
15491
+ }
15492
+ return (
15493
+ !!this.getAttribute("url") &&
15494
+ this.#hasKnownExtension(
15495
+ this.#urlFilename,
15496
+ FIG_INPUT_FILE_AUDIO_EXTENSIONS,
15497
+ )
15498
+ );
15499
+ }
15500
+
15501
+ #selectedFileIcon() {
15502
+ if (this.#files && this.#files.length > 0) {
15503
+ const files = Array.from(this.#files);
15504
+ if (
15505
+ files.every((file) =>
15506
+ this.#isKnownFile(
15507
+ file,
15508
+ FIG_INPUT_FILE_AUDIO_EXTENSIONS,
15509
+ "audio/",
15510
+ ),
15511
+ )
15512
+ ) {
15513
+ return "sound";
15514
+ }
15515
+ if (
15516
+ files.every((file) =>
15517
+ this.#isKnownFile(
15518
+ file,
15519
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15520
+ "image/",
15521
+ ),
15522
+ )
15523
+ ) {
15524
+ return "image";
15525
+ }
15526
+ }
15527
+ if (
15528
+ this.#hasKnownExtension(
15529
+ this.#urlFilename,
15530
+ FIG_INPUT_FILE_AUDIO_EXTENSIONS,
15531
+ )
15532
+ ) {
15533
+ return "sound";
15534
+ }
15535
+ if (
15536
+ this.#hasKnownExtension(
15537
+ this.#urlFilename,
15538
+ FIG_INPUT_FILE_IMAGE_EXTENSIONS,
15539
+ )
15540
+ ) {
15541
+ return "image";
15542
+ }
15543
+ return "upload";
15544
+ }
15545
+
14904
15546
  connectedCallback() {
14905
15547
  this.#render();
14906
15548
  this.addEventListener("dragover", this.#onDragOver);
@@ -14911,6 +15553,8 @@ class FigInputFile extends HTMLElement {
14911
15553
  disconnectedCallback() {
14912
15554
  this.#fileInput?.removeEventListener("change", this.#onFileChange);
14913
15555
  this.#clearBtn?.removeEventListener("click", this.#onClear);
15556
+ this.#disconnectPreview();
15557
+ this.#disconnectAudioPreview();
14914
15558
  this.removeEventListener("dragover", this.#onDragOver);
14915
15559
  this.removeEventListener("dragleave", this.#onDragLeave);
14916
15560
  this.removeEventListener("drop", this.#onDrop);
@@ -14918,6 +15562,7 @@ class FigInputFile extends HTMLElement {
14918
15562
 
14919
15563
  attributeChangedCallback(name, oldValue, newValue) {
14920
15564
  if (oldValue === newValue) return;
15565
+ if (this.#suppressAttributeRender) return;
14921
15566
  this.#render();
14922
15567
  }
14923
15568
 
@@ -14951,6 +15596,7 @@ class FigInputFile extends HTMLElement {
14951
15596
  };
14952
15597
 
14953
15598
  #onDragOver = (e) => {
15599
+ if (this.#previewImage) return;
14954
15600
  if (
14955
15601
  this.hasAttribute("disabled") &&
14956
15602
  this.getAttribute("disabled") !== "false"
@@ -14967,6 +15613,7 @@ class FigInputFile extends HTMLElement {
14967
15613
  };
14968
15614
 
14969
15615
  #onDragLeave = () => {
15616
+ if (this.#previewImage) return;
14970
15617
  this.removeAttribute("dragover");
14971
15618
  if (this.#uploadBtn && this.#uploadBtn.dataset.prevText !== undefined) {
14972
15619
  this.#uploadBtn.textContent = this.#uploadBtn.dataset.prevText;
@@ -14975,6 +15622,7 @@ class FigInputFile extends HTMLElement {
14975
15622
  };
14976
15623
 
14977
15624
  #onDrop = (e) => {
15625
+ if (this.#previewImage) return;
14978
15626
  e.preventDefault();
14979
15627
  this.removeAttribute("dragover");
14980
15628
  if (this.#uploadBtn && this.#uploadBtn.dataset.prevText !== undefined) {
@@ -14987,7 +15635,7 @@ class FigInputFile extends HTMLElement {
14987
15635
  )
14988
15636
  return;
14989
15637
 
14990
- const accepts = this.getAttribute("accepts");
15638
+ const accepts = this.#effectiveAccepts;
14991
15639
  let dropped = Array.from(e.dataTransfer.files);
14992
15640
  if (accepts) {
14993
15641
  const allowed = accepts.split(",").map((s) => s.trim().toLowerCase());
@@ -15021,24 +15669,210 @@ class FigInputFile extends HTMLElement {
15021
15669
  this.#emitEvents();
15022
15670
  };
15023
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
+ this.#removeExternalFileAttributes();
15711
+ this.#render();
15712
+ if (!this.#syncingPreview) this.#emitEvents({ cleared: true });
15713
+ };
15714
+
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;
15720
+ }
15721
+
15722
+ #disconnectAudioPreview() {
15723
+ this.#audioPreview?.pause();
15724
+ if (this.#audioBlobUrl) {
15725
+ URL.revokeObjectURL(this.#audioBlobUrl);
15726
+ this.#audioBlobUrl = null;
15727
+ }
15728
+ this.#audioPreview = null;
15729
+ this.#audioIcon = null;
15730
+ this.#audioWaveform = null;
15731
+ this.#audioPlaying = false;
15732
+ }
15733
+
15734
+ #setAudioPlaybackUi(playing) {
15735
+ this.#audioPlaying = playing;
15736
+ this.#audioIcon?.setAttribute("name", playing ? "pause" : "play");
15737
+ this.#uploadBtn?.toggleAttribute("selected", playing);
15738
+ this.#uploadBtn?.setAttribute(
15739
+ "aria-label",
15740
+ playing ? "Pause audio" : "Play audio",
15741
+ );
15742
+ }
15743
+
15744
+ #syncAudioWaveformProgress() {
15745
+ if (!this.#audioPreview || !this.#audioWaveform) return;
15746
+ const duration = this.#audioPreview.duration;
15747
+ const currentTime = this.#audioPreview.currentTime;
15748
+ const progress =
15749
+ Number.isFinite(duration) && duration > 0 && Number.isFinite(currentTime)
15750
+ ? Math.max(0, Math.min(currentTime / duration, 1))
15751
+ : 0;
15752
+ this.#audioWaveform.setAttribute("progress", String(progress));
15753
+ }
15754
+
15755
+ #onAudioPlaybackToggle = (event) => {
15756
+ event.preventDefault();
15757
+ if (!this.#audioPreview || this.#uploadBtn?.hasAttribute("disabled")) return;
15758
+ if (this.#audioPlaying) {
15759
+ this.#audioPreview.pause();
15760
+ return;
15761
+ }
15762
+ const playback = this.#audioPreview.play();
15763
+ if (playback && typeof playback.catch === "function") {
15764
+ playback.catch(() => this.#setAudioPlaybackUi(false));
15765
+ }
15766
+ };
15767
+
15768
+ #renderAudioPreview() {
15769
+ const audio = document.createElement("audio");
15770
+ audio.hidden = true;
15771
+ audio.preload = "metadata";
15772
+ if (this.#files?.[0]) {
15773
+ this.#audioBlobUrl = URL.createObjectURL(this.#files[0]);
15774
+ audio.src = this.#audioBlobUrl;
15775
+ } else {
15776
+ audio.src = this.getAttribute("url") || "";
15777
+ }
15778
+
15779
+ audio.addEventListener("play", () => this.#setAudioPlaybackUi(true));
15780
+ audio.addEventListener("pause", () => this.#setAudioPlaybackUi(false));
15781
+ audio.addEventListener("ended", () => {
15782
+ this.#setAudioPlaybackUi(false);
15783
+ this.#audioWaveform?.setAttribute("progress", "0");
15784
+ });
15785
+ audio.addEventListener("timeupdate", () =>
15786
+ this.#syncAudioWaveformProgress(),
15787
+ );
15788
+ audio.addEventListener("durationchange", () =>
15789
+ this.#syncAudioWaveformProgress(),
15790
+ );
15791
+
15792
+ this.#audioPreview = audio;
15793
+ this.#audioWaveform = document.createElement("fig-audio-waveform");
15794
+ this.#audioWaveform.className = "fig-input-file-audio-waveform";
15795
+ this.#audioWaveform.setAttribute("url", audio.src);
15796
+ this.#audioWaveform.setAttribute("polarization", "3");
15797
+ this.#audioWaveform.setAttribute("progress", "0");
15798
+ this.#uploadBtn?.append(this.#audioWaveform);
15799
+ this.#uploadBtn?.addEventListener("click", this.#onAudioPlaybackToggle);
15800
+ this.#setAudioPlaybackUi(false);
15801
+ this.append(audio);
15802
+ }
15803
+
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);
15818
+
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", "");
15826
+
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
+ }
15835
+ }
15836
+ }
15837
+
15024
15838
  #render() {
15025
- const accepts = this.getAttribute("accepts") || "";
15026
- const label = this.getAttribute("label") || "Upload";
15839
+ const accepts = this.#effectiveAccepts;
15840
+ const customLabel = this.getAttribute("label");
15841
+ const label = customLabel?.trim() ? customLabel : this.#generatedLabel();
15027
15842
  const disabled =
15028
15843
  this.hasAttribute("disabled") &&
15029
15844
  this.getAttribute("disabled") !== "false";
15030
15845
  const multiple =
15031
15846
  this.hasAttribute("multiple") && this.getAttribute("multiple") !== "false";
15032
- const variant = this.getAttribute("variant") || "input";
15847
+ const variant = this.getAttribute("variant") || "property";
15033
15848
  const hasFile =
15034
15849
  (this.#files && this.#files.length > 0) ||
15035
15850
  !!this.getAttribute("url") ||
15036
15851
  !!this.getAttribute("filename");
15852
+ const showsImagePreview =
15853
+ this.#previewEnabled && this.#hasPreviewableImage();
15854
+ const showsAudioPreview =
15855
+ this.#previewEnabled && this.#hasPreviewableAudio();
15037
15856
 
15857
+ this.#disconnectPreview();
15858
+ this.#disconnectAudioPreview();
15038
15859
  this.replaceChildren();
15860
+ this.#fileInput = null;
15861
+ this.#uploadBtn = null;
15862
+ this.#clearBtn = null;
15863
+ this.#tooltipEl = null;
15864
+ this.toggleAttribute("data-previewing", showsImagePreview);
15865
+
15866
+ if (showsImagePreview) {
15867
+ this.#renderImagePreview({ accepts, label, disabled, multiple });
15868
+ return;
15869
+ }
15039
15870
 
15040
15871
  if (hasFile) {
15041
- const tooltipText = accepts
15872
+ const filename = this.value;
15873
+ const tooltipText = showsAudioPreview
15874
+ ? filename
15875
+ : accepts
15042
15876
  ? `Accepts ${accepts
15043
15877
  .split(",")
15044
15878
  .map((s) => s.trim())
@@ -15047,26 +15881,38 @@ class FigInputFile extends HTMLElement {
15047
15881
 
15048
15882
  this.#uploadBtn = document.createElement("fig-button");
15049
15883
  this.#uploadBtn.setAttribute("variant", variant);
15050
- this.#uploadBtn.setAttribute("type", "upload");
15884
+ if (variant === "property") this.#uploadBtn.setAttribute("full", "");
15051
15885
  this.#uploadBtn.className = "fig-input-file-filename";
15052
15886
  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);
15887
+ const fileIcon = createFigIcon(
15888
+ showsAudioPreview ? "play" : this.#selectedFileIcon(),
15889
+ );
15890
+ if (showsAudioPreview) {
15891
+ this.#audioIcon = fileIcon;
15892
+ fileIcon.setAttribute("slot", "prepend");
15893
+ this.#uploadBtn.append(fileIcon);
15894
+ } else {
15895
+ const truncEl = document.createElement("fig-truncate");
15896
+ truncEl.setAttribute("position", "middle");
15897
+ truncEl.setAttribute("tooltip", "");
15898
+ const dotIdx = filename.lastIndexOf(".");
15899
+ if (dotIdx > 0) truncEl.setAttribute("tail", filename.slice(dotIdx));
15900
+ truncEl.textContent = filename;
15901
+ fileIcon.setAttribute("slot", "prepend");
15902
+ this.#uploadBtn.append(fileIcon, truncEl);
15903
+ }
15904
+
15905
+ if (!showsAudioPreview) {
15906
+ this.#uploadBtn.setAttribute("type", "upload");
15907
+ this.#fileInput = document.createElement("input");
15908
+ this.#fileInput.type = "file";
15909
+ this.#fileInput.title = "";
15910
+ this.#fileInput.disabled = disabled;
15911
+ if (accepts) this.#fileInput.setAttribute("accept", accepts);
15912
+ if (multiple) this.#fileInput.setAttribute("multiple", "");
15913
+ this.#fileInput.addEventListener("change", this.#onFileChange);
15914
+ this.#uploadBtn.appendChild(this.#fileInput);
15915
+ }
15070
15916
 
15071
15917
  if (tooltipText) {
15072
15918
  this.#tooltipEl = document.createElement("fig-tooltip");
@@ -15089,6 +15935,7 @@ class FigInputFile extends HTMLElement {
15089
15935
  this.#clearBtn.addEventListener("click", this.#onClear);
15090
15936
  clearTooltip.appendChild(this.#clearBtn);
15091
15937
  this.appendChild(clearTooltip);
15938
+ if (showsAudioPreview) this.#renderAudioPreview();
15092
15939
  } else {
15093
15940
  const tooltipText = accepts
15094
15941
  ? `Accepts ${accepts
@@ -15104,8 +15951,12 @@ class FigInputFile extends HTMLElement {
15104
15951
 
15105
15952
  this.#uploadBtn = document.createElement("fig-button");
15106
15953
  this.#uploadBtn.setAttribute("variant", variant);
15954
+ if (variant === "property") this.#uploadBtn.setAttribute("full", "");
15107
15955
  this.#uploadBtn.setAttribute("type", "upload");
15108
15956
  this.#uploadBtn.textContent = label;
15957
+ const uploadIcon = createFigIcon("upload");
15958
+ uploadIcon.setAttribute("slot", "prepend");
15959
+ this.#uploadBtn.prepend(uploadIcon);
15109
15960
  if (disabled) this.#uploadBtn.setAttribute("disabled", "");
15110
15961
 
15111
15962
  this.#fileInput = document.createElement("input");
@@ -19458,6 +20309,12 @@ const FIG_ICON_TOKENS = {
19458
20309
  search: "--icon-24-search",
19459
20310
  download: "--icon-24-download",
19460
20311
  upload: "--icon-24-upload",
20312
+ image: "--icon-24-image",
20313
+ sound: { medium: "--icon-24-sound", small: "--icon-16-sound" },
20314
+ "sound-off": {
20315
+ medium: "--icon-24-sound-off",
20316
+ small: "--icon-16-sound-off",
20317
+ },
19461
20318
  sun: "--icon-24-sun",
19462
20319
  moon: "--icon-24-moon",
19463
20320
  edit: { medium: "--icon-24-edit", small: "--icon-16-edit" },