From 4ca25b6e8949b529e635b4abaa8d94a4ca3bba11 Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Wed, 22 Apr 2026 21:25:49 +0700 Subject: [PATCH 01/10] =?UTF-8?q?fileupload:=20=D1=81=D1=82=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F,=20=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D1=81=D1=8B,=20=D0=BE=D0=B1=D1=91=D1=80=D1=82?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + .../fileupload/fileupload.component.ts | 217 ++++++++++++++++++ src/prime-preset/map-tokens.ts | 5 + .../tokens/components/fileupload.ts | 164 +++++++++++++ .../examples/fileupload-default.component.ts | 22 ++ .../fileupload/fileupload.stories.ts | 137 +++++++++++ 6 files changed, 547 insertions(+) create mode 100644 src/lib/components/fileupload/fileupload.component.ts create mode 100644 src/prime-preset/tokens/components/fileupload.ts create mode 100644 src/stories/components/fileupload/examples/fileupload-default.component.ts create mode 100644 src/stories/components/fileupload/fileupload.stories.ts diff --git a/.gitignore b/.gitignore index 808f9bdc..ce8e3c03 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,5 @@ src/assets/components/themes /storybook-static /debug-storybook.log /documentation.json + +.claude/* \ No newline at end of file diff --git a/src/lib/components/fileupload/fileupload.component.ts b/src/lib/components/fileupload/fileupload.component.ts new file mode 100644 index 00000000..c0d16f0f --- /dev/null +++ b/src/lib/components/fileupload/fileupload.component.ts @@ -0,0 +1,217 @@ +import { Component, Input, Output, EventEmitter, ViewChild } from '@angular/core'; +import { NgClass } from '@angular/common'; +import { FileUpload } from 'primeng/fileupload'; +import { ProgressBar } from 'primeng/progressbar'; +import { Message } from 'primeng/message'; +import { PrimeTemplate } from 'primeng/api'; +import { ButtonComponent } from '../button/button.component'; + +@Component({ + selector: 'fileupload', + standalone: true, + imports: [FileUpload, ProgressBar, Message, PrimeTemplate, ButtonComponent, NgClass], + host: { style: 'display: contents' }, + template: ` + + +
+
+ +
+ {{ dropzoneTitle }} + + + {{ dropzoneCaption }} + +
+
+
+
+ + +
+ @if (isUploading) { + + } + @if (uploadSuccess) { + + Файлы успешно загружены + + } + @if (files.length > 0) { +
+ @for (file of files; 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 (files.length > 0 || uploadedFiles.length > 0) { + + } +
+
+
+ `, +}) +export class FileUploadComponent { + @ViewChild('fuRef') fuRef!: FileUpload; + + @Input() name = 'files[]'; + @Input() url = '/api/upload'; + @Input() multiple = true; + @Input() accept = 'image/*,.pdf,.doc,.docx'; + @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(); + + totalSize = 0; + totalSizePercent = 0; + uploadSuccess = false; + isUploading = false; + + private uploadCbRef: (() => void) | null = null; + private clearCbRef: (() => void) | null = null; + + get uploadCb(): (() => void) | null { + return this.uploadCbRef; + } + + storeCallbacks(upload: () => void, clear: () => void): string { + this.uploadCbRef = upload; + this.clearCbRef = clear; + return ''; + } + + 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 { + const files: File[] = event.files; + this.totalSize = files.reduce((acc, f) => acc + f.size, 0); + this.uploadSuccess = false; + this.isUploading = files.length > 0; + this.totalSizePercent = 0; + + let progress = 0; + const interval = setInterval(() => { + progress += 10; + this.totalSizePercent = Math.min(progress, 100); + if (progress >= 100) clearInterval(interval); + }, 40); + + this.onSelectEvent.emit(event); + } + + onUploader(event: any): void { + setTimeout(() => { + this.clearCbRef?.(); + this.totalSize = 0; + this.totalSizePercent = 0; + this.uploadSuccess = true; + this.isUploading = false; + }, 1500); + this.onUpload.emit(event); + } + + onRemoveFile(file: File, removeFileCallback: (index: number) => void, index: number): void { + removeFileCallback(index); + this.totalSize -= file.size; + this.totalSizePercent = Math.min((this.totalSize / (this.maxFileSize || 1000000)) * 100, 100); + if (this.totalSize <= 0) { + this.isUploading = false; + } + } + + onClearUpload(): void { + this.clearCbRef?.(); + this.totalSize = 0; + this.totalSizePercent = 0; + this.uploadSuccess = false; + this.isUploading = false; + } +} diff --git a/src/prime-preset/map-tokens.ts b/src/prime-preset/map-tokens.ts index 0194af83..809bb9ab 100644 --- a/src/prime-preset/map-tokens.ts +++ b/src/prime-preset/map-tokens.ts @@ -6,6 +6,7 @@ import tokens from './tokens/tokens.json'; import { avatarCss } from './tokens/components/avatar'; import { buttonCss } from './tokens/components/button'; import { checkboxCss } from './tokens/components/checkbox'; +import { fileuploadCss } from './tokens/components/fileupload'; import { progressspinnerCss } from './tokens/components/progressspinner'; import { tagCss } from './tokens/components/tag'; import { tooltipCss } from './tokens/components/tooltip'; @@ -27,6 +28,10 @@ const presetTokens: Preset = { ...(tokens.components.button as unknown as ComponentsDesignTokens['button']), css: buttonCss, }, + fileupload: { + ...(tokens.components.fileupload as unknown as ComponentsDesignTokens['fileupload']), + css: fileuploadCss, + }, progressspinner: { ...(tokens.components.progressspinner as unknown as ComponentsDesignTokens['progressspinner']), css: progressspinnerCss, diff --git a/src/prime-preset/tokens/components/fileupload.ts b/src/prime-preset/tokens/components/fileupload.ts new file mode 100644 index 00000000..7d2685d1 --- /dev/null +++ b/src/prime-preset/tokens/components/fileupload.ts @@ -0,0 +1,164 @@ +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__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 00000000..119ffe18 --- /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/*,.pdf,.doc,.docx'; + @Input() maxFileSize = 1000000; + @Input() disabled = false; +} diff --git a/src/stories/components/fileupload/fileupload.stories.ts b/src/stories/components/fileupload/fileupload.stories.ts new file mode 100644 index 00000000..b89659d7 --- /dev/null +++ b/src/stories/components/fileupload/fileupload.stories.ts @@ -0,0 +1,137 @@ +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'; + +const meta: Meta = { + title: 'Components/Form/FileUpload', + component: FileUploadComponent, + tags: ['autodocs'], + decorators: [ + applicationConfig({ providers: [provideHttpClient()] }), + moduleMetadata({ + imports: [FileUploadDefaultComponent], + }), + ], + 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 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/*,.pdf,.doc,.docx', + 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 {} + `, + }, + }, + }, +}; From 7a404010202bb120135904facd13d52971bf4b23 Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Wed, 22 Apr 2026 21:58:38 +0700 Subject: [PATCH 02/10] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=20=D0=B0=D0=BF=D0=BB=D0=BE=D0=B0=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=B0;=20=D1=81=D1=82=D0=B8=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fileupload/fileupload.component.ts | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/src/lib/components/fileupload/fileupload.component.ts b/src/lib/components/fileupload/fileupload.component.ts index c0d16f0f..9f330f15 100644 --- a/src/lib/components/fileupload/fileupload.component.ts +++ b/src/lib/components/fileupload/fileupload.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, Output, EventEmitter, ViewChild } from '@angular/core'; +import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectorRef, inject } from '@angular/core'; import { NgClass } from '@angular/common'; import { FileUpload } from 'primeng/fileupload'; import { ProgressBar } from 'primeng/progressbar'; @@ -35,9 +35,9 @@ import { ButtonComponent } from '../button/button.component'; (onClear)="onClearEvent.emit()" (onError)="onError.emit($event)" > - +
-
+
{{ dropzoneTitle }} @@ -50,7 +50,7 @@ import { ButtonComponent } from '../button/button.component';
-
@if (isUploading) { @@ -61,9 +61,9 @@ import { ButtonComponent } from '../button/button.component'; Файлы успешно загружены } - @if (files.length > 0) { + @if (selectedFiles.length > 0) {
- @for (file of files; track file.name + file.size; let i = $index) { + @for (file of selectedFiles; track file.name + file.size; let i = $index) {
@if (isImage(file)) { @@ -79,7 +79,7 @@ import { ButtonComponent } from '../button/button.component';
-
} @@ -96,17 +96,17 @@ import { ButtonComponent } from '../button/button.component'; Загружено
-
}
} - @if (files.length > 0 || uploadedFiles.length > 0) { + @if (selectedFiles.length > 0 || uploadedFiles.length > 0) { } @@ -115,6 +115,8 @@ import { ButtonComponent } from '../button/button.component'; `, }) export class FileUploadComponent { + private el = inject(ElementRef); + private cdr = inject(ChangeDetectorRef); @ViewChild('fuRef') fuRef!: FileUpload; @Input() name = 'files[]'; @@ -140,6 +142,8 @@ export class FileUploadComponent { @Output() onError = new EventEmitter(); @Output() onUpload = new EventEmitter(); + selectedFiles: any[] = []; + uploadedFiles: any[] = []; totalSize = 0; totalSizePercent = 0; uploadSuccess = false; @@ -158,6 +162,11 @@ export class FileUploadComponent { return ''; } + onChooseClick(): void { + const input = this.el.nativeElement.querySelector('input[type="file"]') as HTMLInputElement; + input?.click(); + } + isImage(file: File): boolean { return file.type.startsWith('image/'); } @@ -171,10 +180,10 @@ export class FileUploadComponent { } onSelectedFiles(event: any): void { - const files: File[] = event.files; - this.totalSize = files.reduce((acc, f) => acc + f.size, 0); + this.selectedFiles = [...(this.fuRef?.files || [])]; + this.totalSize = this.selectedFiles.reduce((acc, f) => acc + f.size, 0); this.uploadSuccess = false; - this.isUploading = files.length > 0; + this.isUploading = this.selectedFiles.length > 0; this.totalSizePercent = 0; let progress = 0; @@ -182,36 +191,46 @@ export class FileUploadComponent { progress += 10; this.totalSizePercent = Math.min(progress, 100); if (progress >= 100) clearInterval(interval); + this.cdr.markForCheck(); }, 40); + 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.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.cdr.detectChanges(); } onClearUpload(): void { this.clearCbRef?.(); + this.selectedFiles = []; + this.uploadedFiles = []; this.totalSize = 0; this.totalSizePercent = 0; this.uploadSuccess = false; this.isUploading = false; + this.cdr.detectChanges(); } } From a4c934aa9dcad7ee3c28e3557c5a40afa8971348 Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Thu, 23 Apr 2026 10:30:28 +0700 Subject: [PATCH 03/10] =?UTF-8?q?messageCss=20=D0=B2=D0=BE=D1=81=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B2=20map?= =?UTF-8?q?-tokens.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/prime-preset/map-tokens.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/prime-preset/map-tokens.ts b/src/prime-preset/map-tokens.ts index 809bb9ab..bd43cfb5 100644 --- a/src/prime-preset/map-tokens.ts +++ b/src/prime-preset/map-tokens.ts @@ -7,6 +7,7 @@ import { avatarCss } from './tokens/components/avatar'; import { buttonCss } from './tokens/components/button'; import { checkboxCss } from './tokens/components/checkbox'; import { fileuploadCss } from './tokens/components/fileupload'; +import { messageCss } from './tokens/components/message'; import { progressspinnerCss } from './tokens/components/progressspinner'; import { tagCss } from './tokens/components/tag'; import { tooltipCss } from './tokens/components/tooltip'; @@ -32,6 +33,10 @@ const presetTokens: Preset = { ...(tokens.components.fileupload as unknown as ComponentsDesignTokens['fileupload']), css: fileuploadCss, }, + message: { + ...(tokens.components.message as unknown as ComponentsDesignTokens['message']), + css: messageCss, + }, progressspinner: { ...(tokens.components.progressspinner as unknown as ComponentsDesignTokens['progressspinner']), css: progressspinnerCss, From 01dd0d0430724a3e54d1ae7b1334d2cad9977930 Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Mon, 1 Jun 2026 15:26:53 +0800 Subject: [PATCH 04/10] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20Ext?= =?UTF-8?q?raButtonComponent=20=D0=B2=20fileupload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/components/fileupload/fileupload.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/fileupload/fileupload.component.ts b/src/lib/components/fileupload/fileupload.component.ts index 9f330f15..b677b9b7 100644 --- a/src/lib/components/fileupload/fileupload.component.ts +++ b/src/lib/components/fileupload/fileupload.component.ts @@ -4,12 +4,12 @@ import { FileUpload } from 'primeng/fileupload'; import { ProgressBar } from 'primeng/progressbar'; import { Message } from 'primeng/message'; import { PrimeTemplate } from 'primeng/api'; -import { ButtonComponent } from '../button/button.component'; +import { ExtraButtonComponent } from '../button/button.component'; @Component({ selector: 'fileupload', standalone: true, - imports: [FileUpload, ProgressBar, Message, PrimeTemplate, ButtonComponent, NgClass], + imports: [FileUpload, ProgressBar, Message, PrimeTemplate, ExtraButtonComponent, NgClass], host: { style: 'display: contents' }, template: ` Date: Mon, 1 Jun 2026 15:39:29 +0800 Subject: [PATCH 05/10] =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D1=91=D0=BD=20hover=20=D1=83=20dropzone=20=D0=B2=20=D1=81?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B8=20disabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/components/fileupload/fileupload.component.ts | 2 +- .../providers/prime-preset/tokens/components/fileupload.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/components/fileupload/fileupload.component.ts b/src/lib/components/fileupload/fileupload.component.ts index b677b9b7..5364801e 100644 --- a/src/lib/components/fileupload/fileupload.component.ts +++ b/src/lib/components/fileupload/fileupload.component.ts @@ -37,7 +37,7 @@ import { ExtraButtonComponent } from '../button/button.component'; >
-
+
{{ dropzoneTitle }} diff --git a/src/lib/providers/prime-preset/tokens/components/fileupload.ts b/src/lib/providers/prime-preset/tokens/components/fileupload.ts index 7d2685d1..a41671fc 100644 --- a/src/lib/providers/prime-preset/tokens/components/fileupload.ts +++ b/src/lib/providers/prime-preset/tokens/components/fileupload.ts @@ -42,6 +42,10 @@ export const fileuploadCss = ({ dt }: { dt: (token: string) => string }): string 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')}; From acb5a63cab6c1e47612d11f330c35372ea6260e1 Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Mon, 1 Jun 2026 15:41:28 +0800 Subject: [PATCH 06/10] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=BA=D0=B0=20drag=20and=20drop=20=D0=BD=D0=B0=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=BC=D0=BD=D1=8B=D0=B9=20dropzone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/fileupload/fileupload.component.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/lib/components/fileupload/fileupload.component.ts b/src/lib/components/fileupload/fileupload.component.ts index 5364801e..970e640a 100644 --- a/src/lib/components/fileupload/fileupload.component.ts +++ b/src/lib/components/fileupload/fileupload.component.ts @@ -37,7 +37,11 @@ import { ExtraButtonComponent } from '../button/button.component'; >
-
+
{{ dropzoneTitle }} @@ -162,6 +166,13 @@ export class FileUploadComponent { return ''; } + onDrop(event: DragEvent): void { + event.preventDefault(); + const files = event.dataTransfer?.files; + if (!files?.length || this.disabled) return; + this.fuRef.onFileSelect({ target: { files } } as unknown as Event); + } + onChooseClick(): void { const input = this.el.nativeElement.querySelector('input[type="file"]') as HTMLInputElement; input?.click(); From 4458850db408c52b73a8f861f5a9db1afe64fd31 Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Mon, 1 Jun 2026 15:45:02 +0800 Subject: [PATCH 07/10] =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=20ControlValueAccessor,=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=20=D1=81=20=D1=80=D0=B5=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D0=B2=D0=BD=D0=BE=D0=B9=20=D1=84=D0=BE=D1=80=D0=BC=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fileupload/fileupload.component.ts | 31 +++++++++++++++-- .../examples/fileupload-form.component.ts | 24 +++++++++++++ .../fileupload/fileupload.stories.ts | 34 ++++++++++++++++++- 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 src/stories/components/fileupload/examples/fileupload-form.component.ts diff --git a/src/lib/components/fileupload/fileupload.component.ts b/src/lib/components/fileupload/fileupload.component.ts index 970e640a..c347efbf 100644 --- a/src/lib/components/fileupload/fileupload.component.ts +++ b/src/lib/components/fileupload/fileupload.component.ts @@ -1,4 +1,5 @@ -import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectorRef, inject } from '@angular/core'; +import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectorRef, inject, forwardRef } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { NgClass } from '@angular/common'; import { FileUpload } from 'primeng/fileupload'; import { ProgressBar } from 'primeng/progressbar'; @@ -11,6 +12,7 @@ import { ExtraButtonComponent } from '../button/button.component'; standalone: true, imports: [FileUpload, ProgressBar, Message, PrimeTemplate, ExtraButtonComponent, NgClass], host: { style: 'display: contents' }, + providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => FileUploadComponent), multi: true }], template: ` `, }) -export class FileUploadComponent { +export class FileUploadComponent implements ControlValueAccessor { private el = inject(ElementRef); private cdr = inject(ChangeDetectorRef); @ViewChild('fuRef') fuRef!: FileUpload; @@ -155,6 +157,26 @@ export class FileUploadComponent { 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; @@ -205,6 +227,8 @@ export class FileUploadComponent { this.cdr.markForCheck(); }, 40); + this.onChange(this.selectedFiles); + this.onTouched(); this.cdr.detectChanges(); this.onSelectEvent.emit(event); } @@ -218,6 +242,7 @@ export class FileUploadComponent { this.totalSizePercent = 0; this.uploadSuccess = true; this.isUploading = false; + this.onChange([]); this.cdr.detectChanges(); }, 1500); this.onUpload.emit(event); @@ -231,6 +256,7 @@ export class FileUploadComponent { if (this.totalSize <= 0) { this.isUploading = false; } + this.onChange(this.selectedFiles); this.cdr.detectChanges(); } @@ -242,6 +268,7 @@ export class FileUploadComponent { this.totalSizePercent = 0; this.uploadSuccess = false; this.isUploading = false; + this.onChange([]); this.cdr.detectChanges(); } } 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 00000000..73b73263 --- /dev/null +++ b/src/stories/components/fileupload/examples/fileupload-form.component.ts @@ -0,0 +1,24 @@ +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) { +

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

+ } +

+ Статус: {{ control.valid ? 'Валидна' : 'Не валидна' }} | + Файлов: {{ control.value?.length ?? 0 }} +

+
+ `, +}) +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 index b89659d7..ff2b1d7a 100644 --- a/src/stories/components/fileupload/fileupload.stories.ts +++ b/src/stories/components/fileupload/fileupload.stories.ts @@ -2,6 +2,7 @@ import { Meta, StoryObj, applicationConfig, moduleMetadata } from '@storybook/an 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', @@ -10,7 +11,7 @@ const meta: Meta = { decorators: [ applicationConfig({ providers: [provideHttpClient()] }), moduleMetadata({ - imports: [FileUploadDefaultComponent], + imports: [FileUploadDefaultComponent, FileUploadFormComponent], }), ], parameters: { @@ -82,6 +83,37 @@ import { FileUploadComponent } from '@cdek-it/angular-ui-kit'; 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) => ({ From bf84438d420b06110ded91d12f5b9d8cd74ce7b0 Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Mon, 1 Jun 2026 15:53:27 +0800 Subject: [PATCH 08/10] =?UTF-8?q?=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=D0=BE=D0=B2=20=D0=B8=20=D1=83=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=D1=82=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fileupload/fileupload.component.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/lib/components/fileupload/fileupload.component.ts b/src/lib/components/fileupload/fileupload.component.ts index c347efbf..59d89d71 100644 --- a/src/lib/components/fileupload/fileupload.component.ts +++ b/src/lib/components/fileupload/fileupload.component.ts @@ -1,6 +1,5 @@ import { Component, Input, Output, EventEmitter, ViewChild, ElementRef, ChangeDetectorRef, inject, forwardRef } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { NgClass } from '@angular/common'; import { FileUpload } from 'primeng/fileupload'; import { ProgressBar } from 'primeng/progressbar'; import { Message } from 'primeng/message'; @@ -10,7 +9,7 @@ import { ExtraButtonComponent } from '../button/button.component'; @Component({ selector: 'fileupload', standalone: true, - imports: [FileUpload, ProgressBar, Message, PrimeTemplate, ExtraButtonComponent, NgClass], + imports: [FileUpload, ProgressBar, Message, PrimeTemplate, ExtraButtonComponent], host: { style: 'display: contents' }, providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => FileUploadComponent), multi: true }], template: ` @@ -85,8 +84,8 @@ import { ExtraButtonComponent } from '../button/button.component';
- +
}
@@ -102,17 +101,17 @@ import { ExtraButtonComponent } from '../button/button.component'; Загружено
- +
}
} @if (selectedFiles.length > 0 || uploadedFiles.length > 0) { } From 1fa6c4e9d7b0e922927c33be19c05f410be8e8cd Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Tue, 2 Jun 2026 12:53:41 +0800 Subject: [PATCH 09/10] =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D1=91=D0=BD=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BB=D0=B0=D0=B4=D0=BE=D1=87=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=20=D1=81=D1=82=D0=B0=D1=82=D1=83?= =?UTF-8?q?=D1=81=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fileupload/examples/fileupload-form.component.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/stories/components/fileupload/examples/fileupload-form.component.ts b/src/stories/components/fileupload/examples/fileupload-form.component.ts index 73b73263..18987015 100644 --- a/src/stories/components/fileupload/examples/fileupload-form.component.ts +++ b/src/stories/components/fileupload/examples/fileupload-form.component.ts @@ -12,10 +12,6 @@ import { FileUploadComponent } from '../../../../lib/components/fileupload/fileu @if (control.invalid && control.touched) {

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

} -

- Статус: {{ control.valid ? 'Валидна' : 'Не валидна' }} | - Файлов: {{ control.value?.length ?? 0 }} -

`, }) From 1a94e8738a89c893a2d0dbe90c3c242d74f0a71c Mon Sep 17 00:00:00 2001 From: Danil Khaliulin Date: Fri, 5 Jun 2026 14:29:22 +0800 Subject: [PATCH 10/10] =?UTF-8?q?@khaliulin=20=D0=B2=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B4=D0=B0=D1=86=D0=B8=D1=8F=20accept=20=D0=BF=D0=BE=20MIME-?= =?UTF-8?q?=D1=82=D0=B8=D0=BF=D1=83,=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=BF=D1=80=D0=B8=20drop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fileupload/fileupload.component.ts | 24 +++++++++++++++++-- .../examples/fileupload-default.component.ts | 2 +- .../fileupload/fileupload.stories.ts | 4 ++-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/lib/components/fileupload/fileupload.component.ts b/src/lib/components/fileupload/fileupload.component.ts index 59d89d71..7e811885 100644 --- a/src/lib/components/fileupload/fileupload.component.ts +++ b/src/lib/components/fileupload/fileupload.component.ts @@ -127,7 +127,7 @@ export class FileUploadComponent implements ControlValueAccessor { @Input() name = 'files[]'; @Input() url = '/api/upload'; @Input() multiple = true; - @Input() accept = 'image/*,.pdf,.doc,.docx'; + @Input() accept = 'image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'; @Input() maxFileSize = 1000000; @Input() fileLimit: number | undefined = undefined; @Input() disabled = false; @@ -191,7 +191,27 @@ export class FileUploadComponent implements ControlValueAccessor { event.preventDefault(); const files = event.dataTransfer?.files; if (!files?.length || this.disabled) return; - this.fuRef.onFileSelect({ target: { files } } as unknown as Event); + 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 { diff --git a/src/stories/components/fileupload/examples/fileupload-default.component.ts b/src/stories/components/fileupload/examples/fileupload-default.component.ts index 119ffe18..c7442e7e 100644 --- a/src/stories/components/fileupload/examples/fileupload-default.component.ts +++ b/src/stories/components/fileupload/examples/fileupload-default.component.ts @@ -16,7 +16,7 @@ import { FileUploadComponent } from '../../../../lib/components/fileupload/fileu }) export class FileUploadDefaultComponent { @Input() multiple = true; - @Input() accept = 'image/*,.pdf,.doc,.docx'; + @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/fileupload.stories.ts b/src/stories/components/fileupload/fileupload.stories.ts index ff2b1d7a..7ccf5163 100644 --- a/src/stories/components/fileupload/fileupload.stories.ts +++ b/src/stories/components/fileupload/fileupload.stories.ts @@ -134,7 +134,7 @@ export const Default: Story = { }), args: { multiple: true, - accept: 'image/*,.pdf,.doc,.docx', + accept: 'image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document', maxFileSize: 1000000, disabled: false, }, @@ -156,7 +156,7 @@ import { FileUploadComponent } from '@cdek-it/angular-ui-kit'; template: \` \`,