feat: add custom file input for images
This commit is contained in:
20
src/app/components/image-uploader/image-uploader.html
Normal file
20
src/app/components/image-uploader/image-uploader.html
Normal 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>
|
||||
26
src/app/components/image-uploader/image-uploader.scss
Normal file
26
src/app/components/image-uploader/image-uploader.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
69
src/app/components/image-uploader/image-uploader.spec.ts
Normal file
69
src/app/components/image-uploader/image-uploader.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
45
src/app/components/image-uploader/image-uploader.ts
Normal file
45
src/app/components/image-uploader/image-uploader.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user