Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
4ca25b6
fileupload: стилизация, сторисы, обёртки
Apr 22, 2026
7a40401
фикс вызова аплоадера; стилизация интерфейса после загрузки файла
Apr 22, 2026
a4c934a
messageCss восстановлен в map-tokens.ts
Apr 23, 2026
dbd76b0
Merge feature/styles-debug into file.upload, resolve conflicts
Jun 1, 2026
01dd0d0
исправлен импорт ExtraButtonComponent в fileupload
Jun 1, 2026
f4da1ae
отключён hover у dropzone в состоянии disabled
Jun 1, 2026
acb5a63
добавлена поддержка drag and drop на кастомный dropzone
Jun 1, 2026
4458850
реализован ControlValueAccessor, добавлена стори с реактивной формой
Jun 1, 2026
bf84438
кнопки удаления файлов и управления отправкой
Jun 1, 2026
1fa6c4e
удалён отладочный вывод статуса формы
Jun 2, 2026
1a94e87
@khaliulin валидация accept по MIME-типу, фильтрация файлов при drop
Jun 5, 2026
d676b2b
Merge branch 'feature/styles-debug' into file.upload
khaliulin Jun 9, 2026
f7f41e0
OnPush-стратегия и markForCheck вместо detectChanges в fileupload
Jun 24, 2026
884a155
Типизация событий fileupload через типы PrimeNG вместо any
Jun 24, 2026
e3d8720
Регистрация CSS компонента fileupload в map-tokens
Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,4 @@ src/assets/components/themes

.playwright-mcp/*


.claude/*
303 changes: 303 additions & 0 deletions src/lib/components/fileupload/fileupload.component.ts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Надо добавить стори с формой, где компонент используется как formControl

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MoskvaVoronezh реализован ControlValueAccessor, добавлена стори с реактивной формой 4458850

Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectorRef, ChangeDetectionStrategy, inject, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import {
FileUpload,
FileSelectEvent,
FileRemoveEvent,
FileUploadErrorEvent,
FileUploadHandlerEvent,
} from 'primeng/fileupload';
import { ProgressBar } from 'primeng/progressbar';
import { Message } from 'primeng/message';
import { PrimeTemplate } from 'primeng/api';
import { ExtraButtonComponent } from '../button/button.component';

// PrimeNG добавляет objectURL для превью в рантайме, но не типизирует его
type PreviewFile = File & { objectURL?: string };

@Component({
selector: 'fileupload',
standalone: true,
imports: [FileUpload, ProgressBar, Message, PrimeTemplate, ExtraButtonComponent],
host: { style: 'display: contents' },
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => FileUploadComponent), multi: true }],
template: `
<p-fileupload
#fuRef
[name]="name"
[url]="url"
[multiple]="multiple"
[accept]="accept"
[maxFileSize]="maxFileSize"
[fileLimit]="fileLimit"
[disabled]="disabled"
[customUpload]="true"
[auto]="false"
[invalidFileSizeMessageSummary]="invalidFileSizeMessageSummary"
[invalidFileSizeMessageDetail]="invalidFileSizeMessageDetail"
[invalidFileTypeMessageSummary]="invalidFileTypeMessageSummary"
[invalidFileTypeMessageDetail]="invalidFileTypeMessageDetail"
[invalidFileLimitMessageSummary]="invalidFileLimitMessageSummary"
[invalidFileLimitMessageDetail]="invalidFileLimitMessageDetail"
(onSelect)="onSelectedFiles($event)"
(uploadHandler)="onUploader($event)"
(onRemove)="onRemoveEvent.emit($event)"
(onClear)="onClearEvent.emit()"
(onError)="onError.emit($event)"
>
<ng-template pTemplate="header" let-uploadCallback="uploadCallback" let-clearCallback="clearCallback">
<div class="fu-header" [attr.data-ref]="storeCallbacks(uploadCallback, clearCallback)">
<div class="fu-dropzone"
[class.fu-dropzone--disabled]="disabled"
(click)="onChooseClick()"
(dragover)="$event.preventDefault()"
(drop)="onDrop($event)">
<i class="ti ti-upload fu-dropzone__icon"></i>
<div class="fu-dropzone__info">
<span class="fu-dropzone__title">{{ dropzoneTitle }}</span>
<span class="fu-dropzone__caption">
<i class="ti ti-info-circle"></i>
{{ dropzoneCaption }}
</span>
</div>
</div>
</div>
</ng-template>

<ng-template pTemplate="content"
let-removeFileCallback="removeFileCallback" let-removeUploadedFileCallback="removeUploadedFileCallback">
<div class="fu-content">
@if (isUploading) {
<p-progressBar [value]="totalSizePercent" [showValue]="false"></p-progressBar>
}
@if (uploadSuccess) {
<p-message severity="success" icon="ti ti-circle-check" [closable]="true" (onClose)="uploadSuccess = false">
Файлы успешно загружены
</p-message>
}
@if (selectedFiles.length > 0) {
<div class="fu-file-list">
@for (file of selectedFiles; track file.name + file.size; let i = $index) {
<div class="fu-file-card">
<div class="fu-file-card__wrap">
@if (isImage(file)) {
<img [src]="file.objectURL" [alt]="file.name" class="fu-file-card__thumbnail" />
} @else {
<i class="ti ti-file fu-file-card__icon"></i>
}
<div class="fu-file-card__info">
<span class="fu-file-card__name">{{ file.name }}</span>
<span class="fu-file-card__size">
<i class="ti ti-info-circle"></i>
{{ formatSize(file.size) }}
</span>
</div>
</div>
<extra-button icon="ti ti-trash" variant="text" [rounded]="true" size="small" [iconOnly]="true"
(click)="onRemoveFile(file, removeFileCallback, i)"></extra-button>
</div>
}
</div>
}
@if (uploadedFiles.length > 0) {
<div class="fu-file-list">
@for (file of uploadedFiles; track file.name + file.size; let i = $index) {
<div class="fu-file-card fu-file-card--uploaded">
<div class="fu-file-card__wrap">
<i class="ti ti-file-check fu-file-card__icon"></i>
<div class="fu-file-card__info">
<span class="fu-file-card__name">{{ file.name }}</span>
<span class="fu-file-card__size">Загружено</span>
</div>
</div>
<extra-button icon="ti ti-trash" variant="text" [rounded]="true" size="small" [iconOnly]="true"
(click)="removeUploadedFileCallback(i)"></extra-button>
</div>
}
</div>
}
@if (selectedFiles.length > 0 || uploadedFiles.length > 0) {
<div class="fu-footer">
<extra-button label="Отправить" [disabled]="!selectedFiles.length" (click)="uploadCb?.()"></extra-button>
<extra-button label="Очистить" severity="danger" variant="text"
[disabled]="!selectedFiles.length && !uploadedFiles.length" (click)="onClearUpload()"></extra-button>
</div>
}
</div>
</ng-template>
</p-fileupload>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FileUploadComponent implements ControlValueAccessor {
Comment thread
persi14 marked this conversation as resolved.
private el = inject(ElementRef);
private cdr = inject(ChangeDetectorRef);
@ViewChild('fuRef') fuRef!: FileUpload;

@Input() name = 'files[]';
@Input() url = '/api/upload';
@Input() multiple = true;
@Input() accept = 'image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
@Input() maxFileSize = 1000000;
@Input() fileLimit: number | undefined = undefined;
@Input() disabled = false;
@Input() dropzoneTitle = 'Чтобы загрузить файлы кликните или перетащите их в эту область';
@Input() dropzoneCaption = 'Можно загрузить не более 10 файлов размером 1 MB';

@Input() invalidFileSizeMessageSummary = '{0}: Некорректный размер файла';
@Input() invalidFileSizeMessageDetail = 'Максимальный размер — {0}';
@Input() invalidFileTypeMessageSummary = '{0}: Некорректный тип файла';
@Input() invalidFileTypeMessageDetail = 'Допустимые типы: {0}';
@Input() invalidFileLimitMessageSummary = 'Превышен лимит файлов';
@Input() invalidFileLimitMessageDetail = 'Максимум: {0}';

@Output() onSelectEvent = new EventEmitter<FileSelectEvent>();
@Output() onRemoveEvent = new EventEmitter<FileRemoveEvent>();
@Output() onClearEvent = new EventEmitter<void>();
@Output() onError = new EventEmitter<FileUploadErrorEvent>();
@Output() onUpload = new EventEmitter<FileUploadHandlerEvent>();

selectedFiles: PreviewFile[] = [];
uploadedFiles: PreviewFile[] = [];
totalSize = 0;
totalSizePercent = 0;
uploadSuccess = false;
isUploading = false;

private uploadCbRef: (() => void) | null = null;
private clearCbRef: (() => void) | null = null;
private onChange: (files: File[]) => void = () => {};
private onTouched: () => void = () => {};

writeValue(files: File[]): void {
this.selectedFiles = files ?? [];
this.cdr.markForCheck();
}

registerOnChange(fn: (files: File[]) => void): void {
this.onChange = fn;
}

registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}

setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
this.cdr.markForCheck();
}

get uploadCb(): (() => void) | null {
return this.uploadCbRef;
}

storeCallbacks(upload: () => void, clear: () => void): string {
this.uploadCbRef = upload;
this.clearCbRef = clear;
return '';
}

onDrop(event: DragEvent): void {
event.preventDefault();
const files = event.dataTransfer?.files;
if (!files?.length || this.disabled) return;
const accepted = this.filterFilesByAccept(Array.from(files));
if (!accepted.length) return;
const dt = new DataTransfer();
accepted.forEach(f => dt.items.add(f));
this.fuRef.onFileSelect({ target: { files: dt.files } } as unknown as Event);
}

private filterFilesByAccept(files: File[]): File[] {
if (!this.accept) return files;
const types = this.accept.split(',').map(t => t.trim());
return files.filter(file =>
types.some(type => {
if (type.includes('*')) {
return file.type.startsWith(type.replace('*', ''));
}
if (type.startsWith('.')) {
return file.name.toLowerCase().endsWith(type.toLowerCase());
}
return file.type === type;
}),
);
}

onChooseClick(): void {
const input = this.el.nativeElement.querySelector('input[type="file"]') as HTMLInputElement;
input?.click();
}

isImage(file: File): boolean {
return file.type.startsWith('image/');
}

formatSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(3)) + ' ' + sizes[i];
}

onSelectedFiles(event: FileSelectEvent): void {
this.selectedFiles = [...(this.fuRef?.files || [])];
this.totalSize = this.selectedFiles.reduce((acc, f) => acc + f.size, 0);
this.uploadSuccess = false;
this.isUploading = this.selectedFiles.length > 0;
this.totalSizePercent = 0;

let progress = 0;
const interval = setInterval(() => {
progress += 10;
this.totalSizePercent = Math.min(progress, 100);
if (progress >= 100) clearInterval(interval);
this.cdr.markForCheck();
}, 40);

this.onChange(this.selectedFiles);
this.onTouched();
this.cdr.markForCheck();
this.onSelectEvent.emit(event);
}

onUploader(event: FileUploadHandlerEvent): void {
setTimeout(() => {
this.clearCbRef?.();
this.selectedFiles = [];
this.uploadedFiles = [...(event.files || [])];
this.totalSize = 0;
this.totalSizePercent = 0;
this.uploadSuccess = true;
this.isUploading = false;
this.onChange([]);
this.cdr.markForCheck();
}, 1500);
this.onUpload.emit(event);
}

onRemoveFile(file: File, removeFileCallback: (index: number) => void, index: number): void {
removeFileCallback(index);
this.selectedFiles = [...(this.fuRef?.files || [])];
this.totalSize -= file.size;
this.totalSizePercent = Math.min((this.totalSize / (this.maxFileSize || 1000000)) * 100, 100);
if (this.totalSize <= 0) {
this.isUploading = false;
}
this.onChange(this.selectedFiles);
this.cdr.markForCheck();
}

onClearUpload(): void {
this.clearCbRef?.();
this.selectedFiles = [];
this.uploadedFiles = [];
this.totalSize = 0;
this.totalSizePercent = 0;
this.uploadSuccess = false;
this.isUploading = false;
this.onChange([]);
this.cdr.markForCheck();
}
}
5 changes: 5 additions & 0 deletions src/lib/providers/prime-preset/map-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { toggleswitchCss } from './tokens/components/toggleswitch';
import { autocompleteCss } from './tokens/components/autocomplete';
import { popoverCss } from './tokens/components/popover';
import { selectbuttonCss } from './tokens/components/selectbutton';
import { fileuploadCss } from './tokens/components/fileupload';

const presetTokens: Preset<AuraBaseDesignTokens> = {
primitive: tokens.primitive as unknown as AuraBaseDesignTokens['primitive'],
Expand Down Expand Up @@ -243,6 +244,10 @@ const presetTokens: Preset<AuraBaseDesignTokens> = {
selectbutton: {
...(tokens.components.selectbutton as unknown as ComponentsDesignTokens['selectbutton']),
css: selectbuttonCss
},
fileupload: {
...(tokens.components.fileupload as unknown as ComponentsDesignTokens['fileupload']),
css: fileuploadCss
}
} as ComponentsDesignTokens
};
Expand Down
Loading
Loading