From 919e34f978aede889e8a347656ece8af0c644551 Mon Sep 17 00:00:00 2001 From: Kevin Buhmann Date: Thu, 12 Dec 2024 12:52:57 -0600 Subject: [PATCH] feat(file-input): add file list (advanced file picker) --- .../advanced-file-input-states.stories.ts | 153 +++++++++++++ .../file-input/advanced-file-input.stories.ts | 75 +++++++ .../file-input/file-input-states.stories.ts | 89 +++----- projects/angular/clarity.api.md | 203 ++++++++++++++---- .../src/forms/common/abstract-container.ts | 12 +- .../common/providers/ng-control.service.ts | 7 + .../forms/file-input/file-input-container.ts | 39 +++- .../file-input/file-input-validator-errors.ts | 62 ++++++ .../forms/file-input/file-input-validator.ts | 11 +- .../src/forms/file-input/file-input.module.ts | 20 +- .../angular/src/forms/file-input/file-list.ts | 130 +++++++++++ .../file-input/file-messages-template.ts | 37 ++++ .../src/forms/file-input/file-messages.ts | 48 +++++ .../angular/src/forms/file-input/index.ts | 4 + .../src/forms/styles/_containers.clarity.scss | 1 + .../src/forms/styles/_file-input.clarity.scss | 47 ++++ .../demo/src/app/forms/controls/file.html | 42 ++++ projects/demo/src/app/forms/controls/file.ts | 4 + 18 files changed, 874 insertions(+), 110 deletions(-) create mode 100644 .storybook/stories/file-input/advanced-file-input-states.stories.ts create mode 100644 .storybook/stories/file-input/advanced-file-input.stories.ts create mode 100644 projects/angular/src/forms/file-input/file-input-validator-errors.ts create mode 100644 projects/angular/src/forms/file-input/file-list.ts create mode 100644 projects/angular/src/forms/file-input/file-messages-template.ts create mode 100644 projects/angular/src/forms/file-input/file-messages.ts diff --git a/.storybook/stories/file-input/advanced-file-input-states.stories.ts b/.storybook/stories/file-input/advanced-file-input-states.stories.ts new file mode 100644 index 0000000000..62f57b7848 --- /dev/null +++ b/.storybook/stories/file-input/advanced-file-input-states.stories.ts @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2016-2024 Broadcom. All Rights Reserved. + * The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { FormsModule } from '@angular/forms'; +import { ClrFileInputModule, ClrFormLayout, ClrFormsModule } from '@clr/angular'; +import { moduleMetadata, StoryContext, StoryFn, StoryObj } from '@storybook/angular'; + +import { clearFiles, selectFiles } from '../../../projects/angular/src/forms/file-input/file-input.helpers'; + +export default { + title: 'File Input/Advanced File Input States', + decorators: [ + moduleMetadata({ + imports: [FormsModule, ClrFormsModule, ClrFileInputModule], + }), + ], + argTypes: { + // form inputs + clrLayout: { control: false }, + }, +}; + +const advancedFileInputStatesTemplate: StoryFn = args => ({ + template: ` +
+
${advancedFileInputTemplateFn('Success State')}
+
${advancedFileInputTemplateFn('Empty Error State')}
+
${advancedFileInputTemplateFn('Multiple Files Success State')}
+
${advancedFileInputTemplateFn('Multiple Files Error State')}
+
${advancedFileInputTemplateFn('Multiple Files Mixed State')}
+
${advancedFileInputTemplateFn('Long Filename')}
+ + + + + +
+ `, + props: { ...args }, +}); + +function advancedFileInputTemplateFn(label: string) { + const id = `${label.toLowerCase().replace(/\s+/g, '-')}-file-input`; + const ngModel = `${label[0].toLowerCase()}${label.substring(1).replace(/\s+/g, '')}File`; + + return ` + + + + Helper text for file input control + Success message for file input control + Required + Error message for file input control + + + + + Info text for {{ file.name }} + Success message for {{ file.name }} + File type not accepted + File size too small + File size too large + + + +`; +} + +export const VerticalAdvancedFileInputStates: StoryObj = { + render: advancedFileInputStatesTemplate, + play: fileInputStatesPlayFn, + args: { + clrLayout: ClrFormLayout.VERTICAL, + }, +}; + +export const HorizontalAdvancedFileInputStates: StoryObj = { + render: advancedFileInputStatesTemplate, + play: fileInputStatesPlayFn, + args: { + clrLayout: ClrFormLayout.HORIZONTAL, + }, +}; + +export const CompactAdvancedFileInputStates: StoryObj = { + render: advancedFileInputStatesTemplate, + play: fileInputStatesPlayFn, + args: { + clrLayout: ClrFormLayout.COMPACT, + }, +}; + +function fileInputStatesPlayFn({ canvasElement }: StoryContext) { + const successStateFileInputElement = canvasElement.querySelector('#success-state-file-input'); + selectFiles(successStateFileInputElement, [new File(['.'.repeat(50)], 'file.txt')]); + + const emptyErrorStateFileInputElement = canvasElement.querySelector( + '#empty-error-state-file-input' + ); + selectFiles(emptyErrorStateFileInputElement, [new File([''], 'file.txt')]); + clearFiles(emptyErrorStateFileInputElement); + + const multipleFilesSuccessStateFileInputElement = canvasElement.querySelector( + '#multiple-files-success-state-file-input' + ); + selectFiles(multipleFilesSuccessStateFileInputElement, [ + new File(['.'.repeat(50)], 'file-1.txt'), + new File(['.'.repeat(50)], 'file-2.txt'), + new File(['.'.repeat(50)], 'file-3.txt'), + ]); + + const multipleFilesErrorStateFileInputElement = canvasElement.querySelector( + '#multiple-files-error-state-file-input' + ); + selectFiles(multipleFilesErrorStateFileInputElement, [ + new File(['.'.repeat(25)], 'file-1.txt'), + new File(['.'.repeat(501)], 'file-2.txt'), + new File(['.'.repeat(50)], 'file-3.png'), + ]); + + const multipleFilesMixedStateFileInputElement = canvasElement.querySelector( + '#multiple-files-mixed-state-file-input' + ); + selectFiles(multipleFilesMixedStateFileInputElement, [ + new File(['.'.repeat(25)], 'file-1.txt'), + new File(['.'.repeat(50)], 'file-2.txt'), + new File(['.'.repeat(501)], 'file-3.txt'), + ]); + + const longFilenameFileInputElement = canvasElement.querySelector('#long-filename-file-input'); + selectFiles(longFilenameFileInputElement, [ + new File( + [''], + 'long-filename-lorem-ipsum-dolor-sit-amet-consectetur-adipiscing-elit-mauris-a-ante-pharetra-hendrerit-turpis-nec-volutpat-leo-duis-consectetur-tincidunt-risus-non-lobortis.txt' + ), + ]); +} diff --git a/.storybook/stories/file-input/advanced-file-input.stories.ts b/.storybook/stories/file-input/advanced-file-input.stories.ts new file mode 100644 index 0000000000..9edf69b214 --- /dev/null +++ b/.storybook/stories/file-input/advanced-file-input.stories.ts @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2016-2024 Broadcom. All Rights Reserved. + * The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { FormsModule } from '@angular/forms'; +import { ClrFileInputModule, ClrFormLayout, commonStringsDefault } from '@clr/angular'; +import { moduleMetadata, StoryFn, StoryObj } from '@storybook/angular'; + +export default { + title: 'File Input/Advanced File Input', + decorators: [ + moduleMetadata({ + imports: [FormsModule, ClrFileInputModule], + }), + ], + argTypes: { + // inputs + clrButtonLabel: { type: 'string' }, + // form inputs + clrLayout: { control: false }, + }, + args: { + // inputs + clrButtonLabel: commonStringsDefault.browse, + }, +}; + +const advancedAdvancedFileInputTemplate = ` +
+ + + + Helper message + Success message + Required + + + + + Info text for {{ file.name }} + Success message for {{ file.name }} + + + +
+`; + +const advancedAdvancedFileInputStoryFn: StoryFn = args => ({ + template: advancedAdvancedFileInputTemplate, + props: { ...args }, +}); + +export const VerticalAdvancedFileInput: StoryObj = { + render: advancedAdvancedFileInputStoryFn, + args: { + clrLayout: ClrFormLayout.VERTICAL, + }, +}; + +export const HorizontalAdvancedFileInput: StoryObj = { + render: advancedAdvancedFileInputStoryFn, + args: { + clrLayout: ClrFormLayout.HORIZONTAL, + }, +}; + +export const CompactAdvancedFileInput: StoryObj = { + render: advancedAdvancedFileInputStoryFn, + args: { + clrLayout: ClrFormLayout.COMPACT, + }, +}; diff --git a/.storybook/stories/file-input/file-input-states.stories.ts b/.storybook/stories/file-input/file-input-states.stories.ts index 597682217b..e13b1e0ebc 100644 --- a/.storybook/stories/file-input/file-input-states.stories.ts +++ b/.storybook/stories/file-input/file-input-states.stories.ts @@ -27,68 +27,10 @@ export default { const fileInputStatesTemplate: StoryFn = args => ({ template: `
- - - - Helper message - Success message - Required - - - - - - Helper message - Required - - - - - - Helper message - Success message - Required - - - - - - Helper message - Success message - Required - +
${fileInputTemplateFn('Success State')}
+
${fileInputTemplateFn('Error State')}
+
${fileInputTemplateFn('Multiple Files')}
+
${fileInputTemplateFn('Long Filename')}
@@ -99,6 +41,29 @@ const fileInputStatesTemplate: StoryFn = args => ({ props: { ...args }, }); +function fileInputTemplateFn(label: string) { + const id = `${label.toLowerCase().replace(/\s+/g, '-')}-file-input`; + const ngModel = `${label[0].toLowerCase()}${label.substring(1).replace(/\s+/g, '')}File`; + + return ` + + + + Helper message + Success message + Required + +`; +} + export const VerticalFileInputStates: StoryObj = { render: fileInputStatesTemplate, play: fileInputStatesPlayFn, diff --git a/projects/angular/clarity.api.md b/projects/angular/clarity.api.md index 55ce539a45..c26e26757d 100644 --- a/projects/angular/clarity.api.md +++ b/projects/angular/clarity.api.md @@ -93,9 +93,9 @@ export class ClarityModule { // Warning: (ae-forgotten-export) The symbol "i2_4" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i3_2" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i4_12" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i5_7" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i5_8" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i6_2" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i7_6" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i7_7" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i8_6" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i9_2" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i10_4" needs to be exported by the entry point index.d.ts @@ -109,7 +109,7 @@ export class ClarityModule { // Warning: (ae-forgotten-export) The symbol "i18_2" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -127,6 +127,9 @@ export const CLR_DATEPICKER_DIRECTIVES: Type[]; // @public (undocumented) export const CLR_DROPDOWN_DIRECTIVES: Type[]; +// @public (undocumented) +export const CLR_FILE_MESSAGES_TEMPLATE_CONTEXT: InjectionToken; + // @public (undocumented) export const CLR_ICON_DIRECTIVES: Type[]; @@ -201,6 +204,8 @@ export abstract class ClrAbstractContainer implements DynamicWrapper, OnDestroy, controlSuccessComponent: ClrControlSuccess; // (undocumented) _dynamic: boolean; + // (undocumented) + protected get errorMessagePresent(): boolean; // Warning: (ae-forgotten-export) The symbol "IfControlStateService" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -227,6 +232,8 @@ export abstract class ClrAbstractContainer implements DynamicWrapper, OnDestroy, // (undocumented) protected subscriptions: Subscription[]; // (undocumented) + protected get successMessagePresent(): boolean; + // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; @@ -283,12 +290,12 @@ export class ClrAccordionModule { // Warning: (ae-forgotten-export) The symbol "i2_36" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i3_28" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i4_20" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i5_15" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i6_10" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i7_9" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i5_16" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i6_11" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i7_10" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -1668,7 +1675,7 @@ export class ClrDatagridModule { // Warning: (ae-forgotten-export) The symbol "i46" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -1974,10 +1981,10 @@ export class ClrDatalistModule { // Warning: (ae-forgotten-export) The symbol "i1_26" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i2_20" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i3_15" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i7_5" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i7_6" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -2297,6 +2304,30 @@ export class ClrExpandableAnimation { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public (undocumented) +export interface ClrFileAcceptError { + accept: string[]; + extension: string; + name: string; + type: string; +} + +// @public (undocumented) +export class ClrFileError { + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public (undocumented) +export class ClrFileInfo { + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + // @public (undocumented) export class ClrFileInput extends WrappedFormControl { constructor(injector: Injector, renderer: Renderer2, viewContainerRef: ViewContainerRef, elementRef: ElementRef, control: NgControl, commonStrings: ClrCommonStringsService); @@ -2317,17 +2348,23 @@ export class ClrFileInputContainer extends ClrAbstractContainer { // (undocumented) protected get browseButtonDescribedBy(): string; // (undocumented) - protected clearSelectedFiles(): void; + protected get browseButtonText(): string; // (undocumented) - protected readonly commonStrings: ClrCommonStringsService; + protected clearSelectedFiles(): void; // (undocumented) customButtonLabel: string; // (undocumented) protected get disabled(): boolean; // (undocumented) - protected readonly fileInput: ClrFileInput; + protected get errorMessagePresent(): boolean; + // (undocumented) + readonly fileInput: ClrFileInput; + // (undocumented) + protected readonly fileList: ClrFileList; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + protected get successMessagePresent(): boolean; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -2343,9 +2380,12 @@ export class ClrFileInputModule { // Warning: (ae-forgotten-export) The symbol "i2_13" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i3_13" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i4_9" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i5_7" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i6_8" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i7_5" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -2388,6 +2428,85 @@ export class ClrFileInputValueAccessor implements ControlValueAccessor { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public (undocumented) +export class ClrFileList { + constructor(); + // (undocumented) + protected clearFile(fileToRemove: File): void; + // (undocumented) + protected createFileMessagesTemplateContext(file: File): ClrFileMessagesTemplateContext; + // (undocumented) + protected createFileMessagesTemplateInjector(fileMessagesTemplateContext: ClrFileMessagesTemplateContext): Injector; + // (undocumented) + protected readonly fileMessagesTemplate: ClrFileMessagesTemplate; + // (undocumented) + protected get files(): File[]; + // (undocumented) + protected getClearFileLabel(filename: string): string; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public (undocumented) +export interface ClrFileListValidationErrors { + // (undocumented) + accept?: ClrFileAcceptError[]; + // (undocumented) + maxFileSize?: ClrFileMaxFileSizeError[]; + // (undocumented) + minFileSize?: ClrFileMinFileSizeError[]; + // (undocumented) + required?: boolean; +} + +// @public (undocumented) +export interface ClrFileMaxFileSizeError { + actualFileSize: number; + maxFileSize: number; + name: string; +} + +// @public (undocumented) +export class ClrFileMessagesTemplate { + // (undocumented) + static ngTemplateContextGuard(directive: ClrFileMessagesTemplate, context: unknown): context is ClrFileMessagesTemplateContext; + // (undocumented) + readonly templateRef: TemplateRef; + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public (undocumented) +export interface ClrFileMessagesTemplateContext { + // (undocumented) + $implicit: File; + // (undocumented) + errors: ClrSingleFileValidationErrors; + // (undocumented) + success: boolean; +} + +// @public (undocumented) +export interface ClrFileMinFileSizeError { + actualFileSize: number; + minFileSize: number; + name: string; +} + +// @public (undocumented) +export class ClrFileSuccess { + // (undocumented) + protected readonly context: ClrFileMessagesTemplateContext; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + // @public (undocumented) export class ClrFocusOnViewInit implements AfterViewInit, OnDestroy { constructor(el: ElementRef, platformId: any, focusOnViewInit: boolean, document: any, renderer: Renderer2, ngZone: NgZone); @@ -2463,7 +2582,7 @@ export class ClrFormsModule { // Warning: (ae-forgotten-export) The symbol "i13_2" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -2936,10 +3055,10 @@ export class ClrNavigationModule { // Warning: (ae-forgotten-export) The symbol "i2_28" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i3_19" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i4_13" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i5_10" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i5_11" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -3664,7 +3783,7 @@ export class ClrSignpostModule { // Warning: (ae-forgotten-export) The symbol "i4_17" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -3695,6 +3814,16 @@ export class ClrSignpostTrigger implements OnDestroy { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public (undocumented) +export interface ClrSingleFileValidationErrors { + // (undocumented) + accept?: ClrFileAcceptError; + // (undocumented) + maxFileSize?: ClrFileMaxFileSizeError; + // (undocumented) + minFileSize?: ClrFileMinFileSizeError; +} + // @public (undocumented) export class ClrSpinner { // (undocumented) @@ -3844,10 +3973,10 @@ export class ClrStackViewModule { // Warning: (ae-forgotten-export) The symbol "i2_22" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i3_16" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i4_10" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i5_8" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i5_9" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -3924,11 +4053,11 @@ export class ClrStepperModule { // Warning: (ae-forgotten-export) The symbol "i2_35" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i3_27" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i4_19" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i5_14" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i5_15" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i8_9" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -4155,15 +4284,15 @@ export class ClrTabsModule { // Warning: (ae-forgotten-export) The symbol "i2_29" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i3_21" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i4_14" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i5_11" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i6_8" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i7_7" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i5_12" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i6_9" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i7_8" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i8_7" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i9_6" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i13_3" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -4230,10 +4359,10 @@ export class ClrTimelineModule { // Warning: (ae-forgotten-export) The symbol "i2_37" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i3_29" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i4_21" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i5_16" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i5_17" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -4471,10 +4600,10 @@ export class ClrTreeViewModule { // Warning: (ae-forgotten-export) The symbol "i2_23" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i3_18" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i4_11" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i5_9" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i5_10" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -4582,10 +4711,10 @@ export class ClrVerticalNavModule { // Warning: (ae-forgotten-export) The symbol "i2_30" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i3_22" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i4_16" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i5_12" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i5_13" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public (undocumented) @@ -4749,16 +4878,16 @@ export class ClrWizardModule { // Warning: (ae-forgotten-export) The symbol "i2_34" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i3_26" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i4_18" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i5_13" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i6_9" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "i7_8" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i5_14" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i6_10" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "i7_9" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i8_8" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i9_7" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i10_5" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "i11_4" needs to be exported by the entry point index.d.ts // // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public diff --git a/projects/angular/src/forms/common/abstract-container.ts b/projects/angular/src/forms/common/abstract-container.ts index b7dff49aa3..0e4e7babb5 100644 --- a/projects/angular/src/forms/common/abstract-container.ts +++ b/projects/angular/src/forms/common/abstract-container.ts @@ -82,11 +82,19 @@ export abstract class ClrAbstractContainer implements DynamicWrapper, OnDestroy, } get showValid(): boolean { - return this.touched && this.state === CONTROL_STATE.VALID && !!this.controlSuccessComponent; + return this.touched && this.state === CONTROL_STATE.VALID && this.successMessagePresent; } get showInvalid(): boolean { - return this.touched && this.state === CONTROL_STATE.INVALID && !!this.controlErrorComponent; + return this.touched && this.state === CONTROL_STATE.INVALID && this.errorMessagePresent; + } + + protected get successMessagePresent() { + return !!this.controlSuccessComponent; + } + + protected get errorMessagePresent() { + return !!this.controlErrorComponent; } private get touched() { diff --git a/projects/angular/src/forms/common/providers/ng-control.service.ts b/projects/angular/src/forms/common/providers/ng-control.service.ts index c901213f31..3feef97cc8 100644 --- a/projects/angular/src/forms/common/providers/ng-control.service.ts +++ b/projects/angular/src/forms/common/providers/ng-control.service.ts @@ -18,11 +18,17 @@ export interface Helpers { @Injectable() export class NgControlService { + private _control: NgControl; + // Observable to subscribe to the control, since its not available immediately for projected content private _controlChanges = new Subject(); private _helpers = new Subject(); + get control() { + return this._control; + } + get controlChanges(): Observable { return this._controlChanges.asObservable(); } @@ -32,6 +38,7 @@ export class NgControlService { } setControl(control: NgControl) { + this._control = control; this._controlChanges.next(control); } diff --git a/projects/angular/src/forms/file-input/file-input-container.ts b/projects/angular/src/forms/file-input/file-input-container.ts index f05c3efa9a..1eff422f87 100644 --- a/projects/angular/src/forms/file-input/file-input-container.ts +++ b/projects/angular/src/forms/file-input/file-input-container.ts @@ -14,6 +14,8 @@ import { ControlClassService } from '../common/providers/control-class.service'; import { ControlIdService } from '../common/providers/control-id.service'; import { NgControlService } from '../common/providers/ng-control.service'; import { ClrFileInput } from './file-input'; +import { ClrFileList } from './file-list'; +import { ClrFileError, ClrFileSuccess } from './file-messages'; @Component({ selector: 'clr-file-input-container', @@ -32,12 +34,10 @@ import { ClrFileInput } from './file-input'; (click)="browse()" > - - {{ fileInput?.selection?.buttonLabel || customButtonLabel || commonStrings.keys.browse }} - + {{ browseButtonText }} + + + + + + + + + `, + host: { + '[attr.role]': '"list"', + '[class.clr-file-list]': 'true', + }, +}) +export class ClrFileList { + @ContentChild(ClrFileMessagesTemplate) protected readonly fileMessagesTemplate: ClrFileMessagesTemplate; + + private readonly injector = inject(Injector); + private readonly commonStrings = inject(ClrCommonStringsService); + private readonly ngControlService = inject(NgControlService, { optional: true }); + private readonly fileInputContainer = inject(ClrFileInputContainer, { optional: true }); + + constructor() { + if (!this.ngControlService || !this.fileInputContainer) { + throw new Error('The clr-file-list component can only be used within a clr-file-input-container.'); + } + } + + protected get files() { + if (!this.fileInputContainer.fileInput) { + return []; + } + + const fileInputElement = this.fileInputContainer.fileInput.elementRef.nativeElement; + + return Array.from(fileInputElement.files).sort((a, b) => a.name.localeCompare(b.name)); + } + + protected getClearFileLabel(filename: string) { + return this.commonStrings.parse(this.commonStrings.keys.clearFile, { + FILE: filename, + }); + } + + protected clearFile(fileToRemove: File) { + if (!this.fileInputContainer.fileInput) { + return; + } + + const fileInputElement = this.fileInputContainer.fileInput.elementRef.nativeElement; + const files = Array.from(fileInputElement.files); + const newFiles = files.filter(file => file !== fileToRemove); + + selectFiles(fileInputElement, newFiles); + } + + protected createFileMessagesTemplateContext(file: File): ClrFileMessagesTemplateContext { + const fileInputErrors: ClrFileListValidationErrors = this.ngControlService.control.errors || {}; + + const errors: ClrSingleFileValidationErrors = { + accept: fileInputErrors.accept?.find(error => error.name === file.name), + minFileSize: fileInputErrors.minFileSize?.find(error => error.name === file.name), + maxFileSize: fileInputErrors.maxFileSize?.find(error => error.name === file.name), + }; + + const success = Object.values(errors).every(error => !error); + + return { $implicit: file, success, errors }; + } + + protected createFileMessagesTemplateInjector(fileMessagesTemplateContext: ClrFileMessagesTemplateContext) { + return Injector.create({ + parent: this.injector, + providers: [{ provide: CLR_FILE_MESSAGES_TEMPLATE_CONTEXT, useValue: fileMessagesTemplateContext }], + }); + } +} diff --git a/projects/angular/src/forms/file-input/file-messages-template.ts b/projects/angular/src/forms/file-input/file-messages-template.ts new file mode 100644 index 0000000000..ece89ab4cb --- /dev/null +++ b/projects/angular/src/forms/file-input/file-messages-template.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2016-2024 Broadcom. All Rights Reserved. + * The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { inject, TemplateRef } from '@angular/core'; +import { Directive } from '@angular/core'; + +import { ClrFileAcceptError, ClrFileMaxFileSizeError, ClrFileMinFileSizeError } from './file-input-validator-errors'; + +export interface ClrSingleFileValidationErrors { + accept?: ClrFileAcceptError; + minFileSize?: ClrFileMinFileSizeError; + maxFileSize?: ClrFileMaxFileSizeError; +} + +export interface ClrFileMessagesTemplateContext { + $implicit: File; + success: boolean; + errors: ClrSingleFileValidationErrors; +} + +@Directive({ + selector: 'ng-template[clr-file-messages]', +}) +export class ClrFileMessagesTemplate { + readonly templateRef: TemplateRef = inject(TemplateRef); + + static ngTemplateContextGuard( + directive: ClrFileMessagesTemplate, + context: unknown + ): context is ClrFileMessagesTemplateContext { + return true; + } +} diff --git a/projects/angular/src/forms/file-input/file-messages.ts b/projects/angular/src/forms/file-input/file-messages.ts new file mode 100644 index 0000000000..2c9aba85f3 --- /dev/null +++ b/projects/angular/src/forms/file-input/file-messages.ts @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2016-2024 Broadcom. All Rights Reserved. + * The term "Broadcom" refers to Broadcom Inc. and/or its subsidiaries. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */ + +import { Component, inject, InjectionToken } from '@angular/core'; + +import { ClrFileMessagesTemplateContext } from './file-messages-template'; + +export const CLR_FILE_MESSAGES_TEMPLATE_CONTEXT = new InjectionToken( + 'ClrFileMessagesTemplateContext' +); + +@Component({ + selector: 'clr-file-info', + template: ``, + host: { + '[class.clr-subtext]': 'true', + }, +}) +export class ClrFileInfo {} + +@Component({ + selector: 'clr-file-success', + // We check for success here so that consumers don't have to. + template: ``, + host: { + '[style.display]': 'context.success ? "inline-block" : "none"', + '[class.clr-subtext]': 'true', + '[class.success]': 'true', + }, +}) +export class ClrFileSuccess { + protected readonly context: ClrFileMessagesTemplateContext = inject(CLR_FILE_MESSAGES_TEMPLATE_CONTEXT); +} + +@Component({ + selector: 'clr-file-error', + // The host should have an `*ngIf` or `@if` that checks for the relevant error. + template: ``, + host: { + '[class.clr-subtext]': 'true', + '[class.error]': 'true', + }, +}) +export class ClrFileError {} diff --git a/projects/angular/src/forms/file-input/index.ts b/projects/angular/src/forms/file-input/index.ts index 347f3eb351..c9a08a3bb9 100644 --- a/projects/angular/src/forms/file-input/index.ts +++ b/projects/angular/src/forms/file-input/index.ts @@ -7,6 +7,10 @@ export * from './file-input'; export * from './file-input-validator'; +export * from './file-input-validator-errors'; export * from './file-input-value-accessor'; export * from './file-input-container'; +export * from './file-list'; +export * from './file-messages-template'; +export * from './file-messages'; export * from './file-input.module'; diff --git a/projects/angular/src/forms/styles/_containers.clarity.scss b/projects/angular/src/forms/styles/_containers.clarity.scss index 5e5f85f21f..074c39da7b 100644 --- a/projects/angular/src/forms/styles/_containers.clarity.scss +++ b/projects/angular/src/forms/styles/_containers.clarity.scss @@ -181,6 +181,7 @@ .clr-control-container { display: flex; + flex-wrap: wrap; } .clr-subtext { diff --git a/projects/angular/src/forms/styles/_file-input.clarity.scss b/projects/angular/src/forms/styles/_file-input.clarity.scss index a5d37d094f..caecd729e0 100644 --- a/projects/angular/src/forms/styles/_file-input.clarity.scss +++ b/projects/angular/src/forms/styles/_file-input.clarity.scss @@ -54,4 +54,51 @@ margin-top: 0; } } + + .clr-file-list { + .clr-file-list-item { + margin-top: tokens.$cds-global-space-5; + max-width: 500px; + } + + .clr-file-label-and-status-icon { + display: inline-flex; + align-items: center; + } + + .clr-file-label { + white-space: wrap; + height: unset; + margin-right: tokens.$cds-global-space-2; + } + + .clr-file-clear-button { + padding-right: tokens.$cds-global-space-2; + padding-left: tokens.$cds-global-space-2; + margin: 0; + height: tokens.$cds-alias-typography-caption-line-height; // match label text line height + + cds-icon { + margin: 0; + } + } + } + + .clr-form-compact { + .clr-file-list-item { + display: flex; + flex-wrap: wrap; + align-items: center; + column-gap: tokens.$cds-global-space-3; + + .clr-subtext { + margin: 0 !important; + } + } + + .clr-file-list-break { + flex-basis: 100%; + height: 0; + } + } } diff --git a/projects/demo/src/app/forms/controls/file.html b/projects/demo/src/app/forms/controls/file.html index 270d2bd67a..0ff31b6716 100644 --- a/projects/demo/src/app/forms/controls/file.html +++ b/projects/demo/src/app/forms/controls/file.html @@ -46,6 +46,48 @@

File Input Component

+

Advanced File Input Component

+ +
+ + + + Helper text for file input control + Success message for file input control + Required + + + + + Info text for {{ file.name }} + Success message for {{ file.name }} + File type not accepted + File size too small + File size too large + + + + +
+touched: {{ advancedForm.controls.files.touched }}
+
+status: {{ advancedForm.controls.files.status }}
+
+value (length): {{ advancedForm.controls.files.value?.length }}
+
+errors: {{ advancedForm.controls.files.errors | json }}
+  
+
+

File Input CSS-Only

diff --git a/projects/demo/src/app/forms/controls/file.ts b/projects/demo/src/app/forms/controls/file.ts index f6c6f089f1..b481b9aa2d 100644 --- a/projects/demo/src/app/forms/controls/file.ts +++ b/projects/demo/src/app/forms/controls/file.ts @@ -15,4 +15,8 @@ export class FormsFileDemo { readonly form = new FormGroup({ file: new FormControl(), }); + + readonly advancedForm = new FormGroup({ + files: new FormControl(), + }); }