diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index 204b8ea..1709ea3 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.13.0] - 2026-05-22
+
+### Added
+- Files module (EXTENDED): `provideFiles(config?)` factory with `FILES_CONFIG` injection token
+- `FileValidationService` — validates file size, MIME type, and extension with configurable rules
+- `FilePickerService` — programmatic native file picker via hidden ``
+- `FileUploadService` — HTTP POST upload with real-time progress tracking via `Observable`
+- `FileDownloadService` — triggers browser downloads from URLs or in-memory Blobs
+- `FilePreviewService` — blob URL lifecycle management with `createBlobUrl()`, `revokeBlobUrl()`, `revokeAll()`
+- `FileExportService` — orchestrates tabular data export (CSV/PDF/Excel) with automatic format selection and download
+- `exportToCsv()` — pure JS CSV exporter with UTF-8 BOM, configurable separators, field escaping
+- `exportToPdf()` — PDF exporter via dynamic `import('jspdf')` with title, autoTable layout, column widths
+- `exportToExcel()` — Excel exporter via dynamic `import('exceljs')` with styled headers, sheet naming
+- Types: `ExportFormat`, `ExportConfig`, `ExportColumn`, `UploadProgress`, `FileValidationConfig`, `ValidationResult`, `FilesConfig`
+- Optional peer dependencies: `jspdf >=2.5.0`, `jspdf-autotable >=3.8.0`, `exceljs >=4.4.0`
+
## [0.12.0] - 2026-05-20
### Added
diff --git a/packages/core/eslint.config.mjs b/packages/core/eslint.config.mjs
index 1e626ce..e894107 100644
--- a/packages/core/eslint.config.mjs
+++ b/packages/core/eslint.config.mjs
@@ -19,6 +19,9 @@ export default [
'@angular/common',
'@angular/router',
'rxjs',
+ 'jspdf',
+ 'jspdf-autotable',
+ 'exceljs',
],
},
],
diff --git a/packages/core/package.json b/packages/core/package.json
index d541bdd..4132f7b 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@fireflyframework/core",
- "version": "0.12.0",
+ "version": "0.13.0",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
@@ -16,12 +16,24 @@
"rxjs": "~7.8.0",
"@jsverse/transloco": "^8.3.0",
"@fireflyframework/utils": ">=0.1.0",
- "@jsverse/transloco-messageformat": "^8.3.0"
+ "@jsverse/transloco-messageformat": "^8.3.0",
+ "jspdf": ">=2.5.0",
+ "jspdf-autotable": ">=3.8.0",
+ "exceljs": ">=4.4.0"
},
"sideEffects": false,
"peerDependenciesMeta": {
"@jsverse/transloco-messageformat": {
"optional": true
+ },
+ "jspdf": {
+ "optional": true
+ },
+ "jspdf-autotable": {
+ "optional": true
+ },
+ "exceljs": {
+ "optional": true
}
}
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 8eb002b..54d97a6 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -13,3 +13,4 @@ export * from './lib/feature-flags';
export * from './lib/storage';
export * from './lib/environment';
export * from './lib/security';
+export * from './lib/files';
diff --git a/packages/core/src/lib/files/exporters/csv-exporter.spec.ts b/packages/core/src/lib/files/exporters/csv-exporter.spec.ts
new file mode 100644
index 0000000..cd7a620
--- /dev/null
+++ b/packages/core/src/lib/files/exporters/csv-exporter.spec.ts
@@ -0,0 +1,160 @@
+import { exportToCsv } from './csv-exporter';
+import { ExportConfig } from '../file.types';
+
+interface TestRow {
+ name: string;
+ email: string;
+ age: number;
+}
+
+const columns: ExportConfig['columns'] = [
+ { key: 'name', header: 'Name' },
+ { key: 'email', header: 'Email' },
+ { key: 'age', header: 'Age' },
+];
+
+function blobToText(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsText(blob);
+ });
+}
+
+describe('exportToCsv', () => {
+ const baseConfig: ExportConfig = {
+ format: 'csv',
+ filename: 'test',
+ columns,
+ };
+
+ it('should generate CSV with headers and data rows', async () => {
+ const data: TestRow[] = [
+ { name: 'Alice', email: 'alice@test.com', age: 30 },
+ { name: 'Bob', email: 'bob@test.com', age: 25 },
+ ];
+
+ const blob = exportToCsv(data, baseConfig);
+ const text = await blobToText(blob);
+
+ expect(text).toContain('Name,Email,Age');
+ expect(text).toContain('Alice,alice@test.com,30');
+ expect(text).toContain('Bob,bob@test.com,25');
+ });
+
+ it('should include UTF-8 BOM as first bytes', async () => {
+ const blob = exportToCsv([], baseConfig);
+ const buffer = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as ArrayBuffer);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsArrayBuffer(blob);
+ });
+ const bytes = new Uint8Array(buffer);
+ // UTF-8 BOM = EF BB BF
+ expect(bytes[0]).toBe(0xef);
+ expect(bytes[1]).toBe(0xbb);
+ expect(bytes[2]).toBe(0xbf);
+ });
+
+ it('should have correct MIME type', () => {
+ const blob = exportToCsv([], baseConfig);
+ expect(blob.type).toBe('text/csv;charset=utf-8');
+ });
+
+ it('should use comma as default separator', async () => {
+ const data: TestRow[] = [{ name: 'Alice', email: 'a@b.com', age: 30 }];
+ const blob = exportToCsv(data, baseConfig);
+ const text = await blobToText(blob);
+
+ const lines = text.split('\r\n');
+ expect(lines[0]).toContain('Name,Email,Age');
+ expect(lines[1]).toBe('Alice,a@b.com,30');
+ });
+
+ it('should support semicolon separator', async () => {
+ const config: ExportConfig = { ...baseConfig, csvSeparator: ';' };
+ const data: TestRow[] = [{ name: 'Alice', email: 'a@b.com', age: 30 }];
+ const blob = exportToCsv(data, config);
+ const text = await blobToText(blob);
+
+ const lines = text.split('\r\n');
+ expect(lines[0]).toContain('Name;Email;Age');
+ expect(lines[1]).toBe('Alice;a@b.com;30');
+ });
+
+ it('should support tab separator', async () => {
+ const config: ExportConfig = { ...baseConfig, csvSeparator: '\t' };
+ const data: TestRow[] = [{ name: 'Alice', email: 'a@b.com', age: 30 }];
+ const blob = exportToCsv(data, config);
+ const text = await blobToText(blob);
+
+ const lines = text.split('\r\n');
+ expect(lines[0]).toContain('Name\tEmail\tAge');
+ expect(lines[1]).toBe('Alice\ta@b.com\t30');
+ });
+
+ it('should escape fields containing the separator', async () => {
+ const data: TestRow[] = [{ name: 'Doe, Jane', email: 'j@b.com', age: 28 }];
+ const blob = exportToCsv(data, baseConfig);
+ const text = await blobToText(blob);
+
+ expect(text).toContain('"Doe, Jane"');
+ });
+
+ it('should escape fields containing double quotes', async () => {
+ const data: TestRow[] = [{ name: 'She said "hi"', email: 'a@b.com', age: 25 }];
+ const blob = exportToCsv(data, baseConfig);
+ const text = await blobToText(blob);
+
+ expect(text).toContain('"She said ""hi"""');
+ });
+
+ it('should escape fields containing newlines', async () => {
+ const data: TestRow[] = [{ name: 'Line1\nLine2', email: 'a@b.com', age: 25 }];
+ const blob = exportToCsv(data, baseConfig);
+ const text = await blobToText(blob);
+
+ expect(text).toContain('"Line1\nLine2"');
+ });
+
+ it('should handle null/undefined values as empty strings', async () => {
+ interface Partial { name: string; note: string | null }
+ const config: ExportConfig = {
+ format: 'csv',
+ filename: 'test',
+ columns: [
+ { key: 'name', header: 'Name' },
+ { key: 'note', header: 'Note' },
+ ],
+ };
+ const data = [{ name: 'Alice', note: null }] as Partial[];
+ const blob = exportToCsv(data, config);
+ const text = await blobToText(blob);
+
+ const lines = text.split('\r\n');
+ expect(lines[1]).toBe('Alice,');
+ });
+
+ it('should handle empty data array (headers only)', async () => {
+ const blob = exportToCsv([], baseConfig);
+ const text = await blobToText(blob);
+
+ const lines = text.split('\r\n');
+ expect(lines.length).toBe(1);
+ expect(lines[0]).toContain('Name,Email,Age');
+ });
+
+ it('should use CRLF line endings', async () => {
+ const data: TestRow[] = [
+ { name: 'Alice', email: 'a@b.com', age: 30 },
+ { name: 'Bob', email: 'b@b.com', age: 25 },
+ ];
+ const blob = exportToCsv(data, baseConfig);
+ const text = await blobToText(blob);
+
+ const crlfCount = (text.match(/\r\n/g) || []).length;
+ expect(crlfCount).toBe(2);
+ });
+});
diff --git a/packages/core/src/lib/files/exporters/csv-exporter.ts b/packages/core/src/lib/files/exporters/csv-exporter.ts
new file mode 100644
index 0000000..8b92b85
--- /dev/null
+++ b/packages/core/src/lib/files/exporters/csv-exporter.ts
@@ -0,0 +1,70 @@
+import { ExportConfig } from '../file.types';
+
+/** UTF-8 BOM for Excel compatibility. */
+const UTF8_BOM = '\uFEFF';
+
+/**
+ * Escape a CSV field value.
+ *
+ * If the value contains the separator, a double-quote, or a newline,
+ * it is wrapped in double-quotes with internal quotes escaped.
+ */
+function escapeField(value: string, separator: string): string {
+ if (
+ value.includes(separator) ||
+ value.includes('"') ||
+ value.includes('\n') ||
+ value.includes('\r')
+ ) {
+ return `"${value.replace(/"/g, '""')}"`;
+ }
+ return value;
+}
+
+/**
+ * Export tabular data to a CSV Blob.
+ *
+ * Pure JS implementation with no external dependencies. Supports
+ * configurable field separators and includes a UTF-8 BOM for
+ * Excel compatibility.
+ *
+ * @param data — Array of data objects to export.
+ * @param config — Export configuration with columns and optional csvSeparator.
+ * @returns A `Blob` with MIME type `text/csv;charset=utf-8`.
+ *
+ * @example
+ * ```typescript
+ * const blob = exportToCsv(users, {
+ * format: 'csv',
+ * filename: 'users',
+ * columns: [
+ * { key: 'name', header: 'Name' },
+ * { key: 'email', header: 'Email' },
+ * ],
+ * });
+ * ```
+ */
+export function exportToCsv(data: T[], config: ExportConfig): Blob {
+ const separator = config.csvSeparator ?? ',';
+ const columns = config.columns;
+
+ // Header row
+ const headerRow = columns
+ .map(col => escapeField(col.header, separator))
+ .join(separator);
+
+ // Data rows
+ const dataRows = data.map(row =>
+ columns
+ .map(col => {
+ const value = row[col.key];
+ const str = value == null ? '' : String(value);
+ return escapeField(str, separator);
+ })
+ .join(separator),
+ );
+
+ const csvContent = UTF8_BOM + [headerRow, ...dataRows].join('\r\n');
+
+ return new Blob([csvContent], { type: 'text/csv;charset=utf-8' });
+}
diff --git a/packages/core/src/lib/files/exporters/excel-exporter.spec.ts b/packages/core/src/lib/files/exporters/excel-exporter.spec.ts
new file mode 100644
index 0000000..813f09c
--- /dev/null
+++ b/packages/core/src/lib/files/exporters/excel-exporter.spec.ts
@@ -0,0 +1,151 @@
+import { ExportConfig } from '../file.types';
+
+interface TestRow {
+ name: string;
+ email: string;
+}
+
+const baseConfig: ExportConfig = {
+ format: 'xlsx',
+ filename: 'test',
+ columns: [
+ { key: 'name', header: 'Name' },
+ { key: 'email', header: 'Email' },
+ ],
+};
+
+// Shared mock state
+let mockWorksheet: any;
+let mockWorkbook: any;
+
+vi.mock('exceljs', () => {
+ function MockWorkbook() { return mockWorkbook; }
+ return { Workbook: MockWorkbook, default: { Workbook: MockWorkbook } };
+});
+
+describe('exportToExcel', () => {
+ beforeEach(() => {
+ mockWorksheet = {
+ columns: [],
+ getRow: vi.fn().mockReturnValue({
+ font: {},
+ fill: {},
+ alignment: {},
+ }),
+ addRow: vi.fn(),
+ };
+
+ mockWorkbook = {
+ addWorksheet: vi.fn().mockReturnValue(mockWorksheet),
+ title: undefined,
+ xlsx: {
+ writeBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(10)),
+ },
+ };
+ });
+
+ async function run(data: T[], config: ExportConfig) {
+ const { exportToExcel } = await import('./excel-exporter');
+ return exportToExcel(data, config);
+ }
+
+ it('should generate an XLSX blob', async () => {
+ const blob = await run([{ name: 'Alice', email: 'a@b.com' }], baseConfig);
+
+ expect(blob).toBeInstanceOf(Blob);
+ expect(blob.type).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
+ });
+
+ it('should create a worksheet with the default sheet name', async () => {
+ await run([], baseConfig);
+
+ expect(mockWorkbook.addWorksheet).toHaveBeenCalledWith('Sheet1');
+ });
+
+ it('should use custom sheet name when provided', async () => {
+ await run([], { ...baseConfig, sheetName: 'Users' });
+
+ expect(mockWorkbook.addWorksheet).toHaveBeenCalledWith('Users');
+ });
+
+ it('should set column definitions with headers and keys', async () => {
+ await run([], baseConfig);
+
+ expect(mockWorksheet.columns).toEqual([
+ { header: 'Name', key: 'name', width: 20 },
+ { header: 'Email', key: 'email', width: 20 },
+ ]);
+ });
+
+ it('should apply custom column widths', async () => {
+ const config: ExportConfig = {
+ ...baseConfig,
+ columns: [
+ { key: 'name', header: 'Name', width: 30 },
+ { key: 'email', header: 'Email', width: 40 },
+ ],
+ };
+ await run([], config);
+
+ expect(mockWorksheet.columns).toEqual([
+ { header: 'Name', key: 'name', width: 30 },
+ { header: 'Email', key: 'email', width: 40 },
+ ]);
+ });
+
+ it('should style the header row as bold', async () => {
+ const headerRow = { font: {}, fill: {}, alignment: {} };
+ mockWorksheet.getRow = vi.fn().mockReturnValue(headerRow);
+ await run([], baseConfig);
+
+ expect(mockWorksheet.getRow).toHaveBeenCalledWith(1);
+ expect(headerRow.font).toEqual({ bold: true });
+ });
+
+ it('should add data rows with string values', async () => {
+ const data: TestRow[] = [
+ { name: 'Alice', email: 'alice@test.com' },
+ { name: 'Bob', email: 'bob@test.com' },
+ ];
+ await run(data, baseConfig);
+
+ expect(mockWorksheet.addRow).toHaveBeenCalledTimes(2);
+ expect(mockWorksheet.addRow).toHaveBeenCalledWith({ name: 'Alice', email: 'alice@test.com' });
+ expect(mockWorksheet.addRow).toHaveBeenCalledWith({ name: 'Bob', email: 'bob@test.com' });
+ });
+
+ it('should handle null values as empty strings', async () => {
+ interface Partial { name: string; note: string | null }
+ const config: ExportConfig = {
+ format: 'xlsx', filename: 'test',
+ columns: [{ key: 'name', header: 'Name' }, { key: 'note', header: 'Note' }],
+ };
+ await run([{ name: 'Alice', note: null } as Partial], config);
+
+ expect(mockWorksheet.addRow).toHaveBeenCalledWith({ name: 'Alice', note: '' });
+ });
+
+ it('should set workbook title when provided', async () => {
+ await run([], { ...baseConfig, title: 'My Report' });
+
+ expect(mockWorkbook.title).toBe('My Report');
+ });
+
+ it('should not set workbook title when not provided', async () => {
+ await run([], baseConfig);
+
+ expect(mockWorkbook.title).toBeUndefined();
+ });
+
+ it('should call writeBuffer to generate the output', async () => {
+ await run([], baseConfig);
+
+ expect(mockWorkbook.xlsx.writeBuffer).toHaveBeenCalled();
+ });
+
+ it('should have descriptive error message for missing exceljs', () => {
+ const msg = 'ExcelExporter: "exceljs" is not installed. Install it with: npm install exceljs';
+ expect(msg).toMatch(/exceljs.*not installed/i);
+ expect(msg).toContain('npm install exceljs');
+ });
+});
diff --git a/packages/core/src/lib/files/exporters/excel-exporter.ts b/packages/core/src/lib/files/exporters/excel-exporter.ts
new file mode 100644
index 0000000..75fe6af
--- /dev/null
+++ b/packages/core/src/lib/files/exporters/excel-exporter.ts
@@ -0,0 +1,83 @@
+import { ExportConfig } from '../file.types';
+
+/**
+ * Export tabular data to an Excel (XLSX) Blob.
+ *
+ * Uses `exceljs` loaded via dynamic import for tree-shaking.
+ * If the package is not installed, a descriptive error is thrown.
+ *
+ * @param data — Array of data objects to export.
+ * @param config — Export configuration with columns, optional sheetName and title.
+ * @returns A `Promise` with MIME type for XLSX.
+ *
+ * @example
+ * ```typescript
+ * const blob = await exportToExcel(users, {
+ * format: 'xlsx',
+ * filename: 'users-report',
+ * title: 'User Report',
+ * sheetName: 'Users',
+ * columns: [
+ * { key: 'name', header: 'Name', width: 30 },
+ * { key: 'email', header: 'Email', width: 40 },
+ * ],
+ * });
+ * ```
+ */
+export async function exportToExcel(data: T[], config: ExportConfig): Promise {
+ let ExcelJS: any;
+
+ try {
+ // @ts-expect-error — optional peer dependency, resolved at runtime
+ ExcelJS = await import('exceljs');
+ } catch {
+ throw new Error(
+ 'ExcelExporter: "exceljs" is not installed. Install it with: npm install exceljs',
+ );
+ }
+
+ const Workbook = ExcelJS.Workbook ?? ExcelJS.default?.Workbook;
+ if (!Workbook) {
+ throw new Error('ExcelExporter: Could not resolve exceljs Workbook class.');
+ }
+
+ const workbook = new Workbook();
+ const sheetName = config.sheetName ?? 'Sheet1';
+ const worksheet = workbook.addWorksheet(sheetName);
+
+ // Column definitions
+ worksheet.columns = config.columns.map(col => ({
+ header: col.header,
+ key: col.key,
+ width: col.width ?? 20,
+ }));
+
+ // Style header row (bold + fill)
+ const headerRow = worksheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FF2980B9' },
+ };
+ headerRow.alignment = { vertical: 'middle' };
+
+ // Add data rows
+ for (const row of data) {
+ const values: Record = {};
+ for (const col of config.columns) {
+ const value = row[col.key];
+ values[col.key] = value == null ? '' : String(value);
+ }
+ worksheet.addRow(values);
+ }
+
+ // Title as sheet header (if provided)
+ if (config.title) {
+ workbook.title = config.title;
+ }
+
+ const buffer = await workbook.xlsx.writeBuffer();
+ const xlsxMime = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+ return new Blob([buffer], { type: xlsxMime });
+}
diff --git a/packages/core/src/lib/files/exporters/pdf-exporter.spec.ts b/packages/core/src/lib/files/exporters/pdf-exporter.spec.ts
new file mode 100644
index 0000000..2457ffe
--- /dev/null
+++ b/packages/core/src/lib/files/exporters/pdf-exporter.spec.ts
@@ -0,0 +1,127 @@
+import { ExportConfig } from '../file.types';
+
+interface TestRow {
+ name: string;
+ email: string;
+}
+
+const baseConfig: ExportConfig = {
+ format: 'pdf',
+ filename: 'test',
+ columns: [
+ { key: 'name', header: 'Name' },
+ { key: 'email', header: 'Email' },
+ ],
+};
+
+// Shared mock state — mutated by the factory, read by tests
+let mockDoc: any;
+let mockAutoTable: ReturnType;
+
+vi.mock('jspdf', () => {
+ function MockJsPDF() { return mockDoc; }
+ return { default: MockJsPDF, jsPDF: MockJsPDF };
+});
+
+vi.mock('jspdf-autotable', () => ({
+ default: (...args: any[]) => mockAutoTable(...args),
+}));
+
+describe('exportToPdf', () => {
+ beforeEach(() => {
+ mockDoc = {
+ setFontSize: vi.fn(),
+ text: vi.fn(),
+ output: vi.fn().mockReturnValue(new Blob(['%PDF'], { type: 'application/pdf' })),
+ };
+ mockAutoTable = vi.fn();
+ });
+
+ // Lazy import so mocks are in place
+ async function run(data: T[], config: ExportConfig) {
+ const { exportToPdf } = await import('./pdf-exporter');
+ return exportToPdf(data, config);
+ }
+
+ it('should generate a PDF blob', async () => {
+ const blob = await run([{ name: 'Alice', email: 'a@b.com' }], baseConfig);
+ expect(blob).toBeInstanceOf(Blob);
+ expect(blob.type).toBe('application/pdf');
+ });
+
+ it('should call autoTable with head and body', async () => {
+ await run([{ name: 'Alice', email: 'alice@test.com' }], baseConfig);
+
+ expect(mockAutoTable).toHaveBeenCalledTimes(1);
+ const args = mockAutoTable.mock.calls[0];
+ expect(args[0]).toBe(mockDoc);
+ expect(args[1].head).toEqual([['Name', 'Email']]);
+ expect(args[1].body).toEqual([['Alice', 'alice@test.com']]);
+ });
+
+ it('should add title when provided', async () => {
+ await run([], { ...baseConfig, title: 'My Report' });
+
+ expect(mockDoc.setFontSize).toHaveBeenCalledWith(16);
+ expect(mockDoc.text).toHaveBeenCalledWith('My Report', 14, 20);
+ });
+
+ it('should not add title when not provided', async () => {
+ await run([], baseConfig);
+
+ expect(mockDoc.setFontSize).not.toHaveBeenCalled();
+ expect(mockDoc.text).not.toHaveBeenCalled();
+ });
+
+ it('should set startY=30 after title', async () => {
+ await run([], { ...baseConfig, title: 'Report' });
+ expect(mockAutoTable.mock.calls[0][1].startY).toBe(30);
+ });
+
+ it('should set startY=14 when no title', async () => {
+ await run([], baseConfig);
+ expect(mockAutoTable.mock.calls[0][1].startY).toBe(14);
+ });
+
+ it('should apply column widths when specified', async () => {
+ const config: ExportConfig = {
+ ...baseConfig,
+ columns: [
+ { key: 'name', header: 'Name', width: 80 },
+ { key: 'email', header: 'Email', width: 120 },
+ ],
+ };
+ await run([], config);
+
+ expect(mockAutoTable.mock.calls[0][1].columnStyles).toEqual({
+ 0: { cellWidth: 80 },
+ 1: { cellWidth: 120 },
+ });
+ });
+
+ it('should handle null values as empty strings', async () => {
+ interface Partial { name: string; note: string | null }
+ const config: ExportConfig = {
+ format: 'pdf', filename: 'test',
+ columns: [{ key: 'name', header: 'Name' }, { key: 'note', header: 'Note' }],
+ };
+ await run([{ name: 'Alice', note: null } as Partial], config);
+
+ expect(mockAutoTable.mock.calls[0][1].body).toEqual([['Alice', '']]);
+ });
+
+ it('should have descriptive error message for missing jspdf', () => {
+ const msg = 'PdfExporter: "jspdf" is not installed. Install it with: npm install jspdf jspdf-autotable';
+ expect(msg).toMatch(/jspdf.*not installed/i);
+ });
+
+ it('should have descriptive error message for missing jspdf-autotable', () => {
+ const msg = 'PdfExporter: "jspdf-autotable" is not installed. Install it with: npm install jspdf-autotable';
+ expect(msg).toMatch(/jspdf-autotable.*not installed/i);
+ });
+
+ it('should call doc.output with blob format', async () => {
+ await run([], baseConfig);
+ expect(mockDoc.output).toHaveBeenCalledWith('blob');
+ });
+});
diff --git a/packages/core/src/lib/files/exporters/pdf-exporter.ts b/packages/core/src/lib/files/exporters/pdf-exporter.ts
new file mode 100644
index 0000000..e8e885d
--- /dev/null
+++ b/packages/core/src/lib/files/exporters/pdf-exporter.ts
@@ -0,0 +1,87 @@
+import { ExportConfig } from '../file.types';
+
+/**
+ * Export tabular data to a PDF Blob.
+ *
+ * Uses `jspdf` and `jspdf-autotable` loaded via dynamic import for
+ * tree-shaking. If these packages are not installed, a descriptive
+ * error is thrown.
+ *
+ * @param data — Array of data objects to export.
+ * @param config — Export configuration with columns and optional title.
+ * @returns A `Promise` with MIME type `application/pdf`.
+ *
+ * @example
+ * ```typescript
+ * const blob = await exportToPdf(users, {
+ * format: 'pdf',
+ * filename: 'users-report',
+ * title: 'User Report',
+ * columns: [
+ * { key: 'name', header: 'Name', width: 80 },
+ * { key: 'email', header: 'Email', width: 120 },
+ * ],
+ * });
+ * ```
+ */
+export async function exportToPdf(data: T[], config: ExportConfig): Promise {
+ let jsPDF: any;
+ let autoTable: any;
+
+ try {
+ // @ts-expect-error — optional peer dependency, resolved at runtime
+ const jspdfModule = await import('jspdf');
+ jsPDF = jspdfModule.default ?? jspdfModule.jsPDF;
+ } catch {
+ throw new Error(
+ 'PdfExporter: "jspdf" is not installed. Install it with: npm install jspdf jspdf-autotable',
+ );
+ }
+
+ try {
+ // @ts-expect-error — optional peer dependency, resolved at runtime
+ const autoTableModule = await import('jspdf-autotable');
+ autoTable = autoTableModule.default ?? autoTableModule;
+ } catch {
+ throw new Error(
+ 'PdfExporter: "jspdf-autotable" is not installed. Install it with: npm install jspdf-autotable',
+ );
+ }
+
+ const doc = new jsPDF();
+
+ // Title
+ if (config.title) {
+ doc.setFontSize(16);
+ doc.text(config.title, 14, 20);
+ }
+
+ // Table
+ const startY = config.title ? 30 : 14;
+ const head = [config.columns.map(col => col.header)];
+ const body = data.map(row =>
+ config.columns.map(col => {
+ const value = row[col.key];
+ return value == null ? '' : String(value);
+ }),
+ );
+
+ const columnStyles: Record = {};
+ config.columns.forEach((col, i) => {
+ if (col.width) {
+ columnStyles[i] = { cellWidth: col.width };
+ }
+ });
+
+ autoTable(doc, {
+ startY,
+ head,
+ body,
+ columnStyles: Object.keys(columnStyles).length > 0 ? columnStyles : undefined,
+ styles: { fontSize: 10 },
+ headStyles: { fillColor: [41, 128, 185] },
+ margin: { top: 14 },
+ });
+
+ return doc.output('blob');
+}
diff --git a/packages/core/src/lib/files/file-download.service.spec.ts b/packages/core/src/lib/files/file-download.service.spec.ts
new file mode 100644
index 0000000..ddc8016
--- /dev/null
+++ b/packages/core/src/lib/files/file-download.service.spec.ts
@@ -0,0 +1,125 @@
+import { TestBed } from '@angular/core/testing';
+import { DOCUMENT } from '@angular/common';
+import { FileDownloadService } from './file-download.service';
+
+describe('FileDownloadService', () => {
+ let svc: FileDownloadService;
+ let mockAnchor: {
+ href: string;
+ download: string;
+ style: { display: string };
+ click: ReturnType;
+ };
+ let mockDoc: {
+ createElement: ReturnType;
+ body: { appendChild: ReturnType; removeChild: ReturnType };
+ };
+
+ beforeEach(() => {
+ mockAnchor = {
+ href: '',
+ download: '',
+ style: { display: '' },
+ click: vi.fn(),
+ };
+
+ mockDoc = {
+ createElement: vi.fn().mockReturnValue(mockAnchor),
+ body: {
+ appendChild: vi.fn(),
+ removeChild: vi.fn(),
+ },
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ FileDownloadService,
+ { provide: DOCUMENT, useValue: mockDoc },
+ ],
+ });
+
+ svc = TestBed.inject(FileDownloadService);
+ });
+
+ // -------------------------------------------------------------------
+ // download()
+ // -------------------------------------------------------------------
+ describe('download()', () => {
+ it('should create an anchor element', () => {
+ svc.download('/api/file/1', 'report.pdf');
+ expect(mockDoc.createElement).toHaveBeenCalledWith('a');
+ });
+
+ it('should set href and download attributes', () => {
+ svc.download('/api/file/1', 'report.pdf');
+ expect(mockAnchor.href).toBe('/api/file/1');
+ expect(mockAnchor.download).toBe('report.pdf');
+ });
+
+ it('should hide the anchor element', () => {
+ svc.download('/api/file/1', 'report.pdf');
+ expect(mockAnchor.style.display).toBe('none');
+ });
+
+ it('should append anchor to body, click it, and remove it', () => {
+ svc.download('/api/file/1', 'report.pdf');
+
+ expect(mockDoc.body.appendChild).toHaveBeenCalledWith(mockAnchor);
+ expect(mockAnchor.click).toHaveBeenCalled();
+ expect(mockDoc.body.removeChild).toHaveBeenCalledWith(mockAnchor);
+
+ // Verify order: append -> click -> remove
+ const appendOrder = mockDoc.body.appendChild.mock.invocationCallOrder[0];
+ const clickOrder = mockAnchor.click.mock.invocationCallOrder[0];
+ const removeOrder = mockDoc.body.removeChild.mock.invocationCallOrder[0];
+ expect(appendOrder).toBeLessThan(clickOrder);
+ expect(clickOrder).toBeLessThan(removeOrder);
+ });
+ });
+
+ // -------------------------------------------------------------------
+ // downloadBlob()
+ // -------------------------------------------------------------------
+ describe('downloadBlob()', () => {
+ let createObjectURLSpy: ReturnType;
+ let revokeObjectURLSpy: ReturnType;
+
+ beforeEach(() => {
+ createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url');
+ revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => { /* noop */ });
+ });
+
+ afterEach(() => {
+ createObjectURLSpy.mockRestore();
+ revokeObjectURLSpy.mockRestore();
+ });
+
+ it('should create a blob URL from the blob', () => {
+ const blob = new Blob(['test'], { type: 'text/plain' });
+ svc.downloadBlob(blob, 'test.txt');
+ expect(createObjectURLSpy).toHaveBeenCalledWith(blob);
+ });
+
+ it('should download using the blob URL', () => {
+ const blob = new Blob(['test'], { type: 'text/plain' });
+ svc.downloadBlob(blob, 'test.txt');
+ expect(mockAnchor.href).toBe('blob:mock-url');
+ expect(mockAnchor.download).toBe('test.txt');
+ });
+
+ it('should revoke the blob URL after download', () => {
+ const blob = new Blob(['test'], { type: 'text/plain' });
+ svc.downloadBlob(blob, 'test.txt');
+ expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url');
+ });
+
+ it('should revoke after clicking (correct order)', () => {
+ const blob = new Blob(['test'], { type: 'text/plain' });
+ svc.downloadBlob(blob, 'test.txt');
+
+ const clickOrder = mockAnchor.click.mock.invocationCallOrder[0];
+ const revokeOrder = revokeObjectURLSpy.mock.invocationCallOrder[0];
+ expect(clickOrder).toBeLessThan(revokeOrder);
+ });
+ });
+});
diff --git a/packages/core/src/lib/files/file-download.service.ts b/packages/core/src/lib/files/file-download.service.ts
new file mode 100644
index 0000000..0936506
--- /dev/null
+++ b/packages/core/src/lib/files/file-download.service.ts
@@ -0,0 +1,61 @@
+import { Injectable, inject } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
+
+/**
+ * Triggers file downloads programmatically.
+ *
+ * Uses the "hidden anchor + click" trick to initiate browser downloads
+ * from URLs or in-memory Blobs. Blob URLs are revoked after download
+ * to prevent memory leaks.
+ *
+ * @example
+ * ```typescript
+ * const dl = inject(FileDownloadService);
+ *
+ * // Download from URL
+ * dl.download('/api/files/123', 'report.pdf');
+ *
+ * // Download in-memory blob
+ * const blob = new Blob(['hello'], { type: 'text/plain' });
+ * dl.downloadBlob(blob, 'greeting.txt');
+ * ```
+ */
+@Injectable()
+export class FileDownloadService {
+ private readonly doc = inject(DOCUMENT);
+
+ /**
+ * Download a file from a URL.
+ *
+ * Creates a hidden `` element with the download attribute,
+ * clicks it, and removes it from the DOM.
+ *
+ * @param url — URL to download from.
+ * @param filename — Suggested filename for the download.
+ */
+ download(url: string, filename: string): void {
+ const anchor = this.doc.createElement('a');
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.style.display = 'none';
+
+ this.doc.body.appendChild(anchor);
+ anchor.click();
+ this.doc.body.removeChild(anchor);
+ }
+
+ /**
+ * Download an in-memory Blob as a file.
+ *
+ * Creates a temporary blob URL, triggers the download, then
+ * revokes the URL to free memory.
+ *
+ * @param blob — The Blob or File to download.
+ * @param filename — Suggested filename for the download.
+ */
+ downloadBlob(blob: Blob, filename: string): void {
+ const url = URL.createObjectURL(blob);
+ this.download(url, filename);
+ URL.revokeObjectURL(url);
+ }
+}
diff --git a/packages/core/src/lib/files/file-export.service.spec.ts b/packages/core/src/lib/files/file-export.service.spec.ts
new file mode 100644
index 0000000..4b57ac8
--- /dev/null
+++ b/packages/core/src/lib/files/file-export.service.spec.ts
@@ -0,0 +1,153 @@
+import { TestBed } from '@angular/core/testing';
+import { FileExportService } from './file-export.service';
+import { FileDownloadService } from './file-download.service';
+import { ExportConfig } from './file.types';
+
+// ---------------------------------------------------------------------------
+// Mock the three exporter modules
+// ---------------------------------------------------------------------------
+let mockCsvBlob: Blob;
+let mockPdfBlob: Blob;
+let mockExcelBlob: Blob;
+
+vi.mock('./exporters/csv-exporter', () => ({
+ exportToCsv: (...args: any[]) => mockCsvBlob,
+}));
+
+vi.mock('./exporters/pdf-exporter', () => ({
+ exportToPdf: (...args: any[]) => Promise.resolve(mockPdfBlob),
+}));
+
+vi.mock('./exporters/excel-exporter', () => ({
+ exportToExcel: (...args: any[]) => Promise.resolve(mockExcelBlob),
+}));
+
+interface TestRow {
+ name: string;
+ email: string;
+}
+
+const TEST_DATA: TestRow[] = [
+ { name: 'Alice', email: 'alice@test.com' },
+ { name: 'Bob', email: 'bob@test.com' },
+];
+
+function makeConfig(format: 'csv' | 'pdf' | 'xlsx'): ExportConfig {
+ return {
+ format,
+ filename: 'test-export',
+ columns: [
+ { key: 'name', header: 'Name' },
+ { key: 'email', header: 'Email' },
+ ],
+ };
+}
+
+describe('FileExportService', () => {
+ let svc: FileExportService;
+ let downloadSpy: ReturnType;
+
+ beforeEach(() => {
+ mockCsvBlob = new Blob(['csv-data'], { type: 'text/csv' });
+ mockPdfBlob = new Blob(['pdf-data'], { type: 'application/pdf' });
+ mockExcelBlob = new Blob(['excel-data'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+
+ downloadSpy = vi.fn();
+
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({
+ providers: [
+ FileExportService,
+ { provide: FileDownloadService, useValue: { downloadBlob: downloadSpy } },
+ ],
+ });
+
+ svc = TestBed.inject(FileExportService);
+ });
+
+ // -------------------------------------------------------------------
+ // export() — format routing
+ // -------------------------------------------------------------------
+ it('should export CSV and trigger download with .csv extension', async () => {
+ await svc.export(TEST_DATA, makeConfig('csv'));
+
+ expect(downloadSpy).toHaveBeenCalledOnce();
+ expect(downloadSpy).toHaveBeenCalledWith(mockCsvBlob, 'test-export.csv');
+ });
+
+ it('should export PDF and trigger download with .pdf extension', async () => {
+ await svc.export(TEST_DATA, makeConfig('pdf'));
+
+ expect(downloadSpy).toHaveBeenCalledOnce();
+ expect(downloadSpy).toHaveBeenCalledWith(mockPdfBlob, 'test-export.pdf');
+ });
+
+ it('should export Excel and trigger download with .xlsx extension', async () => {
+ await svc.export(TEST_DATA, makeConfig('xlsx'));
+
+ expect(downloadSpy).toHaveBeenCalledOnce();
+ expect(downloadSpy).toHaveBeenCalledWith(mockExcelBlob, 'test-export.xlsx');
+ });
+
+ // -------------------------------------------------------------------
+ // generateBlob() — returns blob without download
+ // -------------------------------------------------------------------
+ it('should generate CSV blob without triggering download', async () => {
+ const blob = await svc.generateBlob(TEST_DATA, makeConfig('csv'));
+
+ expect(blob).toBe(mockCsvBlob);
+ expect(downloadSpy).not.toHaveBeenCalled();
+ });
+
+ it('should generate PDF blob without triggering download', async () => {
+ const blob = await svc.generateBlob(TEST_DATA, makeConfig('pdf'));
+
+ expect(blob).toBe(mockPdfBlob);
+ expect(downloadSpy).not.toHaveBeenCalled();
+ });
+
+ it('should generate Excel blob without triggering download', async () => {
+ const blob = await svc.generateBlob(TEST_DATA, makeConfig('xlsx'));
+
+ expect(blob).toBe(mockExcelBlob);
+ expect(downloadSpy).not.toHaveBeenCalled();
+ });
+
+ // -------------------------------------------------------------------
+ // Error handling
+ // -------------------------------------------------------------------
+ it('should throw for unsupported format', async () => {
+ const config = { ...makeConfig('csv'), format: 'xml' as any };
+
+ await expect(svc.export(TEST_DATA, config)).rejects.toThrow(
+ /Unsupported export format "xml"/,
+ );
+ expect(downloadSpy).not.toHaveBeenCalled();
+ });
+
+ it('should include supported formats in error message', async () => {
+ const config = { ...makeConfig('csv'), format: 'html' as any };
+
+ await expect(svc.generateBlob(TEST_DATA, config)).rejects.toThrow(
+ /Supported: csv, pdf, xlsx/,
+ );
+ });
+
+ // -------------------------------------------------------------------
+ // Filename extension
+ // -------------------------------------------------------------------
+ it('should append .csv extension to filename', async () => {
+ await svc.export(TEST_DATA, { ...makeConfig('csv'), filename: 'report' });
+ expect(downloadSpy.mock.calls[0][1]).toBe('report.csv');
+ });
+
+ it('should append .pdf extension to filename', async () => {
+ await svc.export(TEST_DATA, { ...makeConfig('pdf'), filename: 'report' });
+ expect(downloadSpy.mock.calls[0][1]).toBe('report.pdf');
+ });
+
+ it('should append .xlsx extension to filename', async () => {
+ await svc.export(TEST_DATA, { ...makeConfig('xlsx'), filename: 'report' });
+ expect(downloadSpy.mock.calls[0][1]).toBe('report.xlsx');
+ });
+});
diff --git a/packages/core/src/lib/files/file-export.service.ts b/packages/core/src/lib/files/file-export.service.ts
new file mode 100644
index 0000000..9f10397
--- /dev/null
+++ b/packages/core/src/lib/files/file-export.service.ts
@@ -0,0 +1,75 @@
+import { Injectable, inject } from '@angular/core';
+import { FileDownloadService } from './file-download.service';
+import { ExportConfig, ExportFormat } from './file.types';
+import { exportToCsv } from './exporters/csv-exporter';
+import { exportToPdf } from './exporters/pdf-exporter';
+import { exportToExcel } from './exporters/excel-exporter';
+
+/** Maps export formats to file extensions. */
+const FORMAT_EXTENSIONS: Record = {
+ csv: '.csv',
+ pdf: '.pdf',
+ xlsx: '.xlsx',
+};
+
+/**
+ * Orchestrates tabular data export by selecting the correct exporter
+ * and delegating the download to `FileDownloadService`.
+ *
+ * @example
+ * ```typescript
+ * const exporter = inject(FileExportService);
+ * await exporter.export(users, {
+ * format: 'csv',
+ * filename: 'users-report',
+ * columns: [
+ * { key: 'name', header: 'Name' },
+ * { key: 'email', header: 'Email' },
+ * ],
+ * });
+ * ```
+ */
+@Injectable()
+export class FileExportService {
+ private readonly downloadService = inject(FileDownloadService);
+
+ /**
+ * Export data to a file and trigger browser download.
+ *
+ * Selects the exporter based on `config.format`, generates the Blob,
+ * and delegates download to `FileDownloadService.downloadBlob()`.
+ *
+ * @param data — Array of data objects to export.
+ * @param config — Export configuration (format, filename, columns, etc.).
+ */
+ async export(data: T[], config: ExportConfig): Promise {
+ const blob = await this.generateBlob(data, config);
+ const extension = FORMAT_EXTENSIONS[config.format];
+ const filename = config.filename + extension;
+ this.downloadService.downloadBlob(blob, filename);
+ }
+
+ /**
+ * Generate a Blob without triggering download.
+ *
+ * Useful when the caller needs the Blob for preview or further processing.
+ *
+ * @param data — Array of data objects to export.
+ * @param config — Export configuration.
+ * @returns The generated Blob.
+ */
+ async generateBlob(data: T[], config: ExportConfig): Promise {
+ switch (config.format) {
+ case 'csv':
+ return exportToCsv(data, config);
+ case 'pdf':
+ return exportToPdf(data, config);
+ case 'xlsx':
+ return exportToExcel(data, config);
+ default:
+ throw new Error(
+ `FileExportService: Unsupported export format "${config.format}". Supported: csv, pdf, xlsx.`,
+ );
+ }
+ }
+}
diff --git a/packages/core/src/lib/files/file-picker.service.spec.ts b/packages/core/src/lib/files/file-picker.service.spec.ts
new file mode 100644
index 0000000..7c4b2ed
--- /dev/null
+++ b/packages/core/src/lib/files/file-picker.service.spec.ts
@@ -0,0 +1,196 @@
+import { TestBed } from '@angular/core/testing';
+import { DOCUMENT } from '@angular/common';
+import { FilePickerService } from './file-picker.service';
+
+describe('FilePickerService', () => {
+ let mockInput: {
+ type: string;
+ accept: string;
+ multiple: boolean;
+ style: { display: string };
+ files: FileList | null;
+ click: ReturnType;
+ addEventListener: ReturnType;
+ removeEventListener: ReturnType;
+ parentNode: { removeChild: ReturnType } | null;
+ };
+ let listeners: Record void>;
+ let mockDoc: {
+ createElement: ReturnType;
+ body: { appendChild: ReturnType };
+ };
+
+ function setup() {
+ listeners = {};
+ mockInput = {
+ type: '',
+ accept: '',
+ multiple: false,
+ style: { display: '' },
+ files: null,
+ click: vi.fn(),
+ addEventListener: vi.fn((event: string, fn: (...args: unknown[]) => void) => {
+ listeners[event] = fn;
+ }),
+ removeEventListener: vi.fn(),
+ parentNode: { removeChild: vi.fn() },
+ };
+
+ mockDoc = {
+ createElement: vi.fn().mockReturnValue(mockInput),
+ body: { appendChild: vi.fn() },
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ FilePickerService,
+ { provide: DOCUMENT, useValue: mockDoc },
+ ],
+ });
+ return TestBed.inject(FilePickerService);
+ }
+
+ function createFileList(files: File[]): FileList {
+ const list = {
+ length: files.length,
+ item: (i: number) => files[i] ?? null,
+ [Symbol.iterator]: function* () { yield* files; },
+ } as unknown as FileList;
+ for (let i = 0; i < files.length; i++) {
+ (list as any)[i] = files[i];
+ }
+ return list;
+ }
+
+ // -------------------------------------------------------------------
+ // pickFile
+ // -------------------------------------------------------------------
+ it('should create a hidden file input and click it', async () => {
+ const svc = setup();
+ const promise = svc.pickFile();
+
+ expect(mockDoc.createElement).toHaveBeenCalledWith('input');
+ expect(mockInput.type).toBe('file');
+ expect(mockInput.style.display).toBe('none');
+ expect(mockDoc.body.appendChild).toHaveBeenCalledWith(mockInput);
+ expect(mockInput.click).toHaveBeenCalled();
+
+ // Simulate user selecting a file
+ const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
+ mockInput.files = createFileList([file]);
+ listeners['change']();
+
+ const result = await promise;
+ expect(result).toEqual([file]);
+ });
+
+ it('should set accept attribute when provided', async () => {
+ const svc = setup();
+ const promise = svc.pickFile('image/*');
+ mockInput.files = createFileList([]);
+ listeners['change']();
+ await promise;
+
+ expect(mockInput.accept).toBe('image/*');
+ });
+
+ it('should set multiple attribute when true', async () => {
+ const svc = setup();
+ const promise = svc.pickFile(undefined, true);
+ mockInput.files = createFileList([]);
+ listeners['change']();
+ await promise;
+
+ expect(mockInput.multiple).toBe(true);
+ });
+
+ it('should not set multiple when false or undefined', async () => {
+ const svc = setup();
+ const promise = svc.pickFile();
+ mockInput.files = createFileList([]);
+ listeners['change']();
+ await promise;
+
+ expect(mockInput.multiple).toBe(false);
+ });
+
+ it('should return multiple files when multiple is true', async () => {
+ const svc = setup();
+ const promise = svc.pickFile(undefined, true);
+
+ const f1 = new File(['a'], 'a.txt', { type: 'text/plain' });
+ const f2 = new File(['b'], 'b.txt', { type: 'text/plain' });
+ mockInput.files = createFileList([f1, f2]);
+ listeners['change']();
+
+ const result = await promise;
+ expect(result).toEqual([f1, f2]);
+ });
+
+ it('should return empty array when cancelled', async () => {
+ const svc = setup();
+ const promise = svc.pickFile();
+ listeners['cancel']();
+
+ const result = await promise;
+ expect(result).toEqual([]);
+ });
+
+ it('should return empty array when files is null on change', async () => {
+ const svc = setup();
+ const promise = svc.pickFile();
+ mockInput.files = null;
+ listeners['change']();
+
+ const result = await promise;
+ expect(result).toEqual([]);
+ });
+
+ it('should cleanup input after selection', async () => {
+ const svc = setup();
+ const promise = svc.pickFile();
+ mockInput.files = createFileList([]);
+ listeners['change']();
+ await promise;
+
+ expect(mockInput.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
+ expect(mockInput.removeEventListener).toHaveBeenCalledWith('cancel', expect.any(Function));
+ expect(mockInput.parentNode!.removeChild).toHaveBeenCalledWith(mockInput);
+ });
+
+ it('should cleanup input after cancel', async () => {
+ const svc = setup();
+ const promise = svc.pickFile();
+ listeners['cancel']();
+ await promise;
+
+ expect(mockInput.removeEventListener).toHaveBeenCalled();
+ });
+
+ // -------------------------------------------------------------------
+ // pickImage
+ // -------------------------------------------------------------------
+ it('should call pickFile with image/* and single mode', async () => {
+ const svc = setup();
+ const promise = svc.pickImage();
+
+ expect(mockInput.accept).toBe('image/*');
+ expect(mockInput.multiple).toBe(false);
+
+ const img = new File(['pixels'], 'photo.png', { type: 'image/png' });
+ mockInput.files = createFileList([img]);
+ listeners['change']();
+
+ const result = await promise;
+ expect(result).toEqual(img);
+ });
+
+ it('should return null from pickImage when cancelled', async () => {
+ const svc = setup();
+ const promise = svc.pickImage();
+ listeners['cancel']();
+
+ const result = await promise;
+ expect(result).toBeNull();
+ });
+});
diff --git a/packages/core/src/lib/files/file-picker.service.ts b/packages/core/src/lib/files/file-picker.service.ts
new file mode 100644
index 0000000..30892e8
--- /dev/null
+++ b/packages/core/src/lib/files/file-picker.service.ts
@@ -0,0 +1,70 @@
+import { Injectable, inject } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
+
+/**
+ * Opens the browser's native file picker programmatically.
+ *
+ * Creates a hidden `` element, triggers a click,
+ * and resolves with the selected files. The input is removed after use.
+ *
+ * @example
+ * ```typescript
+ * const files = await picker.pickFile('application/pdf,.pdf', true);
+ * const image = await picker.pickImage();
+ * ```
+ */
+@Injectable()
+export class FilePickerService {
+ private readonly doc = inject(DOCUMENT);
+
+ /**
+ * Open the native file picker dialog.
+ *
+ * @param accept — MIME types or extensions (e.g. `'image/*'`, `'.pdf,.docx'`)
+ * @param multiple — Allow selecting multiple files. Default: `false`.
+ * @returns Selected files, or empty array if cancelled.
+ */
+ pickFile(accept?: string, multiple?: boolean): Promise {
+ return new Promise(resolve => {
+ const input = this.doc.createElement('input') as HTMLInputElement;
+ input.type = 'file';
+ if (accept) input.accept = accept;
+ if (multiple) input.multiple = true;
+ input.style.display = 'none';
+
+ const cleanup = () => {
+ input.removeEventListener('change', onChange);
+ input.removeEventListener('cancel', onCancel);
+ if (input.parentNode) input.parentNode.removeChild(input);
+ };
+
+ const onChange = () => {
+ const files = input.files ? Array.from(input.files) : [];
+ cleanup();
+ resolve(files);
+ };
+
+ const onCancel = () => {
+ cleanup();
+ resolve([]);
+ };
+
+ input.addEventListener('change', onChange);
+ input.addEventListener('cancel', onCancel);
+
+ this.doc.body.appendChild(input);
+ input.click();
+ });
+ }
+
+ /**
+ * Open the native file picker filtered to images only.
+ *
+ * Shorthand for `pickFile('image/*', false)`.
+ * @returns The selected image, or `null` if cancelled.
+ */
+ async pickImage(): Promise {
+ const files = await this.pickFile('image/*', false);
+ return files[0] ?? null;
+ }
+}
diff --git a/packages/core/src/lib/files/file-preview.service.spec.ts b/packages/core/src/lib/files/file-preview.service.spec.ts
new file mode 100644
index 0000000..efcdf07
--- /dev/null
+++ b/packages/core/src/lib/files/file-preview.service.spec.ts
@@ -0,0 +1,176 @@
+import { TestBed } from '@angular/core/testing';
+import { DOCUMENT } from '@angular/common';
+import { FilePreviewService } from './file-preview.service';
+import { FILES_CONFIG } from './file.types';
+
+describe('FilePreviewService', () => {
+ let svc: FilePreviewService;
+ let mockWindow: { open: ReturnType };
+ let mockDoc: { defaultView: any };
+ let createObjectURLSpy: ReturnType;
+ let revokeObjectURLSpy: ReturnType;
+ let urlCounter: number;
+
+ function setup(config?: object) {
+ TestBed.resetTestingModule();
+
+ mockWindow = { open: vi.fn() };
+ mockDoc = { defaultView: mockWindow };
+
+ TestBed.configureTestingModule({
+ providers: [
+ FilePreviewService,
+ { provide: DOCUMENT, useValue: mockDoc },
+ ...(config ? [{ provide: FILES_CONFIG, useValue: config }] : []),
+ ],
+ });
+
+ svc = TestBed.inject(FilePreviewService);
+ }
+
+ beforeEach(() => {
+ urlCounter = 0;
+ createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockImplementation(() => {
+ return `blob:mock-url-${++urlCounter}`;
+ });
+ revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => { /* noop */ });
+ });
+
+ afterEach(() => {
+ createObjectURLSpy.mockRestore();
+ revokeObjectURLSpy.mockRestore();
+ });
+
+ // -------------------------------------------------------------------
+ // createBlobUrl()
+ // -------------------------------------------------------------------
+ describe('createBlobUrl()', () => {
+ it('should create a blob URL from a blob', () => {
+ setup();
+ const blob = new Blob(['test'], { type: 'text/plain' });
+ const url = svc.createBlobUrl(blob);
+
+ expect(createObjectURLSpy).toHaveBeenCalledWith(blob);
+ expect(url).toBe('blob:mock-url-1');
+ });
+
+ it('should track the created URL', () => {
+ setup();
+ const blob = new Blob(['test'], { type: 'text/plain' });
+ svc.createBlobUrl(blob);
+
+ expect(svc.activeUrlCount).toBe(1);
+ });
+ });
+
+ // -------------------------------------------------------------------
+ // revokeBlobUrl()
+ // -------------------------------------------------------------------
+ describe('revokeBlobUrl()', () => {
+ it('should revoke a blob URL', () => {
+ setup();
+ const blob = new Blob(['test'], { type: 'text/plain' });
+ const url = svc.createBlobUrl(blob);
+ svc.revokeBlobUrl(url);
+
+ expect(revokeObjectURLSpy).toHaveBeenCalledWith(url);
+ });
+
+ it('should remove the URL from tracking', () => {
+ setup();
+ const blob = new Blob(['test'], { type: 'text/plain' });
+ const url = svc.createBlobUrl(blob);
+ svc.revokeBlobUrl(url);
+
+ expect(svc.activeUrlCount).toBe(0);
+ });
+ });
+
+ // -------------------------------------------------------------------
+ // revokeAll()
+ // -------------------------------------------------------------------
+ describe('revokeAll()', () => {
+ it('should revoke all tracked URLs', () => {
+ setup();
+ const b1 = new Blob(['a'], { type: 'text/plain' });
+ const b2 = new Blob(['b'], { type: 'text/plain' });
+ svc.createBlobUrl(b1);
+ svc.createBlobUrl(b2);
+
+ svc.revokeAll();
+
+ expect(revokeObjectURLSpy).toHaveBeenCalledTimes(2);
+ expect(svc.activeUrlCount).toBe(0);
+ });
+ });
+
+ // -------------------------------------------------------------------
+ // previewInNewTab()
+ // -------------------------------------------------------------------
+ describe('previewInNewTab()', () => {
+ it('should create a blob URL and open it in a new tab', () => {
+ setup();
+ const blob = new Blob(['pdf content'], { type: 'application/pdf' });
+ const url = svc.previewInNewTab(blob);
+
+ expect(createObjectURLSpy).toHaveBeenCalled();
+ expect(mockWindow.open).toHaveBeenCalledWith(url, '_blank');
+ expect(url).toBe('blob:mock-url-1');
+ });
+
+ it('should track the URL for cleanup', () => {
+ setup();
+ const blob = new Blob(['content'], { type: 'text/plain' });
+ svc.previewInNewTab(blob);
+
+ expect(svc.activeUrlCount).toBe(1);
+ });
+
+ it('should create a new blob with override MIME type when provided', () => {
+ setup();
+ const blob = new Blob(['content'], { type: 'application/octet-stream' });
+ svc.previewInNewTab(blob, 'application/pdf');
+
+ // createObjectURL is called with a new Blob (with overridden type)
+ const calledBlob = createObjectURLSpy.mock.calls[0][0] as Blob;
+ expect(calledBlob.type).toBe('application/pdf');
+ });
+
+ it('should return null when enablePreview is false', () => {
+ setup({
+ maxFileSizeBytes: 10_000_000,
+ allowedMimeTypes: [],
+ maxConcurrentUploads: 5,
+ enablePreview: false,
+ });
+ const blob = new Blob(['content'], { type: 'text/plain' });
+ const result = svc.previewInNewTab(blob);
+
+ expect(result).toBeNull();
+ expect(mockWindow.open).not.toHaveBeenCalled();
+ });
+
+ it('should work when enablePreview is true', () => {
+ setup({
+ maxFileSizeBytes: 10_000_000,
+ allowedMimeTypes: [],
+ maxConcurrentUploads: 5,
+ enablePreview: true,
+ });
+ const blob = new Blob(['content'], { type: 'text/plain' });
+ const url = svc.previewInNewTab(blob);
+
+ expect(url).not.toBeNull();
+ expect(mockWindow.open).toHaveBeenCalled();
+ });
+
+ it('should work without FILES_CONFIG (preview enabled by default)', () => {
+ setup(); // no config
+ const blob = new Blob(['content'], { type: 'text/plain' });
+ const url = svc.previewInNewTab(blob);
+
+ expect(url).not.toBeNull();
+ expect(mockWindow.open).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/core/src/lib/files/file-preview.service.ts b/packages/core/src/lib/files/file-preview.service.ts
new file mode 100644
index 0000000..8a1d4cf
--- /dev/null
+++ b/packages/core/src/lib/files/file-preview.service.ts
@@ -0,0 +1,93 @@
+import { Injectable, inject } from '@angular/core';
+import { DOCUMENT } from '@angular/common';
+import { FILES_CONFIG } from './file.types';
+
+/**
+ * Manages file previews and blob URL lifecycle.
+ *
+ * Creates temporary blob URLs for displaying file content and opens
+ * previews in new browser tabs. Tracks active blob URLs and provides
+ * cleanup to prevent memory leaks.
+ *
+ * @example
+ * ```typescript
+ * const preview = inject(FilePreviewService);
+ *
+ * // Preview a blob in a new tab
+ * preview.previewInNewTab(blob, 'application/pdf');
+ *
+ * // Manual blob URL management
+ * const url = preview.createBlobUrl(blob);
+ * // ... use url in an
or