import { css, html } from "lit";
import { BaseElement } from "../../core/base-element.ts";
import { type Cell } from "@commontools/runner";
import { createStringCellController } from "../../core/cell-controller.ts";
/**
* MIME type to file extension mapping
*/
const MIME_EXTENSIONS: Record = {
"application/json": "json",
"text/plain": "txt",
"text/csv": "csv",
"text/markdown": "md",
"text/html": "html",
"application/pdf": "pdf",
"image/png": "png",
"image/jpeg": "jpg",
"image/svg+xml": "svg",
"application/xml": "xml",
"application/octet-stream": "bin",
};
/**
* CTFileDownload - File download button with automatic visual feedback
*
* Triggers a file download from string data. The component encapsulates
* the blob/ObjectURL/anchor download pattern, allowing patterns to trigger
* downloads without directly accessing globalThis or browser DOM APIs.
*
* @element ct-file-download
*
* @property {string|Cell} data - Content to download (required)
* @property {string|Cell} filename - Download filename (auto-generated if not provided)
* @attr {string} mime-type - MIME type for the file (default: "application/octet-stream")
* @attr {boolean} base64 - If true, decode data as base64 before downloading (default: false)
* @attr {string} variant - Button style variant (default: "secondary")
* Options: "primary" | "secondary" | "destructive" | "outline" | "ghost" | "link" | "pill"
* @attr {string} size - Button size (default: "default")
* Options: "default" | "sm" | "lg" | "icon" | "md"
* @attr {boolean} disabled - Disable the button
* @attr {number} feedback-duration - Success feedback duration in ms (default: 2000)
* @attr {boolean} icon-only - Only show icon, no text (default: false)
*
* @fires ct-download-success - Fired when download succeeds
* Detail: { filename: string, size: number, mimeType: string }
* @fires ct-download-error - Fired when download fails
* Detail: { error: Error, filename: string }
*
* @slot - Button label text (optional, defaults based on state)
*
* @example
* // Basic usage
* Download
*
* // With Cell binding (in pattern)
* Export
*
* // Icon only
*
*
* // Base64 binary data
* Download Image
*/
export class CTFileDownload extends BaseElement {
static override styles = [
BaseElement.baseStyles,
css`
/* Ensure icon-only buttons maintain square aspect ratio */
:host([icon-only]) ct-button {
min-width: 2.25rem;
display: inline-flex;
}
/* Adjust for different sizes when icon-only */
:host([icon-only]) ct-button::part(button) {
aspect-ratio: 1;
min-width: fit-content;
}
`,
];
static override properties = {
data: { attribute: false },
filename: { attribute: false },
mimeType: { type: String, attribute: "mime-type" },
base64: { type: Boolean },
variant: { type: String },
size: { type: String },
disabled: { type: Boolean, reflect: true },
feedbackDuration: { type: Number, attribute: "feedback-duration" },
iconOnly: { type: Boolean, attribute: "icon-only", reflect: true },
};
declare data: Cell | string;
declare filename: Cell | string;
declare mimeType: string;
declare base64: boolean;
declare variant?:
| "primary"
| "secondary"
| "destructive"
| "outline"
| "ghost"
| "link"
| "pill";
declare size?: "default" | "sm" | "lg" | "icon" | "md";
declare disabled: boolean;
declare feedbackDuration: number;
declare iconOnly: boolean;
private _downloaded = false;
private _downloading = false;
private _resetTimeout?: ReturnType;
/** Maximum file size in bytes (100MB) */
private static readonly MAX_FILE_SIZE = 100 * 1024 * 1024;
/** Delay before revoking object URL to ensure download starts (ms) */
private static readonly URL_REVOKE_DELAY = 100;
/** CellController for data property */
private _dataController = createStringCellController(this, {
timing: { strategy: "immediate" },
});
/** CellController for filename property */
private _filenameController = createStringCellController(this, {
timing: { strategy: "immediate" },
});
constructor() {
super();
this.data = "";
this.filename = "";
this.mimeType = "application/octet-stream";
this.base64 = false;
this.variant = "secondary";
this.size = "default";
this.disabled = false;
this.feedbackDuration = 2000;
this.iconOnly = false;
}
/**
* Get the data value (string content to download)
*/
private _getDataValue(): string {
return this._dataController.getValue() ?? "";
}
/**
* Sanitize filename to remove potentially problematic characters
*/
private _sanitizeFilename(filename: string): string {
// Remove path traversal attempts and problematic characters
return filename
.replace(/\.\./g, "_") // No path traversal
// deno-lint-ignore no-control-regex
.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") // No special chars
.slice(0, 255); // Max filename length
}
/**
* Get the filename, auto-generating if not provided
*/
private _getFilename(): string {
const fn = this._filenameController.getValue();
if (fn) return this._sanitizeFilename(fn);
// Auto-generate filename with timestamp
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.slice(0, 19);
const ext = MIME_EXTENSIONS[this.mimeType] || "bin";
return `download-${timestamp}.${ext}`;
}
/**
* Create a Blob from the data, optionally decoding base64
*/
private _createBlob(data: string): Blob {
if (this.base64) {
try {
// Trim whitespace that may be present in base64 data from various sources
const binaryString = atob(data.trim());
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes], { type: this.mimeType });
} catch (e) {
throw new Error(`Invalid base64 data: ${(e as Error).message}`);
}
}
return new Blob([data], { type: this.mimeType });
}
override willUpdate(
changedProperties: Map,
) {
super.willUpdate(changedProperties);
// Bind CellControllers when properties change
if (changedProperties.has("data")) {
this._dataController.bind(this.data);
}
if (changedProperties.has("filename")) {
this._filenameController.bind(this.filename);
}
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this._resetTimeout) {
clearTimeout(this._resetTimeout);
}
}
private _handleClick(e: Event) {
e.preventDefault();
e.stopPropagation();
// Guard against rapid clicks and disabled state
if (this.disabled || this._downloading) return;
const data = this._getDataValue();
const filename = this._getFilename();
// Check for empty data
if (!data) {
this.emit("ct-download-error", {
error: new Error("No data to download"),
filename,
});
return;
}
// Set downloading state to prevent rapid clicks
this._downloading = true;
this.requestUpdate();
try {
// Create blob from data
const blob = this._createBlob(data);
// Check file size limit
if (blob.size > CTFileDownload.MAX_FILE_SIZE) {
throw new Error(
`File size (${
Math.round(blob.size / 1024 / 1024)
}MB) exceeds maximum allowed (${
Math.round(CTFileDownload.MAX_FILE_SIZE / 1024 / 1024)
}MB)`,
);
}
const url = URL.createObjectURL(blob);
// Create and trigger download via anchor element
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Delay URL revocation to ensure download starts in all browsers
setTimeout(
() => URL.revokeObjectURL(url),
CTFileDownload.URL_REVOKE_DELAY,
);
// Update state for visual feedback
this._downloaded = true;
this._downloading = false;
this.requestUpdate();
this.emit("ct-download-success", {
filename,
size: blob.size,
mimeType: this.mimeType,
});
// Reset downloaded state after duration
if (this._resetTimeout) {
clearTimeout(this._resetTimeout);
}
this._resetTimeout = setTimeout(() => {
this._downloaded = false;
this.requestUpdate();
}, this.feedbackDuration);
} catch (error) {
this._downloading = false;
this.requestUpdate();
this.emit("ct-download-error", {
error: error as Error,
filename,
});
}
}
override render() {
const title = this._downloading
? "Downloading..."
: this._downloaded
? "Downloaded!"
: "Download file";
const ariaLabel = this._downloading
? "Downloading file"
: this._downloaded
? "File downloaded"
: "Download file";
const hasData = !!this._getDataValue();
return html`
${this.iconOnly
? html`
${this._downloading
? "\u23F3"
: this._downloaded
? "\u2713"
: "\u2B07"}
`
: html`
${this._downloading
? "\u23F3 Downloading..."
: this._downloaded
? "\u2713 Downloaded!"
: "\u2B07 Download"}
`}
`;
}
}
globalThis.customElements.define("ct-file-download", CTFileDownload);