ng-hub-ui-forms 22.5.0 → 22.7.0

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.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { input, model, Directive, inject, TemplateRef, InjectionToken, makeEnvironmentProviders, ElementRef, ChangeDetectorRef, contentChild, contentChildren, signal, computed, effect, numberAttribute, booleanAttribute, output, DestroyRef, viewChild, ViewEncapsulation, ChangeDetectionStrategy, Component, viewChildren, PLATFORM_ID, afterRenderEffect, afterNextRender, Injectable, Renderer2, NgZone, afterEveryRender, HostAttributeToken, linkedSignal, forwardRef, Injector, HostListener, LOCALE_ID, Pipe } from '@angular/core';
2
+ import { input, model, Directive, inject, TemplateRef, InjectionToken, makeEnvironmentProviders, ElementRef, ChangeDetectorRef, contentChild, contentChildren, signal, computed, effect, numberAttribute, booleanAttribute, output, DestroyRef, viewChild, isDevMode, ViewEncapsulation, ChangeDetectionStrategy, Component, viewChildren, PLATFORM_ID, afterRenderEffect, afterNextRender, Injectable, Renderer2, NgZone, afterEveryRender, HostAttributeToken, linkedSignal, forwardRef, Injector, HostListener, LOCALE_ID, Pipe } from '@angular/core';
3
3
  import * as i1 from '@angular/forms';
4
4
  import { FormControl, Validators, NgControl, ControlContainer, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
5
5
  import { Observable, Subject, takeUntil, tap, animationFrameScheduler, asapScheduler, fromEvent } from 'rxjs';
@@ -340,6 +340,79 @@ const defaultHubDatepickerConfig = {
340
340
  labels: defaultHubDatepickerLabels
341
341
  };
342
342
 
343
+ /** Binary size units, ascending. */
344
+ const FILE_SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
345
+ /**
346
+ * Formats a byte count as human-readable text using binary multiples (1 KB = 1024 B).
347
+ *
348
+ * Values below 10 in their unit keep one decimal (`1.4 MB`); larger ones are rounded (`24 MB`).
349
+ * The decimal separator follows the given locale, or the runtime's default when omitted.
350
+ *
351
+ * @param bytes - The size in bytes.
352
+ * @param locale - BCP 47 locale used to format the number. Defaults to the runtime locale.
353
+ * @returns The formatted size, or an empty string when `bytes` is not a finite, non-negative number.
354
+ */
355
+ function formatFileSize(bytes, locale) {
356
+ if (!Number.isFinite(bytes) || bytes < 0) {
357
+ return '';
358
+ }
359
+ if (bytes < 1024) {
360
+ return `${bytes} ${FILE_SIZE_UNITS[0]}`;
361
+ }
362
+ let value = bytes;
363
+ let unit = 0;
364
+ while (value >= 1024 && unit < FILE_SIZE_UNITS.length - 1) {
365
+ value /= 1024;
366
+ unit++;
367
+ }
368
+ const formatted = new Intl.NumberFormat(locale, { maximumFractionDigits: value < 10 ? 1 : 0 }).format(value);
369
+ return `${formatted} ${FILE_SIZE_UNITS[unit]}`;
370
+ }
371
+
372
+ /** Built-in (English) file-input labels. */
373
+ const defaultHubFileInputLabels = {
374
+ browse: 'Browse files',
375
+ dropHere: 'Drag files here, or',
376
+ dropSubtext: '',
377
+ remove: 'Remove file',
378
+ clear: 'Remove all',
379
+ retry: 'Retry upload',
380
+ cancel: 'Cancel upload',
381
+ filesSelected: (count) => (count === 1 ? '1 file selected' : `${count} files selected`),
382
+ formatSize: (bytes) => formatFileSize(bytes),
383
+ rejection: ({ file, reason, limit }) => {
384
+ switch (reason) {
385
+ case 'accept':
386
+ return `${file.name}: file type not allowed.`;
387
+ case 'maxSize':
388
+ return `${file.name}: exceeds the maximum size of ${formatFileSize(Number(limit))}.`;
389
+ case 'minSize':
390
+ return `${file.name}: below the minimum size of ${formatFileSize(Number(limit))}.`;
391
+ case 'maxFiles':
392
+ return `${file.name}: at most ${limit} files can be selected.`;
393
+ case 'maxTotalSize':
394
+ return `${file.name}: the total size would exceed ${formatFileSize(Number(limit))}.`;
395
+ case 'duplicate':
396
+ return `${file.name}: already selected.`;
397
+ default:
398
+ return `${file.name}: rejected.`;
399
+ }
400
+ },
401
+ constraints: ({ accept, maxSize, maxFiles }) => {
402
+ const parts = [];
403
+ if (accept) {
404
+ parts.push(accept);
405
+ }
406
+ if (maxSize != null) {
407
+ parts.push(`up to ${formatFileSize(maxSize)} each`);
408
+ }
409
+ if (maxFiles != null) {
410
+ parts.push(maxFiles === 1 ? '1 file max' : `${maxFiles} files max`);
411
+ }
412
+ return parts.join(' · ');
413
+ }
414
+ };
415
+
343
416
  /**
344
417
  * Default, human-readable messages for the most common Angular validation errors.
345
418
  *
@@ -353,6 +426,18 @@ const defaultHubDatepickerConfig = {
353
426
  */
354
427
  function defaultInvalidFeedback(key, value) {
355
428
  switch (key) {
429
+ case 'fileAccept':
430
+ return `Only ${value?.accept ?? 'certain'} files are allowed.`;
431
+ case 'fileMaxSize':
432
+ return `Each file must be at most ${formatFileSize(value?.max)}.`;
433
+ case 'fileMinSize':
434
+ return `Each file must be at least ${formatFileSize(value?.min)}.`;
435
+ case 'fileMaxTotalSize':
436
+ return `The files must add up to at most ${formatFileSize(value?.max)}.`;
437
+ case 'fileMaxFiles':
438
+ return `Select at most ${value?.max ?? ''} files.`;
439
+ case 'fileMinFiles':
440
+ return `Select at least ${value?.min ?? ''} files.`;
356
441
  case 'required':
357
442
  return 'This field is required.';
358
443
  case 'email':
@@ -382,7 +467,8 @@ function defaultInvalidFeedback(key, value) {
382
467
  const defaultHubFormsConfig = {
383
468
  invalidFeedbackTemplateFn: defaultInvalidFeedback,
384
469
  showValid: false,
385
- datepicker: defaultHubDatepickerConfig
470
+ datepicker: defaultHubDatepickerConfig,
471
+ fileInput: defaultHubFileInputLabels
386
472
  };
387
473
  /**
388
474
  * Injection token holding the resolved {@link HubFormsConfig}.
@@ -417,10 +503,14 @@ function provideHubForms(config) {
417
503
  ...config?.datepicker,
418
504
  labels: { ...defaultHubFormsConfig.datepicker.labels, ...config?.datepicker?.labels }
419
505
  };
506
+ const fileInput = {
507
+ ...defaultHubFormsConfig.fileInput,
508
+ ...config?.fileInput
509
+ };
420
510
  return makeEnvironmentProviders([
421
511
  {
422
512
  provide: HUB_FORMS_CONFIG,
423
- useValue: { ...defaultHubFormsConfig, ...config, datepicker }
513
+ useValue: { ...defaultHubFormsConfig, ...config, datepicker, fileInput }
424
514
  }
425
515
  ]);
426
516
  }
@@ -723,6 +813,42 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
723
813
  type: Directive
724
814
  }], ctorParameters: () => [], propDecorators: { group: [{ type: i0.Input, args: [{ isSignal: true, alias: "group", required: false }] }], groupName: [{ type: i0.Input, args: [{ isSignal: true, alias: "groupName", required: false }] }], errorTrigger: [{ type: i0.Input, args: [{ isSignal: true, alias: "errorTrigger", required: false }] }], invalidFeedbackTemplateFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "invalidFeedbackTemplateFn", required: false }] }], errorTpts: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => HubValidationErrorDirective), { isSignal: true }] }] } });
725
815
 
816
+ /**
817
+ * Injection token holding the {@link HubFileUploader} that `<hub-file-input>` delegates uploads to.
818
+ *
819
+ * It has **no default**: when nothing is registered, the component resolves it as `null` and behaves
820
+ * as a pure picker — no automatic upload, no progress bar, no cancel or retry. Register one with
821
+ * {@link provideHubFileUploader}.
822
+ */
823
+ const HUB_FILE_UPLOADER = new InjectionToken('HUB_FILE_UPLOADER');
824
+ /**
825
+ * Registers the uploader that `<hub-file-input>` uses to send files.
826
+ *
827
+ * The library defines the contract, not the transport: an uploader that knows your endpoint, field
828
+ * name, headers and retry policy is application code. Implement {@link HubFileUploader} there and
829
+ * hand it over here.
830
+ *
831
+ * Passing a class provides it in the same injector and aliases the token to that single instance, so
832
+ * the uploader can inject `HttpClient` and hold its own state.
833
+ *
834
+ * @param uploader - The uploader class (dependency-injected) or a ready-made instance.
835
+ * @returns Environment providers to add to `bootstrapApplication` (or a route's `providers`).
836
+ *
837
+ * @example
838
+ * ```ts
839
+ * bootstrapApplication(App, {
840
+ * providers: [provideHttpClient(), provideHubFileUploader(ApiFileUploader)]
841
+ * });
842
+ * ```
843
+ */
844
+ function provideHubFileUploader(uploader) {
845
+ if (typeof uploader === 'function') {
846
+ const uploaderType = uploader;
847
+ return makeEnvironmentProviders([uploaderType, { provide: HUB_FILE_UPLOADER, useExisting: uploaderType }]);
848
+ }
849
+ return makeEnvironmentProviders([{ provide: HUB_FILE_UPLOADER, useValue: uploader }]);
850
+ }
851
+
726
852
  /**
727
853
  * Enum for form helper text display types.
728
854
  */
@@ -896,9 +1022,13 @@ function applyMask(value, mask) {
896
1022
  * `invalidFeedbackTemplateFn` input, or projected `hubValidationError` templates.
897
1023
  *
898
1024
  * Formats: `text`, `number`, `password`, `email`, `tel`, `url`, `color`, `checkbox`, `switch`,
899
- * `counter`. Label types: `stacked`, `floating`, `horizontal`. Text-like formats support prepend /
900
- * append addons (input groups). Numeric formats auto-attach `min`/`max` validators when bound to a
901
- * reactive control.
1025
+ * `counter` and `file`. Label types: `stacked`, `floating`, `horizontal`. Text-like formats support
1026
+ * prepend / append addons (input groups). Numeric formats auto-attach `min`/`max` validators when
1027
+ * bound to a reactive control.
1028
+ *
1029
+ * The `file` format is **deprecated since 22.6.0** in favour of `<hub-file-input>`: it is a bare
1030
+ * picker that cannot enforce `accept` on a drop, and has no size limits, no preview and no per-file
1031
+ * removal. It keeps working until the next major.
902
1032
  *
903
1033
  * @example
904
1034
  * ```html
@@ -1005,12 +1135,25 @@ class HubInputComponent extends HubFieldControl {
1005
1135
  #lastSearchTerm = null;
1006
1136
  /** Clears any pending debounce timer when the component is destroyed. */
1007
1137
  _searchCleanup = inject(DestroyRef).onDestroy(() => clearTimeout(this.#searchTimer));
1008
- /** Accepted file types (file format), e.g. `image/*,.pdf`. */
1138
+ /**
1139
+ * Accepted file types (file format), e.g. `image/*,.pdf`.
1140
+ *
1141
+ * @deprecated Since 22.6.0. Use `<hub-file-input>`, which enforces `accept` on drops and pastes
1142
+ * too. Removed in the next major.
1143
+ */
1009
1144
  accept = input('*', /* @ts-ignore */
1010
1145
  ...(ngDevMode ? [{ debugName: "accept" }] : /* istanbul ignore next */ []));
1011
- /** Whether multiple files can be selected (file format). */
1146
+ /**
1147
+ * Whether multiple files can be selected (file format).
1148
+ *
1149
+ * @deprecated Since 22.6.0. Use `<hub-file-input>`. Removed in the next major.
1150
+ */
1012
1151
  multiple = input(false, { ...(ngDevMode ? { debugName: "multiple" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
1013
- /** Text of the file-picker button (file format). */
1152
+ /**
1153
+ * Text of the file-picker button (file format).
1154
+ *
1155
+ * @deprecated Since 22.6.0. Use `<hub-file-input>` and its `buttonLabel`. Removed in the next major.
1156
+ */
1014
1157
  buttonLabel = input('Choose file', /* @ts-ignore */
1015
1158
  ...(ngDevMode ? [{ debugName: "buttonLabel" }] : /* istanbul ignore next */ []));
1016
1159
  /** Reference to the hidden native file input. */
@@ -1067,6 +1210,7 @@ class HubInputComponent extends HubFieldControl {
1067
1210
  ngAfterContentInit() {
1068
1211
  super.ngAfterContentInit();
1069
1212
  this.#attachNumericValidators();
1213
+ this.#warnDeprecatedFileFormat();
1070
1214
  }
1071
1215
  writeValue(value) {
1072
1216
  if (this.isCheckable()) {
@@ -1201,6 +1345,17 @@ class HubInputComponent extends HubFieldControl {
1201
1345
  this.onChange?.(null);
1202
1346
  this.valueChange.emit(null);
1203
1347
  }
1348
+ /**
1349
+ * Warns once, in development only, that the `file` format is deprecated.
1350
+ *
1351
+ * It still works, but it cannot enforce `accept` on a drop, has no size limits, no preview and
1352
+ * no per-file removal. `<hub-file-input>` supersedes it.
1353
+ */
1354
+ #warnDeprecatedFileFormat() {
1355
+ if (isDevMode() && this.type() === this._inputFormats.File) {
1356
+ console.warn('[ng-hub-ui-forms] <hub-input type="file"> is deprecated since 22.6.0 and will be removed in the next major. Use <hub-file-input> instead.');
1357
+ }
1358
+ }
1204
1359
  /**
1205
1360
  * Auto-attaches `Validators.min` / `Validators.max` to the bound reactive control when `min` /
1206
1361
  * `max` are set and not already present, so the inline bounds become real validation.
@@ -6117,6 +6272,832 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
6117
6272
  }, template: "<div\n\tclass=\"hub-field hub-datepicker\"\n\t[class.hub-field--horizontal]=\"labelType() === _labelTypes.Horizontal || (!label() && required())\"\n\t[class.hub-field--disabled]=\"disabled()\"\n\t[class.hub-field--invalid]=\"isInvalid\"\n\t[class.hub-field--valid]=\"showsValid\"\n>\n\t@if (label() || required()) {\n\t\t<label [for]=\"id\" class=\"hub-field__label\">\n\t\t\t{{ label() }}\n\t\t\t@if (required()) {\n\t\t\t\t<span class=\"hub-field__required\" aria-hidden=\"true\">*</span>\n\t\t\t}\n\t\t</label>\n\t}\n\n\t<div class=\"hub-field__body hub-datepicker__trigger\" cdkOverlayOrigin #trigger=\"cdkOverlayOrigin\">\n\t\t<input\n\t\t\tclass=\"hub-field__control hub-datepicker__input\"\n\t\t\t[class.hub-field__control--invalid]=\"isInvalid\"\n\t\t\t[class.hub-field__control--valid]=\"showsValid\"\n\t\t\t[class.hub-datepicker__input--clearable]=\"clearable() && displayValue()\"\n\t\t\t[id]=\"id\"\n\t\t\ttype=\"text\"\n\t\t\treadonly\n\t\t\t[value]=\"displayValue()\"\n\t\t\t[placeholder]=\"placeholder()\"\n\t\t\t[disabled]=\"disabled()\"\n\t\t\t[required]=\"!!required()\"\n\t\t\t[attr.aria-invalid]=\"isInvalid\"\n\t\t\t[attr.aria-expanded]=\"_open()\"\n\t\t\taria-haspopup=\"dialog\"\n\t\t\t(click)=\"toggleCalendar()\"\n\t\t\t(blur)=\"handleBlur($event)\"\n\t\t/>\n\t\t@if (clearable() && displayValue() && !disabled() && !readonly()) {\n\t\t\t<button type=\"button\" class=\"hub-datepicker__clear\" [attr.aria-label]=\"_labels().clear\" (click)=\"clear()\">&times;</button>\n\t\t}\n\t\t<button\n\t\t\ttype=\"button\"\n\t\t\tclass=\"hub-datepicker__icon\"\n\t\t\ttabindex=\"-1\"\n\t\t\t[attr.aria-label]=\"_labels().openCalendar\"\n\t\t\t[disabled]=\"disabled() || readonly()\"\n\t\t\t(click)=\"toggleCalendar()\"\n\t\t></button>\n\t</div>\n\n\t@if (formText() || formTextTmp()) {\n\t\t<div class=\"hub-field__form-text\" [class.hub-field__form-text--disabled]=\"disabled()\">\n\t\t\t@if (formTextTmp()) {\n\t\t\t\t<ng-container [ngTemplateOutlet]=\"formTextTmp()!\"></ng-container>\n\t\t\t} @else {\n\t\t\t\t{{ formText() }}\n\t\t\t}\n\t\t</div>\n\t}\n\n\t@if (isInvalid) {\n\t\t<div class=\"hub-field__feedback\" role=\"alert\">\n\t\t\t<ul>\n\t\t\t\t@for (error of errors | keyvalue; track error.key) {\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<ng-container\n\t\t\t\t\t\t\t[ngTemplateOutlet]=\"_errorTemplates[error.key] ?? defaultErrorTpt\"\n\t\t\t\t\t\t\t[ngTemplateOutletContext]=\"{ $implicit: error.value, value: error.value }\"\n\t\t\t\t\t\t></ng-container>\n\t\t\t\t\t\t<ng-template #defaultErrorTpt>\n\t\t\t\t\t\t\t<span class=\"hub-field__feedback-text\" [innerHTML]=\"getInvalidFeedbackTemplate(error.key, error.value)\"></span>\n\t\t\t\t\t\t</ng-template>\n\t\t\t\t\t</li>\n\t\t\t\t}\n\t\t\t</ul>\n\t\t</div>\n\t}\n\n\t@if (showsValid && validFeedback()) {\n\t\t<div class=\"hub-field__feedback hub-field__feedback--valid\" role=\"status\">\n\t\t\t<span class=\"hub-field__feedback-text\">{{ validFeedback() }}</span>\n\t\t</div>\n\t}\n</div>\n\n<ng-template\n\tcdkConnectedOverlay\n\t[cdkConnectedOverlayOrigin]=\"trigger\"\n\t[cdkConnectedOverlayOpen]=\"_open()\"\n\t[cdkConnectedOverlayHasBackdrop]=\"true\"\n\tcdkConnectedOverlayBackdropClass=\"cdk-overlay-transparent-backdrop\"\n\t[cdkConnectedOverlayOffsetY]=\"4\"\n\t(backdropClick)=\"close()\"\n\t(detach)=\"close()\"\n>\n\t<div class=\"hub-datepicker__panel\" role=\"dialog\" [attr.aria-label]=\"monthYearLabel()\">\n\t\t<header class=\"hub-datepicker__header\">\n\t\t\t<div class=\"hub-datepicker__nav-group\">\n\t\t\t\t<button type=\"button\" class=\"hub-datepicker__nav\" [attr.aria-label]=\"_labels().previousYear\" (click)=\"shiftMonth(-12)\">\u00AB</button>\n\t\t\t\t<button type=\"button\" class=\"hub-datepicker__nav\" [attr.aria-label]=\"_labels().previousMonth\" (click)=\"shiftMonth(-1)\">\u2039</button>\n\t\t\t</div>\n\t\t\t<span class=\"hub-datepicker__title\" aria-live=\"polite\">{{ monthYearLabel() }}</span>\n\t\t\t<div class=\"hub-datepicker__nav-group\">\n\t\t\t\t<button type=\"button\" class=\"hub-datepicker__nav\" [attr.aria-label]=\"_labels().nextMonth\" (click)=\"shiftMonth(1)\">\u203A</button>\n\t\t\t\t<button type=\"button\" class=\"hub-datepicker__nav\" [attr.aria-label]=\"_labels().nextYear\" (click)=\"shiftMonth(12)\">\u00BB</button>\n\t\t\t</div>\n\t\t</header>\n\n\t\t<div class=\"hub-datepicker__weekdays\" aria-hidden=\"true\">\n\t\t\t@for (wd of weekdays(); track $index) {\n\t\t\t\t<span class=\"hub-datepicker__weekday\">{{ wd }}</span>\n\t\t\t}\n\t\t</div>\n\n\t\t<div class=\"hub-datepicker__grid\" role=\"grid\" (keydown)=\"onGridKeydown($event)\">\n\t\t\t@for (cell of grid(); track cell.date.getTime()) {\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\trole=\"gridcell\"\n\t\t\t\t\tclass=\"hub-datepicker__cell\"\n\t\t\t\t\t[class.hub-datepicker__cell--outside]=\"!cell.currentMonth\"\n\t\t\t\t\t[class.hub-datepicker__cell--today]=\"cell.today\"\n\t\t\t\t\t[class.hub-datepicker__cell--selected]=\"cell.selected || cell.rangeStart || cell.rangeEnd\"\n\t\t\t\t\t[class.hub-datepicker__cell--range-start]=\"cell.rangeStart\"\n\t\t\t\t\t[class.hub-datepicker__cell--range-end]=\"cell.rangeEnd\"\n\t\t\t\t\t[class.hub-datepicker__cell--in-range]=\"cell.inRange\"\n\t\t\t\t\t[attr.data-time]=\"cell.date.getTime()\"\n\t\t\t\t\t[attr.tabindex]=\"cell.focused ? 0 : -1\"\n\t\t\t\t\t[attr.aria-selected]=\"cell.selected || cell.rangeStart || cell.rangeEnd\"\n\t\t\t\t\t[disabled]=\"cell.disabled\"\n\t\t\t\t\t(click)=\"selectDay(cell)\"\n\t\t\t\t>\n\t\t\t\t\t{{ cell.day }}\n\t\t\t\t</button>\n\t\t\t}\n\t\t</div>\n\n\t\t@if (showToday() || clearable()) {\n\t\t\t<footer class=\"hub-datepicker__footer\">\n\t\t\t\t@if (showToday()) {\n\t\t\t\t\t<button type=\"button\" class=\"hub-datepicker__action\" (click)=\"goToToday()\">{{ _labels().today }}</button>\n\t\t\t\t}\n\t\t\t\t@if (clearable()) {\n\t\t\t\t\t<button type=\"button\" class=\"hub-datepicker__action\" (click)=\"clear()\">{{ _labels().clear }}</button>\n\t\t\t\t}\n\t\t\t</footer>\n\t\t}\n\t</div>\n</ng-template>\n", styles: ["hub-datepicker{display:block}hub-datepicker.hidden-control{display:none!important}.hub-datepicker__trigger{position:relative}.hub-datepicker__input{padding-right:var(--hub-datepicker-icon-width);cursor:pointer}.hub-datepicker__input--clearable{padding-right:calc(var(--hub-datepicker-icon-width) + 1.5rem)}.hub-datepicker__clear{position:absolute;top:0;right:var(--hub-datepicker-icon-width);height:100%;width:1.5rem;padding:0;color:var(--hub-daterangepicker-nav-arrow-color);font-size:1.1rem;line-height:1;background:transparent;border:0;cursor:pointer}.hub-datepicker__clear:hover{color:var(--hub-form-invalid-color)}.hub-datepicker__icon{position:absolute;top:0;right:0;width:var(--hub-datepicker-icon-width);height:100%;padding:0;background:transparent;border:0;cursor:pointer}.hub-datepicker__icon:after{content:\"\";display:block;width:var(--hub-datepicker-icon-size);height:var(--hub-datepicker-icon-size);margin:0 auto;background:var(--hub-datepicker-icon) center/contain no-repeat}.hub-datepicker__icon:disabled{cursor:not-allowed;opacity:var(--hub-form-disabled-opacity)}.hub-datepicker__panel{width:max-content;padding:var(--hub-daterangepicker-padding-y, var(--hub-ref-space-3, 1rem)) var(--hub-daterangepicker-padding-x, var(--hub-ref-space-3, 1rem));color:var(--hub-daterangepicker-color);background:var(--hub-daterangepicker-bg);border:var(--hub-input-border-width) solid var(--hub-daterangepicker-border-color);border-radius:var(--hub-daterangepicker-border-radius);box-shadow:var(--hub-daterangepicker-box-shadow);font-family:var(--hub-input-font-family)}.hub-datepicker__panel .hub-datepicker__header{display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--hub-ref-space-2, .5rem)}.hub-datepicker__panel .hub-datepicker__nav-group{display:inline-flex;gap:.125rem}.hub-datepicker__panel .hub-datepicker__nav{display:inline-flex;align-items:center;justify-content:center;width:var(--hub-daterangepicker-cell-size);height:var(--hub-daterangepicker-cell-size);font-size:1.25rem;line-height:1;color:var(--hub-daterangepicker-nav-arrow-color);background:transparent;border:0;border-radius:var(--hub-daterangepicker-cell-border-radius);cursor:pointer}.hub-datepicker__panel .hub-datepicker__nav:hover{color:var(--hub-daterangepicker-nav-arrow-hover-color);background:var(--hub-daterangepicker-cell-hover-bg)}.hub-datepicker__panel .hub-datepicker__title{font-weight:var(--hub-ref-font-weight-medium, 500);text-transform:capitalize}.hub-datepicker__panel .hub-datepicker__weekdays,.hub-datepicker__panel .hub-datepicker__grid{display:grid;grid-template-columns:repeat(7,var(--hub-daterangepicker-cell-size));gap:.125rem}.hub-datepicker__panel .hub-datepicker__weekday{display:inline-flex;align-items:center;justify-content:center;height:1.5rem;font-size:var(--hub-ref-font-size-sm, .875rem);color:var(--hub-daterangepicker-off-color);text-transform:capitalize}.hub-datepicker__panel .hub-datepicker__cell{display:inline-flex;align-items:center;justify-content:center;width:var(--hub-daterangepicker-cell-size);height:var(--hub-daterangepicker-cell-size);color:var(--hub-daterangepicker-cell-color);background:transparent;border:0;border-radius:var(--hub-daterangepicker-cell-border-radius);font-size:var(--hub-input-font-size);cursor:pointer}.hub-datepicker__panel .hub-datepicker__cell:hover{background:var(--hub-daterangepicker-cell-hover-bg)}.hub-datepicker__panel .hub-datepicker__cell--outside{color:var(--hub-daterangepicker-off-color)}.hub-datepicker__panel .hub-datepicker__cell--today{font-weight:var(--hub-ref-font-weight-bold, 700);box-shadow:inset 0 0 0 1px var(--hub-daterangepicker-border-color)}.hub-datepicker__panel .hub-datepicker__cell--in-range{background:var(--hub-daterangepicker-in-range-bg);border-radius:0}.hub-datepicker__panel .hub-datepicker__cell--range-start{border-top-right-radius:0;border-bottom-right-radius:0}.hub-datepicker__panel .hub-datepicker__cell--range-end{border-top-left-radius:0;border-bottom-left-radius:0}.hub-datepicker__panel .hub-datepicker__cell--selected,.hub-datepicker__panel .hub-datepicker__cell--selected:hover{color:var(--hub-daterangepicker-active-color);background:var(--hub-daterangepicker-active-bg)}.hub-datepicker__panel .hub-datepicker__cell:disabled{color:var(--hub-daterangepicker-off-color);background:transparent;cursor:not-allowed;opacity:.6}.hub-datepicker__panel .hub-datepicker__cell:focus-visible{outline:0;box-shadow:var(--hub-input-focus-box-shadow)}.hub-datepicker__panel .hub-datepicker__footer{display:flex;justify-content:flex-end;gap:var(--hub-ref-space-2, .5rem);margin-top:var(--hub-ref-space-2, .5rem);padding-top:var(--hub-ref-space-2, .5rem);border-top:var(--hub-input-border-width) solid var(--hub-daterangepicker-border-color)}.hub-datepicker__panel .hub-datepicker__action{padding:.2rem .5rem;color:var(--hub-sys-color-primary, #0d6efd);font-size:var(--hub-ref-font-size-sm, .875rem);background:transparent;border:0;border-radius:var(--hub-daterangepicker-cell-border-radius);cursor:pointer}.hub-datepicker__panel .hub-datepicker__action:hover{background:var(--hub-daterangepicker-cell-hover-bg)}\n"] }]
6118
6273
  }], propDecorators: { mode: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], labelType: [{ type: i0.Input, args: [{ isSignal: true, alias: "labelType", required: false }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], firstDayOfWeek: [{ type: i0.Input, args: [{ isSignal: true, alias: "firstDayOfWeek", required: false }] }], weekdayFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "weekdayFormat", required: false }] }], displayFormat: [{ type: i0.Input, args: [{ isSignal: true, alias: "displayFormat", required: false }] }], rangeSeparator: [{ type: i0.Input, args: [{ isSignal: true, alias: "rangeSeparator", required: false }] }], min: [{ type: i0.Input, args: [{ isSignal: true, alias: "min", required: false }] }], max: [{ type: i0.Input, args: [{ isSignal: true, alias: "max", required: false }] }], disabledDates: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabledDates", required: false }] }], clearable: [{ type: i0.Input, args: [{ isSignal: true, alias: "clearable", required: false }] }], showToday: [{ type: i0.Input, args: [{ isSignal: true, alias: "showToday", required: false }] }], closeOnSelect: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeOnSelect", required: false }] }], labels: [{ type: i0.Input, args: [{ isSignal: true, alias: "labels", required: false }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], formText: [{ type: i0.Input, args: [{ isSignal: true, alias: "formText", required: false }] }], formTextType: [{ type: i0.Input, args: [{ isSignal: true, alias: "formTextType", required: false }] }], classlist: [{ type: i0.Input, args: [{ isSignal: true, alias: "classlist", required: false }] }], valueChange: [{ type: i0.Output, args: ["valueChange"] }], opened: [{ type: i0.Output, args: ["opened"] }], closed: [{ type: i0.Output, args: ["closed"] }], viewChange: [{ type: i0.Output, args: ["viewChange"] }], cleared: [{ type: i0.Output, args: ["cleared"] }] } });
6119
6274
 
6275
+ /**
6276
+ * Overrides the glyph `<hub-file-input>` renders next to a non-image file.
6277
+ *
6278
+ * The library never depends on `ng-hub-ui-icons`: it draws a neutral document glyph from the
6279
+ * `--hub-file-input-file-icon` CSS token and lets you project anything richer — a `<hub-icon>` from
6280
+ * any pack, an inline `<svg>`, an emoji — through this template. The item is the implicit context,
6281
+ * so the glyph can vary with `file.type`.
6282
+ *
6283
+ * @example
6284
+ * ```html
6285
+ * <hub-file-input formControlName="attachments" [multiple]="true">
6286
+ * <ng-template hubFileIcon let-item>
6287
+ * <hub-icon [name]="item.file.type === 'application/pdf' ? 'fa:solid:file-pdf' : 'fa:solid:file'" />
6288
+ * </ng-template>
6289
+ * </hub-file-input>
6290
+ * ```
6291
+ */
6292
+ class HubFileIconDirective {
6293
+ /** Template reference for the icon. */
6294
+ template = inject(TemplateRef);
6295
+ /**
6296
+ * Narrows the implicit context for the template type-checker.
6297
+ *
6298
+ * @param _directive - The directive instance.
6299
+ * @param _context - The candidate context.
6300
+ * @returns Always `true`; exists only to type `let-item`.
6301
+ */
6302
+ static ngTemplateContextGuard(_directive, _context) {
6303
+ return true;
6304
+ }
6305
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: HubFileIconDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
6306
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.1", type: HubFileIconDirective, isStandalone: true, selector: "[hubFileIcon]", ngImport: i0 });
6307
+ }
6308
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: HubFileIconDirective, decorators: [{
6309
+ type: Directive,
6310
+ args: [{
6311
+ selector: '[hubFileIcon]'
6312
+ }]
6313
+ }] });
6314
+
6315
+ /**
6316
+ * Projects arbitrary markup inside a `<hub-file-input>` dropzone, between the glyph and the
6317
+ * invitation.
6318
+ *
6319
+ * The slot exists for a notice the consumer computes per instance — "2 documents still missing",
6320
+ * a badge, a warning — which neither `dropText` (the invitation) nor `hint` (the constraints
6321
+ * summary, rendered at the bottom) can express, and which is too instance-specific to live in the
6322
+ * global labels.
6323
+ *
6324
+ * Renders nothing when the template is absent, so the dropzone is unchanged for every consumer
6325
+ * that does not project one.
6326
+ *
6327
+ * @example
6328
+ * ```html
6329
+ * <hub-file-input [multiple]="true">
6330
+ * <ng-template hubFileDropzoneNotice>
6331
+ * <strong class="missing">2 documents still missing</strong>
6332
+ * </ng-template>
6333
+ * </hub-file-input>
6334
+ * ```
6335
+ */
6336
+ class HubFileDropzoneNoticeDirective {
6337
+ /** Template reference for the notice. */
6338
+ template = inject(TemplateRef);
6339
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: HubFileDropzoneNoticeDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
6340
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.1", type: HubFileDropzoneNoticeDirective, isStandalone: true, selector: "[hubFileDropzoneNotice]", ngImport: i0 });
6341
+ }
6342
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: HubFileDropzoneNoticeDirective, decorators: [{
6343
+ type: Directive,
6344
+ args: [{
6345
+ selector: '[hubFileDropzoneNotice]'
6346
+ }]
6347
+ }] });
6348
+
6349
+ /**
6350
+ * Replaces the entire rendering of a selected file in `<hub-file-input>`.
6351
+ *
6352
+ * Where `hubFileIcon` swaps a glyph, this takes over the whole row (or grid tile): thumbnail, name,
6353
+ * size, progress bar and buttons. The template receives the item plus the actions it needs, so a
6354
+ * custom preview keeps working removal, retry and cancel without reaching into the component.
6355
+ *
6356
+ * @example
6357
+ * ```html
6358
+ * <hub-file-input formControlName="attachments" [multiple]="true">
6359
+ * <ng-template hubFilePreview let-item let-remove="remove">
6360
+ * <article class="attachment">
6361
+ * <img *ngIf="item.previewUrl" [src]="item.previewUrl" alt="" />
6362
+ * <span>{{ item.file.name }}</span>
6363
+ * <button type="button" (click)="remove()">Remove</button>
6364
+ * </article>
6365
+ * </ng-template>
6366
+ * </hub-file-input>
6367
+ * ```
6368
+ */
6369
+ class HubFilePreviewDirective {
6370
+ /** Template reference for the item. */
6371
+ template = inject(TemplateRef);
6372
+ /**
6373
+ * Narrows the context for the template type-checker.
6374
+ *
6375
+ * @param _directive - The directive instance.
6376
+ * @param _context - The candidate context.
6377
+ * @returns Always `true`; exists only to type `let-item` and the action bindings.
6378
+ */
6379
+ static ngTemplateContextGuard(_directive, _context) {
6380
+ return true;
6381
+ }
6382
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: HubFilePreviewDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
6383
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.1", type: HubFilePreviewDirective, isStandalone: true, selector: "[hubFilePreview]", ngImport: i0 });
6384
+ }
6385
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: HubFilePreviewDirective, decorators: [{
6386
+ type: Directive,
6387
+ args: [{
6388
+ selector: '[hubFilePreview]'
6389
+ }]
6390
+ }] });
6391
+
6392
+ /**
6393
+ * Tests a file against an `accept` specification.
6394
+ *
6395
+ * The native `accept` attribute only filters the operating-system file dialog: files arriving by
6396
+ * drag-and-drop or paste bypass it entirely. This function applies the same rules by hand, so a
6397
+ * dropped file is held to the constraint the consumer declared.
6398
+ *
6399
+ * Supported tokens, comma-separated, matched case-insensitively:
6400
+ * - an extension — `.pdf`
6401
+ * - a wildcard MIME type — `image/*`
6402
+ * - an exact MIME type — `application/pdf`
6403
+ * - the catch-alls `*` and `*​/*`
6404
+ *
6405
+ * Note that a MIME token can never match a file the browser could not type (`file.type === ''`,
6406
+ * common for unusual extensions); use an extension token for those.
6407
+ *
6408
+ * @param file - The file to test.
6409
+ * @param accept - The `accept` specification. Empty or `*` accepts everything.
6410
+ * @returns `true` when the file satisfies at least one token.
6411
+ */
6412
+ function matchesAccept(file, accept) {
6413
+ const specification = (accept ?? '').trim();
6414
+ if (!specification || specification === '*' || specification === '*/*') {
6415
+ return true;
6416
+ }
6417
+ const name = file.name.toLowerCase();
6418
+ const type = (file.type ?? '').toLowerCase();
6419
+ return specification
6420
+ .split(',')
6421
+ .map((token) => token.trim().toLowerCase())
6422
+ .filter((token) => token.length > 0)
6423
+ .some((token) => {
6424
+ if (token === '*' || token === '*/*') {
6425
+ return true;
6426
+ }
6427
+ // An extension, e.g. `.pdf`
6428
+ if (token.startsWith('.')) {
6429
+ return name.endsWith(token);
6430
+ }
6431
+ // A wildcard MIME type, e.g. `image/*` — the file's type must start with `image/`
6432
+ if (token.endsWith('/*')) {
6433
+ return type.length > 0 && type.startsWith(token.slice(0, -1));
6434
+ }
6435
+ // An exact MIME type
6436
+ return type === token;
6437
+ });
6438
+ }
6439
+
6440
+ /**
6441
+ * Builds the identity key used to detect duplicate selections.
6442
+ *
6443
+ * Two `File` objects are never referentially equal across separate drops, so identity is derived
6444
+ * from the tuple the browser exposes: name, byte size and last-modified timestamp. Collisions are
6445
+ * possible in theory (same name, size and mtime) but describe the same file in every practical case.
6446
+ *
6447
+ * @param file - The file to key.
6448
+ * @returns A stable string key for the file.
6449
+ */
6450
+ function fileKey(file) {
6451
+ return `${file.name}:${file.size}:${file.lastModified}`;
6452
+ }
6453
+
6454
+ /**
6455
+ * Normalizes any file-shaped control value to a plain array.
6456
+ *
6457
+ * Accepts the shapes a control can realistically hold: a single `File` (single mode), an array of
6458
+ * files (multiple mode), a native `FileList` (a control fed straight from a DOM event or from the
6459
+ * deprecated `<hub-input type="file">`), or `null`/`undefined`.
6460
+ *
6461
+ * @param value - The raw control value.
6462
+ * @returns The files it contains, in order; an empty array when it holds none.
6463
+ */
6464
+ function toFileArray(value) {
6465
+ if (value == null) {
6466
+ return [];
6467
+ }
6468
+ if (value instanceof File) {
6469
+ return [value];
6470
+ }
6471
+ if (Array.isArray(value)) {
6472
+ return value.filter((entry) => entry instanceof File);
6473
+ }
6474
+ if (typeof FileList !== 'undefined' && value instanceof FileList) {
6475
+ return Array.from(value);
6476
+ }
6477
+ return [];
6478
+ }
6479
+
6480
+ /**
6481
+ * Accessible file field with drag-and-drop, clipboard paste, per-file constraints, previews and
6482
+ * optional upload progress.
6483
+ *
6484
+ * A full `ng-hub-ui-forms` field: it extends {@link HubFieldControl}, so it binds with
6485
+ * `formControlName` / `ngModel` and shares the label, helper text and validation chrome with every
6486
+ * other control.
6487
+ *
6488
+ * The **form value stays native** — a `File` in single mode, a `File[]` in multiple mode, `null`
6489
+ * when empty — so it can be handed straight to a `FormData`. The rich per-file state (preview URL,
6490
+ * upload status, progress, error) lives in the {@link files} signal instead of contaminating the
6491
+ * control.
6492
+ *
6493
+ * Constraints declared as inputs (`accept`, `maxSize`, `minSize`, `maxTotalSize`, `maxFiles`) act as
6494
+ * a **filter**: an offending file never reaches the value and surfaces through {@link rejected}.
6495
+ * They are enforced by hand rather than delegated to the native `accept` attribute, which only
6496
+ * filters the operating-system dialog and is bypassed entirely by a drop or a paste. To make the
6497
+ * *control itself* invalid — the right choice when a value can also be patched in programmatically —
6498
+ * add the matching validators (`hubAcceptedFiles`, `hubMaxFileSize`, …).
6499
+ *
6500
+ * Uploading is opt-in and transport-agnostic: register a {@link HubFileUploader} with
6501
+ * `provideHubFileUploader()` and the component drives progress, cancel and retry. With no uploader
6502
+ * registered it is a pure picker.
6503
+ *
6504
+ * @example
6505
+ * ```html
6506
+ * <hub-file-input
6507
+ * formControlName="attachments"
6508
+ * label="Attachments"
6509
+ * [multiple]="true"
6510
+ * accept="image/*,.pdf"
6511
+ * [maxSize]="5 * 1024 * 1024"
6512
+ * [maxFiles]="3"
6513
+ * preview="grid"
6514
+ * (rejected)="notify($event)"
6515
+ * />
6516
+ * ```
6517
+ */
6518
+ class HubFileInputComponent extends HubFieldControl {
6519
+ #config = inject(HUB_FORMS_CONFIG);
6520
+ #uploader = inject(HUB_FILE_UPLOADER, { optional: true });
6521
+ #isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
6522
+ #subscriptions = new Map();
6523
+ _formTextTypes = FormTextTypes;
6524
+ _items = signal([], /* @ts-ignore */
6525
+ ...(ngDevMode ? [{ debugName: "_items" }] : /* istanbul ignore next */ []));
6526
+ /** Depth counter for `dragenter`/`dragleave`: children of the dropzone fire their own events. */
6527
+ #dragDepth = 0;
6528
+ /** Releases every object URL and aborts every in-flight upload when the field goes away. */
6529
+ _cleanup = inject(DestroyRef).onDestroy(() => {
6530
+ this.#subscriptions.forEach((subscription) => subscription.unsubscribe());
6531
+ this.#subscriptions.clear();
6532
+ this._items().forEach((item) => this.#revokePreview(item));
6533
+ });
6534
+ /** Label text. */
6535
+ label = input('', /* @ts-ignore */
6536
+ ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
6537
+ /** Whether more than one file can be held. Switches the control value to `File[]`. */
6538
+ multiple = input(false, { ...(ngDevMode ? { debugName: "multiple" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
6539
+ /** Accepted file types, e.g. `image/*,.pdf`. Enforced on drop and paste, not just in the dialog. */
6540
+ accept = input('*', /* @ts-ignore */
6541
+ ...(ngDevMode ? [{ debugName: "accept" }] : /* istanbul ignore next */ []));
6542
+ /** Maximum size per file, in bytes. */
6543
+ maxSize = input(null, /* @ts-ignore */
6544
+ ...(ngDevMode ? [{ debugName: "maxSize" }] : /* istanbul ignore next */ []));
6545
+ /** Minimum size per file, in bytes. Rejects the 0-byte files a failed export produces. */
6546
+ minSize = input(null, /* @ts-ignore */
6547
+ ...(ngDevMode ? [{ debugName: "minSize" }] : /* istanbul ignore next */ []));
6548
+ /** Maximum combined size of every held file, in bytes. */
6549
+ maxTotalSize = input(null, /* @ts-ignore */
6550
+ ...(ngDevMode ? [{ debugName: "maxTotalSize" }] : /* istanbul ignore next */ []));
6551
+ /** Maximum number of files. Only meaningful with `multiple`. */
6552
+ maxFiles = input(null, /* @ts-ignore */
6553
+ ...(ngDevMode ? [{ debugName: "maxFiles" }] : /* istanbul ignore next */ []));
6554
+ /** Whether files can be dropped onto the field. */
6555
+ dragDrop = input(true, { ...(ngDevMode ? { debugName: "dragDrop" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
6556
+ /** Whether files can be pasted into the focused field (e.g. a screenshot). */
6557
+ paste = input(true, { ...(ngDevMode ? { debugName: "paste" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
6558
+ /** How the held files are rendered: not at all, as a list, or as a thumbnail grid. */
6559
+ preview = input('list', /* @ts-ignore */
6560
+ ...(ngDevMode ? [{ debugName: "preview" }] : /* istanbul ignore next */ []));
6561
+ /** Whether the same file can be selected twice (keyed on name, size and last-modified date). */
6562
+ allowDuplicates = input(false, { ...(ngDevMode ? { debugName: "allowDuplicates" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
6563
+ /** Opens the device camera instead of the file browser, on the platforms that support it. */
6564
+ capture = input(null, /* @ts-ignore */
6565
+ ...(ngDevMode ? [{ debugName: "capture" }] : /* istanbul ignore next */ []));
6566
+ /** Whether a newly accepted file starts uploading immediately. Ignored with no uploader registered. */
6567
+ autoUpload = input(true, { ...(ngDevMode ? { debugName: "autoUpload" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
6568
+ /** Overrides the "browse" button text coming from the global labels. */
6569
+ buttonLabel = input(null, /* @ts-ignore */
6570
+ ...(ngDevMode ? [{ debugName: "buttonLabel" }] : /* istanbul ignore next */ []));
6571
+ /** Overrides the dropzone invitation coming from the global labels. */
6572
+ dropText = input(null, /* @ts-ignore */
6573
+ ...(ngDevMode ? [{ debugName: "dropText" }] : /* istanbul ignore next */ []));
6574
+ /** Overrides the second invitation line coming from the global labels. Pass `''` to hide it. */
6575
+ dropSubtext = input(null, /* @ts-ignore */
6576
+ ...(ngDevMode ? [{ debugName: "dropSubtext" }] : /* istanbul ignore next */ []));
6577
+ /** Overrides the auto-generated hint that summarizes the active constraints. Pass `''` to hide it. */
6578
+ hint = input(null, /* @ts-ignore */
6579
+ ...(ngDevMode ? [{ debugName: "hint" }] : /* istanbul ignore next */ []));
6580
+ /** Helper text shown below the control. */
6581
+ formText = input('', /* @ts-ignore */
6582
+ ...(ngDevMode ? [{ debugName: "formText" }] : /* istanbul ignore next */ []));
6583
+ /** Helper text placement. Only `bottom` is supported. */
6584
+ formTextType = input(FormTextTypes.Bottom, /* @ts-ignore */
6585
+ ...(ngDevMode ? [{ debugName: "formTextType" }] : /* istanbul ignore next */ []));
6586
+ /** Extra CSS classes applied to the host element. */
6587
+ classlist = input('', /* @ts-ignore */
6588
+ ...(ngDevMode ? [{ debugName: "classlist" }] : /* istanbul ignore next */ []));
6589
+ /** Emits the native value whenever the selection changes. */
6590
+ valueChange = output();
6591
+ /** Emits the files refused by the declared constraints, with the reason for each. */
6592
+ rejected = output();
6593
+ /** Emits the file removed from the selection. */
6594
+ fileRemoved = output();
6595
+ /** Emits the full item list whenever an upload changes status or progress. */
6596
+ uploadStateChange = output();
6597
+ /** The hidden native file input, kept focusable so the dropzone label activates it. */
6598
+ nativeInput = viewChild('nativeInput', /* @ts-ignore */
6599
+ ...(ngDevMode ? [{ debugName: "nativeInput" }] : /* istanbul ignore next */ []));
6600
+ /** Projected per-file icon template. */
6601
+ iconTpt = contentChild(HubFileIconDirective, /* @ts-ignore */
6602
+ ...(ngDevMode ? [{ debugName: "iconTpt" }] : /* istanbul ignore next */ []));
6603
+ /** Projected per-file preview template, replacing the built-in item rendering. */
6604
+ previewTpt = contentChild(HubFilePreviewDirective, /* @ts-ignore */
6605
+ ...(ngDevMode ? [{ debugName: "previewTpt" }] : /* istanbul ignore next */ []));
6606
+ /** Projected notice rendered between the glyph and the invitation. */
6607
+ noticeTpt = contentChild(HubFileDropzoneNoticeDirective, /* @ts-ignore */
6608
+ ...(ngDevMode ? [{ debugName: "noticeTpt" }] : /* istanbul ignore next */ []));
6609
+ /** Whether a drag is currently hovering the dropzone. */
6610
+ isDragging = signal(false, /* @ts-ignore */
6611
+ ...(ngDevMode ? [{ debugName: "isDragging" }] : /* istanbul ignore next */ []));
6612
+ /** Latest message for the screen-reader live region. */
6613
+ announcement = signal('', /* @ts-ignore */
6614
+ ...(ngDevMode ? [{ debugName: "announcement" }] : /* istanbul ignore next */ []));
6615
+ /** The rich, per-file state of the current selection. */
6616
+ files = this._items.asReadonly();
6617
+ /** Whether at least one file is currently uploading. Gate a submit button on this. */
6618
+ uploading = computed(() => this._items().some((item) => item.status === 'uploading'), /* @ts-ignore */
6619
+ ...(ngDevMode ? [{ debugName: "uploading" }] : /* istanbul ignore next */ []));
6620
+ /** Whether an uploader is registered, which is what enables progress, cancel and retry. */
6621
+ hasUploader = !!this.#uploader;
6622
+ /** The resolved, localizable labels. */
6623
+ labels = computed(() => this.#config.fileInput, /* @ts-ignore */
6624
+ ...(ngDevMode ? [{ debugName: "labels" }] : /* istanbul ignore next */ []));
6625
+ /** Text of the browse action. */
6626
+ browseLabel = computed(() => this.buttonLabel() ?? this.labels().browse, /* @ts-ignore */
6627
+ ...(ngDevMode ? [{ debugName: "browseLabel" }] : /* istanbul ignore next */ []));
6628
+ /** The dropzone invitation. */
6629
+ dropTextLabel = computed(() => this.dropText() ?? this.labels().dropHere, /* @ts-ignore */
6630
+ ...(ngDevMode ? [{ debugName: "dropTextLabel" }] : /* istanbul ignore next */ []));
6631
+ /** The second invitation line. Empty renders nothing. */
6632
+ dropSubtextLabel = computed(() => this.dropSubtext() ?? this.labels().dropSubtext, /* @ts-ignore */
6633
+ ...(ngDevMode ? [{ debugName: "dropSubtextLabel" }] : /* istanbul ignore next */ []));
6634
+ /** The hint summarizing the active constraints. */
6635
+ constraintsHint = computed(() => {
6636
+ const override = this.hint();
6637
+ if (override != null) {
6638
+ return override;
6639
+ }
6640
+ const accept = this.accept();
6641
+ return this.labels().constraints({
6642
+ accept: !accept || accept === '*' || accept === '*/*' ? null : accept,
6643
+ maxSize: this.maxSize(),
6644
+ maxFiles: this.multiple() ? this.maxFiles() : null
6645
+ });
6646
+ }, /* @ts-ignore */
6647
+ ...(ngDevMode ? [{ debugName: "constraintsHint" }] : /* istanbul ignore next */ []));
6648
+ /** Whether the dropzone currently accepts a drop. */
6649
+ dropEnabled = computed(() => this.dragDrop() && !this.disabled(), /* @ts-ignore */
6650
+ ...(ngDevMode ? [{ debugName: "dropEnabled" }] : /* istanbul ignore next */ []));
6651
+ /**
6652
+ * Derives the native validation errors from the held files rather than from the DOM.
6653
+ *
6654
+ * The hidden native input is emptied after every `change` so the same file can be picked twice,
6655
+ * and it never sees a dropped or pasted file at all. Its `validity` therefore reports
6656
+ * `valueMissing` even when files are held — reading it would mark a satisfied field as required.
6657
+ *
6658
+ * @param _target - Ignored; the file state is the source of truth.
6659
+ */
6660
+ updateNativeErrors(_target) {
6661
+ if (this._control) {
6662
+ return;
6663
+ }
6664
+ this._nativeErrors.set(this.required() && this._items().length === 0 ? { required: true } : null);
6665
+ }
6666
+ /**
6667
+ * Resolves a human-readable message for a failed upload.
6668
+ *
6669
+ * @param item - The item in the `error` state.
6670
+ * @returns The error message.
6671
+ */
6672
+ errorText(item) {
6673
+ const error = item.error;
6674
+ return error?.message ?? String(error ?? '');
6675
+ }
6676
+ writeValue(value) {
6677
+ this._items().forEach((item) => this.#revokePreview(item));
6678
+ this.#subscriptions.forEach((subscription) => subscription.unsubscribe());
6679
+ this.#subscriptions.clear();
6680
+ this._items.set(toFileArray(value).map((file) => this.#createItem(file)));
6681
+ }
6682
+ /** Opens the native file dialog. */
6683
+ open() {
6684
+ if (!this.disabled()) {
6685
+ this.nativeInput()?.nativeElement.click();
6686
+ }
6687
+ }
6688
+ /**
6689
+ * Removes a file from the selection, aborting its upload and releasing its preview.
6690
+ *
6691
+ * @param id - The id of the item to remove.
6692
+ */
6693
+ remove(id) {
6694
+ const item = this._items().find((candidate) => candidate.id === id);
6695
+ if (!item || this.disabled()) {
6696
+ return;
6697
+ }
6698
+ this.#abort(id);
6699
+ this.#revokePreview(item);
6700
+ this._items.update((items) => items.filter((candidate) => candidate.id !== id));
6701
+ this.#emitValue();
6702
+ this.fileRemoved.emit(item.file);
6703
+ this.#announce(this.labels().filesSelected(this._items().length));
6704
+ }
6705
+ /** Removes every file, aborting any upload in flight. */
6706
+ clear() {
6707
+ if (this.disabled()) {
6708
+ return;
6709
+ }
6710
+ this.#subscriptions.forEach((subscription) => subscription.unsubscribe());
6711
+ this.#subscriptions.clear();
6712
+ this._items().forEach((item) => this.#revokePreview(item));
6713
+ this._items.set([]);
6714
+ this.#emitValue();
6715
+ this.#announce(this.labels().filesSelected(0));
6716
+ }
6717
+ /** Uploads every file that has not been uploaded yet. No-op without a registered uploader. */
6718
+ upload() {
6719
+ this._items()
6720
+ .filter((item) => item.status === 'ready' || item.status === 'error')
6721
+ .forEach((item) => this.#startUpload(item.id));
6722
+ }
6723
+ /**
6724
+ * Aborts an in-flight upload, returning the file to the `ready` state.
6725
+ *
6726
+ * @param id - The id of the uploading item.
6727
+ */
6728
+ cancel(id) {
6729
+ if (!this.#subscriptions.has(id)) {
6730
+ return;
6731
+ }
6732
+ this.#abort(id);
6733
+ this.#patch(id, { status: 'ready', progress: null, error: null, response: null });
6734
+ }
6735
+ /**
6736
+ * Re-runs the upload of a file, typically after a failure.
6737
+ *
6738
+ * @param id - The id of the item to upload again.
6739
+ */
6740
+ retry(id) {
6741
+ this.#startUpload(id);
6742
+ }
6743
+ /**
6744
+ * Reads the files chosen through the native dialog.
6745
+ *
6746
+ * @param event - The `change` event of the hidden native input.
6747
+ */
6748
+ handleNativeChange(event) {
6749
+ const native = event.target;
6750
+ this.#addFiles(Array.from(native.files ?? []));
6751
+ // Without this, re-picking the very same file fires no second `change` event.
6752
+ native.value = '';
6753
+ }
6754
+ /**
6755
+ * Accepts files dropped onto the dropzone.
6756
+ *
6757
+ * @param event - The drop event.
6758
+ */
6759
+ handleDrop(event) {
6760
+ if (!this.dropEnabled()) {
6761
+ return;
6762
+ }
6763
+ event.preventDefault();
6764
+ this.#dragDepth = 0;
6765
+ this.isDragging.set(false);
6766
+ this.#addFiles(this.#extractFiles(event.dataTransfer));
6767
+ this.handleBlur();
6768
+ }
6769
+ /**
6770
+ * Highlights the dropzone once a drag carrying files enters it.
6771
+ *
6772
+ * @param event - The dragenter event.
6773
+ */
6774
+ handleDragEnter(event) {
6775
+ if (!this.dropEnabled() || !this.#carriesFiles(event)) {
6776
+ return;
6777
+ }
6778
+ event.preventDefault();
6779
+ this.#dragDepth++;
6780
+ this.isDragging.set(true);
6781
+ }
6782
+ /**
6783
+ * Keeps the drop target alive. A `dragover` that does not call `preventDefault` refuses the drop.
6784
+ *
6785
+ * @param event - The dragover event.
6786
+ */
6787
+ handleDragOver(event) {
6788
+ if (!this.dropEnabled() || !this.#carriesFiles(event)) {
6789
+ return;
6790
+ }
6791
+ event.preventDefault();
6792
+ if (event.dataTransfer) {
6793
+ event.dataTransfer.dropEffect = 'copy';
6794
+ }
6795
+ }
6796
+ /**
6797
+ * Un-highlights the dropzone, but only once the drag has left it *and* all of its children.
6798
+ *
6799
+ * @param event - The dragleave event.
6800
+ */
6801
+ handleDragLeave(event) {
6802
+ if (!this.dropEnabled()) {
6803
+ return;
6804
+ }
6805
+ event.preventDefault();
6806
+ this.#dragDepth = Math.max(0, this.#dragDepth - 1);
6807
+ if (this.#dragDepth === 0) {
6808
+ this.isDragging.set(false);
6809
+ }
6810
+ }
6811
+ /**
6812
+ * Accepts files pasted into the focused field, e.g. a screenshot from the clipboard.
6813
+ *
6814
+ * @param event - The paste event.
6815
+ */
6816
+ handlePaste(event) {
6817
+ if (!this.paste() || this.disabled()) {
6818
+ return;
6819
+ }
6820
+ const files = this.#extractFiles(event.clipboardData);
6821
+ if (files.length > 0) {
6822
+ event.preventDefault();
6823
+ this.#addFiles(files);
6824
+ }
6825
+ }
6826
+ /**
6827
+ * Builds the context handed to a projected `hubFilePreview` template.
6828
+ *
6829
+ * @param item - The item being rendered.
6830
+ * @returns The template context, complete with the actions the item needs.
6831
+ */
6832
+ previewContext(item) {
6833
+ return {
6834
+ $implicit: item,
6835
+ remove: () => this.remove(item.id),
6836
+ retry: () => this.retry(item.id),
6837
+ cancel: () => this.cancel(item.id)
6838
+ };
6839
+ }
6840
+ /**
6841
+ * Validates and appends the incoming files, emitting the new value and any rejections.
6842
+ *
6843
+ * In single mode the incoming file replaces the current one, so the constraint checks run against
6844
+ * an empty baseline rather than against the file about to be discarded.
6845
+ *
6846
+ * @param incoming - The files to add.
6847
+ */
6848
+ #addFiles(incoming) {
6849
+ if (this.disabled() || incoming.length === 0) {
6850
+ return;
6851
+ }
6852
+ const multiple = this.multiple();
6853
+ const current = this._items();
6854
+ const baseline = multiple ? current : [];
6855
+ const accepted = [];
6856
+ const rejections = [];
6857
+ const keys = new Set(baseline.map((item) => fileKey(item.file)));
6858
+ let count = baseline.length;
6859
+ let total = baseline.reduce((sum, item) => sum + item.file.size, 0);
6860
+ for (const file of incoming) {
6861
+ const rejection = this.#reject(file, count, total, keys);
6862
+ if (rejection) {
6863
+ rejections.push(rejection);
6864
+ continue;
6865
+ }
6866
+ keys.add(fileKey(file));
6867
+ count++;
6868
+ total += file.size;
6869
+ accepted.push(this.#createItem(file));
6870
+ }
6871
+ if (accepted.length > 0) {
6872
+ if (!multiple) {
6873
+ current.forEach((item) => this.#abort(item.id));
6874
+ current.forEach((item) => this.#revokePreview(item));
6875
+ }
6876
+ this._items.set([...baseline, ...accepted]);
6877
+ this.#emitValue();
6878
+ if (this.#uploader && this.autoUpload()) {
6879
+ accepted.forEach((item) => this.#startUpload(item.id));
6880
+ }
6881
+ }
6882
+ if (rejections.length > 0) {
6883
+ this.rejected.emit(rejections);
6884
+ }
6885
+ this.#announce(this.#summarize(accepted.length, rejections));
6886
+ }
6887
+ /**
6888
+ * Applies the declared constraints to a single candidate file.
6889
+ *
6890
+ * @param file - The candidate.
6891
+ * @param count - How many files are already accepted.
6892
+ * @param total - The combined size of the already-accepted files.
6893
+ * @param keys - Identity keys of the already-accepted files, for duplicate detection.
6894
+ * @returns The rejection, or `null` when the file passes every constraint.
6895
+ */
6896
+ #reject(file, count, total, keys) {
6897
+ const build = (reason, limit) => ({
6898
+ file,
6899
+ reason,
6900
+ limit
6901
+ });
6902
+ if (!this.allowDuplicates() && keys.has(fileKey(file))) {
6903
+ return build('duplicate', null);
6904
+ }
6905
+ if (!matchesAccept(file, this.accept())) {
6906
+ return build('accept', this.accept());
6907
+ }
6908
+ const minSize = this.minSize();
6909
+ if (minSize != null && file.size < minSize) {
6910
+ return build('minSize', minSize);
6911
+ }
6912
+ const maxSize = this.maxSize();
6913
+ if (maxSize != null && file.size > maxSize) {
6914
+ return build('maxSize', maxSize);
6915
+ }
6916
+ const maxFiles = this.multiple() ? this.maxFiles() : 1;
6917
+ if (maxFiles != null && count + 1 > maxFiles) {
6918
+ return build('maxFiles', maxFiles);
6919
+ }
6920
+ const maxTotalSize = this.maxTotalSize();
6921
+ if (maxTotalSize != null && total + file.size > maxTotalSize) {
6922
+ return build('maxTotalSize', maxTotalSize);
6923
+ }
6924
+ return null;
6925
+ }
6926
+ /**
6927
+ * Wraps a file in an item, minting its preview URL when it is a previewable image.
6928
+ *
6929
+ * @param file - The accepted file.
6930
+ * @returns The new item, in the `ready` state.
6931
+ */
6932
+ #createItem(file) {
6933
+ const previewable = this.#isBrowser && this.preview() !== 'none' && file.type.startsWith('image/');
6934
+ return {
6935
+ id: uuid(),
6936
+ file,
6937
+ previewUrl: previewable ? URL.createObjectURL(file) : null,
6938
+ status: 'ready',
6939
+ progress: null,
6940
+ error: null,
6941
+ response: null
6942
+ };
6943
+ }
6944
+ /**
6945
+ * Releases the object URL of an item. Skipping this leaks the whole file for the page's lifetime.
6946
+ *
6947
+ * @param item - The item whose preview should be released.
6948
+ */
6949
+ #revokePreview(item) {
6950
+ if (item.previewUrl && this.#isBrowser) {
6951
+ URL.revokeObjectURL(item.previewUrl);
6952
+ }
6953
+ }
6954
+ /** Writes the native value to the form control and emits it. */
6955
+ #emitValue() {
6956
+ const files = this._items().map((item) => item.file);
6957
+ const value = this.multiple() ? files : (files[0] ?? null);
6958
+ this.onChange?.(value);
6959
+ this.valueChange.emit(value);
6960
+ }
6961
+ /**
6962
+ * Subscribes to the registered uploader for one item, mapping its events onto the item's status.
6963
+ *
6964
+ * @param id - The id of the item to upload.
6965
+ */
6966
+ #startUpload(id) {
6967
+ const uploader = this.#uploader;
6968
+ const item = this._items().find((candidate) => candidate.id === id);
6969
+ if (!uploader || !item) {
6970
+ return;
6971
+ }
6972
+ this.#abort(id);
6973
+ this.#patch(id, { status: 'uploading', progress: null, error: null, response: null });
6974
+ const subscription = uploader.upload(item.file, { id }).subscribe({
6975
+ next: (event) => {
6976
+ if (event.status === 'progress') {
6977
+ // A transport that cannot report a total leaves `progress` null, which the template
6978
+ // renders as an indeterminate bar rather than a bar frozen at 0%.
6979
+ const progress = event.total ? Math.round((event.loaded / event.total) * 100) : null;
6980
+ this.#patch(id, { status: 'uploading', progress });
6981
+ return;
6982
+ }
6983
+ if (event.status === 'done') {
6984
+ // The body is what identifies the stored file server-side; without keeping it the
6985
+ // application has no way to reference what it just uploaded.
6986
+ this.#patch(id, { status: 'done', progress: 100, error: null, response: event.response ?? null });
6987
+ return;
6988
+ }
6989
+ this.#patch(id, { status: 'error', error: event.error });
6990
+ },
6991
+ error: (error) => this.#patch(id, { status: 'error', error }),
6992
+ complete: () => {
6993
+ // An uploader that completes without emitting `done` still finished successfully.
6994
+ const current = this._items().find((candidate) => candidate.id === id);
6995
+ if (current?.status === 'uploading') {
6996
+ this.#patch(id, { status: 'done', progress: 100, error: null });
6997
+ }
6998
+ }
6999
+ });
7000
+ this.#subscriptions.set(id, subscription);
7001
+ }
7002
+ /**
7003
+ * Unsubscribes from an item's upload, which aborts the underlying request.
7004
+ *
7005
+ * @param id - The id of the item.
7006
+ */
7007
+ #abort(id) {
7008
+ this.#subscriptions.get(id)?.unsubscribe();
7009
+ this.#subscriptions.delete(id);
7010
+ }
7011
+ /**
7012
+ * Immutably updates one item and notifies listeners of the new upload state.
7013
+ *
7014
+ * @param id - The id of the item to update.
7015
+ * @param patch - The fields to overwrite.
7016
+ */
7017
+ #patch(id, patch) {
7018
+ let changed = false;
7019
+ this._items.update((items) => items.map((item) => {
7020
+ if (item.id !== id) {
7021
+ return item;
7022
+ }
7023
+ changed = true;
7024
+ return { ...item, ...patch };
7025
+ }));
7026
+ if (changed) {
7027
+ this.uploadStateChange.emit(this._items());
7028
+ }
7029
+ }
7030
+ /**
7031
+ * Extracts the files carried by a drop or a paste.
7032
+ *
7033
+ * Reads `items` when present so non-file payloads (dragged text, HTML) are skipped, and falls
7034
+ * back to `files` otherwise.
7035
+ *
7036
+ * @param transfer - The `DataTransfer` of the event.
7037
+ * @returns The files it carries.
7038
+ */
7039
+ #extractFiles(transfer) {
7040
+ if (!transfer) {
7041
+ return [];
7042
+ }
7043
+ if (transfer.items?.length) {
7044
+ return Array.from(transfer.items)
7045
+ .filter((item) => item.kind === 'file')
7046
+ .map((item) => item.getAsFile())
7047
+ .filter((file) => file !== null);
7048
+ }
7049
+ return Array.from(transfer.files ?? []);
7050
+ }
7051
+ /**
7052
+ * Whether a drag event carries files, as opposed to text or a page element.
7053
+ *
7054
+ * @param event - The drag event.
7055
+ * @returns `true` when the payload includes files.
7056
+ */
7057
+ #carriesFiles(event) {
7058
+ return Array.from(event.dataTransfer?.types ?? []).includes('Files');
7059
+ }
7060
+ /**
7061
+ * Builds the live-region message describing the outcome of an add.
7062
+ *
7063
+ * @param acceptedCount - How many files were accepted.
7064
+ * @param rejections - The files that were refused.
7065
+ * @returns The message to announce.
7066
+ */
7067
+ #summarize(acceptedCount, rejections) {
7068
+ const labels = this.labels();
7069
+ const parts = [];
7070
+ if (acceptedCount > 0) {
7071
+ parts.push(labels.filesSelected(this._items().length));
7072
+ }
7073
+ rejections.forEach((rejection) => parts.push(labels.rejection(rejection)));
7074
+ return parts.join(' ');
7075
+ }
7076
+ /**
7077
+ * Publishes a message to the live region, forcing a re-announcement of identical text.
7078
+ *
7079
+ * @param message - The message to announce.
7080
+ */
7081
+ #announce(message) {
7082
+ if (!message) {
7083
+ return;
7084
+ }
7085
+ // A screen reader ignores a live region whose text did not change; the zero-width space makes
7086
+ // two consecutive identical messages differ.
7087
+ this.announcement.set(this.announcement() === message ? `${message}​` : message);
7088
+ }
7089
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: HubFileInputComponent, deps: null, target: i0.ɵɵFactoryTarget.Component });
7090
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.1", type: HubFileInputComponent, isStandalone: true, selector: "hub-file-input", inputs: { label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, accept: { classPropertyName: "accept", publicName: "accept", isSignal: true, isRequired: false, transformFunction: null }, maxSize: { classPropertyName: "maxSize", publicName: "maxSize", isSignal: true, isRequired: false, transformFunction: null }, minSize: { classPropertyName: "minSize", publicName: "minSize", isSignal: true, isRequired: false, transformFunction: null }, maxTotalSize: { classPropertyName: "maxTotalSize", publicName: "maxTotalSize", isSignal: true, isRequired: false, transformFunction: null }, maxFiles: { classPropertyName: "maxFiles", publicName: "maxFiles", isSignal: true, isRequired: false, transformFunction: null }, dragDrop: { classPropertyName: "dragDrop", publicName: "dragDrop", isSignal: true, isRequired: false, transformFunction: null }, paste: { classPropertyName: "paste", publicName: "paste", isSignal: true, isRequired: false, transformFunction: null }, preview: { classPropertyName: "preview", publicName: "preview", isSignal: true, isRequired: false, transformFunction: null }, allowDuplicates: { classPropertyName: "allowDuplicates", publicName: "allowDuplicates", isSignal: true, isRequired: false, transformFunction: null }, capture: { classPropertyName: "capture", publicName: "capture", isSignal: true, isRequired: false, transformFunction: null }, autoUpload: { classPropertyName: "autoUpload", publicName: "autoUpload", isSignal: true, isRequired: false, transformFunction: null }, buttonLabel: { classPropertyName: "buttonLabel", publicName: "buttonLabel", isSignal: true, isRequired: false, transformFunction: null }, dropText: { classPropertyName: "dropText", publicName: "dropText", isSignal: true, isRequired: false, transformFunction: null }, dropSubtext: { classPropertyName: "dropSubtext", publicName: "dropSubtext", isSignal: true, isRequired: false, transformFunction: null }, hint: { classPropertyName: "hint", publicName: "hint", isSignal: true, isRequired: false, transformFunction: null }, formText: { classPropertyName: "formText", publicName: "formText", isSignal: true, isRequired: false, transformFunction: null }, formTextType: { classPropertyName: "formTextType", publicName: "formTextType", isSignal: true, isRequired: false, transformFunction: null }, classlist: { classPropertyName: "classlist", publicName: "classlist", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { valueChange: "valueChange", rejected: "rejected", fileRemoved: "fileRemoved", uploadStateChange: "uploadStateChange" }, host: { listeners: { "paste": "handlePaste($event)" }, properties: { "class": "classlist()", "class.hub-file-input-host": "true" } }, queries: [{ propertyName: "iconTpt", first: true, predicate: HubFileIconDirective, descendants: true, isSignal: true }, { propertyName: "previewTpt", first: true, predicate: HubFilePreviewDirective, descendants: true, isSignal: true }, { propertyName: "noticeTpt", first: true, predicate: HubFileDropzoneNoticeDirective, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "nativeInput", first: true, predicate: ["nativeInput"], descendants: true, isSignal: true }], usesInheritance: true, ngImport: i0, template: "<div\n\tclass=\"hub-field hub-file-input\"\n\t[class.hub-field--disabled]=\"disabled()\"\n\t[class.hub-field--invalid]=\"isInvalid\"\n\t[class.hub-field--valid]=\"showsValid\"\n>\n\t@if (label() || required()) {\n\t\t<label [for]=\"id\" class=\"hub-field__label\">\n\t\t\t{{ label() }}\n\t\t\t@if (required()) {\n\t\t\t\t<span class=\"hub-field__required\" aria-hidden=\"true\">*</span>\n\t\t\t}\n\t\t</label>\n\t}\n\n\t<div class=\"hub-field__body\">\n\t\t<!--\n\t\t\tThe dropzone is a <label> bound to the hidden native input: clicking or activating it with\n\t\t\tthe keyboard opens the file dialog through the platform, so no role, tabindex or key\n\t\t\thandler is needed. The \"browse\" affordance is a <span>, not a <button>, because a button\n\t\t\tinside a label would open the dialog twice.\n\t\t-->\n\t\t<label\n\t\t\tclass=\"hub-file-input__dropzone\"\n\t\t\t[for]=\"id\"\n\t\t\t[class.hub-file-input__dropzone--dragover]=\"isDragging()\"\n\t\t\t[class.hub-file-input__dropzone--no-drop]=\"!dragDrop()\"\n\t\t\t[class.hub-file-input__dropzone--invalid]=\"isInvalid\"\n\t\t\t(dragenter)=\"handleDragEnter($event)\"\n\t\t\t(dragover)=\"handleDragOver($event)\"\n\t\t\t(dragleave)=\"handleDragLeave($event)\"\n\t\t\t(drop)=\"handleDrop($event)\"\n\t\t>\n\t\t\t<input\n\t\t\t\t#nativeInput\n\t\t\t\ttype=\"file\"\n\t\t\t\tclass=\"hub-file-input__native\"\n\t\t\t\t[id]=\"id\"\n\t\t\t\t[accept]=\"accept()\"\n\t\t\t\t[multiple]=\"multiple()\"\n\t\t\t\t[disabled]=\"disabled()\"\n\t\t\t\t[attr.capture]=\"capture()\"\n\t\t\t\t[attr.aria-required]=\"required() ? 'true' : null\"\n\t\t\t\t[attr.aria-invalid]=\"isInvalid\"\n\t\t\t\t[attr.aria-describedby]=\"constraintsHint() ? id + '-hint' : null\"\n\t\t\t\t(change)=\"handleNativeChange($event)\"\n\t\t\t\t(blur)=\"handleBlur($event)\"\n\t\t\t/>\n\n\t\t\t<span class=\"hub-file-input__icon\" aria-hidden=\"true\"></span>\n\n\t\t\t@if (noticeTpt(); as notice) {\n\t\t\t\t<ng-container [ngTemplateOutlet]=\"notice.template\"></ng-container>\n\t\t\t}\n\n\t\t\t<span class=\"hub-file-input__prompt\">\n\t\t\t\t@if (dragDrop()) {\n\t\t\t\t\t<span class=\"hub-file-input__drop-text\">{{ dropTextLabel() }}</span>\n\t\t\t\t}\n\t\t\t\t@if (dropSubtextLabel()) {\n\t\t\t\t\t<span class=\"hub-file-input__drop-subtext\">{{ dropSubtextLabel() }}</span>\n\t\t\t\t}\n\t\t\t\t<span class=\"hub-file-input__browse\">{{ browseLabel() }}</span>\n\t\t\t</span>\n\n\t\t\t@if (constraintsHint()) {\n\t\t\t\t<span class=\"hub-file-input__hint\" [id]=\"id + '-hint'\">{{ constraintsHint() }}</span>\n\t\t\t}\n\t\t</label>\n\n\t\t@if (preview() !== 'none' && _items().length > 0) {\n\t\t\t<ul class=\"hub-file-input__list\" [class.hub-file-input__list--grid]=\"preview() === 'grid'\">\n\t\t\t\t@for (item of _items(); track item.id) {\n\t\t\t\t\t<li class=\"hub-file-input__item\" [attr.data-status]=\"item.status\">\n\t\t\t\t\t\t@if (previewTpt(); as tpt) {\n\t\t\t\t\t\t\t<ng-container\n\t\t\t\t\t\t\t\t[ngTemplateOutlet]=\"tpt.template\"\n\t\t\t\t\t\t\t\t[ngTemplateOutletContext]=\"previewContext(item)\"\n\t\t\t\t\t\t\t></ng-container>\n\t\t\t\t\t\t} @else {\n\t\t\t\t\t\t\t<span class=\"hub-file-input__thumb\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t\t@if (item.previewUrl) {\n\t\t\t\t\t\t\t\t\t<img class=\"hub-file-input__image\" [src]=\"item.previewUrl\" alt=\"\" />\n\t\t\t\t\t\t\t\t} @else if (iconTpt(); as icon) {\n\t\t\t\t\t\t\t\t\t<ng-container\n\t\t\t\t\t\t\t\t\t\t[ngTemplateOutlet]=\"icon.template\"\n\t\t\t\t\t\t\t\t\t\t[ngTemplateOutletContext]=\"{ $implicit: item }\"\n\t\t\t\t\t\t\t\t\t></ng-container>\n\t\t\t\t\t\t\t\t} @else {\n\t\t\t\t\t\t\t\t\t<span class=\"hub-file-input__file-icon\"></span>\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t</span>\n\n\t\t\t\t\t\t\t<span class=\"hub-file-input__meta\">\n\t\t\t\t\t\t\t\t<span class=\"hub-file-input__name\" [title]=\"item.file.name\">{{ item.file.name }}</span>\n\t\t\t\t\t\t\t\t<span class=\"hub-file-input__size\">{{ labels().formatSize(item.file.size) }}</span>\n\n\t\t\t\t\t\t\t\t@if (item.status === 'uploading') {\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\tclass=\"hub-file-input__progress\"\n\t\t\t\t\t\t\t\t\t\t[class.hub-file-input__progress--indeterminate]=\"item.progress === null\"\n\t\t\t\t\t\t\t\t\t\trole=\"progressbar\"\n\t\t\t\t\t\t\t\t\t\taria-valuemin=\"0\"\n\t\t\t\t\t\t\t\t\t\taria-valuemax=\"100\"\n\t\t\t\t\t\t\t\t\t\t[attr.aria-valuenow]=\"item.progress\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tclass=\"hub-file-input__progress-bar\"\n\t\t\t\t\t\t\t\t\t\t\t[style.--hub-file-input-progress-value]=\"item.progress ?? 0\"\n\t\t\t\t\t\t\t\t\t\t></span>\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t@if (item.status === 'error') {\n\t\t\t\t\t\t\t\t\t<span class=\"hub-file-input__error\">{{ errorText(item) }}</span>\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t</span>\n\n\t\t\t\t\t\t\t<span class=\"hub-file-input__actions\">\n\t\t\t\t\t\t\t\t@if (item.status === 'uploading') {\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tclass=\"hub-file-input__action hub-file-input__action--cancel\"\n\t\t\t\t\t\t\t\t\t\t[attr.aria-label]=\"labels().cancel\"\n\t\t\t\t\t\t\t\t\t\t(click)=\"cancel(item.id)\"\n\t\t\t\t\t\t\t\t\t></button>\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t@if (item.status === 'error') {\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tclass=\"hub-file-input__action hub-file-input__action--retry\"\n\t\t\t\t\t\t\t\t\t\t[attr.aria-label]=\"labels().retry\"\n\t\t\t\t\t\t\t\t\t\t(click)=\"retry(item.id)\"\n\t\t\t\t\t\t\t\t\t></button>\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t@if (!disabled() && item.status !== 'uploading') {\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tclass=\"hub-file-input__action hub-file-input__action--remove\"\n\t\t\t\t\t\t\t\t\t\t[attr.aria-label]=\"labels().remove\"\n\t\t\t\t\t\t\t\t\t\t(click)=\"remove(item.id)\"\n\t\t\t\t\t\t\t\t\t></button>\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t}\n\t\t\t\t\t</li>\n\t\t\t\t}\n\t\t\t</ul>\n\n\t\t\t@if (_items().length > 1 && !disabled()) {\n\t\t\t\t<button type=\"button\" class=\"hub-file-input__clear\" (click)=\"clear()\">{{ labels().clear }}</button>\n\t\t\t}\n\t\t}\n\t</div>\n\n\t@if (formText() || formTextTmp()) {\n\t\t<div class=\"hub-field__form-text\" [class.hub-field__form-text--disabled]=\"disabled()\">\n\t\t\t@if (formTextTmp()) {\n\t\t\t\t<ng-container [ngTemplateOutlet]=\"formTextTmp()!\"></ng-container>\n\t\t\t} @else {\n\t\t\t\t{{ formText() }}\n\t\t\t}\n\t\t</div>\n\t}\n\n\t@if (isInvalid) {\n\t\t<div class=\"hub-field__feedback\" role=\"alert\">\n\t\t\t<ul>\n\t\t\t\t@for (error of errors | keyvalue; track error.key) {\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<ng-container\n\t\t\t\t\t\t\t[ngTemplateOutlet]=\"_errorTemplates[error.key] ?? defaultErrorTpt\"\n\t\t\t\t\t\t\t[ngTemplateOutletContext]=\"{ $implicit: error.value, value: error.value }\"\n\t\t\t\t\t\t></ng-container>\n\t\t\t\t\t\t<ng-template #defaultErrorTpt>\n\t\t\t\t\t\t\t<span class=\"hub-field__feedback-text\" [innerHTML]=\"getInvalidFeedbackTemplate(error.key, error.value)\"></span>\n\t\t\t\t\t\t</ng-template>\n\t\t\t\t\t</li>\n\t\t\t\t}\n\t\t\t</ul>\n\t\t</div>\n\t}\n\n\t@if (showsValid && validFeedback()) {\n\t\t<div class=\"hub-field__feedback hub-field__feedback--valid\" role=\"status\">\n\t\t\t<span class=\"hub-field__feedback-text\">{{ validFeedback() }}</span>\n\t\t</div>\n\t}\n\n\t<span class=\"hub-file-input__live\" aria-live=\"polite\">{{ announcement() }}</span>\n</div>\n", styles: [".hub-file-input__dropzone{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--hub-file-input-gap);min-height:var(--hub-file-input-min-height);padding:var(--hub-file-input-padding-y) var(--hub-file-input-padding-x);color:var(--hub-file-input-color);background-color:var(--hub-file-input-bg);border:var(--hub-file-input-border-width) var(--hub-file-input-border-style) var(--hub-file-input-border-color);border-radius:var(--hub-file-input-border-radius);text-align:center;cursor:pointer;transition:var(--hub-file-input-transition)}.hub-file-input__dropzone:hover{background-color:var(--hub-file-input-hover-bg);border-color:var(--hub-file-input-hover-border-color)}.hub-file-input__dropzone:has(.hub-file-input__native:focus-visible){border-color:var(--hub-file-input-focus-border-color);box-shadow:var(--hub-file-input-focus-box-shadow);outline:none}.hub-file-input__dropzone--dragover,.hub-file-input__dropzone--dragover:hover{background-color:var(--hub-file-input-dragover-bg);border-color:var(--hub-file-input-dragover-border-color)}.hub-file-input__dropzone--invalid{border-color:var(--hub-form-invalid-border-color)}.hub-file-input__dropzone:has(.hub-file-input__native:disabled){cursor:not-allowed}.hub-file-input__dropzone:has(.hub-file-input__native:disabled):hover{background-color:var(--hub-file-input-bg);border-color:var(--hub-file-input-border-color)}.hub-file-input__native{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.hub-file-input__icon{display:inline-flex;flex:0 0 auto;align-items:center;justify-content:center;width:var(--hub-file-input-icon-chip-size);height:var(--hub-file-input-icon-chip-size);background-color:var(--hub-file-input-icon-bg);border-radius:var(--hub-file-input-icon-chip-radius)}.hub-file-input__icon:before{content:\"\";width:var(--hub-file-input-icon-size);height:var(--hub-file-input-icon-size);background-color:var(--hub-file-input-icon-color);-webkit-mask:var(--hub-file-input-icon) center/contain no-repeat;mask:var(--hub-file-input-icon) center/contain no-repeat}.hub-file-input__prompt{display:flex;flex-direction:var(--hub-file-input-prompt-direction);flex-wrap:wrap;align-items:var(--hub-file-input-prompt-align);justify-content:center;gap:var(--hub-file-input-prompt-gap);color:var(--hub-file-input-prompt-color);font-size:var(--hub-file-input-prompt-font-size)}.hub-file-input__drop-text{color:var(--hub-file-input-drop-text-color);font-size:var(--hub-file-input-drop-text-font-size);font-weight:var(--hub-file-input-drop-text-font-weight)}.hub-file-input__drop-subtext{color:var(--hub-file-input-drop-subtext-color);font-size:var(--hub-file-input-drop-subtext-font-size)}.hub-file-input__browse{display:inline-flex;align-items:center;gap:var(--hub-file-input-browse-gap);margin-top:var(--hub-file-input-browse-margin-top);padding:var(--hub-file-input-browse-padding-y) var(--hub-file-input-browse-padding-x);color:var(--hub-file-input-browse-color);font-size:var(--hub-file-input-browse-font-size);font-weight:var(--hub-file-input-browse-font-weight);text-decoration:var(--hub-file-input-browse-text-decoration);background-color:var(--hub-file-input-browse-bg);border-radius:var(--hub-file-input-browse-radius);transition:var(--hub-file-input-transition)}.hub-file-input__browse:before{content:\"\";display:var(--hub-file-input-browse-icon-display);flex:0 0 auto;width:var(--hub-file-input-browse-icon-size);height:var(--hub-file-input-browse-icon-size);background-color:currentcolor;-webkit-mask:var(--hub-file-input-browse-icon) center/contain no-repeat;mask:var(--hub-file-input-browse-icon) center/contain no-repeat}.hub-file-input__dropzone:hover .hub-file-input__browse{background-color:var(--hub-file-input-browse-hover-bg)}.hub-file-input__hint{color:var(--hub-file-input-hint-color);font-size:var(--hub-file-input-hint-font-size)}.hub-file-input__list{display:flex;flex-direction:column;gap:var(--hub-file-input-list-gap);margin:var(--hub-file-input-list-margin-top) 0 0;padding:0;list-style:none}.hub-file-input__list--grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--hub-file-input-grid-min-width),1fr));gap:var(--hub-file-input-grid-gap)}.hub-file-input__item{display:flex;align-items:center;gap:var(--hub-file-input-item-gap);padding:var(--hub-file-input-item-padding-y) var(--hub-file-input-item-padding-x);color:var(--hub-file-input-item-color);background-color:var(--hub-file-input-item-bg);border:var(--hub-file-input-border-width) solid var(--hub-file-input-item-border-color);border-radius:var(--hub-file-input-item-border-radius);min-width:0}.hub-file-input__item[data-status=error]{border-color:var(--hub-file-input-error-color)}.hub-file-input__item[data-status=done]{border-color:var(--hub-file-input-done-color)}.hub-file-input__thumb{display:flex;flex:none;align-items:center;justify-content:center;width:var(--hub-file-input-thumb-size);height:var(--hub-file-input-thumb-size);overflow:hidden;background-color:var(--hub-file-input-thumb-bg);border-radius:var(--hub-file-input-thumb-radius)}.hub-file-input__image{width:100%;height:100%;object-fit:cover}.hub-file-input__file-icon{display:block;width:var(--hub-file-input-file-icon-size);height:var(--hub-file-input-file-icon-size);background-color:var(--hub-file-input-file-icon-color);-webkit-mask:var(--hub-file-input-file-icon) center/contain no-repeat;mask:var(--hub-file-input-file-icon) center/contain no-repeat}.hub-file-input__meta{display:flex;flex:1 1 auto;flex-direction:column;gap:.125rem;min-width:0}.hub-file-input__name{overflow:hidden;font-size:var(--hub-file-input-name-font-size);text-overflow:ellipsis;white-space:nowrap}.hub-file-input__size{color:var(--hub-file-input-size-color);font-size:var(--hub-file-input-size-font-size)}.hub-file-input__error{color:var(--hub-file-input-error-color);font-size:var(--hub-file-input-error-font-size)}.hub-file-input__progress{display:block;width:100%;height:var(--hub-file-input-progress-height);overflow:hidden;background-color:var(--hub-file-input-progress-track-bg);border-radius:var(--hub-file-input-progress-radius)}.hub-file-input__progress-bar{display:block;width:calc(var(--hub-file-input-progress-value, 0) * 1%);height:100%;background-color:var(--hub-file-input-progress-bar-bg);border-radius:inherit;transition:width .15s linear}.hub-file-input__progress--indeterminate .hub-file-input__progress-bar{width:33%;animation:hub-file-input-indeterminate var(--hub-file-input-progress-indeterminate-duration) ease-in-out infinite}.hub-file-input__actions{display:flex;flex:none;align-items:center;gap:.25rem}.hub-file-input__action{display:block;width:var(--hub-file-input-action-size);height:var(--hub-file-input-action-size);padding:0;background-color:var(--hub-file-input-action-color);border:0;cursor:pointer;transition:var(--hub-file-input-transition)}.hub-file-input__action:hover{background-color:var(--hub-file-input-action-hover-color)}.hub-file-input__action--remove{-webkit-mask:var(--hub-file-input-remove-icon) center/contain no-repeat;mask:var(--hub-file-input-remove-icon) center/contain no-repeat}.hub-file-input__action--remove:hover{background-color:var(--hub-file-input-remove-hover-color)}.hub-file-input__action--cancel{-webkit-mask:var(--hub-file-input-cancel-icon) center/contain no-repeat;mask:var(--hub-file-input-cancel-icon) center/contain no-repeat}.hub-file-input__action--retry{-webkit-mask:var(--hub-file-input-retry-icon) center/contain no-repeat;mask:var(--hub-file-input-retry-icon) center/contain no-repeat}.hub-file-input__clear{align-self:flex-start;margin-top:var(--hub-file-input-list-margin-top);padding:0;color:var(--hub-file-input-clear-color);font-size:var(--hub-file-input-clear-font-size);background:none;border:0;text-decoration:underline;cursor:pointer}.hub-file-input__item[data-status=done] .hub-file-input__size:after{display:inline-block;width:.75em;height:.75em;margin-inline-start:.375em;background-color:var(--hub-file-input-done-color);-webkit-mask:var(--hub-file-input-done-icon) center/contain no-repeat;mask:var(--hub-file-input-done-icon) center/contain no-repeat;content:\"\"}.hub-file-input__live{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.hub-file-input__list--grid .hub-file-input__item{flex-direction:column;align-items:stretch}.hub-file-input__list--grid .hub-file-input__thumb{width:100%;height:var(--hub-file-input-grid-thumb-height)}.hub-file-input__list--grid .hub-file-input__actions{justify-content:flex-end}@keyframes hub-file-input-indeterminate{0%{transform:translate(-100%)}to{transform:translate(300%)}}@media(prefers-reduced-motion:reduce){.hub-file-input__progress--indeterminate .hub-file-input__progress-bar{animation:none;width:100%;opacity:.5}}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "pipe", type: KeyValuePipe, name: "keyvalue" }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
7091
+ }
7092
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: HubFileInputComponent, decorators: [{
7093
+ type: Component,
7094
+ args: [{ selector: 'hub-file-input', imports: [NgTemplateOutlet, KeyValuePipe], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, host: {
7095
+ '[class]': 'classlist()',
7096
+ '[class.hub-file-input-host]': 'true',
7097
+ '(paste)': 'handlePaste($event)'
7098
+ }, template: "<div\n\tclass=\"hub-field hub-file-input\"\n\t[class.hub-field--disabled]=\"disabled()\"\n\t[class.hub-field--invalid]=\"isInvalid\"\n\t[class.hub-field--valid]=\"showsValid\"\n>\n\t@if (label() || required()) {\n\t\t<label [for]=\"id\" class=\"hub-field__label\">\n\t\t\t{{ label() }}\n\t\t\t@if (required()) {\n\t\t\t\t<span class=\"hub-field__required\" aria-hidden=\"true\">*</span>\n\t\t\t}\n\t\t</label>\n\t}\n\n\t<div class=\"hub-field__body\">\n\t\t<!--\n\t\t\tThe dropzone is a <label> bound to the hidden native input: clicking or activating it with\n\t\t\tthe keyboard opens the file dialog through the platform, so no role, tabindex or key\n\t\t\thandler is needed. The \"browse\" affordance is a <span>, not a <button>, because a button\n\t\t\tinside a label would open the dialog twice.\n\t\t-->\n\t\t<label\n\t\t\tclass=\"hub-file-input__dropzone\"\n\t\t\t[for]=\"id\"\n\t\t\t[class.hub-file-input__dropzone--dragover]=\"isDragging()\"\n\t\t\t[class.hub-file-input__dropzone--no-drop]=\"!dragDrop()\"\n\t\t\t[class.hub-file-input__dropzone--invalid]=\"isInvalid\"\n\t\t\t(dragenter)=\"handleDragEnter($event)\"\n\t\t\t(dragover)=\"handleDragOver($event)\"\n\t\t\t(dragleave)=\"handleDragLeave($event)\"\n\t\t\t(drop)=\"handleDrop($event)\"\n\t\t>\n\t\t\t<input\n\t\t\t\t#nativeInput\n\t\t\t\ttype=\"file\"\n\t\t\t\tclass=\"hub-file-input__native\"\n\t\t\t\t[id]=\"id\"\n\t\t\t\t[accept]=\"accept()\"\n\t\t\t\t[multiple]=\"multiple()\"\n\t\t\t\t[disabled]=\"disabled()\"\n\t\t\t\t[attr.capture]=\"capture()\"\n\t\t\t\t[attr.aria-required]=\"required() ? 'true' : null\"\n\t\t\t\t[attr.aria-invalid]=\"isInvalid\"\n\t\t\t\t[attr.aria-describedby]=\"constraintsHint() ? id + '-hint' : null\"\n\t\t\t\t(change)=\"handleNativeChange($event)\"\n\t\t\t\t(blur)=\"handleBlur($event)\"\n\t\t\t/>\n\n\t\t\t<span class=\"hub-file-input__icon\" aria-hidden=\"true\"></span>\n\n\t\t\t@if (noticeTpt(); as notice) {\n\t\t\t\t<ng-container [ngTemplateOutlet]=\"notice.template\"></ng-container>\n\t\t\t}\n\n\t\t\t<span class=\"hub-file-input__prompt\">\n\t\t\t\t@if (dragDrop()) {\n\t\t\t\t\t<span class=\"hub-file-input__drop-text\">{{ dropTextLabel() }}</span>\n\t\t\t\t}\n\t\t\t\t@if (dropSubtextLabel()) {\n\t\t\t\t\t<span class=\"hub-file-input__drop-subtext\">{{ dropSubtextLabel() }}</span>\n\t\t\t\t}\n\t\t\t\t<span class=\"hub-file-input__browse\">{{ browseLabel() }}</span>\n\t\t\t</span>\n\n\t\t\t@if (constraintsHint()) {\n\t\t\t\t<span class=\"hub-file-input__hint\" [id]=\"id + '-hint'\">{{ constraintsHint() }}</span>\n\t\t\t}\n\t\t</label>\n\n\t\t@if (preview() !== 'none' && _items().length > 0) {\n\t\t\t<ul class=\"hub-file-input__list\" [class.hub-file-input__list--grid]=\"preview() === 'grid'\">\n\t\t\t\t@for (item of _items(); track item.id) {\n\t\t\t\t\t<li class=\"hub-file-input__item\" [attr.data-status]=\"item.status\">\n\t\t\t\t\t\t@if (previewTpt(); as tpt) {\n\t\t\t\t\t\t\t<ng-container\n\t\t\t\t\t\t\t\t[ngTemplateOutlet]=\"tpt.template\"\n\t\t\t\t\t\t\t\t[ngTemplateOutletContext]=\"previewContext(item)\"\n\t\t\t\t\t\t\t></ng-container>\n\t\t\t\t\t\t} @else {\n\t\t\t\t\t\t\t<span class=\"hub-file-input__thumb\" aria-hidden=\"true\">\n\t\t\t\t\t\t\t\t@if (item.previewUrl) {\n\t\t\t\t\t\t\t\t\t<img class=\"hub-file-input__image\" [src]=\"item.previewUrl\" alt=\"\" />\n\t\t\t\t\t\t\t\t} @else if (iconTpt(); as icon) {\n\t\t\t\t\t\t\t\t\t<ng-container\n\t\t\t\t\t\t\t\t\t\t[ngTemplateOutlet]=\"icon.template\"\n\t\t\t\t\t\t\t\t\t\t[ngTemplateOutletContext]=\"{ $implicit: item }\"\n\t\t\t\t\t\t\t\t\t></ng-container>\n\t\t\t\t\t\t\t\t} @else {\n\t\t\t\t\t\t\t\t\t<span class=\"hub-file-input__file-icon\"></span>\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t</span>\n\n\t\t\t\t\t\t\t<span class=\"hub-file-input__meta\">\n\t\t\t\t\t\t\t\t<span class=\"hub-file-input__name\" [title]=\"item.file.name\">{{ item.file.name }}</span>\n\t\t\t\t\t\t\t\t<span class=\"hub-file-input__size\">{{ labels().formatSize(item.file.size) }}</span>\n\n\t\t\t\t\t\t\t\t@if (item.status === 'uploading') {\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\tclass=\"hub-file-input__progress\"\n\t\t\t\t\t\t\t\t\t\t[class.hub-file-input__progress--indeterminate]=\"item.progress === null\"\n\t\t\t\t\t\t\t\t\t\trole=\"progressbar\"\n\t\t\t\t\t\t\t\t\t\taria-valuemin=\"0\"\n\t\t\t\t\t\t\t\t\t\taria-valuemax=\"100\"\n\t\t\t\t\t\t\t\t\t\t[attr.aria-valuenow]=\"item.progress\"\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\tclass=\"hub-file-input__progress-bar\"\n\t\t\t\t\t\t\t\t\t\t\t[style.--hub-file-input-progress-value]=\"item.progress ?? 0\"\n\t\t\t\t\t\t\t\t\t\t></span>\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t@if (item.status === 'error') {\n\t\t\t\t\t\t\t\t\t<span class=\"hub-file-input__error\">{{ errorText(item) }}</span>\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t</span>\n\n\t\t\t\t\t\t\t<span class=\"hub-file-input__actions\">\n\t\t\t\t\t\t\t\t@if (item.status === 'uploading') {\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tclass=\"hub-file-input__action hub-file-input__action--cancel\"\n\t\t\t\t\t\t\t\t\t\t[attr.aria-label]=\"labels().cancel\"\n\t\t\t\t\t\t\t\t\t\t(click)=\"cancel(item.id)\"\n\t\t\t\t\t\t\t\t\t></button>\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t@if (item.status === 'error') {\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tclass=\"hub-file-input__action hub-file-input__action--retry\"\n\t\t\t\t\t\t\t\t\t\t[attr.aria-label]=\"labels().retry\"\n\t\t\t\t\t\t\t\t\t\t(click)=\"retry(item.id)\"\n\t\t\t\t\t\t\t\t\t></button>\n\t\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t\t@if (!disabled() && item.status !== 'uploading') {\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tclass=\"hub-file-input__action hub-file-input__action--remove\"\n\t\t\t\t\t\t\t\t\t\t[attr.aria-label]=\"labels().remove\"\n\t\t\t\t\t\t\t\t\t\t(click)=\"remove(item.id)\"\n\t\t\t\t\t\t\t\t\t></button>\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t}\n\t\t\t\t\t</li>\n\t\t\t\t}\n\t\t\t</ul>\n\n\t\t\t@if (_items().length > 1 && !disabled()) {\n\t\t\t\t<button type=\"button\" class=\"hub-file-input__clear\" (click)=\"clear()\">{{ labels().clear }}</button>\n\t\t\t}\n\t\t}\n\t</div>\n\n\t@if (formText() || formTextTmp()) {\n\t\t<div class=\"hub-field__form-text\" [class.hub-field__form-text--disabled]=\"disabled()\">\n\t\t\t@if (formTextTmp()) {\n\t\t\t\t<ng-container [ngTemplateOutlet]=\"formTextTmp()!\"></ng-container>\n\t\t\t} @else {\n\t\t\t\t{{ formText() }}\n\t\t\t}\n\t\t</div>\n\t}\n\n\t@if (isInvalid) {\n\t\t<div class=\"hub-field__feedback\" role=\"alert\">\n\t\t\t<ul>\n\t\t\t\t@for (error of errors | keyvalue; track error.key) {\n\t\t\t\t\t<li>\n\t\t\t\t\t\t<ng-container\n\t\t\t\t\t\t\t[ngTemplateOutlet]=\"_errorTemplates[error.key] ?? defaultErrorTpt\"\n\t\t\t\t\t\t\t[ngTemplateOutletContext]=\"{ $implicit: error.value, value: error.value }\"\n\t\t\t\t\t\t></ng-container>\n\t\t\t\t\t\t<ng-template #defaultErrorTpt>\n\t\t\t\t\t\t\t<span class=\"hub-field__feedback-text\" [innerHTML]=\"getInvalidFeedbackTemplate(error.key, error.value)\"></span>\n\t\t\t\t\t\t</ng-template>\n\t\t\t\t\t</li>\n\t\t\t\t}\n\t\t\t</ul>\n\t\t</div>\n\t}\n\n\t@if (showsValid && validFeedback()) {\n\t\t<div class=\"hub-field__feedback hub-field__feedback--valid\" role=\"status\">\n\t\t\t<span class=\"hub-field__feedback-text\">{{ validFeedback() }}</span>\n\t\t</div>\n\t}\n\n\t<span class=\"hub-file-input__live\" aria-live=\"polite\">{{ announcement() }}</span>\n</div>\n", styles: [".hub-file-input__dropzone{position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--hub-file-input-gap);min-height:var(--hub-file-input-min-height);padding:var(--hub-file-input-padding-y) var(--hub-file-input-padding-x);color:var(--hub-file-input-color);background-color:var(--hub-file-input-bg);border:var(--hub-file-input-border-width) var(--hub-file-input-border-style) var(--hub-file-input-border-color);border-radius:var(--hub-file-input-border-radius);text-align:center;cursor:pointer;transition:var(--hub-file-input-transition)}.hub-file-input__dropzone:hover{background-color:var(--hub-file-input-hover-bg);border-color:var(--hub-file-input-hover-border-color)}.hub-file-input__dropzone:has(.hub-file-input__native:focus-visible){border-color:var(--hub-file-input-focus-border-color);box-shadow:var(--hub-file-input-focus-box-shadow);outline:none}.hub-file-input__dropzone--dragover,.hub-file-input__dropzone--dragover:hover{background-color:var(--hub-file-input-dragover-bg);border-color:var(--hub-file-input-dragover-border-color)}.hub-file-input__dropzone--invalid{border-color:var(--hub-form-invalid-border-color)}.hub-file-input__dropzone:has(.hub-file-input__native:disabled){cursor:not-allowed}.hub-file-input__dropzone:has(.hub-file-input__native:disabled):hover{background-color:var(--hub-file-input-bg);border-color:var(--hub-file-input-border-color)}.hub-file-input__native{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.hub-file-input__icon{display:inline-flex;flex:0 0 auto;align-items:center;justify-content:center;width:var(--hub-file-input-icon-chip-size);height:var(--hub-file-input-icon-chip-size);background-color:var(--hub-file-input-icon-bg);border-radius:var(--hub-file-input-icon-chip-radius)}.hub-file-input__icon:before{content:\"\";width:var(--hub-file-input-icon-size);height:var(--hub-file-input-icon-size);background-color:var(--hub-file-input-icon-color);-webkit-mask:var(--hub-file-input-icon) center/contain no-repeat;mask:var(--hub-file-input-icon) center/contain no-repeat}.hub-file-input__prompt{display:flex;flex-direction:var(--hub-file-input-prompt-direction);flex-wrap:wrap;align-items:var(--hub-file-input-prompt-align);justify-content:center;gap:var(--hub-file-input-prompt-gap);color:var(--hub-file-input-prompt-color);font-size:var(--hub-file-input-prompt-font-size)}.hub-file-input__drop-text{color:var(--hub-file-input-drop-text-color);font-size:var(--hub-file-input-drop-text-font-size);font-weight:var(--hub-file-input-drop-text-font-weight)}.hub-file-input__drop-subtext{color:var(--hub-file-input-drop-subtext-color);font-size:var(--hub-file-input-drop-subtext-font-size)}.hub-file-input__browse{display:inline-flex;align-items:center;gap:var(--hub-file-input-browse-gap);margin-top:var(--hub-file-input-browse-margin-top);padding:var(--hub-file-input-browse-padding-y) var(--hub-file-input-browse-padding-x);color:var(--hub-file-input-browse-color);font-size:var(--hub-file-input-browse-font-size);font-weight:var(--hub-file-input-browse-font-weight);text-decoration:var(--hub-file-input-browse-text-decoration);background-color:var(--hub-file-input-browse-bg);border-radius:var(--hub-file-input-browse-radius);transition:var(--hub-file-input-transition)}.hub-file-input__browse:before{content:\"\";display:var(--hub-file-input-browse-icon-display);flex:0 0 auto;width:var(--hub-file-input-browse-icon-size);height:var(--hub-file-input-browse-icon-size);background-color:currentcolor;-webkit-mask:var(--hub-file-input-browse-icon) center/contain no-repeat;mask:var(--hub-file-input-browse-icon) center/contain no-repeat}.hub-file-input__dropzone:hover .hub-file-input__browse{background-color:var(--hub-file-input-browse-hover-bg)}.hub-file-input__hint{color:var(--hub-file-input-hint-color);font-size:var(--hub-file-input-hint-font-size)}.hub-file-input__list{display:flex;flex-direction:column;gap:var(--hub-file-input-list-gap);margin:var(--hub-file-input-list-margin-top) 0 0;padding:0;list-style:none}.hub-file-input__list--grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--hub-file-input-grid-min-width),1fr));gap:var(--hub-file-input-grid-gap)}.hub-file-input__item{display:flex;align-items:center;gap:var(--hub-file-input-item-gap);padding:var(--hub-file-input-item-padding-y) var(--hub-file-input-item-padding-x);color:var(--hub-file-input-item-color);background-color:var(--hub-file-input-item-bg);border:var(--hub-file-input-border-width) solid var(--hub-file-input-item-border-color);border-radius:var(--hub-file-input-item-border-radius);min-width:0}.hub-file-input__item[data-status=error]{border-color:var(--hub-file-input-error-color)}.hub-file-input__item[data-status=done]{border-color:var(--hub-file-input-done-color)}.hub-file-input__thumb{display:flex;flex:none;align-items:center;justify-content:center;width:var(--hub-file-input-thumb-size);height:var(--hub-file-input-thumb-size);overflow:hidden;background-color:var(--hub-file-input-thumb-bg);border-radius:var(--hub-file-input-thumb-radius)}.hub-file-input__image{width:100%;height:100%;object-fit:cover}.hub-file-input__file-icon{display:block;width:var(--hub-file-input-file-icon-size);height:var(--hub-file-input-file-icon-size);background-color:var(--hub-file-input-file-icon-color);-webkit-mask:var(--hub-file-input-file-icon) center/contain no-repeat;mask:var(--hub-file-input-file-icon) center/contain no-repeat}.hub-file-input__meta{display:flex;flex:1 1 auto;flex-direction:column;gap:.125rem;min-width:0}.hub-file-input__name{overflow:hidden;font-size:var(--hub-file-input-name-font-size);text-overflow:ellipsis;white-space:nowrap}.hub-file-input__size{color:var(--hub-file-input-size-color);font-size:var(--hub-file-input-size-font-size)}.hub-file-input__error{color:var(--hub-file-input-error-color);font-size:var(--hub-file-input-error-font-size)}.hub-file-input__progress{display:block;width:100%;height:var(--hub-file-input-progress-height);overflow:hidden;background-color:var(--hub-file-input-progress-track-bg);border-radius:var(--hub-file-input-progress-radius)}.hub-file-input__progress-bar{display:block;width:calc(var(--hub-file-input-progress-value, 0) * 1%);height:100%;background-color:var(--hub-file-input-progress-bar-bg);border-radius:inherit;transition:width .15s linear}.hub-file-input__progress--indeterminate .hub-file-input__progress-bar{width:33%;animation:hub-file-input-indeterminate var(--hub-file-input-progress-indeterminate-duration) ease-in-out infinite}.hub-file-input__actions{display:flex;flex:none;align-items:center;gap:.25rem}.hub-file-input__action{display:block;width:var(--hub-file-input-action-size);height:var(--hub-file-input-action-size);padding:0;background-color:var(--hub-file-input-action-color);border:0;cursor:pointer;transition:var(--hub-file-input-transition)}.hub-file-input__action:hover{background-color:var(--hub-file-input-action-hover-color)}.hub-file-input__action--remove{-webkit-mask:var(--hub-file-input-remove-icon) center/contain no-repeat;mask:var(--hub-file-input-remove-icon) center/contain no-repeat}.hub-file-input__action--remove:hover{background-color:var(--hub-file-input-remove-hover-color)}.hub-file-input__action--cancel{-webkit-mask:var(--hub-file-input-cancel-icon) center/contain no-repeat;mask:var(--hub-file-input-cancel-icon) center/contain no-repeat}.hub-file-input__action--retry{-webkit-mask:var(--hub-file-input-retry-icon) center/contain no-repeat;mask:var(--hub-file-input-retry-icon) center/contain no-repeat}.hub-file-input__clear{align-self:flex-start;margin-top:var(--hub-file-input-list-margin-top);padding:0;color:var(--hub-file-input-clear-color);font-size:var(--hub-file-input-clear-font-size);background:none;border:0;text-decoration:underline;cursor:pointer}.hub-file-input__item[data-status=done] .hub-file-input__size:after{display:inline-block;width:.75em;height:.75em;margin-inline-start:.375em;background-color:var(--hub-file-input-done-color);-webkit-mask:var(--hub-file-input-done-icon) center/contain no-repeat;mask:var(--hub-file-input-done-icon) center/contain no-repeat;content:\"\"}.hub-file-input__live{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.hub-file-input__list--grid .hub-file-input__item{flex-direction:column;align-items:stretch}.hub-file-input__list--grid .hub-file-input__thumb{width:100%;height:var(--hub-file-input-grid-thumb-height)}.hub-file-input__list--grid .hub-file-input__actions{justify-content:flex-end}@keyframes hub-file-input-indeterminate{0%{transform:translate(-100%)}to{transform:translate(300%)}}@media(prefers-reduced-motion:reduce){.hub-file-input__progress--indeterminate .hub-file-input__progress-bar{animation:none;width:100%;opacity:.5}}\n"] }]
7099
+ }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], multiple: [{ type: i0.Input, args: [{ isSignal: true, alias: "multiple", required: false }] }], accept: [{ type: i0.Input, args: [{ isSignal: true, alias: "accept", required: false }] }], maxSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxSize", required: false }] }], minSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "minSize", required: false }] }], maxTotalSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxTotalSize", required: false }] }], maxFiles: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxFiles", required: false }] }], dragDrop: [{ type: i0.Input, args: [{ isSignal: true, alias: "dragDrop", required: false }] }], paste: [{ type: i0.Input, args: [{ isSignal: true, alias: "paste", required: false }] }], preview: [{ type: i0.Input, args: [{ isSignal: true, alias: "preview", required: false }] }], allowDuplicates: [{ type: i0.Input, args: [{ isSignal: true, alias: "allowDuplicates", required: false }] }], capture: [{ type: i0.Input, args: [{ isSignal: true, alias: "capture", required: false }] }], autoUpload: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoUpload", required: false }] }], buttonLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "buttonLabel", required: false }] }], dropText: [{ type: i0.Input, args: [{ isSignal: true, alias: "dropText", required: false }] }], dropSubtext: [{ type: i0.Input, args: [{ isSignal: true, alias: "dropSubtext", required: false }] }], hint: [{ type: i0.Input, args: [{ isSignal: true, alias: "hint", required: false }] }], formText: [{ type: i0.Input, args: [{ isSignal: true, alias: "formText", required: false }] }], formTextType: [{ type: i0.Input, args: [{ isSignal: true, alias: "formTextType", required: false }] }], classlist: [{ type: i0.Input, args: [{ isSignal: true, alias: "classlist", required: false }] }], valueChange: [{ type: i0.Output, args: ["valueChange"] }], rejected: [{ type: i0.Output, args: ["rejected"] }], fileRemoved: [{ type: i0.Output, args: ["fileRemoved"] }], uploadStateChange: [{ type: i0.Output, args: ["uploadStateChange"] }], nativeInput: [{ type: i0.ViewChild, args: ['nativeInput', { isSignal: true }] }], iconTpt: [{ type: i0.ContentChild, args: [i0.forwardRef(() => HubFileIconDirective), { isSignal: true }] }], previewTpt: [{ type: i0.ContentChild, args: [i0.forwardRef(() => HubFilePreviewDirective), { isSignal: true }] }], noticeTpt: [{ type: i0.ContentChild, args: [i0.forwardRef(() => HubFileDropzoneNoticeDirective), { isSignal: true }] }] } });
7100
+
6120
7101
  /**
6121
7102
  * Inverts a HEX color, or returns the best contrasting black/white when `bw` is enabled.
6122
7103
  */
@@ -6317,6 +7298,166 @@ function hubAreEqual(primaryControlName, secondaryControlName) {
6317
7298
  };
6318
7299
  }
6319
7300
 
7301
+ /**
7302
+ * Validates that every file held by the control matches an `accept` specification.
7303
+ *
7304
+ * Complements the `accept` input of `<hub-file-input>`, which *filters* offending files before they
7305
+ * reach the value. This validator instead makes the control **invalid**, which is what you want when
7306
+ * files can also arrive from outside the component (patched value, restored draft, another control).
7307
+ *
7308
+ * Sets the `fileAccept` error with `{ accept, files }`, where `files` lists the offending names.
7309
+ * An empty control is valid — pair it with `Validators.required` to demand a file.
7310
+ *
7311
+ * @param accept - The accept specification, e.g. `'image/*,.pdf'`.
7312
+ * @returns A validator function for a file-valued control.
7313
+ *
7314
+ * @example
7315
+ * ```ts
7316
+ * new FormControl<File[]>([], [hubAcceptedFiles('image/*,.pdf')]);
7317
+ * ```
7318
+ */
7319
+ function hubAcceptedFiles(accept) {
7320
+ return (control) => {
7321
+ const offending = toFileArray(control.value).filter((file) => !matchesAccept(file, accept));
7322
+ if (offending.length === 0) {
7323
+ return null;
7324
+ }
7325
+ return { fileAccept: { accept, files: offending.map((file) => file.name) } };
7326
+ };
7327
+ }
7328
+
7329
+ /**
7330
+ * Validates that no file held by the control exceeds a maximum size.
7331
+ *
7332
+ * Sets the `fileMaxSize` error with `{ max, actual, files }`, where `actual` is the size of the
7333
+ * largest offending file and `files` lists the offending names. An empty control is valid.
7334
+ *
7335
+ * @param maxBytes - The maximum size per file, in bytes.
7336
+ * @returns A validator function for a file-valued control.
7337
+ *
7338
+ * @example
7339
+ * ```ts
7340
+ * new FormControl<File | null>(null, [hubMaxFileSize(5 * 1024 * 1024)]);
7341
+ * ```
7342
+ */
7343
+ function hubMaxFileSize(maxBytes) {
7344
+ return (control) => {
7345
+ const offending = toFileArray(control.value).filter((file) => file.size > maxBytes);
7346
+ if (offending.length === 0) {
7347
+ return null;
7348
+ }
7349
+ return {
7350
+ fileMaxSize: {
7351
+ max: maxBytes,
7352
+ actual: Math.max(...offending.map((file) => file.size)),
7353
+ files: offending.map((file) => file.name)
7354
+ }
7355
+ };
7356
+ };
7357
+ }
7358
+
7359
+ /**
7360
+ * Validates that no file held by the control falls below a minimum size.
7361
+ *
7362
+ * Useful to reject empty or truncated uploads (a 0-byte file is a common symptom of a failed export).
7363
+ * Sets the `fileMinSize` error with `{ min, actual, files }`, where `actual` is the size of the
7364
+ * smallest offending file. An empty control is valid.
7365
+ *
7366
+ * @param minBytes - The minimum size per file, in bytes.
7367
+ * @returns A validator function for a file-valued control.
7368
+ *
7369
+ * @example
7370
+ * ```ts
7371
+ * new FormControl<File | null>(null, [hubMinFileSize(1)]);
7372
+ * ```
7373
+ */
7374
+ function hubMinFileSize(minBytes) {
7375
+ return (control) => {
7376
+ const offending = toFileArray(control.value).filter((file) => file.size < minBytes);
7377
+ if (offending.length === 0) {
7378
+ return null;
7379
+ }
7380
+ return {
7381
+ fileMinSize: {
7382
+ min: minBytes,
7383
+ actual: Math.min(...offending.map((file) => file.size)),
7384
+ files: offending.map((file) => file.name)
7385
+ }
7386
+ };
7387
+ };
7388
+ }
7389
+
7390
+ /**
7391
+ * Validates that the combined size of every file held by the control stays within a budget.
7392
+ *
7393
+ * This is the constraint a request-body limit actually enforces, and it is not implied by a
7394
+ * per-file maximum: ten files of 2 MB pass `hubMaxFileSize(5 MB)` and still blow a 10 MB request.
7395
+ *
7396
+ * Sets the `fileMaxTotalSize` error with `{ max, actual }`. An empty control is valid.
7397
+ *
7398
+ * @param maxBytes - The maximum combined size, in bytes.
7399
+ * @returns A validator function for a file-valued control.
7400
+ *
7401
+ * @example
7402
+ * ```ts
7403
+ * new FormControl<File[]>([], [hubMaxTotalSize(20 * 1024 * 1024)]);
7404
+ * ```
7405
+ */
7406
+ function hubMaxTotalSize(maxBytes) {
7407
+ return (control) => {
7408
+ const files = toFileArray(control.value);
7409
+ if (files.length === 0) {
7410
+ return null;
7411
+ }
7412
+ const total = files.reduce((sum, file) => sum + file.size, 0);
7413
+ return total > maxBytes ? { fileMaxTotalSize: { max: maxBytes, actual: total } } : null;
7414
+ };
7415
+ }
7416
+
7417
+ /**
7418
+ * Validates that the control holds at most `max` files.
7419
+ *
7420
+ * Sets the `fileMaxFiles` error with `{ max, actual }`. An empty control is valid.
7421
+ *
7422
+ * @param max - The maximum number of files.
7423
+ * @returns A validator function for a file-valued control.
7424
+ *
7425
+ * @example
7426
+ * ```ts
7427
+ * new FormControl<File[]>([], [hubMaxFiles(3)]);
7428
+ * ```
7429
+ */
7430
+ function hubMaxFiles(max) {
7431
+ return (control) => {
7432
+ const count = toFileArray(control.value).length;
7433
+ return count > max ? { fileMaxFiles: { max, actual: count } } : null;
7434
+ };
7435
+ }
7436
+
7437
+ /**
7438
+ * Validates that the control holds at least `min` files.
7439
+ *
7440
+ * Unlike the other file validators, this one **does** reject an empty control: holding zero files
7441
+ * is precisely what a minimum forbids. It therefore subsumes `Validators.required`, and adding both
7442
+ * only produces two errors for the same cause.
7443
+ *
7444
+ * Sets the `fileMinFiles` error with `{ min, actual }`.
7445
+ *
7446
+ * @param min - The minimum number of files.
7447
+ * @returns A validator function for a file-valued control.
7448
+ *
7449
+ * @example
7450
+ * ```ts
7451
+ * new FormControl<File[]>([], [hubMinFiles(2)]);
7452
+ * ```
7453
+ */
7454
+ function hubMinFiles(min) {
7455
+ return (control) => {
7456
+ const count = toFileArray(control.value).length;
7457
+ return count < min ? { fileMinFiles: { min, actual: count } } : null;
7458
+ };
7459
+ }
7460
+
6320
7461
  /**
6321
7462
  * Ready-made {@link HubFormControlAdapter} backed by `HubInputComponent` /
6322
7463
  * `HubSelectComponent`.
@@ -6385,5 +7526,5 @@ const hubFormControlAdapter = {
6385
7526
  * Generated bundle index. Do not edit.
6386
7527
  */
6387
7528
 
6388
- export { FormTextTypes, HUB_FORMS_CONFIG, HubAutoresizeDirective, HubDatepickerComponent, HubFieldControl, HubFieldsetComponent, HubFormComponent, HubFormControl, HubFormTextDirective, HubGroupControl, HubInputComponent, HubInputFormats, HubInputPrefixDirective, HubInputSuffixDirective, HubInvertColorPipe, HubJoinButLastPipe, HubLabelTypes, HubLegendComponent, HubLegendDirective, HubMapPipe, HubOtpInputComponent, HubSafeUrlPipe, HubSegmentedComponent, HubSelectComponent, HubSelectFormats, HubSliderComponent, HubSnakeUpperPipe, HubTextareaComponent, HubUcfirstPipe, HubValidationErrorDirective, NgClearButtonTemplateDirective, NgFooterTemplateDirective, NgHeaderTemplateDirective, NgLabelTemplateDirective, NgLoadingSpinnerTemplateDirective, NgLoadingTextTemplateDirective, NgMultiLabelTemplateDirective, NgNotFoundTemplateDirective, NgOptgroupTemplateDirective, NgOptionComponent, NgOptionTemplateDirective, NgSelectConfig, NgTagTemplateDirective, NgTypeToSearchTemplateDirective, applyMask, areEqual, camelToSnakeUpper, controlHasMinOrMaxValidator, defaultHubDatepickerConfig, defaultHubDatepickerLabels, defaultHubFormsConfig, defaultInvalidFeedback, get, getActiveElement, getMinOrMaxValueFromValidator, hubAreEqual, hubFormControlAdapter, isDefined$1 as isDefined, isMaskActive, isString, joinButLast, provideHubForms, runInZone, uuid };
7529
+ export { FormTextTypes, HUB_FILE_UPLOADER, HUB_FORMS_CONFIG, HubAutoresizeDirective, HubDatepickerComponent, HubFieldControl, HubFieldsetComponent, HubFileDropzoneNoticeDirective, HubFileIconDirective, HubFileInputComponent, HubFilePreviewDirective, HubFormComponent, HubFormControl, HubFormTextDirective, HubGroupControl, HubInputComponent, HubInputFormats, HubInputPrefixDirective, HubInputSuffixDirective, HubInvertColorPipe, HubJoinButLastPipe, HubLabelTypes, HubLegendComponent, HubLegendDirective, HubMapPipe, HubOtpInputComponent, HubSafeUrlPipe, HubSegmentedComponent, HubSelectComponent, HubSelectFormats, HubSliderComponent, HubSnakeUpperPipe, HubTextareaComponent, HubUcfirstPipe, HubValidationErrorDirective, NgClearButtonTemplateDirective, NgFooterTemplateDirective, NgHeaderTemplateDirective, NgLabelTemplateDirective, NgLoadingSpinnerTemplateDirective, NgLoadingTextTemplateDirective, NgMultiLabelTemplateDirective, NgNotFoundTemplateDirective, NgOptgroupTemplateDirective, NgOptionComponent, NgOptionTemplateDirective, NgSelectConfig, NgTagTemplateDirective, NgTypeToSearchTemplateDirective, applyMask, areEqual, camelToSnakeUpper, controlHasMinOrMaxValidator, defaultHubDatepickerConfig, defaultHubDatepickerLabels, defaultHubFileInputLabels, defaultHubFormsConfig, defaultInvalidFeedback, fileKey, formatFileSize, get, getActiveElement, getMinOrMaxValueFromValidator, hubAcceptedFiles, hubAreEqual, hubFormControlAdapter, hubMaxFileSize, hubMaxFiles, hubMaxTotalSize, hubMinFileSize, hubMinFiles, isDefined$1 as isDefined, isMaskActive, isString, joinButLast, matchesAccept, provideHubFileUploader, provideHubForms, runInZone, toFileArray, uuid };
6389
7530
  //# sourceMappingURL=ng-hub-ui-forms.mjs.map