chrv-components 1.12.161 → 1.12.162

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
@@ -4501,9 +4501,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImpor
4501
4501
  args: [{ selector: 'chr-input-data-overlay', template: '<ng-content></ng-content>', styles: [":host{display:flex;flex-direction:column;align-items:start;justify-content:center}\n"] }]
4502
4502
  }] });
4503
4503
 
4504
+ /**
4505
+ * Custom File Input. Support multiple files, base64 encoding and per-file validation.
4506
+ * Disclaimer: this is an AI created input.
4507
+ */
4504
4508
  class ChrFileInputComponent extends ChrInputComponent {
4505
4509
  constructor() {
4506
4510
  super();
4511
+ this.fileService = inject(FileService);
4512
+ this.nextFileId = 0;
4507
4513
  /**
4508
4514
  * Types de fichiers autorisés (ex: "image/*,.pdf")
4509
4515
  */
@@ -4514,24 +4520,31 @@ class ChrFileInputComponent extends ChrInputComponent {
4514
4520
  */
4515
4521
  this.multiple = input(false, /* @ts-ignore */
4516
4522
  ...(ngDevMode ? [{ debugName: "multiple" }] : /* istanbul ignore next */ []));
4523
+ /**
4524
+ * Convert selected files to data URLs before updating the form value
4525
+ */
4526
+ this.base64 = input(false, /* @ts-ignore */
4527
+ ...(ngDevMode ? [{ debugName: "base64" }] : /* istanbul ignore next */ []));
4517
4528
  /**
4518
4529
  * État du glisser-déposer (Drag & Drop)
4519
4530
  */
4520
4531
  this.isDragging = signal(false, /* @ts-ignore */
4521
4532
  ...(ngDevMode ? [{ debugName: "isDragging" }] : /* istanbul ignore next */ []));
4533
+ this.fileEntries = signal([], /* @ts-ignore */
4534
+ ...(ngDevMode ? [{ debugName: "fileEntries" }] : /* istanbul ignore next */ []));
4535
+ this.validationRevision = signal(0, /* @ts-ignore */
4536
+ ...(ngDevMode ? [{ debugName: "validationRevision" }] : /* istanbul ignore next */ []));
4522
4537
  /**
4523
4538
  * Signal calculé pour garantir la manipulation d'un tableau de fichiers
4524
4539
  */
4525
- this.files = computed(() => {
4526
- const val = this.value();
4527
- if (!val)
4528
- return [];
4529
- return Array.isArray(val) ? val : [val];
4530
- }, /* @ts-ignore */
4540
+ this.files = computed(() => this.fileEntries(), /* @ts-ignore */
4531
4541
  ...(ngDevMode ? [{ debugName: "files" }] : /* istanbul ignore next */ []));
4532
4542
  }
4533
4543
  ngOnInit() {
4534
4544
  super.ngOnInit();
4545
+ this.ngControl()?.control?.statusChanges.subscribe(() => {
4546
+ this.validationRevision.update((revision) => revision + 1);
4547
+ });
4535
4548
  // Initialisation du modèle selon le mode unique/multiple
4536
4549
  if (this.multiple() && !Array.isArray(this.value())) {
4537
4550
  this.value.set([]);
@@ -4585,18 +4598,65 @@ class ChrFileInputComponent extends ChrInputComponent {
4585
4598
  * Ajoute les fichiers et met à jour le Form Value
4586
4599
  */
4587
4600
  addFiles(newFiles) {
4588
- if (this.multiple()) {
4589
- const currentList = this.files();
4590
- // Filtrer les doublons par nom et taille
4591
- const filteredNewFiles = newFiles.filter((nf) => !currentList.some((f) => f.name === nf.name && f.size === nf.size));
4592
- const updatedList = [...currentList, ...filteredNewFiles];
4593
- this.value.set(updatedList);
4594
- this.onChange(updatedList);
4601
+ const currentEntries = this.files();
4602
+ const filteredNewFiles = this.multiple()
4603
+ ? newFiles.filter((newFile) => !currentEntries.some((entry) => entry.file.file?.name === newFile.name &&
4604
+ entry.file.file?.size === newFile.size))
4605
+ : newFiles.slice(0, 1);
4606
+ const newEntries = filteredNewFiles.map((file) => ({
4607
+ id: this.nextFileId++,
4608
+ file: new ChrFile({ file }),
4609
+ progress: this.isAcceptedFile(file) && this.base64() ? 0 : 100,
4610
+ error: this.isAcceptedFile(file)
4611
+ ? undefined
4612
+ : 'Type de fichier non autorisé',
4613
+ }));
4614
+ const updatedEntries = this.multiple()
4615
+ ? [...currentEntries, ...newEntries]
4616
+ : newEntries;
4617
+ this.fileEntries.set(updatedEntries);
4618
+ const filesToConvert = newEntries.filter((entry) => !entry.error);
4619
+ if (!this.base64() || filesToConvert.length === 0) {
4620
+ this.updateValue(updatedEntries);
4621
+ return;
4595
4622
  }
4596
- else {
4597
- const singleFile = newFiles[0];
4598
- this.value.set(singleFile);
4599
- this.onChange(singleFile);
4623
+ let completedConversions = 0;
4624
+ let conversionFailed = false;
4625
+ for (const entry of filesToConvert) {
4626
+ this.fileService.toBase64WithProgress(entry.file).subscribe({
4627
+ next: (result) => {
4628
+ const updatedEntries = this.files().map((currentEntry) => currentEntry.id === entry.id
4629
+ ? {
4630
+ ...currentEntry,
4631
+ file: result.file ?? currentEntry.file,
4632
+ progress: result.progress,
4633
+ }
4634
+ : currentEntry);
4635
+ this.fileEntries.set(updatedEntries);
4636
+ },
4637
+ error: () => {
4638
+ conversionFailed = true;
4639
+ this.fileEntries.update((entries) => entries.map((currentEntry) => currentEntry.id === entry.id
4640
+ ? {
4641
+ ...currentEntry,
4642
+ progress: 0,
4643
+ error: 'La conversion du fichier a échoué',
4644
+ }
4645
+ : currentEntry));
4646
+ completedConversions++;
4647
+ if (completedConversions === filesToConvert.length &&
4648
+ !conversionFailed) {
4649
+ this.updateValue(this.files());
4650
+ }
4651
+ },
4652
+ complete: () => {
4653
+ completedConversions++;
4654
+ if (completedConversions === filesToConvert.length &&
4655
+ !conversionFailed) {
4656
+ this.updateValue(this.files());
4657
+ }
4658
+ },
4659
+ });
4600
4660
  }
4601
4661
  }
4602
4662
  /**
@@ -4607,14 +4667,58 @@ class ChrFileInputComponent extends ChrInputComponent {
4607
4667
  if (this.multiple()) {
4608
4668
  const updatedList = [...this.files()];
4609
4669
  updatedList.splice(index, 1);
4610
- this.value.set(updatedList);
4611
- this.onChange(updatedList);
4670
+ this.fileEntries.set(updatedList);
4671
+ this.updateValue(updatedList);
4612
4672
  }
4613
4673
  else {
4674
+ this.fileEntries.set([]);
4614
4675
  this.value.set(null);
4615
4676
  this.onChange(null);
4616
4677
  }
4617
4678
  }
4679
+ updateValue(entries) {
4680
+ const value = entries
4681
+ .filter((entry) => !entry.error)
4682
+ .map((entry) => entry.file);
4683
+ const nextValue = this.multiple() ? value : (value[0] ?? null);
4684
+ this.value.set(nextValue);
4685
+ this.onChange(nextValue);
4686
+ }
4687
+ isAcceptedFile(file) {
4688
+ const acceptedTypes = this.accept()
4689
+ .split(',')
4690
+ .map((type) => type.trim().toLowerCase())
4691
+ .filter(Boolean);
4692
+ if (acceptedTypes.length === 0 || acceptedTypes.includes('*')) {
4693
+ return true;
4694
+ }
4695
+ const fileName = file.name.toLowerCase();
4696
+ const fileType = file.type.toLowerCase();
4697
+ return acceptedTypes.some((acceptedType) => {
4698
+ if (acceptedType.startsWith('.')) {
4699
+ return fileName.endsWith(acceptedType);
4700
+ }
4701
+ if (acceptedType.endsWith('/*')) {
4702
+ return fileType.startsWith(`${acceptedType.slice(0, -1)}`);
4703
+ }
4704
+ return fileType === acceptedType;
4705
+ });
4706
+ }
4707
+ getFileValidationError(index) {
4708
+ this.validationRevision();
4709
+ const errors = this.ngControl()?.control?.errors;
4710
+ if (!errors)
4711
+ return null;
4712
+ const indexedErrors = errors['indexes'];
4713
+ const isSingleFile = !this.multiple() && index === 0;
4714
+ if (!isSingleFile && !indexedErrors?.[index])
4715
+ return null;
4716
+ if (errors['maxfilesize'])
4717
+ return 'Ce fichier est trop grand !';
4718
+ if (errors['minfilesize'])
4719
+ return 'Ce fichier est trop petit !';
4720
+ return null;
4721
+ }
4618
4722
  /**
4619
4723
  * Utilitaire pour afficher la taille en Ko/Mo
4620
4724
  */
@@ -4627,12 +4731,12 @@ class ChrFileInputComponent extends ChrInputComponent {
4627
4731
  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
4628
4732
  }
4629
4733
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: ChrFileInputComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4630
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: ChrFileInputComponent, isStandalone: true, selector: "chr-file-input", inputs: { accept: { classPropertyName: "accept", publicName: "accept", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "fileNativeInput", first: true, predicate: ["fileNativeInput"], descendants: true }], usesInheritance: true, ngImport: i0, template: "<div class=\"file-input-wrapper\">\n <!-- Zone de Drop & S\u00E9lection (remplace visuellement l'input texte standard) -->\n <div class=\"drop-zone\" [class.dragging]=\"isDragging()\" [class.disabled]=\"isDisabled()\" (click)=\"triggerFileSelect()\"\n (dragover)=\"onDragOver($event)\" (dragleave)=\"onDragLeave($event)\" (drop)=\"onDrop($event)\">\n\n <input #fileNativeInput type=\"file\" class=\"native-file-input\" [accept]=\"accept()\" [multiple]=\"multiple()\"\n (change)=\"onFileSelected($event)\" />\n\n <div class=\"drop-zone-content\">\n <span class=\"material-symbols upload-icon\">cloud_upload</span>\n <div class=\"drop-zone-text\">\n <span class=\"primary-text\">\n @if (label()) { {{ label() }} } @else { Cliquez ou glissez un fichier ici }\n </span>\n <span class=\"secondary-text\">\n {{ multiple() ? 'S\u00E9lectionnez un ou plusieurs fichiers' : 'S\u00E9lectionnez un fichier' }}\n </span>\n </div>\n </div>\n </div>\n\n <!-- Liste des fichiers s\u00E9lectionn\u00E9s sous forme de cartes/puces -->\n @if (files().length > 0) {\n <div class=\"file-list\">\n @for (file of files(); track $index) {\n <div class=\"file-item\">\n <span class=\"material-symbols file-icon\">description</span>\n <div class=\"file-info\">\n <span class=\"file-name\">{{ file.name }}</span>\n <span class=\"file-size\">{{ formatFileSize(file.size) }}</span>\n </div>\n <button type=\"button\" class=\"file-remove-btn\" (click)=\"removeFile($index, $event)\"\n [attr.aria-label]=\"'Supprimer ' + file.name\">\n <span class=\"material-symbols\">close</span>\n </button>\n </div>\n }\n </div>\n }\n</div>", styles: [":host{display:flex;flex-direction:column;flex:1 1 auto;justify-self:stretch;align-self:center;min-width:0}.file-input-wrapper{display:flex;flex-direction:column;gap:.75rem;width:100%}.native-file-input{display:none}.drop-zone{border:2px dashed var(--primary-color);border-radius:.5rem;padding:1.25rem 1rem;background-color:var(--background-color);cursor:pointer;transition:all .2s ease-in-out}.drop-zone:hover:not(.disabled){border-color:var(--tertiary-color);background-color:var(--background-neutral-color)}.drop-zone.dragging{border-color:var(--tertiary-color);background-color:var(--background-neutral-color)}.drop-zone.disabled{opacity:.6;cursor:not-allowed}.drop-zone-content{display:flex;align-items:center;justify-content:center;gap:.75rem}.upload-icon{font-size:2rem;color:color-mix(in srgb,var(--primary-color) 80%,transparent 20%)}.drop-zone-text{display:flex;flex-direction:column}.primary-text{font-size:.875rem;font-weight:500;color:var(--text-color)}.secondary-text{font-size:.75rem;color:var(--text-neutral-color)}.file-list{display:flex;flex-direction:column;gap:.375rem}.file-item{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;background-color:var(--background-color);border:1px solid var(--neutral-color);border-radius:.375rem}.file-icon{font-size:1.25rem;color:var(--text-neutral-color)}.file-info{display:flex;flex-direction:column;flex:1;overflow:hidden}.file-name{font-size:.8125rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.file-size{font-size:.6875rem;color:var(--text-neutral-color)}.file-remove-btn{background:transparent;border:none;color:var(--text-neutral-color);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:.25rem;border-radius:.25rem;transition:color .15s,background-color .15s}.file-remove-btn:hover{color:var(--error-color);background-color:color-mix(in srgb,var(--error-color) 10%,transparent 90%)}.file-remove-btn .material-symbols{font-size:1rem}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }] }); }
4734
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: ChrFileInputComponent, isStandalone: true, selector: "chr-file-input", inputs: { accept: { classPropertyName: "accept", publicName: "accept", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, base64: { classPropertyName: "base64", publicName: "base64", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "fileNativeInput", first: true, predicate: ["fileNativeInput"], descendants: true }], usesInheritance: true, ngImport: i0, template: "<div class=\"file-input-wrapper\">\n <!-- Zone de Drop & S\u00E9lection (remplace visuellement l'input texte standard) -->\n <div class=\"drop-zone\" [hidden]=\"files().length>0\" [class.dragging]=\"isDragging()\" [class.disabled]=\"isDisabled()\"\n (click)=\"triggerFileSelect()\" (dragover)=\"onDragOver($event)\" (dragleave)=\"onDragLeave($event)\"\n (drop)=\"onDrop($event)\">\n\n <input #fileNativeInput #input type=\"file\" class=\"native-file-input\" [accept]=\"accept()\" [multiple]=\"multiple()\"\n (change)=\"onFileSelected($event)\" />\n\n <div class=\"drop-zone-content\">\n <span class=\"material-symbols upload-icon\">cloud_upload</span>\n <div class=\"drop-zone-text\">\n <span class=\"primary-text\">\n @if (label()) { {{ label() }} } @else { Cliquez ou glissez un fichier ici }\n </span>\n <span class=\"secondary-text\">\n {{ multiple() ? 'S\u00E9lectionnez un ou plusieurs fichiers' : 'S\u00E9lectionnez un fichier' }}\n </span>\n </div>\n </div>\n </div>\n\n\n <!-- Liste des fichiers s\u00E9lectionn\u00E9s sous forme de cartes/puces -->\n @if (files().length > 0) {\n <div class=\"file-list\">\n @for (entry of files(); track entry.id) {\n <div class=\"file-item\" [class.error]=\"entry.error || getFileValidationError($index)\">\n <span class=\"material-symbols file-icon\">{{ entry.error || getFileValidationError($index) ? 'error' :\n 'description' }}</span>\n <div class=\"file-info\">\n <div>\n <span class=\"file-name\" [contentEditable]=\"true\"\n (focusout)=\"entry.file.name = $any($event.target).innerText.trim()\">{{\n entry.file.name }}\n </span>\n <span class=\"file-name\">.{{entry.file.extension}}</span>\n </div>\n <span class=\"file-size\">{{ formatFileSize(entry.file.file?.size ?? 0) }}</span>\n @if (entry.error) {\n <span class=\"file-error\" role=\"alert\">{{ entry.error }}</span>\n } @else if (getFileValidationError($index); as validationError) {\n <span class=\"file-error\" role=\"alert\">{{ validationError }}</span>\n }\n </div>\n <button type=\"button\" class=\"file-remove-btn\" (click)=\"removeFile($index, $event)\"\n [attr.aria-label]=\"'Supprimer ' + entry.file.file?.name\">\n <span class=\"material-symbols\">close</span>\n </button>\n <div class=\"file-progress\" [class.error]=\"entry.error || getFileValidationError($index)\" role=\"progressbar\"\n [attr.aria-label]=\"entry.error || getFileValidationError($index) || 'Progression de la conversion'\"\n [attr.aria-valuenow]=\"entry.progress\" aria-valuemin=\"0\" aria-valuemax=\"100\">\n <div class=\"file-progress-value\" [style.width.%]=\"entry.progress\"></div>\n </div>\n </div>\n }\n @if(multiple()){\n <button class=\"file-item fixed-size\" (click)=\"triggerFileSelect()\">\n <span class=\"material-symbols\">add</span>\n </button>\n }\n </div>\n }\n</div>", styles: [":host{display:flex;flex-direction:column;flex:1 1 auto;justify-self:stretch;align-self:center;min-width:0}.file-input-wrapper{display:flex;flex-direction:column;gap:.75rem;width:100%}.native-file-input{display:none}.drop-zone{border:2px dashed var(--primary-color);border-radius:.5rem;padding:1.25rem 1rem;background-color:var(--background-color);cursor:pointer;transition:all .2s ease-in-out}.drop-zone:hover:not(.disabled){border-color:var(--tertiary-color);background-color:var(--background-neutral-color)}.drop-zone.dragging{border-color:var(--tertiary-color);background-color:var(--background-neutral-color)}.drop-zone.disabled{opacity:.6;cursor:not-allowed}.drop-zone-content{display:flex;align-items:center;justify-content:center;gap:.75rem}.upload-icon{font-size:2rem;color:color-mix(in srgb,var(--primary-color) 80%,transparent 20%)}.drop-zone-text{display:flex;flex-direction:column}.primary-text{font-size:.875rem;font-weight:500;color:var(--text-color)}.secondary-text{font-size:.75rem;color:var(--text-neutral-color)}.file-list{display:flex;flex-direction:row;flex-wrap:wrap;gap:.375rem}.file-item{position:relative;display:flex;flex:1 1 auto;align-items:center;gap:.5rem;padding:.5rem .75rem;background-color:var(--background-color);border:1px solid var(--neutral-color);border-radius:.375rem;overflow:hidden}.file-item.fixed-size{flex:0 1 auto}.file-item.error{border-color:var(--error-color)}.file-icon{font-size:1.25rem;color:var(--text-neutral-color)}.file-info{display:flex;flex-direction:column;flex:1;overflow:hidden}.file-name{font-size:.8125rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.file-size{font-size:.6875rem;color:var(--text-neutral-color)}.file-error{color:var(--error-color);font-size:.6875rem}.file-remove-btn{background:transparent;border:none;color:var(--text-neutral-color);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:.25rem;border-radius:.25rem;transition:color .15s,background-color .15s}.file-remove-btn:hover{color:var(--error-color);background-color:color-mix(in srgb,var(--error-color) 10%,transparent 90%)}.file-remove-btn .material-symbols{font-size:1rem}.file-progress{position:absolute;right:0;bottom:0;left:0;height:.2rem;background-color:color-mix(in srgb,var(--neutral-color) 55%,transparent 45%)}.file-progress-value{height:100%;background-color:var(--primary-color);transition:width .15s ease-out}.file-progress.error{background-color:color-mix(in srgb,var(--error-color) 20%,transparent 80%)}.file-progress.error .file-progress-value{background-color:var(--error-color)}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }] }); }
4631
4735
  }
4632
4736
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: ChrFileInputComponent, decorators: [{
4633
4737
  type: Component,
4634
- args: [{ selector: 'chr-file-input', imports: [ReactiveFormsModule], template: "<div class=\"file-input-wrapper\">\n <!-- Zone de Drop & S\u00E9lection (remplace visuellement l'input texte standard) -->\n <div class=\"drop-zone\" [class.dragging]=\"isDragging()\" [class.disabled]=\"isDisabled()\" (click)=\"triggerFileSelect()\"\n (dragover)=\"onDragOver($event)\" (dragleave)=\"onDragLeave($event)\" (drop)=\"onDrop($event)\">\n\n <input #fileNativeInput type=\"file\" class=\"native-file-input\" [accept]=\"accept()\" [multiple]=\"multiple()\"\n (change)=\"onFileSelected($event)\" />\n\n <div class=\"drop-zone-content\">\n <span class=\"material-symbols upload-icon\">cloud_upload</span>\n <div class=\"drop-zone-text\">\n <span class=\"primary-text\">\n @if (label()) { {{ label() }} } @else { Cliquez ou glissez un fichier ici }\n </span>\n <span class=\"secondary-text\">\n {{ multiple() ? 'S\u00E9lectionnez un ou plusieurs fichiers' : 'S\u00E9lectionnez un fichier' }}\n </span>\n </div>\n </div>\n </div>\n\n <!-- Liste des fichiers s\u00E9lectionn\u00E9s sous forme de cartes/puces -->\n @if (files().length > 0) {\n <div class=\"file-list\">\n @for (file of files(); track $index) {\n <div class=\"file-item\">\n <span class=\"material-symbols file-icon\">description</span>\n <div class=\"file-info\">\n <span class=\"file-name\">{{ file.name }}</span>\n <span class=\"file-size\">{{ formatFileSize(file.size) }}</span>\n </div>\n <button type=\"button\" class=\"file-remove-btn\" (click)=\"removeFile($index, $event)\"\n [attr.aria-label]=\"'Supprimer ' + file.name\">\n <span class=\"material-symbols\">close</span>\n </button>\n </div>\n }\n </div>\n }\n</div>", styles: [":host{display:flex;flex-direction:column;flex:1 1 auto;justify-self:stretch;align-self:center;min-width:0}.file-input-wrapper{display:flex;flex-direction:column;gap:.75rem;width:100%}.native-file-input{display:none}.drop-zone{border:2px dashed var(--primary-color);border-radius:.5rem;padding:1.25rem 1rem;background-color:var(--background-color);cursor:pointer;transition:all .2s ease-in-out}.drop-zone:hover:not(.disabled){border-color:var(--tertiary-color);background-color:var(--background-neutral-color)}.drop-zone.dragging{border-color:var(--tertiary-color);background-color:var(--background-neutral-color)}.drop-zone.disabled{opacity:.6;cursor:not-allowed}.drop-zone-content{display:flex;align-items:center;justify-content:center;gap:.75rem}.upload-icon{font-size:2rem;color:color-mix(in srgb,var(--primary-color) 80%,transparent 20%)}.drop-zone-text{display:flex;flex-direction:column}.primary-text{font-size:.875rem;font-weight:500;color:var(--text-color)}.secondary-text{font-size:.75rem;color:var(--text-neutral-color)}.file-list{display:flex;flex-direction:column;gap:.375rem}.file-item{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;background-color:var(--background-color);border:1px solid var(--neutral-color);border-radius:.375rem}.file-icon{font-size:1.25rem;color:var(--text-neutral-color)}.file-info{display:flex;flex-direction:column;flex:1;overflow:hidden}.file-name{font-size:.8125rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.file-size{font-size:.6875rem;color:var(--text-neutral-color)}.file-remove-btn{background:transparent;border:none;color:var(--text-neutral-color);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:.25rem;border-radius:.25rem;transition:color .15s,background-color .15s}.file-remove-btn:hover{color:var(--error-color);background-color:color-mix(in srgb,var(--error-color) 10%,transparent 90%)}.file-remove-btn .material-symbols{font-size:1rem}\n"] }]
4635
- }], ctorParameters: () => [], propDecorators: { accept: [{ type: i0.Input, args: [{ isSignal: true, alias: "accept", required: false }] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], fileNativeInput: [{
4738
+ args: [{ selector: 'chr-file-input', imports: [ReactiveFormsModule], template: "<div class=\"file-input-wrapper\">\n <!-- Zone de Drop & S\u00E9lection (remplace visuellement l'input texte standard) -->\n <div class=\"drop-zone\" [hidden]=\"files().length>0\" [class.dragging]=\"isDragging()\" [class.disabled]=\"isDisabled()\"\n (click)=\"triggerFileSelect()\" (dragover)=\"onDragOver($event)\" (dragleave)=\"onDragLeave($event)\"\n (drop)=\"onDrop($event)\">\n\n <input #fileNativeInput #input type=\"file\" class=\"native-file-input\" [accept]=\"accept()\" [multiple]=\"multiple()\"\n (change)=\"onFileSelected($event)\" />\n\n <div class=\"drop-zone-content\">\n <span class=\"material-symbols upload-icon\">cloud_upload</span>\n <div class=\"drop-zone-text\">\n <span class=\"primary-text\">\n @if (label()) { {{ label() }} } @else { Cliquez ou glissez un fichier ici }\n </span>\n <span class=\"secondary-text\">\n {{ multiple() ? 'S\u00E9lectionnez un ou plusieurs fichiers' : 'S\u00E9lectionnez un fichier' }}\n </span>\n </div>\n </div>\n </div>\n\n\n <!-- Liste des fichiers s\u00E9lectionn\u00E9s sous forme de cartes/puces -->\n @if (files().length > 0) {\n <div class=\"file-list\">\n @for (entry of files(); track entry.id) {\n <div class=\"file-item\" [class.error]=\"entry.error || getFileValidationError($index)\">\n <span class=\"material-symbols file-icon\">{{ entry.error || getFileValidationError($index) ? 'error' :\n 'description' }}</span>\n <div class=\"file-info\">\n <div>\n <span class=\"file-name\" [contentEditable]=\"true\"\n (focusout)=\"entry.file.name = $any($event.target).innerText.trim()\">{{\n entry.file.name }}\n </span>\n <span class=\"file-name\">.{{entry.file.extension}}</span>\n </div>\n <span class=\"file-size\">{{ formatFileSize(entry.file.file?.size ?? 0) }}</span>\n @if (entry.error) {\n <span class=\"file-error\" role=\"alert\">{{ entry.error }}</span>\n } @else if (getFileValidationError($index); as validationError) {\n <span class=\"file-error\" role=\"alert\">{{ validationError }}</span>\n }\n </div>\n <button type=\"button\" class=\"file-remove-btn\" (click)=\"removeFile($index, $event)\"\n [attr.aria-label]=\"'Supprimer ' + entry.file.file?.name\">\n <span class=\"material-symbols\">close</span>\n </button>\n <div class=\"file-progress\" [class.error]=\"entry.error || getFileValidationError($index)\" role=\"progressbar\"\n [attr.aria-label]=\"entry.error || getFileValidationError($index) || 'Progression de la conversion'\"\n [attr.aria-valuenow]=\"entry.progress\" aria-valuemin=\"0\" aria-valuemax=\"100\">\n <div class=\"file-progress-value\" [style.width.%]=\"entry.progress\"></div>\n </div>\n </div>\n }\n @if(multiple()){\n <button class=\"file-item fixed-size\" (click)=\"triggerFileSelect()\">\n <span class=\"material-symbols\">add</span>\n </button>\n }\n </div>\n }\n</div>", styles: [":host{display:flex;flex-direction:column;flex:1 1 auto;justify-self:stretch;align-self:center;min-width:0}.file-input-wrapper{display:flex;flex-direction:column;gap:.75rem;width:100%}.native-file-input{display:none}.drop-zone{border:2px dashed var(--primary-color);border-radius:.5rem;padding:1.25rem 1rem;background-color:var(--background-color);cursor:pointer;transition:all .2s ease-in-out}.drop-zone:hover:not(.disabled){border-color:var(--tertiary-color);background-color:var(--background-neutral-color)}.drop-zone.dragging{border-color:var(--tertiary-color);background-color:var(--background-neutral-color)}.drop-zone.disabled{opacity:.6;cursor:not-allowed}.drop-zone-content{display:flex;align-items:center;justify-content:center;gap:.75rem}.upload-icon{font-size:2rem;color:color-mix(in srgb,var(--primary-color) 80%,transparent 20%)}.drop-zone-text{display:flex;flex-direction:column}.primary-text{font-size:.875rem;font-weight:500;color:var(--text-color)}.secondary-text{font-size:.75rem;color:var(--text-neutral-color)}.file-list{display:flex;flex-direction:row;flex-wrap:wrap;gap:.375rem}.file-item{position:relative;display:flex;flex:1 1 auto;align-items:center;gap:.5rem;padding:.5rem .75rem;background-color:var(--background-color);border:1px solid var(--neutral-color);border-radius:.375rem;overflow:hidden}.file-item.fixed-size{flex:0 1 auto}.file-item.error{border-color:var(--error-color)}.file-icon{font-size:1.25rem;color:var(--text-neutral-color)}.file-info{display:flex;flex-direction:column;flex:1;overflow:hidden}.file-name{font-size:.8125rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.file-size{font-size:.6875rem;color:var(--text-neutral-color)}.file-error{color:var(--error-color);font-size:.6875rem}.file-remove-btn{background:transparent;border:none;color:var(--text-neutral-color);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:.25rem;border-radius:.25rem;transition:color .15s,background-color .15s}.file-remove-btn:hover{color:var(--error-color);background-color:color-mix(in srgb,var(--error-color) 10%,transparent 90%)}.file-remove-btn .material-symbols{font-size:1rem}.file-progress{position:absolute;right:0;bottom:0;left:0;height:.2rem;background-color:color-mix(in srgb,var(--neutral-color) 55%,transparent 45%)}.file-progress-value{height:100%;background-color:var(--primary-color);transition:width .15s ease-out}.file-progress.error{background-color:color-mix(in srgb,var(--error-color) 20%,transparent 80%)}.file-progress.error .file-progress-value{background-color:var(--error-color)}\n"] }]
4739
+ }], ctorParameters: () => [], propDecorators: { accept: [{ type: i0.Input, args: [{ isSignal: true, alias: "accept", required: false }] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], base64: [{ type: i0.Input, args: [{ isSignal: true, alias: "base64", required: false }] }], fileNativeInput: [{
4636
4740
  type: ViewChild,
4637
4741
  args: ['fileNativeInput']
4638
4742
  }] } });
@@ -4692,7 +4796,7 @@ class ChrSearchSelectInputComponent extends ChrInputComponent {
4692
4796
  super.ngOnInit();
4693
4797
  }
4694
4798
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: ChrSearchSelectInputComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
4695
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: ChrSearchSelectInputComponent, isStandalone: true, selector: "chr-search-select-input", inputs: { data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: false, transformFunction: null }, display: { classPropertyName: "display", publicName: "display", isSignal: true, isRequired: false, transformFunction: null } }, usesInheritance: true, ngImport: i0, template: "<div class=\"search-select-input-wrapper\">\n <chr-input [id]=\"id()\" [name]=\"name()\" type=\"text\" [label]=\"label()\" [displayValue]=\"displayValue()\"\n [value]=\"displayValue()\" (input)=\"dl.open();onInput($event);\">\n <chr-input-indicators>\n <ng-content select=\"chr-input-indicators\"></ng-content>\n <!-- @if(isFocused()){ -->\n @if(value() == null){\n <span class=\"material-symbols selection-status-indicator error\">close</span>\n }@else {\n <span class=\"material-symbols selection-status-indicator success\">check</span>\n }\n <span class=\"selection-status-indicator\" style=\"width: 1rem; \"></span>\n <!-- } -->\n </chr-input-indicators>\n <chr-input-data-overlay>\n <chr-data-list #dl [for]=\"''+name()\" [suggestions]=\"data()\" [display]=\"display()\" [selectOnFullMatch]=\"true\"\n (optionSelected)=\"selectOption($event.value)\"></chr-data-list>\n </chr-input-data-overlay>\n </chr-input>\n</div>\n\n<div class=\"data-overlay\">\n @if(isTouched()){\n @let error = getLastError();\n @if(error != null){\n <div class=\"error card glass\">\n {{getLastError() ?? null}}\n </div>\n }\n }\n <ng-content select=\"chr-input-data-overlay\">\n </ng-content>\n</div>\n<div class=\"data\">\n <ng-content select=\"chr-input-data\">\n <!-- Fallback to default projection-->\n <ng-content></ng-content>\n </ng-content>\n</div>", styles: [":host{display:flex;flex-direction:column;flex:1 1 auto;min-width:0}.search-select-input-wrapper{position:relative;min-height:1.5rem;display:flex;flex:1 1 auto;min-width:0;flex-direction:column}.selection-status-indicator.success{color:var(--success-color)}.selection-status-indicator.error{color:var(--error-color)}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "component", type: ChrInputComponent, selector: "chr-input", inputs: ["label", "id", "name", "value", "displayValue", "type", "outputType", "step", "debounceTime", "isDisabled", "isFocused", "min", "max", "minLength", "maxLength", "ngControl", "tooltip", "tooltipPosition"], outputs: ["idChange", "nameChange", "valueChange", "displayValueChange", "outputTypeChange", "isDisabledChange", "isFocusedChange", "ngControlChange", "input", "debouncedInput", "focus", "enter", "blur"] }, { kind: "component", type: ChrInputIndicators, selector: "chr-input-indicators" }, { kind: "component", type: DataListComponent, selector: "chr-data-list", inputs: ["suggestions", "for", "allowStringify", "display", "targetElement", "blurOnSelect", "selectOnFullMatch"], outputs: ["optionSelected"] }, { kind: "component", type: ChrInputDataOverlay, selector: "chr-input-data-overlay" }] }); }
4799
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.8", type: ChrSearchSelectInputComponent, isStandalone: true, selector: "chr-search-select-input", inputs: { data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: false, transformFunction: null }, display: { classPropertyName: "display", publicName: "display", isSignal: true, isRequired: false, transformFunction: null } }, usesInheritance: true, ngImport: i0, template: "<div class=\"search-select-input-wrapper\">\n <chr-input [id]=\"id()\" [name]=\"name()\" type=\"text\" [label]=\"label()\" [displayValue]=\"displayValue()\"\n [value]=\"displayValue()\" (input)=\"dl.open();onInput($event);\">\n <chr-input-indicators>\n <ng-content select=\"chr-input-indicators\"></ng-content>\n <!-- @if(isFocused()){ -->\n @if(value() == null){\n <span class=\"material-symbols selection-status-indicator error\">close</span>\n }@else {\n <span class=\"material-symbols selection-status-indicator success\">check</span>\n }\n <span class=\"selection-status-indicator\" style=\"width: 1rem; \"></span>\n <!-- } -->\n </chr-input-indicators>\n <chr-input-data-overlay>\n <chr-data-list #dl [for]=\"''+name()\" [suggestions]=\"data()\" [display]=\"display()\" [selectOnFullMatch]=\"true\"\n (optionSelected)=\"selectOption($event.value)\"></chr-data-list>\n </chr-input-data-overlay>\n </chr-input>\n</div>\n\n<div class=\"data-overlay\">\n @if(isTouched()){\n @let error = getLastError();\n @if(error != null){\n <div class=\"error card glass\">\n {{getLastError() ?? null}}\n </div>\n }\n }\n <ng-content select=\"chr-input-data-overlay\">\n </ng-content>\n</div>\n<div class=\"data\">\n <ng-content select=\"chr-input-data\">\n <!-- Fallback to default projection-->\n <ng-content></ng-content>\n </ng-content>\n</div>", styles: [":host{display:flex;flex-direction:column;flex:1 1 auto;min-width:0}.search-select-input-wrapper{position:relative;min-height:1.5rem;max-height:1.5rem;display:flex;flex:1 1 auto;min-width:0;flex-direction:column}.selection-status-indicator.success{color:var(--success-color)}.selection-status-indicator.error{color:var(--error-color)}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "component", type: ChrInputComponent, selector: "chr-input", inputs: ["label", "id", "name", "value", "displayValue", "type", "outputType", "step", "debounceTime", "isDisabled", "isFocused", "min", "max", "minLength", "maxLength", "ngControl", "tooltip", "tooltipPosition"], outputs: ["idChange", "nameChange", "valueChange", "displayValueChange", "outputTypeChange", "isDisabledChange", "isFocusedChange", "ngControlChange", "input", "debouncedInput", "focus", "enter", "blur"] }, { kind: "component", type: ChrInputIndicators, selector: "chr-input-indicators" }, { kind: "component", type: DataListComponent, selector: "chr-data-list", inputs: ["suggestions", "for", "allowStringify", "display", "targetElement", "blurOnSelect", "selectOnFullMatch"], outputs: ["optionSelected"] }, { kind: "component", type: ChrInputDataOverlay, selector: "chr-input-data-overlay" }] }); }
4696
4800
  }
4697
4801
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: ChrSearchSelectInputComponent, decorators: [{
4698
4802
  type: Component,
@@ -4702,7 +4806,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImpor
4702
4806
  ChrInputIndicators,
4703
4807
  DataListComponent,
4704
4808
  ChrInputDataOverlay,
4705
- ], template: "<div class=\"search-select-input-wrapper\">\n <chr-input [id]=\"id()\" [name]=\"name()\" type=\"text\" [label]=\"label()\" [displayValue]=\"displayValue()\"\n [value]=\"displayValue()\" (input)=\"dl.open();onInput($event);\">\n <chr-input-indicators>\n <ng-content select=\"chr-input-indicators\"></ng-content>\n <!-- @if(isFocused()){ -->\n @if(value() == null){\n <span class=\"material-symbols selection-status-indicator error\">close</span>\n }@else {\n <span class=\"material-symbols selection-status-indicator success\">check</span>\n }\n <span class=\"selection-status-indicator\" style=\"width: 1rem; \"></span>\n <!-- } -->\n </chr-input-indicators>\n <chr-input-data-overlay>\n <chr-data-list #dl [for]=\"''+name()\" [suggestions]=\"data()\" [display]=\"display()\" [selectOnFullMatch]=\"true\"\n (optionSelected)=\"selectOption($event.value)\"></chr-data-list>\n </chr-input-data-overlay>\n </chr-input>\n</div>\n\n<div class=\"data-overlay\">\n @if(isTouched()){\n @let error = getLastError();\n @if(error != null){\n <div class=\"error card glass\">\n {{getLastError() ?? null}}\n </div>\n }\n }\n <ng-content select=\"chr-input-data-overlay\">\n </ng-content>\n</div>\n<div class=\"data\">\n <ng-content select=\"chr-input-data\">\n <!-- Fallback to default projection-->\n <ng-content></ng-content>\n </ng-content>\n</div>", styles: [":host{display:flex;flex-direction:column;flex:1 1 auto;min-width:0}.search-select-input-wrapper{position:relative;min-height:1.5rem;display:flex;flex:1 1 auto;min-width:0;flex-direction:column}.selection-status-indicator.success{color:var(--success-color)}.selection-status-indicator.error{color:var(--error-color)}\n"] }]
4809
+ ], template: "<div class=\"search-select-input-wrapper\">\n <chr-input [id]=\"id()\" [name]=\"name()\" type=\"text\" [label]=\"label()\" [displayValue]=\"displayValue()\"\n [value]=\"displayValue()\" (input)=\"dl.open();onInput($event);\">\n <chr-input-indicators>\n <ng-content select=\"chr-input-indicators\"></ng-content>\n <!-- @if(isFocused()){ -->\n @if(value() == null){\n <span class=\"material-symbols selection-status-indicator error\">close</span>\n }@else {\n <span class=\"material-symbols selection-status-indicator success\">check</span>\n }\n <span class=\"selection-status-indicator\" style=\"width: 1rem; \"></span>\n <!-- } -->\n </chr-input-indicators>\n <chr-input-data-overlay>\n <chr-data-list #dl [for]=\"''+name()\" [suggestions]=\"data()\" [display]=\"display()\" [selectOnFullMatch]=\"true\"\n (optionSelected)=\"selectOption($event.value)\"></chr-data-list>\n </chr-input-data-overlay>\n </chr-input>\n</div>\n\n<div class=\"data-overlay\">\n @if(isTouched()){\n @let error = getLastError();\n @if(error != null){\n <div class=\"error card glass\">\n {{getLastError() ?? null}}\n </div>\n }\n }\n <ng-content select=\"chr-input-data-overlay\">\n </ng-content>\n</div>\n<div class=\"data\">\n <ng-content select=\"chr-input-data\">\n <!-- Fallback to default projection-->\n <ng-content></ng-content>\n </ng-content>\n</div>", styles: [":host{display:flex;flex-direction:column;flex:1 1 auto;min-width:0}.search-select-input-wrapper{position:relative;min-height:1.5rem;max-height:1.5rem;display:flex;flex:1 1 auto;min-width:0;flex-direction:column}.selection-status-indicator.success{color:var(--success-color)}.selection-status-indicator.error{color:var(--error-color)}\n"] }]
4706
4810
  }], ctorParameters: () => [], propDecorators: { data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: false }] }], display: [{ type: i0.Input, args: [{ isSignal: true, alias: "display", required: false }] }] } });
4707
4811
 
4708
4812
  class ChrTagSelectInputComponent extends ChrInputComponent {