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