diff --git a/.gitignore b/.gitignore index 37c47d0..e535eb8 100644 --- a/.gitignore +++ b/.gitignore @@ -58,5 +58,4 @@ src/assets/components/themes .playwright-mcp/* - .claude/* diff --git a/src/lib/components/fileupload/fileupload.component.ts b/src/lib/components/fileupload/fileupload.component.ts new file mode 100644 index 0000000..7e81188 --- /dev/null +++ b/src/lib/components/fileupload/fileupload.component.ts @@ -0,0 +1,293 @@ +import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectorRef, inject, forwardRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { FileUpload } from 'primeng/fileupload'; +import { ProgressBar } from 'primeng/progressbar'; +import { Message } from 'primeng/message'; +import { PrimeTemplate } from 'primeng/api'; +import { ExtraButtonComponent } from '../button/button.component'; + +@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: ` + + +
+
+ +
+ {{ dropzoneTitle }} + + + {{ dropzoneCaption }} + +
+
+
+
+ + +
+ @if (isUploading) { + + } + @if (uploadSuccess) { + + Файлы успешно загружены + + } + @if (selectedFiles.length > 0) { +
+ @for (file of selectedFiles; track file.name + file.size; let i = $index) { +
+
+ @if (isImage(file)) { + + } @else { + + } +
+ {{ file.name }} + + + {{ formatSize(file.size) }} + +
+
+ +
+ } +
+ } + @if (uploadedFiles.length > 0) { +
+ @for (file of uploadedFiles; track file.name + file.size; let i = $index) { +
+
+ +
+ {{ file.name }} + Загружено +
+
+ +
+ } +
+ } + @if (selectedFiles.length > 0 || uploadedFiles.length > 0) { + + } +
+
+
+ `, +}) +export class FileUploadComponent implements ControlValueAccessor { + 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(); + @Output() onRemoveEvent = new EventEmitter(); + @Output() onClearEvent = new EventEmitter(); + @Output() onError = new EventEmitter(); + @Output() onUpload = new EventEmitter(); + + selectedFiles: any[] = []; + uploadedFiles: any[] = []; + 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: any): 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.detectChanges(); + this.onSelectEvent.emit(event); + } + + onUploader(event: any): 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.detectChanges(); + }, 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.detectChanges(); + } + + onClearUpload(): void { + this.clearCbRef?.(); + this.selectedFiles = []; + this.uploadedFiles = []; + this.totalSize = 0; + this.totalSizePercent = 0; + this.uploadSuccess = false; + this.isUploading = false; + this.onChange([]); + this.cdr.detectChanges(); + } +} diff --git a/src/lib/providers/prime-preset/map-tokens.ts b/src/lib/providers/prime-preset/map-tokens.ts index d6a4356..df731bc 100644 --- a/src/lib/providers/prime-preset/map-tokens.ts +++ b/src/lib/providers/prime-preset/map-tokens.ts @@ -24,6 +24,7 @@ import { carouselCss } from './tokens/components/carousel'; import { galleriaCss } from './tokens/components/galleria'; import { confirmDialogCss } from './tokens/components/confirm-dialog'; import { drawerCss } from './tokens/components/drawer'; +import { fileuploadCss } from './tokens/components/fileupload'; const presetTokens: Preset = { primitive: tokens.primitive as unknown as AuraBaseDesignTokens['primitive'], @@ -108,6 +109,10 @@ const presetTokens: Preset = { drawer: { ...(tokens.components.drawer as unknown as ComponentsDesignTokens['drawer']), css: drawerCss + }, + fileupload: { + ...(tokens.components.fileupload as unknown as ComponentsDesignTokens['fileupload']), + css: fileuploadCss } } as ComponentsDesignTokens }; diff --git a/src/lib/providers/prime-preset/tokens/components/fileupload.ts b/src/lib/providers/prime-preset/tokens/components/fileupload.ts new file mode 100644 index 0000000..a41671f --- /dev/null +++ b/src/lib/providers/prime-preset/tokens/components/fileupload.ts @@ -0,0 +1,168 @@ +export const fileuploadCss = ({ dt }: { dt: (token: string) => string }): string => ` + +.p-fileupload { + display: flex; + flex-direction: column; + gap: ${dt('form.gap.200')}; + border: unset; +} + +.p-fileupload .p-fileupload-header { + padding: 0; +} + +.p-fileupload .p-fileupload-content { + padding: 0; + border: unset; +} + +.p-fileupload .p-message .p-message-content { + align-items: center; +} + +/* ─── Drop zone ─── */ +.fu-header { + width: 100%; +} + +.fu-dropzone { + border: ${dt('form.borderWidth')} dashed ${dt('fileupload.extend.extContent.highlightBorderDefault')}; + border-radius: ${dt('fileupload.extend.extDragNdrop.borderRadius')}; + padding: ${dt('fileupload.extend.extDragNdrop.padding')}; + background: ${dt('fileupload.extend.extDragNdrop.background')}; + display: flex; + flex-direction: column; + align-items: center; + gap: ${dt('fileupload.content.gap')}; + cursor: pointer; + transition: border-color ${dt('fileupload.root.transitionDuration')}; +} + +.fu-dropzone:hover { + border-color: ${dt('fileupload.content.highlightBorderColor')}; +} + +.fu-dropzone.fu-dropzone--disabled:hover { + border-color: ${dt('fileupload.extend.extContent.highlightBorderDefault')}; +} + +.fu-dropzone__icon { + font-size: ${dt('fileupload.extend.extDragNdrop.iconSize')}; + color: ${dt('content.color')}; +} + +.fu-dropzone__info { + display: flex; + flex-direction: column; + gap: ${dt('fileupload.extend.extDragNdrop.info.gap')}; + width: 100%; +} + +.fu-dropzone__title { + font-family: ${dt('fonts.fontFamily.heading')}; + font-size: ${dt('fonts.fontSize.300')}; + font-weight: ${dt('fonts.fontWeight.bold')}; + line-height: ${dt('fonts.lineHeight.500')}; + color: ${dt('content.color')}; + align-self: center; +} + +.fu-dropzone__caption { + display: flex; + align-items: center; + gap: ${dt('form.gap.100')}; + font-family: ${dt('fonts.fontFamily.base')}; + font-size: ${dt('fonts.fontSize.200')}; + font-weight: ${dt('fonts.fontWeight.regular')}; + line-height: ${dt('fonts.lineHeight.250')}; + color: ${dt('text.mutedColor')}; + align-self: center; +} + +.fu-dropzone__caption .ti { + font-size: ${dt('fonts.fontSize.300')}; + line-height: ${dt('fonts.lineHeight.250')}; +} + +/* ─── Content ─── */ +.fu-content { + display: flex; + flex-direction: column; + gap: ${dt('fileupload.content.gap')}; +} + +/* ─── File list ─── */ +.fu-file-list { + display: flex; + flex-direction: column; + gap: ${dt('fileupload.fileList.gap')}; +} + +.fu-file-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: ${dt('fileupload.file.padding')}; + border: ${dt('form.borderWidth')} solid ${dt('fileupload.file.borderColor')}; + border-radius: ${dt('fileupload.extend.extDragNdrop.borderRadius')}; + gap: ${dt('fileupload.file.gap')}; + transition: border-color ${dt('fileupload.root.transitionDuration')}; +} + +.fu-file-card--uploaded { + opacity: ${dt('opacity.500')}; + background: ${dt('surface.50')}; +} + +.fu-file-card__wrap { + display: flex; + align-items: center; + gap: ${dt('form.gap.300')}; +} + +.fu-file-card__icon { + font-size: ${dt('fileupload.extend.extFile.iconSize')}; + color: ${dt('content.color')}; + flex-shrink: 0; +} + +.fu-file-card__thumbnail { + width: ${dt('fileupload.extend.extFile.iconSize')}; + height: ${dt('fileupload.extend.extFile.iconSize')}; + object-fit: cover; + border-radius: ${dt('fileupload.extend.extContent.borderRadius')}; + flex-shrink: 0; +} + +.fu-file-card__info { + display: flex; + flex-direction: column; + gap: ${dt('fileupload.file.info.gap')}; +} + +.fu-file-card__name { + font-size: ${dt('fonts.fontSize.300')}; + color: ${dt('content.color')}; + line-height: ${dt('fonts.lineHeight.400')}; +} + +.fu-file-card__size { + display: flex; + align-items: center; + gap: ${dt('form.gap.100')}; + font-size: ${dt('fonts.fontSize.200')}; + color: ${dt('text.mutedColor')}; +} + +.fu-file-card__size .ti { + font-size: ${dt('fonts.fontSize.300')}; +} + +/* ─── Footer ─── */ +.fu-footer { + display: flex; + align-items: center; + justify-content: space-between; +} + +`; diff --git a/src/stories/components/fileupload/examples/fileupload-default.component.ts b/src/stories/components/fileupload/examples/fileupload-default.component.ts new file mode 100644 index 0000000..c7442e7 --- /dev/null +++ b/src/stories/components/fileupload/examples/fileupload-default.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core'; +import { FileUploadComponent } from '../../../../lib/components/fileupload/fileupload.component'; + +@Component({ + selector: 'app-fileupload-default', + standalone: true, + imports: [FileUploadComponent], + template: ` + + `, +}) +export class FileUploadDefaultComponent { + @Input() multiple = true; + @Input() accept = 'image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + @Input() maxFileSize = 1000000; + @Input() disabled = false; +} diff --git a/src/stories/components/fileupload/examples/fileupload-form.component.ts b/src/stories/components/fileupload/examples/fileupload-form.component.ts new file mode 100644 index 0000000..1898701 --- /dev/null +++ b/src/stories/components/fileupload/examples/fileupload-form.component.ts @@ -0,0 +1,20 @@ +import { Component } from '@angular/core'; +import { ReactiveFormsModule, FormControl, Validators } from '@angular/forms'; +import { FileUploadComponent } from '../../../../lib/components/fileupload/fileupload.component'; + +@Component({ + selector: 'app-fileupload-form', + standalone: true, + imports: [FileUploadComponent, ReactiveFormsModule], + template: ` +
+ + @if (control.invalid && control.touched) { +

Необходимо выбрать хотя бы один файл

+ } +
+ `, +}) +export class FileUploadFormComponent { + control = new FormControl([], { validators: [Validators.required] }); +} diff --git a/src/stories/components/fileupload/fileupload.stories.ts b/src/stories/components/fileupload/fileupload.stories.ts new file mode 100644 index 0000000..7ccf516 --- /dev/null +++ b/src/stories/components/fileupload/fileupload.stories.ts @@ -0,0 +1,169 @@ +import { Meta, StoryObj, applicationConfig, moduleMetadata } from '@storybook/angular'; +import { provideHttpClient } from '@angular/common/http'; +import { FileUploadComponent } from '../../../lib/components/fileupload/fileupload.component'; +import { FileUploadDefaultComponent } from './examples/fileupload-default.component'; +import { FileUploadFormComponent } from './examples/fileupload-form.component'; + +const meta: Meta = { + title: 'Components/Form/FileUpload', + component: FileUploadComponent, + tags: ['autodocs'], + decorators: [ + applicationConfig({ providers: [provideHttpClient()] }), + moduleMetadata({ + imports: [FileUploadDefaultComponent, FileUploadFormComponent], + }), + ], + parameters: { + docs: { + description: { + component: `Компонент загрузки файлов с поддержкой drag-and-drop, прогресс-бара и предпросмотра файлов. + +\`\`\`typescript +import { FileUploadComponent } from '@cdek-it/angular-ui-kit'; +\`\`\``, + }, + }, + designTokens: { prefix: '--p-fileupload' }, + }, + argTypes: { + multiple: { + control: 'boolean', + description: 'Разрешает выбирать несколько файлов за один раз', + table: { + category: 'Props', + defaultValue: { summary: 'true' }, + type: { summary: 'boolean' }, + }, + }, + accept: { + control: 'text', + description: 'Шаблон разрешённых типов файлов', + table: { + category: 'Props', + type: { summary: 'string' }, + }, + }, + maxFileSize: { + control: 'number', + description: 'Максимальный размер одного файла в байтах', + table: { + category: 'Props', + type: { summary: 'number' }, + }, + }, + disabled: { + control: 'boolean', + description: 'Отключает возможность выбора и загрузки файлов', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + dropzoneTitle: { table: { disable: true } }, + dropzoneCaption: { table: { disable: true } }, + name: { table: { disable: true } }, + url: { table: { disable: true } }, + fileLimit: { table: { disable: true } }, + invalidFileSizeMessageSummary: { table: { disable: true } }, + invalidFileSizeMessageDetail: { table: { disable: true } }, + invalidFileTypeMessageSummary: { table: { disable: true } }, + invalidFileTypeMessageDetail: { table: { disable: true } }, + invalidFileLimitMessageSummary: { table: { disable: true } }, + invalidFileLimitMessageDetail: { table: { disable: true } }, + onSelectEvent: { table: { disable: true } }, + onRemoveEvent: { table: { disable: true } }, + onClearEvent: { table: { disable: true } }, + onError: { table: { disable: true } }, + onUpload: { table: { disable: true } }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const WithForm: Story = { + name: 'Reactive Form', + render: () => ({ + template: ``, + }), + parameters: { + docs: { + description: { + story: 'Пример использования компонента как formControl в реактивной форме с валидацией required.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { ReactiveFormsModule, FormControl, Validators } from '@angular/forms'; +import { FileUploadComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + standalone: true, + imports: [FileUploadComponent, ReactiveFormsModule], + template: \`\`, +}) +export class ExampleComponent { + control = new FormControl([], { validators: [Validators.required] }); +} + `, + }, + }, + }, +}; + +export const Default: Story = { + name: 'Default', + render: (args: any) => ({ + props: { + multiple: args['multiple'], + accept: args['accept'], + maxFileSize: args['maxFileSize'], + disabled: args['disabled'], + }, + template: ` + + `, + }), + args: { + multiple: true, + accept: 'image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document', + maxFileSize: 1000000, + disabled: false, + }, + parameters: { + docs: { + description: { + story: 'Базовый пример загрузчика файлов с drag-and-drop зоной, карточками файлов и кнопками отправки/очистки.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { FileUploadComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-example', + standalone: true, + imports: [FileUploadComponent], + template: \` + + \`, +}) +export class ExampleComponent {} + `, + }, + }, + }, +};