feat: add custom file input for images

This commit is contained in:
2026-02-07 23:00:12 -03:00
parent e803c670f4
commit 2c172dd3d1
4 changed files with 160 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
@if (imgUrl()) {
<div class="image-preview__container">
<img [src]="imgUrl()" [alt]="fileName()" width="300" height="300" />
</div>
}
<input
(change)="onFileSelected($event)"
#fileUpload
accept=".jpeg, .jpg, .png, .webp, .gif"
class="file-input"
type="file"
/>
<div class="file-upload">
<div class="file-upload__description" title="{{ fileName() }}">
{{ fileName() || 'common.no_file_yet' | translate | upperfirst }}
</div>
<button matMiniFab color="primary" class="upload-btn" (click)="fileUpload.click()">
<mat-icon>attach_file</mat-icon>
</button>
</div>

View File

@@ -0,0 +1,26 @@
.image-preview {
&__container {
display: flex;
justify-content: center;
margin-bottom: 16px;
}
}
.file-input {
display: none;
}
.file-upload {
align-items: center;
box-sizing: border-box;
display: inline-flex;
justify-content: space-between;
min-width: 196px;
width: 100%;
&__description {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@@ -0,0 +1,69 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ImageUploader } from './image-uploader';
import { provideTranslateService } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { FiletypeUtils } from '../../services/filetype-utils';
describe('ImageUploader', () => {
let component: ImageUploader;
let fixture: ComponentFixture<ImageUploader>;
let filetypeUtils: Partial<FiletypeUtils>;
beforeEach(async () => {
filetypeUtils = {
isValidImageMimeType: vi.fn().mockReturnValue(true)
};
await TestBed.configureTestingModule({
imports: [ImageUploader],
providers: [provideTranslateService(), { provide: FiletypeUtils, useValue: filetypeUtils }],
}).compileComponents();
fixture = TestBed.createComponent(ImageUploader);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should update selection when fileIn input changes', async () => {
const file = new File([''], 'test.png', { type: 'image/png' });
fixture.componentRef.setInput('fileIn', file);
fixture.detectChanges();
const descriptionEl = fixture.debugElement.query(By.css('.file-upload__description'));
expect((<HTMLDivElement>descriptionEl.nativeElement).textContent.toLowerCase().trim()).toEqual(
file.name,
);
});
describe('onFileSelected', () => {
it('should update and emit selection on valid file', () => {
const emitSpy = vi.spyOn(component.file, 'emit');
const file = new File([''], 'test.png', { type: 'image/png' });
const inputEvent = { target: { files: [file] } } as any;
component.onFileSelected(inputEvent);
fixture.detectChanges();
const descriptionEl = fixture.debugElement.query(By.css('.file-upload__description'));
expect(
(<HTMLDivElement>descriptionEl.nativeElement).textContent.toLowerCase().trim(),
).toEqual(file.name);
expect(emitSpy).toHaveBeenCalledOnce();
});
it('should discard file if not valid type', () => {
filetypeUtils.isValidImageMimeType = vi.fn().mockReturnValue(false);
const file = new File([''], 'test.txt', { type: 'text/txt' });
const inputEvent = { target: { files: [file] } } as any;
component.onFileSelected(inputEvent);
fixture.detectChanges();
const descriptionEl = fixture.debugElement.query(By.css('.file-upload__description'));
expect(
(<HTMLDivElement>descriptionEl.nativeElement).textContent.toLowerCase().trim(),
).not.toEqual(file.name);
});
});
});

View File

@@ -0,0 +1,45 @@
import { Component, effect, inject, input, output, signal } from '@angular/core';
import { MatMiniFabButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { UpperfirstPipe } from '../../pipes/upperfirst-pipe';
import { FiletypeUtils } from '../../services/filetype-utils';
@Component({
selector: 'app-image-uploader',
imports: [MatIcon, MatMiniFabButton, TranslatePipe, UpperfirstPipe],
templateUrl: './image-uploader.html',
styleUrl: './image-uploader.scss',
})
export class ImageUploader {
file = output<File>();
fileIn = input<File | null>(null);
protected readonly fileName = signal('');
protected readonly imgUrl = signal('');
private readonly filetypeUtils = inject(FiletypeUtils);
constructor() {
effect(() => {
if (this.fileIn()) {
const file = <File>this.fileIn();
this.updateSelection(file);
}
});
}
onFileSelected(e: Partial<Event>) {
const files = (<HTMLInputElement>e.target).files;
if (files) {
const file = files[0];
if (file && this.filetypeUtils.isValidImageMimeType(file.type)) {
this.updateSelection(file);
this.file.emit(file);
}
}
}
private updateSelection(file: File) {
this.fileName.set(file.name);
this.imgUrl.set(URL.createObjectURL(file));
}
}