@rogieking/figui3 3.22.0 → 4.0.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.
Files changed (4) hide show
  1. package/README.md +3 -4
  2. package/components.css +87 -21
  3. package/fig.js +486 -186
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1001,10 +1001,9 @@ An image display component with optional upload, aspect ratio, and object-fit co
1001
1001
  | Attribute | Type | Default | Description |
1002
1002
  |---|---|---|---|
1003
1003
  | `src` | string | — | Image URL |
1004
- | `upload` | boolean | `false` | Show upload button |
1005
- | `download` | boolean | `false` | Show download button |
1006
- | `label` | string | — | Upload button label |
1007
- | `aspect-ratio` | string | — | CSS aspect-ratio (e.g. `"16 / 9"`) |
1004
+ | `upload` | boolean | `false` | Show upload overlay (`fig-input-file`) |
1005
+ | `label` | string | `"Upload"` | Upload button label |
1006
+ | `aspect-ratio` | string | — | CSS aspect-ratio, or `"auto"` for lazy dimension detection |
1008
1007
  | `fit` | string | — | CSS object-fit (`"cover"`, `"contain"`, etc.) |
1009
1008
  | `checkerboard` | boolean | `false` | Show checkerboard behind transparent images |
1010
1009
 
package/components.css CHANGED
@@ -1290,6 +1290,87 @@ fig-chit {
1290
1290
  }
1291
1291
 
1292
1292
  /* Fig Image */
1293
+ fig-truncate {
1294
+ display: inline-block;
1295
+ white-space: nowrap;
1296
+ overflow: hidden;
1297
+ text-overflow: ellipsis;
1298
+ min-width: 0;
1299
+ max-width: 100%;
1300
+ vertical-align: bottom;
1301
+
1302
+ &[position="left"] {
1303
+ display: block;
1304
+ direction: rtl;
1305
+ text-align: left;
1306
+ }
1307
+
1308
+ &[position="middle"] {
1309
+ display: inline-flex;
1310
+ text-overflow: clip;
1311
+ gap: 0;
1312
+
1313
+ & > .start,
1314
+ & > .end {
1315
+ flex: 0 1 auto;
1316
+ white-space: nowrap;
1317
+ overflow: hidden;
1318
+ text-overflow: ellipsis;
1319
+ min-width: 0;
1320
+ }
1321
+
1322
+ & > .end {
1323
+ direction: rtl; /* technique #2 on right half */
1324
+ text-align: left;
1325
+ text-overflow: ellipsis;
1326
+ }
1327
+ &[tail] {
1328
+ & > .end {
1329
+ direction: ltr;
1330
+ text-align: right;
1331
+ flex-shrink: 0;
1332
+ }
1333
+ }
1334
+ }
1335
+ }
1336
+
1337
+ fig-input-file {
1338
+ display: flex;
1339
+ align-items: center;
1340
+ gap: var(--spacer-2);
1341
+ min-width: 0;
1342
+
1343
+ &[full]:not([full="false"]) {
1344
+ flex: 1 1 auto;
1345
+ width: 100%;
1346
+
1347
+ & > fig-tooltip,
1348
+ & > fig-button {
1349
+ flex: 1;
1350
+ min-width: 0;
1351
+ }
1352
+
1353
+ & > fig-tooltip > fig-button {
1354
+ width: 100%;
1355
+ }
1356
+ }
1357
+
1358
+ .fig-input-file-filename {
1359
+ flex: 1;
1360
+ min-width: 0;
1361
+ }
1362
+
1363
+ .fig-input-file-clear {
1364
+ flex-shrink: 0;
1365
+ }
1366
+
1367
+ &[dragover] {
1368
+ outline: 2px dashed var(--figma-color-border-brand);
1369
+ outline-offset: -2px;
1370
+ border-radius: var(--radius-small);
1371
+ }
1372
+ }
1373
+
1293
1374
  fig-image {
1294
1375
  --image-size: 2rem;
1295
1376
  --fit: cover;
@@ -1314,8 +1395,12 @@ fig-image {
1314
1395
  opacity: 1;
1315
1396
  }
1316
1397
  }
1317
- &:not([src]):not([src=""]) fig-button,
1318
- &:hover fig-button {
1398
+ > fig-input-file[data-generated] {
1399
+ opacity: 0;
1400
+ pointer-events: none;
1401
+ }
1402
+ &:not([src]):not([src=""]) > fig-input-file[data-generated],
1403
+ &:hover > fig-input-file[data-generated] {
1319
1404
  opacity: 1;
1320
1405
  pointer-events: all;
1321
1406
  }
@@ -1339,25 +1424,6 @@ fig-image {
1339
1424
  aspect-ratio: var(--aspect-ratio) !important;
1340
1425
  }
1341
1426
  }
1342
- > div {
1343
- display: flex;
1344
- gap: var(--spacer-2);
1345
- opacity: 0;
1346
- pointer-events: none;
1347
- }
1348
- &:not([src]):not([src=""]) > div,
1349
- &:hover > div {
1350
- opacity: 1;
1351
- pointer-events: all;
1352
- }
1353
- fig-button[type="download"] {
1354
- display: none;
1355
- }
1356
- &[src]:not([src=""]) {
1357
- fig-button[type="download"] {
1358
- display: inline-flex;
1359
- }
1360
- }
1361
1427
  }
1362
1428
 
1363
1429
  /* Easing Curve */
package/fig.js CHANGED
@@ -1023,6 +1023,85 @@ class FigTooltip extends HTMLElement {
1023
1023
 
1024
1024
  customElements.define("fig-tooltip", FigTooltip);
1025
1025
 
1026
+ /* Text Truncation */
1027
+ class FigTruncate extends HTMLElement {
1028
+ static observedAttributes = ["position", "tail"];
1029
+
1030
+ #originalText = null;
1031
+ #boundEnter = null;
1032
+ #boundLeave = null;
1033
+
1034
+ connectedCallback() {
1035
+ this.#originalText = this.textContent;
1036
+ requestAnimationFrame(() => {
1037
+ this.#render();
1038
+ this.#setupTooltip();
1039
+ });
1040
+ }
1041
+
1042
+ disconnectedCallback() {
1043
+ this.#teardownTooltip();
1044
+ }
1045
+
1046
+ attributeChangedCallback(name, oldValue, newValue) {
1047
+ if (oldValue === newValue) return;
1048
+ if (this.#originalText === null) return;
1049
+ this.#render();
1050
+ }
1051
+
1052
+ #render() {
1053
+ const position = this.getAttribute("position") || "right";
1054
+ const text = this.#originalText || "";
1055
+ if (position === "middle") {
1056
+ const tail = this.getAttribute("tail");
1057
+ let splitIndex;
1058
+ if (tail) {
1059
+ const idx = text.lastIndexOf(tail);
1060
+ splitIndex = idx > 0 ? idx : Math.ceil(text.length / 2);
1061
+ } else {
1062
+ splitIndex = Math.ceil(text.length / 2);
1063
+ }
1064
+ this.innerHTML = "";
1065
+ const startSpan = document.createElement("span");
1066
+ startSpan.className = "start";
1067
+ startSpan.textContent = text.slice(0, splitIndex);
1068
+ const endSpan = document.createElement("span");
1069
+ endSpan.className = "end";
1070
+ endSpan.textContent = text.slice(splitIndex);
1071
+ this.appendChild(startSpan);
1072
+ this.appendChild(endSpan);
1073
+ } else {
1074
+ this.textContent = text;
1075
+ }
1076
+ }
1077
+
1078
+ #setupTooltip() {
1079
+ if (
1080
+ !this.hasAttribute("tooltip") ||
1081
+ this.getAttribute("tooltip") === "false"
1082
+ )
1083
+ return;
1084
+ this.#boundEnter = () => {
1085
+ if (this.scrollWidth <= this.clientWidth) return;
1086
+ FigTooltip.show(this, this.#originalText);
1087
+ };
1088
+ this.#boundLeave = () => {
1089
+ FigTooltip.hide(this);
1090
+ };
1091
+ this.addEventListener("pointerenter", this.#boundEnter);
1092
+ this.addEventListener("pointerleave", this.#boundLeave);
1093
+ }
1094
+
1095
+ #teardownTooltip() {
1096
+ if (this.#boundEnter)
1097
+ this.removeEventListener("pointerenter", this.#boundEnter);
1098
+ if (this.#boundLeave)
1099
+ this.removeEventListener("pointerleave", this.#boundLeave);
1100
+ FigTooltip.hide(this);
1101
+ }
1102
+ }
1103
+ customElements.define("fig-truncate", FigTruncate);
1104
+
1026
1105
  /* Dialog */
1027
1106
  /**
1028
1107
  * A custom dialog element for modal and non-modal dialogs.
@@ -7928,57 +8007,58 @@ customElements.define("fig-chit", FigChit);
7928
8007
  class FigSwatch extends FigChit {}
7929
8008
  customElements.define("fig-swatch", FigSwatch);
7930
8009
 
7931
- /* Upload */
8010
+ /* Image */
7932
8011
  /**
7933
- * A custom image upload element.
7934
- * @attr {string} src - The current image source URL
7935
- * @attr {boolean} upload - Whether to show the upload button
7936
- * @attr {string} label - The upload button label
7937
- * @attr {string} size - Size of the image preview
8012
+ * @attr {string} src - Image source URL
8013
+ * @attr {boolean} upload - Show upload overlay (generates fig-input-file)
8014
+ * @attr {string} label - Upload button label (default "Upload")
8015
+ * @attr {string} size - small | medium | large | auto
8016
+ * @attr {string} aspect-ratio - CSS aspect-ratio or "auto" (lazy dimension sniff)
8017
+ * @attr {string} fit - CSS object-fit value
8018
+ * @attr {boolean} checkerboard - Show checkerboard behind transparent images
7938
8019
  */
7939
8020
  class FigImage extends HTMLElement {
7940
8021
  #src = null;
8022
+ #chit = null;
8023
+ #fileInput = null;
8024
+ #blobUrl = null;
8025
+ #file = null;
7941
8026
  #boundHandleFileInput = this.#handleFileInput.bind(this);
7942
- #boundHandleDownload = this.#handleDownload.bind(this);
7943
- constructor() {
7944
- super();
8027
+
8028
+ static get observedAttributes() {
8029
+ return ["src", "upload", "aspect-ratio", "fit", "checkerboard"];
7945
8030
  }
7946
- #getInnerHTML() {
7947
- const cb =
7948
- this.hasAttribute("checkerboard") &&
7949
- this.getAttribute("checkerboard") !== "false";
7950
- const bg = this.src
7951
- ? `url(${this.src})`
7952
- : cb
7953
- ? "url()"
7954
- : "var(--figma-color-bg-secondary)";
7955
- return `<fig-chit size="large" data-type="image" background="${bg}" disabled${cb ? " checkerboard" : ""}></fig-chit><div>${
7956
- this.upload
7957
- ? `<fig-button variant="overlay" type="upload">
7958
- ${this.label}
7959
- <input type="file" accept="image/*" />
7960
- </fig-button>`
7961
- : ""
7962
- } ${
7963
- this.download
7964
- ? `<fig-button variant="overlay" icon="true" type="download">
7965
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
7966
- <path d="M17.5 13C17.7761 13 18 13.2239 18 13.5V16.5C18 17.3284 17.3284 18 16.5 18H7.5C6.67157 18 6 17.3284 6 16.5V13.5C6 13.2239 6.22386 13 6.5 13C6.77614 13 7 13.2239 7 13.5V16.5C7 16.7761 7.22386 17 7.5 17H16.5C16.7761 17 17 16.7761 17 16.5V13.5C17 13.2239 17.2239 13 17.5 13ZM12 6C12.2761 6 12.5 6.22386 12.5 6.5V12.293L14.6465 10.1465C14.8417 9.95122 15.1583 9.95122 15.3535 10.1465C15.5488 10.3417 15.5488 10.6583 15.3535 10.8535L12.3535 13.8535C12.2597 13.9473 12.1326 14 12 14C11.9006 14 11.8042 13.9704 11.7227 13.916L11.6465 13.8535L8.64648 10.8535C8.45122 10.6583 8.45122 10.3417 8.64648 10.1465C8.84175 9.95122 9.15825 9.95122 9.35352 10.1465L11.5 12.293V6.5C11.5 6.22386 11.7239 6 12 6Z" fill="black"/>
7967
- </svg></fig-button>`
7968
- : ""
7969
- }</div>`;
8031
+
8032
+ get src() {
8033
+ return this.#src;
7970
8034
  }
8035
+ set src(value) {
8036
+ this.#src = value;
8037
+ this.setAttribute("src", value);
8038
+ }
8039
+
8040
+ get file() {
8041
+ return this.#file;
8042
+ }
8043
+
8044
+ async getBase64() {
8045
+ const src = this.#src;
8046
+ if (!src) return null;
8047
+ const res = await fetch(src);
8048
+ const blob = await res.blob();
8049
+ const bitmap = await createImageBitmap(blob);
8050
+ const canvas = document.createElement("canvas");
8051
+ canvas.width = bitmap.width;
8052
+ canvas.height = bitmap.height;
8053
+ canvas.getContext("2d").drawImage(bitmap, 0, 0);
8054
+ bitmap.close();
8055
+ const dataUrl = canvas.toDataURL();
8056
+ return dataUrl;
8057
+ }
8058
+
7971
8059
  connectedCallback() {
7972
8060
  this.#src = this.getAttribute("src") || "";
7973
- this.upload =
7974
- this.hasAttribute("upload") && this.getAttribute("upload") !== "false";
7975
- this.download =
7976
- this.hasAttribute("download") &&
7977
- this.getAttribute("download") !== "false";
7978
- this.label = this.getAttribute("label") || "Upload";
7979
- this.size = this.getAttribute("size") || "small";
7980
- this.innerHTML = this.#getInnerHTML();
7981
- this.#updateRefs();
8061
+
7982
8062
  const ar = this.getAttribute("aspect-ratio");
7983
8063
  if (ar && ar !== "auto") {
7984
8064
  this.style.setProperty("--aspect-ratio", ar);
@@ -7987,182 +8067,169 @@ class FigImage extends HTMLElement {
7987
8067
  if (fit) {
7988
8068
  this.style.setProperty("--fit", fit);
7989
8069
  }
8070
+
8071
+ if (!this.querySelector("fig-chit")) {
8072
+ const chit = document.createElement("fig-chit");
8073
+ chit.setAttribute("data-generated", "");
8074
+ chit.setAttribute("size", "large");
8075
+ chit.setAttribute("data-type", "image");
8076
+ chit.setAttribute("disabled", "");
8077
+ this.#applyChitBackground(chit);
8078
+ if (this.hasAttribute("checkerboard") && this.getAttribute("checkerboard") !== "false") {
8079
+ chit.setAttribute("checkerboard", "");
8080
+ }
8081
+ this.prepend(chit);
8082
+ }
8083
+ this.#chit = this.querySelector("fig-chit");
8084
+
8085
+ const isUpload = this.hasAttribute("upload") && this.getAttribute("upload") !== "false";
8086
+ if (isUpload && !this.querySelector("fig-input-file[data-generated]")) {
8087
+ this.#createFileInput();
8088
+ }
8089
+
8090
+ if (this.#src && this.getAttribute("aspect-ratio") === "auto") {
8091
+ this.#sniffDimensions(this.#src);
8092
+ }
7990
8093
  }
8094
+
7991
8095
  disconnectedCallback() {
7992
- this.fileInput?.removeEventListener("change", this.#boundHandleFileInput);
7993
- this.downloadButton?.removeEventListener(
7994
- "click",
7995
- this.#boundHandleDownload,
7996
- );
8096
+ this.#fileInput?.removeEventListener("change", this.#boundHandleFileInput);
8097
+ if (this.#blobUrl) {
8098
+ URL.revokeObjectURL(this.#blobUrl);
8099
+ this.#blobUrl = null;
8100
+ }
7997
8101
  }
7998
8102
 
7999
- #updateRefs() {
8000
- requestAnimationFrame(() => {
8001
- this.chit = this.querySelector("fig-chit");
8002
- if (this.upload) {
8003
- this.uploadButton = this.querySelector("fig-button[type='upload']");
8004
- this.fileInput = this.uploadButton?.querySelector("input");
8005
- this.fileInput?.removeEventListener(
8006
- "change",
8007
- this.#boundHandleFileInput,
8008
- );
8009
- this.fileInput?.addEventListener("change", this.#boundHandleFileInput);
8010
- }
8011
- if (this.download) {
8012
- this.downloadButton = this.querySelector("fig-button[type='download']");
8013
- this.downloadButton?.removeEventListener(
8014
- "click",
8015
- this.#boundHandleDownload,
8016
- );
8017
- this.downloadButton?.addEventListener(
8018
- "click",
8019
- this.#boundHandleDownload,
8020
- );
8021
- }
8022
- });
8103
+ #applyChitBackground(chit) {
8104
+ const cb = this.hasAttribute("checkerboard") && this.getAttribute("checkerboard") !== "false";
8105
+ if (this.#src) {
8106
+ chit.setAttribute("background", `url(${this.#src})`);
8107
+ } else {
8108
+ chit.setAttribute("background", cb ? "url()" : "var(--figma-color-bg-secondary)");
8109
+ }
8023
8110
  }
8024
- #handleDownload() {
8025
- //force blob download
8026
- const link = document.createElement("a");
8027
- link.href = this.blob;
8028
- link.download = "image.png";
8029
- link.click();
8030
- }
8031
- async #loadImage(src) {
8032
- // Get blob from canvas
8033
- await new Promise((resolve) => {
8034
- this.image = new Image();
8035
- this.image.crossOrigin = "Anonymous";
8036
- this.image.onload = async () => {
8037
- this.aspectRatio = this.image.width / this.image.height;
8038
- const ar = this.getAttribute("aspect-ratio");
8039
- if (!ar || ar === "auto") {
8040
- this.style.setProperty(
8041
- "--aspect-ratio",
8042
- `${this.image.width}/${this.image.height}`,
8043
- );
8044
- }
8045
- this.dispatchEvent(
8046
- new CustomEvent("loaded", {
8047
- bubbles: true,
8048
- cancelable: true,
8049
- detail: {
8050
- blob: this.blob,
8051
- base64: this.base64,
8052
- },
8053
- }),
8054
- );
8055
- resolve();
8056
-
8057
- // Create canvas to extract blob and base64 from image
8058
- const canvas = document.createElement("canvas");
8059
- const ctx = canvas.getContext("2d");
8060
- canvas.width = this.image.width;
8061
- canvas.height = this.image.height;
8062
- ctx.drawImage(this.image, 0, 0);
8063
-
8064
- // Get base64 from canvas
8065
- this.base64 = canvas.toDataURL();
8066
-
8067
- // Get blob from canvas
8068
- canvas.toBlob((blob) => {
8069
- if (this.blob) {
8070
- URL.revokeObjectURL(this.blob);
8071
- }
8072
- if (blob) {
8073
- this.blob = URL.createObjectURL(blob);
8074
- }
8075
- });
8076
- };
8077
- this.image.src = src;
8078
- });
8111
+
8112
+ #createFileInput() {
8113
+ const fi = document.createElement("fig-input-file");
8114
+ fi.setAttribute("data-generated", "");
8115
+ fi.setAttribute("accepts", "image/*");
8116
+ fi.setAttribute("variant", "overlay");
8117
+ const defaultLabel = this.getAttribute("label") || "Upload";
8118
+ fi.setAttribute("label", this.#src ? "Replace" : defaultLabel);
8119
+ if (this.#src) fi.setAttribute("url", this.#src);
8120
+ fi.addEventListener("change", this.#boundHandleFileInput);
8121
+ this.append(fi);
8122
+ this.#fileInput = fi;
8079
8123
  }
8080
- async #handleFileInput(e) {
8081
- if (this.blob) {
8082
- URL.revokeObjectURL(this.blob);
8083
- }
8084
- this.blob = URL.createObjectURL(e.target.files[0]);
8085
- //set base64 url
8086
- const reader = new FileReader();
8087
- reader.readAsDataURL(e.target.files[0]);
8088
- //await this data url to be set
8089
- await new Promise((resolve) => {
8090
- reader.onload = (e) => {
8091
- this.base64 = e.target.result;
8092
- resolve();
8093
- };
8094
- });
8095
- //emit event for loaded
8124
+
8125
+ #removeFileInput() {
8126
+ if (this.#fileInput) {
8127
+ this.#fileInput.removeEventListener("change", this.#boundHandleFileInput);
8128
+ this.#fileInput.remove();
8129
+ this.#fileInput = null;
8130
+ }
8131
+ }
8132
+
8133
+ #handleFileInput(e) {
8134
+ const file = e.detail?.files?.[0];
8135
+
8136
+ if (!file) {
8137
+ if (this.#blobUrl) {
8138
+ URL.revokeObjectURL(this.#blobUrl);
8139
+ this.#blobUrl = null;
8140
+ }
8141
+ this.#file = null;
8142
+ this.removeAttribute("src");
8143
+ this.dispatchEvent(
8144
+ new CustomEvent("change", { bubbles: true, cancelable: true }),
8145
+ );
8146
+ return;
8147
+ }
8148
+
8149
+ if (this.#blobUrl) {
8150
+ URL.revokeObjectURL(this.#blobUrl);
8151
+ }
8152
+ this.#file = file;
8153
+ this.#blobUrl = URL.createObjectURL(file);
8154
+
8155
+ this.setAttribute("src", this.#blobUrl);
8156
+
8096
8157
  this.dispatchEvent(
8097
8158
  new CustomEvent("loaded", {
8098
8159
  bubbles: true,
8099
8160
  cancelable: true,
8100
- detail: {
8101
- blob: this.blob,
8102
- base64: this.base64,
8103
- },
8161
+ detail: { file, src: this.#blobUrl },
8104
8162
  }),
8105
8163
  );
8106
- //emit for change too
8107
8164
  this.dispatchEvent(
8108
- new CustomEvent("change", {
8109
- bubbles: true,
8110
- cancelable: true,
8111
- }),
8165
+ new CustomEvent("change", { bubbles: true, cancelable: true }),
8112
8166
  );
8113
- this.setAttribute("src", this.blob);
8114
- }
8115
- static get observedAttributes() {
8116
- return ["src", "upload", "download", "aspect-ratio", "fit", "checkerboard"];
8117
- }
8118
- get src() {
8119
- return this.#src;
8167
+
8168
+ if (this.#fileInput) {
8169
+ this.#fileInput.clear();
8170
+ this.#fileInput.setAttribute("label", "Replace");
8171
+ }
8120
8172
  }
8121
- set src(value) {
8122
- this.#src = value;
8123
- this.setAttribute("src", value);
8173
+
8174
+ async #sniffDimensions(src) {
8175
+ try {
8176
+ let blob;
8177
+ if (src.startsWith("blob:")) {
8178
+ const res = await fetch(src);
8179
+ blob = await res.blob();
8180
+ } else {
8181
+ const res = await fetch(src, { mode: "cors" });
8182
+ blob = await res.blob();
8183
+ }
8184
+ const bitmap = await createImageBitmap(blob);
8185
+ this.style.setProperty("--aspect-ratio", `${bitmap.width}/${bitmap.height}`);
8186
+ bitmap.close();
8187
+ } catch {
8188
+ // Non-critical — CSS aspect-ratio fallback handles it
8189
+ }
8124
8190
  }
8125
8191
 
8126
8192
  attributeChangedCallback(name, oldValue, newValue) {
8193
+ if (oldValue === newValue) return;
8194
+
8127
8195
  if (name === "src") {
8128
8196
  this.#src = newValue;
8129
- if (this.chit) {
8130
- const hasCb =
8131
- this.hasAttribute("checkerboard") &&
8132
- this.getAttribute("checkerboard") !== "false";
8197
+ if (this.#chit) {
8198
+ this.#applyChitBackground(this.#chit);
8199
+ }
8200
+ if (this.#fileInput) {
8201
+ const defaultLabel = this.getAttribute("label") || "Upload";
8202
+ this.#fileInput.setAttribute("label", this.#src ? "Replace" : defaultLabel);
8133
8203
  if (this.#src) {
8134
- this.chit.setAttribute("background", `url(${this.#src})`);
8204
+ this.#fileInput.setAttribute("url", this.#src);
8135
8205
  } else {
8136
- this.chit.setAttribute(
8137
- "background",
8138
- hasCb ? "url()" : "var(--figma-color-bg-secondary)",
8139
- );
8206
+ this.#fileInput.removeAttribute("url");
8140
8207
  }
8141
8208
  }
8142
- if (this.#src) {
8143
- this.#loadImage(this.#src);
8209
+ if (this.#src && this.getAttribute("aspect-ratio") === "auto") {
8210
+ this.#sniffDimensions(this.#src);
8144
8211
  }
8145
8212
  }
8213
+
8146
8214
  if (name === "upload") {
8147
- this.upload = newValue !== null && newValue !== "false";
8148
- this.innerHTML = this.#getInnerHTML();
8149
- this.#updateRefs();
8150
- }
8151
- if (name === "download") {
8152
- this.download = newValue !== null && newValue !== "false";
8153
- this.innerHTML = this.#getInnerHTML();
8154
- this.#updateRefs();
8155
- }
8156
- if (name === "size") {
8157
- this.size = newValue;
8215
+ const on = newValue !== null && newValue !== "false";
8216
+ if (on && !this.#fileInput) {
8217
+ this.#createFileInput();
8218
+ } else if (!on) {
8219
+ this.#removeFileInput();
8220
+ }
8158
8221
  }
8222
+
8159
8223
  if (name === "aspect-ratio") {
8160
8224
  if (newValue && newValue !== "auto") {
8161
8225
  this.style.setProperty("--aspect-ratio", newValue);
8162
8226
  } else if (!newValue) {
8163
8227
  this.style.removeProperty("--aspect-ratio");
8228
+ } else if (newValue === "auto" && this.#src) {
8229
+ this.#sniffDimensions(this.#src);
8164
8230
  }
8165
8231
  }
8232
+
8166
8233
  if (name === "fit") {
8167
8234
  if (newValue) {
8168
8235
  this.style.setProperty("--fit", newValue);
@@ -8170,12 +8237,13 @@ class FigImage extends HTMLElement {
8170
8237
  this.style.removeProperty("--fit");
8171
8238
  }
8172
8239
  }
8240
+
8173
8241
  if (name === "checkerboard") {
8174
- if (this.chit) {
8242
+ if (this.#chit) {
8175
8243
  if (newValue !== null && newValue !== "false") {
8176
- this.chit.setAttribute("checkerboard", "");
8244
+ this.#chit.setAttribute("checkerboard", "");
8177
8245
  } else {
8178
- this.chit.removeAttribute("checkerboard");
8246
+ this.#chit.removeAttribute("checkerboard");
8179
8247
  }
8180
8248
  }
8181
8249
  }
@@ -8183,6 +8251,238 @@ class FigImage extends HTMLElement {
8183
8251
  }
8184
8252
  customElements.define("fig-image", FigImage);
8185
8253
 
8254
+ /* File Upload Input */
8255
+ class FigInputFile extends HTMLElement {
8256
+ static observedAttributes = ["accepts", "label", "disabled", "multiple", "variant", "url"];
8257
+
8258
+ #fileInput = null;
8259
+ #filenameEl = null;
8260
+ #clearBtn = null;
8261
+ #tooltipEl = null;
8262
+ #uploadBtn = null;
8263
+ #files = null;
8264
+
8265
+ get files() {
8266
+ return this.#files;
8267
+ }
8268
+
8269
+ get #urlFilename() {
8270
+ const url = this.getAttribute("url");
8271
+ if (!url) return "";
8272
+ try {
8273
+ const path = new URL(url, location.href).pathname;
8274
+ const name = path.split("/").pop();
8275
+ return name ? decodeURIComponent(name) : url;
8276
+ } catch {
8277
+ return url;
8278
+ }
8279
+ }
8280
+
8281
+ get value() {
8282
+ if (this.#files && this.#files.length > 0) {
8283
+ if (this.#files.length === 1) return this.#files[0].name;
8284
+ return `${this.#files.length} files`;
8285
+ }
8286
+ return this.#urlFilename;
8287
+ }
8288
+
8289
+ connectedCallback() {
8290
+ this.#render();
8291
+ this.addEventListener("dragover", this.#onDragOver);
8292
+ this.addEventListener("dragleave", this.#onDragLeave);
8293
+ this.addEventListener("drop", this.#onDrop);
8294
+ }
8295
+
8296
+ disconnectedCallback() {
8297
+ this.#fileInput?.removeEventListener("change", this.#onFileChange);
8298
+ this.#clearBtn?.removeEventListener("click", this.#onClear);
8299
+ this.removeEventListener("dragover", this.#onDragOver);
8300
+ this.removeEventListener("dragleave", this.#onDragLeave);
8301
+ this.removeEventListener("drop", this.#onDrop);
8302
+ }
8303
+
8304
+ attributeChangedCallback(name, oldValue, newValue) {
8305
+ if (oldValue === newValue) return;
8306
+ this.#render();
8307
+ }
8308
+
8309
+ clear() {
8310
+ this.#files = null;
8311
+ if (this.#fileInput) this.#fileInput.value = "";
8312
+ this.removeAttribute("url");
8313
+ this.#render();
8314
+ this.#emitEvents();
8315
+ }
8316
+
8317
+ #emitEvents() {
8318
+ const detail = { files: this.#files };
8319
+ this.dispatchEvent(new CustomEvent("input", { detail, bubbles: true }));
8320
+ this.dispatchEvent(new CustomEvent("change", { detail, bubbles: true }));
8321
+ }
8322
+
8323
+ #onFileChange = () => {
8324
+ if (this.#fileInput.files.length > 0) {
8325
+ this.#files = this.#fileInput.files;
8326
+ this.removeAttribute("url");
8327
+ this.#render();
8328
+ this.#emitEvents();
8329
+ }
8330
+ };
8331
+
8332
+ #onClear = (e) => {
8333
+ e.stopPropagation();
8334
+ this.clear();
8335
+ };
8336
+
8337
+ #onDragOver = (e) => {
8338
+ e.preventDefault();
8339
+ this.setAttribute("dragover", "");
8340
+ };
8341
+
8342
+ #onDragLeave = () => {
8343
+ this.removeAttribute("dragover");
8344
+ };
8345
+
8346
+ #onDrop = (e) => {
8347
+ e.preventDefault();
8348
+ this.removeAttribute("dragover");
8349
+ if (
8350
+ this.hasAttribute("disabled") &&
8351
+ this.getAttribute("disabled") !== "false"
8352
+ )
8353
+ return;
8354
+
8355
+ const accepts = this.getAttribute("accepts");
8356
+ let dropped = Array.from(e.dataTransfer.files);
8357
+ if (accepts) {
8358
+ const allowed = accepts.split(",").map((s) => s.trim().toLowerCase());
8359
+ dropped = dropped.filter((file) => {
8360
+ const ext = "." + file.name.split(".").pop().toLowerCase();
8361
+ const mime = file.type.toLowerCase();
8362
+ return allowed.some(
8363
+ (a) =>
8364
+ a === ext ||
8365
+ a === mime ||
8366
+ (a.endsWith("/*") && mime.startsWith(a.slice(0, -1))),
8367
+ );
8368
+ });
8369
+ }
8370
+ if (!this.hasAttribute("multiple")) {
8371
+ dropped = dropped.slice(0, 1);
8372
+ }
8373
+ if (dropped.length === 0) return;
8374
+
8375
+ const dt = new DataTransfer();
8376
+ dropped.forEach((f) => dt.items.add(f));
8377
+ this.#files = dt.files;
8378
+ if (this.#fileInput) {
8379
+ this.#fileInput.files = dt.files;
8380
+ }
8381
+ this.removeAttribute("url");
8382
+ this.#render();
8383
+ this.#emitEvents();
8384
+ };
8385
+
8386
+ #render() {
8387
+ const accepts = this.getAttribute("accepts") || "";
8388
+ const label = this.getAttribute("label") || "Upload";
8389
+ const disabled =
8390
+ this.hasAttribute("disabled") &&
8391
+ this.getAttribute("disabled") !== "false";
8392
+ const multiple = this.hasAttribute("multiple");
8393
+ const variant = this.getAttribute("variant") || "input";
8394
+ const hasFile = (this.#files && this.#files.length > 0) || !!this.getAttribute("url");
8395
+
8396
+ this.innerHTML = "";
8397
+
8398
+ if (hasFile) {
8399
+ const tooltipText = accepts
8400
+ ? `Accepts ${accepts
8401
+ .split(",")
8402
+ .map((s) => s.trim())
8403
+ .join(", ")}`
8404
+ : "";
8405
+
8406
+ this.#uploadBtn = document.createElement("fig-button");
8407
+ this.#uploadBtn.setAttribute("variant", variant);
8408
+ this.#uploadBtn.setAttribute("type", "upload");
8409
+ this.#uploadBtn.className = "fig-input-file-filename";
8410
+ if (disabled) this.#uploadBtn.setAttribute("disabled", "");
8411
+ const truncEl = document.createElement("fig-truncate");
8412
+ truncEl.setAttribute("position", "middle");
8413
+ truncEl.setAttribute("tooltip", "");
8414
+ const filename = this.value;
8415
+ const dotIdx = filename.lastIndexOf(".");
8416
+ if (dotIdx > 0) truncEl.setAttribute("tail", filename.slice(dotIdx));
8417
+ truncEl.textContent = filename;
8418
+ this.#uploadBtn.appendChild(truncEl);
8419
+
8420
+ this.#fileInput = document.createElement("input");
8421
+ this.#fileInput.type = "file";
8422
+ this.#fileInput.title = "";
8423
+ if (accepts) this.#fileInput.setAttribute("accept", accepts);
8424
+ if (multiple) this.#fileInput.setAttribute("multiple", "");
8425
+ this.#fileInput.addEventListener("change", this.#onFileChange);
8426
+ this.#uploadBtn.appendChild(this.#fileInput);
8427
+
8428
+ if (tooltipText) {
8429
+ this.#tooltipEl = document.createElement("fig-tooltip");
8430
+ this.#tooltipEl.setAttribute("text", tooltipText);
8431
+ this.#tooltipEl.appendChild(this.#uploadBtn);
8432
+ this.appendChild(this.#tooltipEl);
8433
+ } else {
8434
+ this.appendChild(this.#uploadBtn);
8435
+ }
8436
+
8437
+ const clearTooltip = document.createElement("fig-tooltip");
8438
+ clearTooltip.setAttribute("text", "Remove");
8439
+ this.#clearBtn = document.createElement("fig-button");
8440
+ this.#clearBtn.setAttribute("variant", "ghost");
8441
+ this.#clearBtn.setAttribute("icon", "true");
8442
+ this.#clearBtn.className = "fig-input-file-clear";
8443
+ if (disabled) this.#clearBtn.setAttribute("disabled", "");
8444
+ this.#clearBtn.innerHTML = `<span class="fig-mask-icon" style="--icon: var(--icon-minus);"></span>`;
8445
+ this.#clearBtn.addEventListener("click", this.#onClear);
8446
+ clearTooltip.appendChild(this.#clearBtn);
8447
+ this.appendChild(clearTooltip);
8448
+ } else {
8449
+ const tooltipText = accepts
8450
+ ? `Accepts ${accepts
8451
+ .split(",")
8452
+ .map((s) => s.trim())
8453
+ .join(", ")}`
8454
+ : "";
8455
+
8456
+ if (tooltipText) {
8457
+ this.#tooltipEl = document.createElement("fig-tooltip");
8458
+ this.#tooltipEl.setAttribute("text", tooltipText);
8459
+ }
8460
+
8461
+ this.#uploadBtn = document.createElement("fig-button");
8462
+ this.#uploadBtn.setAttribute("variant", variant);
8463
+ this.#uploadBtn.setAttribute("type", "upload");
8464
+ this.#uploadBtn.textContent = label;
8465
+ if (disabled) this.#uploadBtn.setAttribute("disabled", "");
8466
+
8467
+ this.#fileInput = document.createElement("input");
8468
+ this.#fileInput.type = "file";
8469
+ this.#fileInput.title = "";
8470
+ if (accepts) this.#fileInput.setAttribute("accept", accepts);
8471
+ if (multiple) this.#fileInput.setAttribute("multiple", "");
8472
+ this.#fileInput.addEventListener("change", this.#onFileChange);
8473
+ this.#uploadBtn.appendChild(this.#fileInput);
8474
+
8475
+ if (this.#tooltipEl) {
8476
+ this.#tooltipEl.appendChild(this.#uploadBtn);
8477
+ this.appendChild(this.#tooltipEl);
8478
+ } else {
8479
+ this.appendChild(this.#uploadBtn);
8480
+ }
8481
+ }
8482
+ }
8483
+ }
8484
+ customElements.define("fig-input-file", FigInputFile);
8485
+
8186
8486
  /**
8187
8487
  * A bezier / spring easing curve editor with draggable control points.
8188
8488
  * @attr {string} value - Bezier: "0.42, 0, 0.58, 1" or Spring: "spring(200, 15, 1)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogieking/figui3",
3
- "version": "3.22.0",
3
+ "version": "4.0.0",
4
4
  "description": "A lightweight web components library for building Figma plugin and widget UIs with native look and feel",
5
5
  "author": "Rogie King",
6
6
  "license": "MIT",