@cdevhub/ngx-tw 0.5.0 → 0.6.1

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.
Files changed (209) hide show
  1. package/README.md +78 -15
  2. package/fesm2022/cdevhub-ngx-tw-accordion.mjs +16 -5
  3. package/fesm2022/cdevhub-ngx-tw-accordion.mjs.map +1 -1
  4. package/fesm2022/cdevhub-ngx-tw-avatar.mjs +26 -15
  5. package/fesm2022/cdevhub-ngx-tw-avatar.mjs.map +1 -1
  6. package/fesm2022/cdevhub-ngx-tw-badge.mjs +17 -5
  7. package/fesm2022/cdevhub-ngx-tw-badge.mjs.map +1 -1
  8. package/fesm2022/cdevhub-ngx-tw-breadcrumbs.mjs +27 -6
  9. package/fesm2022/cdevhub-ngx-tw-breadcrumbs.mjs.map +1 -1
  10. package/fesm2022/cdevhub-ngx-tw-button.mjs +23 -11
  11. package/fesm2022/cdevhub-ngx-tw-button.mjs.map +1 -1
  12. package/fesm2022/cdevhub-ngx-tw-calendar-luxon.mjs +5 -5
  13. package/fesm2022/cdevhub-ngx-tw-calendar-luxon.mjs.map +1 -1
  14. package/fesm2022/cdevhub-ngx-tw-calendar-testing.mjs +3 -3
  15. package/fesm2022/cdevhub-ngx-tw-calendar-testing.mjs.map +1 -1
  16. package/fesm2022/cdevhub-ngx-tw-calendar.mjs +427 -122
  17. package/fesm2022/cdevhub-ngx-tw-calendar.mjs.map +1 -1
  18. package/fesm2022/cdevhub-ngx-tw-card.mjs +21 -11
  19. package/fesm2022/cdevhub-ngx-tw-card.mjs.map +1 -1
  20. package/fesm2022/cdevhub-ngx-tw-carousel.mjs +215 -96
  21. package/fesm2022/cdevhub-ngx-tw-carousel.mjs.map +1 -1
  22. package/fesm2022/cdevhub-ngx-tw-checkbox.mjs +108 -64
  23. package/fesm2022/cdevhub-ngx-tw-checkbox.mjs.map +1 -1
  24. package/fesm2022/cdevhub-ngx-tw-code-block.mjs +18 -6
  25. package/fesm2022/cdevhub-ngx-tw-code-block.mjs.map +1 -1
  26. package/fesm2022/cdevhub-ngx-tw-collapsible.mjs +55 -30
  27. package/fesm2022/cdevhub-ngx-tw-collapsible.mjs.map +1 -1
  28. package/fesm2022/cdevhub-ngx-tw-combobox-testing.mjs +218 -0
  29. package/fesm2022/cdevhub-ngx-tw-combobox-testing.mjs.map +1 -0
  30. package/fesm2022/cdevhub-ngx-tw-combobox.mjs +236 -211
  31. package/fesm2022/cdevhub-ngx-tw-combobox.mjs.map +1 -1
  32. package/fesm2022/cdevhub-ngx-tw-command-palette-testing.mjs +200 -0
  33. package/fesm2022/cdevhub-ngx-tw-command-palette-testing.mjs.map +1 -0
  34. package/fesm2022/cdevhub-ngx-tw-command-palette.mjs +34 -7
  35. package/fesm2022/cdevhub-ngx-tw-command-palette.mjs.map +1 -1
  36. package/fesm2022/cdevhub-ngx-tw-core.mjs +135 -15
  37. package/fesm2022/cdevhub-ngx-tw-core.mjs.map +1 -1
  38. package/fesm2022/cdevhub-ngx-tw-date-picker-testing.mjs +203 -0
  39. package/fesm2022/cdevhub-ngx-tw-date-picker-testing.mjs.map +1 -0
  40. package/fesm2022/cdevhub-ngx-tw-date-picker.mjs +160 -102
  41. package/fesm2022/cdevhub-ngx-tw-date-picker.mjs.map +1 -1
  42. package/fesm2022/cdevhub-ngx-tw-date-range-picker-testing.mjs +209 -0
  43. package/fesm2022/cdevhub-ngx-tw-date-range-picker-testing.mjs.map +1 -0
  44. package/fesm2022/cdevhub-ngx-tw-date-range-picker.mjs +182 -107
  45. package/fesm2022/cdevhub-ngx-tw-date-range-picker.mjs.map +1 -1
  46. package/fesm2022/cdevhub-ngx-tw-dialog-testing.mjs +122 -0
  47. package/fesm2022/cdevhub-ngx-tw-dialog-testing.mjs.map +1 -0
  48. package/fesm2022/cdevhub-ngx-tw-dialog.mjs +11 -2
  49. package/fesm2022/cdevhub-ngx-tw-dialog.mjs.map +1 -1
  50. package/fesm2022/cdevhub-ngx-tw-empty-state.mjs +1 -1
  51. package/fesm2022/cdevhub-ngx-tw-empty-state.mjs.map +1 -1
  52. package/fesm2022/cdevhub-ngx-tw-file-upload-testing.mjs +149 -0
  53. package/fesm2022/cdevhub-ngx-tw-file-upload-testing.mjs.map +1 -0
  54. package/fesm2022/cdevhub-ngx-tw-file-upload.mjs +57 -67
  55. package/fesm2022/cdevhub-ngx-tw-file-upload.mjs.map +1 -1
  56. package/fesm2022/cdevhub-ngx-tw-flip-card.mjs +42 -24
  57. package/fesm2022/cdevhub-ngx-tw-flip-card.mjs.map +1 -1
  58. package/fesm2022/cdevhub-ngx-tw-form-field.mjs +114 -21
  59. package/fesm2022/cdevhub-ngx-tw-form-field.mjs.map +1 -1
  60. package/fesm2022/cdevhub-ngx-tw-icon.mjs +16 -6
  61. package/fesm2022/cdevhub-ngx-tw-icon.mjs.map +1 -1
  62. package/fesm2022/cdevhub-ngx-tw-input.mjs +52 -61
  63. package/fesm2022/cdevhub-ngx-tw-input.mjs.map +1 -1
  64. package/fesm2022/cdevhub-ngx-tw-item.mjs +28 -4
  65. package/fesm2022/cdevhub-ngx-tw-item.mjs.map +1 -1
  66. package/fesm2022/cdevhub-ngx-tw-menu-testing.mjs +168 -0
  67. package/fesm2022/cdevhub-ngx-tw-menu-testing.mjs.map +1 -0
  68. package/fesm2022/cdevhub-ngx-tw-menu.mjs +58 -21
  69. package/fesm2022/cdevhub-ngx-tw-menu.mjs.map +1 -1
  70. package/fesm2022/cdevhub-ngx-tw-number-input.mjs +34 -1
  71. package/fesm2022/cdevhub-ngx-tw-number-input.mjs.map +1 -1
  72. package/fesm2022/cdevhub-ngx-tw-paginator.mjs +95 -45
  73. package/fesm2022/cdevhub-ngx-tw-paginator.mjs.map +1 -1
  74. package/fesm2022/cdevhub-ngx-tw-popover.mjs +81 -32
  75. package/fesm2022/cdevhub-ngx-tw-popover.mjs.map +1 -1
  76. package/fesm2022/cdevhub-ngx-tw-progress-bar.mjs +1 -1
  77. package/fesm2022/cdevhub-ngx-tw-progress-bar.mjs.map +1 -1
  78. package/fesm2022/cdevhub-ngx-tw-radio.mjs +131 -98
  79. package/fesm2022/cdevhub-ngx-tw-radio.mjs.map +1 -1
  80. package/fesm2022/cdevhub-ngx-tw-segmented-control.mjs +207 -42
  81. package/fesm2022/cdevhub-ngx-tw-segmented-control.mjs.map +1 -1
  82. package/fesm2022/cdevhub-ngx-tw-select-testing.mjs +165 -0
  83. package/fesm2022/cdevhub-ngx-tw-select-testing.mjs.map +1 -0
  84. package/fesm2022/cdevhub-ngx-tw-select.mjs +386 -210
  85. package/fesm2022/cdevhub-ngx-tw-select.mjs.map +1 -1
  86. package/fesm2022/{cdevhub-ngx-tw-sheet-sheet-renderer-ByyNluUd.mjs → cdevhub-ngx-tw-sheet-sheet-renderer-CqVJwgmp.mjs} +3 -3
  87. package/fesm2022/cdevhub-ngx-tw-sheet-sheet-renderer-CqVJwgmp.mjs.map +1 -0
  88. package/fesm2022/cdevhub-ngx-tw-sheet-testing.mjs +129 -0
  89. package/fesm2022/cdevhub-ngx-tw-sheet-testing.mjs.map +1 -0
  90. package/fesm2022/cdevhub-ngx-tw-sheet.mjs +23 -7
  91. package/fesm2022/cdevhub-ngx-tw-sheet.mjs.map +1 -1
  92. package/fesm2022/cdevhub-ngx-tw-slider.mjs +89 -54
  93. package/fesm2022/cdevhub-ngx-tw-slider.mjs.map +1 -1
  94. package/fesm2022/cdevhub-ngx-tw-sort.mjs +122 -17
  95. package/fesm2022/cdevhub-ngx-tw-sort.mjs.map +1 -1
  96. package/fesm2022/cdevhub-ngx-tw-split.mjs +72 -19
  97. package/fesm2022/cdevhub-ngx-tw-split.mjs.map +1 -1
  98. package/fesm2022/cdevhub-ngx-tw-stat.mjs +63 -25
  99. package/fesm2022/cdevhub-ngx-tw-stat.mjs.map +1 -1
  100. package/fesm2022/cdevhub-ngx-tw-stepper.mjs +35 -8
  101. package/fesm2022/cdevhub-ngx-tw-stepper.mjs.map +1 -1
  102. package/fesm2022/cdevhub-ngx-tw-switch.mjs +54 -48
  103. package/fesm2022/cdevhub-ngx-tw-switch.mjs.map +1 -1
  104. package/fesm2022/cdevhub-ngx-tw-tab-nav.mjs +12 -15
  105. package/fesm2022/cdevhub-ngx-tw-tab-nav.mjs.map +1 -1
  106. package/fesm2022/cdevhub-ngx-tw-table.mjs +74 -13
  107. package/fesm2022/cdevhub-ngx-tw-table.mjs.map +1 -1
  108. package/fesm2022/cdevhub-ngx-tw-tabs.mjs +84 -30
  109. package/fesm2022/cdevhub-ngx-tw-tabs.mjs.map +1 -1
  110. package/fesm2022/cdevhub-ngx-tw-tags-input-testing.mjs +172 -0
  111. package/fesm2022/cdevhub-ngx-tw-tags-input-testing.mjs.map +1 -0
  112. package/fesm2022/cdevhub-ngx-tw-tags-input.mjs +107 -71
  113. package/fesm2022/cdevhub-ngx-tw-tags-input.mjs.map +1 -1
  114. package/fesm2022/cdevhub-ngx-tw-textarea.mjs +41 -9
  115. package/fesm2022/cdevhub-ngx-tw-textarea.mjs.map +1 -1
  116. package/fesm2022/cdevhub-ngx-tw-theme.mjs +257 -45
  117. package/fesm2022/cdevhub-ngx-tw-theme.mjs.map +1 -1
  118. package/fesm2022/cdevhub-ngx-tw-time-picker-testing.mjs +201 -0
  119. package/fesm2022/cdevhub-ngx-tw-time-picker-testing.mjs.map +1 -0
  120. package/fesm2022/cdevhub-ngx-tw-time-picker.mjs +184 -77
  121. package/fesm2022/cdevhub-ngx-tw-time-picker.mjs.map +1 -1
  122. package/fesm2022/cdevhub-ngx-tw-timeline.mjs +110 -27
  123. package/fesm2022/cdevhub-ngx-tw-timeline.mjs.map +1 -1
  124. package/fesm2022/{cdevhub-ngx-tw-toast-toast-renderer-DSu4YoTy.mjs → cdevhub-ngx-tw-toast-toast-renderer-BlDxemsU.mjs} +107 -19
  125. package/fesm2022/cdevhub-ngx-tw-toast-toast-renderer-BlDxemsU.mjs.map +1 -0
  126. package/fesm2022/cdevhub-ngx-tw-toast.mjs +21 -13
  127. package/fesm2022/cdevhub-ngx-tw-toast.mjs.map +1 -1
  128. package/fesm2022/cdevhub-ngx-tw-tooltip.mjs +102 -15
  129. package/fesm2022/cdevhub-ngx-tw-tooltip.mjs.map +1 -1
  130. package/fesm2022/cdevhub-ngx-tw-transfer-testing.mjs +219 -0
  131. package/fesm2022/cdevhub-ngx-tw-transfer-testing.mjs.map +1 -0
  132. package/fesm2022/cdevhub-ngx-tw-transfer.mjs +88 -69
  133. package/fesm2022/cdevhub-ngx-tw-transfer.mjs.map +1 -1
  134. package/fesm2022/cdevhub-ngx-tw-tree.mjs +68 -19
  135. package/fesm2022/cdevhub-ngx-tw-tree.mjs.map +1 -1
  136. package/index.json +1 -1
  137. package/package.json +63 -2
  138. package/theme/_base.css +13 -0
  139. package/theme/_dark.css +111 -36
  140. package/theme/_high-contrast-dark.css +367 -0
  141. package/theme/_high-contrast.css +13 -2
  142. package/theme/_light.css +332 -0
  143. package/theme/_semantic.css +60 -26
  144. package/theme/_typography.css +17 -0
  145. package/theme/index.css +86 -7
  146. package/types/cdevhub-ngx-tw-accordion.d.ts +12 -4
  147. package/types/cdevhub-ngx-tw-avatar.d.ts +10 -3
  148. package/types/cdevhub-ngx-tw-button.d.ts +1 -0
  149. package/types/cdevhub-ngx-tw-calendar-luxon.d.ts +2 -2
  150. package/types/cdevhub-ngx-tw-calendar-testing.d.ts +1 -1
  151. package/types/cdevhub-ngx-tw-calendar.d.ts +179 -73
  152. package/types/cdevhub-ngx-tw-card.d.ts +11 -3
  153. package/types/cdevhub-ngx-tw-carousel.d.ts +89 -20
  154. package/types/cdevhub-ngx-tw-checkbox.d.ts +40 -12
  155. package/types/cdevhub-ngx-tw-code-block.d.ts +11 -2
  156. package/types/cdevhub-ngx-tw-collapsible.d.ts +32 -5
  157. package/types/cdevhub-ngx-tw-combobox-testing.d.ts +157 -0
  158. package/types/cdevhub-ngx-tw-combobox.d.ts +94 -53
  159. package/types/cdevhub-ngx-tw-command-palette-testing.d.ts +136 -0
  160. package/types/cdevhub-ngx-tw-command-palette.d.ts +33 -5
  161. package/types/cdevhub-ngx-tw-core.d.ts +152 -21
  162. package/types/cdevhub-ngx-tw-date-picker-testing.d.ts +141 -0
  163. package/types/cdevhub-ngx-tw-date-picker.d.ts +37 -29
  164. package/types/cdevhub-ngx-tw-date-range-picker-testing.d.ts +148 -0
  165. package/types/cdevhub-ngx-tw-date-range-picker.d.ts +36 -29
  166. package/types/cdevhub-ngx-tw-dialog-testing.d.ts +98 -0
  167. package/types/cdevhub-ngx-tw-dialog.d.ts +12 -3
  168. package/types/cdevhub-ngx-tw-file-upload-testing.d.ts +120 -0
  169. package/types/cdevhub-ngx-tw-file-upload.d.ts +8 -10
  170. package/types/cdevhub-ngx-tw-flip-card.d.ts +18 -11
  171. package/types/cdevhub-ngx-tw-form-field.d.ts +19 -2
  172. package/types/cdevhub-ngx-tw-icon.d.ts +3 -3
  173. package/types/cdevhub-ngx-tw-input.d.ts +5 -8
  174. package/types/cdevhub-ngx-tw-item.d.ts +2 -0
  175. package/types/cdevhub-ngx-tw-menu-testing.d.ts +131 -0
  176. package/types/cdevhub-ngx-tw-menu.d.ts +2 -2
  177. package/types/cdevhub-ngx-tw-number-input.d.ts +20 -0
  178. package/types/cdevhub-ngx-tw-paginator.d.ts +58 -24
  179. package/types/cdevhub-ngx-tw-popover.d.ts +41 -10
  180. package/types/cdevhub-ngx-tw-progress-bar.d.ts +1 -1
  181. package/types/cdevhub-ngx-tw-radio.d.ts +68 -25
  182. package/types/cdevhub-ngx-tw-segmented-control.d.ts +79 -16
  183. package/types/cdevhub-ngx-tw-select-testing.d.ts +114 -0
  184. package/types/cdevhub-ngx-tw-select.d.ts +104 -33
  185. package/types/cdevhub-ngx-tw-sheet-testing.d.ts +104 -0
  186. package/types/cdevhub-ngx-tw-sheet.d.ts +19 -3
  187. package/types/cdevhub-ngx-tw-slider.d.ts +79 -25
  188. package/types/cdevhub-ngx-tw-sort.d.ts +45 -4
  189. package/types/cdevhub-ngx-tw-split.d.ts +32 -2
  190. package/types/cdevhub-ngx-tw-stat.d.ts +14 -4
  191. package/types/cdevhub-ngx-tw-stepper.d.ts +16 -2
  192. package/types/cdevhub-ngx-tw-switch.d.ts +35 -13
  193. package/types/cdevhub-ngx-tw-tab-nav.d.ts +7 -6
  194. package/types/cdevhub-ngx-tw-table.d.ts +53 -10
  195. package/types/cdevhub-ngx-tw-tabs.d.ts +41 -10
  196. package/types/cdevhub-ngx-tw-tags-input-testing.d.ts +135 -0
  197. package/types/cdevhub-ngx-tw-tags-input.d.ts +26 -11
  198. package/types/cdevhub-ngx-tw-textarea.d.ts +3 -2
  199. package/types/cdevhub-ngx-tw-theme.d.ts +245 -39
  200. package/types/cdevhub-ngx-tw-time-picker-testing.d.ts +129 -0
  201. package/types/cdevhub-ngx-tw-time-picker.d.ts +76 -19
  202. package/types/cdevhub-ngx-tw-timeline.d.ts +36 -12
  203. package/types/cdevhub-ngx-tw-toast.d.ts +20 -6
  204. package/types/cdevhub-ngx-tw-tooltip.d.ts +28 -6
  205. package/types/cdevhub-ngx-tw-transfer-testing.d.ts +152 -0
  206. package/types/cdevhub-ngx-tw-transfer.d.ts +21 -8
  207. package/types/cdevhub-ngx-tw-tree.d.ts +6 -1
  208. package/fesm2022/cdevhub-ngx-tw-sheet-sheet-renderer-ByyNluUd.mjs.map +0 -1
  209. package/fesm2022/cdevhub-ngx-tw-toast-toast-renderer-DSu4YoTy.mjs.map +0 -1
@@ -0,0 +1,149 @@
1
+ import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing';
2
+
3
+ /**
4
+ * Harness for a single attached file in a `tw-file-upload`'s list.
5
+ *
6
+ * Reach these through {@link FileUploadHarness.getFiles}.
7
+ *
8
+ * Everything here reads the *default* row rendering. A consumer who projects a
9
+ * `*twFileUploadItem` template replaces the row wholesale — including the
10
+ * remove control this harness drives — so {@link getName} and {@link remove}
11
+ * apply only to the built-in row.
12
+ */
13
+ class FileUploadFileHarness extends ComponentHarness {
14
+ static hostSelector = 'ul[role="list"] > li';
15
+ removeButton = this.locatorFor('[data-tw-file-upload-remove]');
16
+ /** Predicate for `locatorFor` / `locatorForAll`. */
17
+ static with(options = {}) {
18
+ return new HarnessPredicate(FileUploadFileHarness, options).addOption('name', options.name, async (h, name) => HarnessPredicate.stringMatches(await h.getName(), name));
19
+ }
20
+ /**
21
+ * The attached file's name, read from the remove control's accessible name
22
+ * (`Remove <name>`). That is the one place the name is exposed through an
23
+ * accessibility contract rather than through presentational markup.
24
+ */
25
+ async getName() {
26
+ const label = (await (await this.removeButton()).getAttribute('aria-label')) ?? '';
27
+ return label.replace(/^Remove /, '');
28
+ }
29
+ /**
30
+ * The row's full rendered text, whitespace-collapsed.
31
+ *
32
+ * The name and the meta line sit in adjacent `<span>`s with only a
33
+ * whitespace-only text node between them, which Angular strips, so they run
34
+ * together here (`notes.txt10 B`). Prefer {@link getName} and
35
+ * {@link getMetaText} for the parts; this is the raw read that still says
36
+ * something about a row rendered from a projected template.
37
+ */
38
+ async getText() {
39
+ return (await (await this.host()).text()).replace(/\s+/g, ' ').trim();
40
+ }
41
+ /**
42
+ * The row's meta line — the formatted size, plus the status and error text
43
+ * once the item leaves `pending` (`10 B`, `10 B · Uploading`,
44
+ * `10 B · Failed — <error>`).
45
+ *
46
+ * This string is the only place per-item status and error surface in the
47
+ * DOM: there is no `data-status` hook, and the status otherwise shows up
48
+ * only in Tailwind classes, which a harness must not read. Derived by
49
+ * subtracting {@link getName} from {@link getText} for the same reason.
50
+ */
51
+ async getMetaText() {
52
+ const text = await this.getText();
53
+ const name = await this.getName();
54
+ return text.startsWith(name) ? text.slice(name.length).trim() : text;
55
+ }
56
+ /**
57
+ * Clicks the row's remove control.
58
+ *
59
+ * The component retargets focus in an `afterNextRender` — deliberately, so
60
+ * that the list has re-rendered before focus moves — so the focus assertion
61
+ * a caller makes after this resolves is the post-render one.
62
+ */
63
+ async remove() {
64
+ await (await this.removeButton()).click();
65
+ await this.waitForTasksOutsideAngular();
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Harness for `tw-file-upload`.
71
+ *
72
+ * ## What this harness deliberately cannot do
73
+ *
74
+ * It cannot attach a file. Selecting a file means populating
75
+ * `HTMLInputElement.files`, which is read-only to script and which CDK's
76
+ * `TestElement` — the whole reason a harness runs unchanged against a real
77
+ * browser — has no operation for. The only way to do it is
78
+ * `Object.defineProperty` on the raw DOM node, which exists solely in the
79
+ * Testbed environment. Exposing that would make the harness silently
80
+ * unusable in every other environment, so attaching stays the caller's job:
81
+ * seed files through the bound form control (`writeValue` takes a `File[]`),
82
+ * or through the component's own API, and use this harness for everything
83
+ * downstream of the attachment.
84
+ *
85
+ * Nothing here needs a CDK overlay, so the ordinary fixture loader is enough.
86
+ *
87
+ * Note where the ARIA lives: the host is a `role="group"`, which ARIA 1.2 does
88
+ * not allow to carry `aria-required` / `aria-invalid`, so both sit on the
89
+ * hidden `<input type="file">` — the element that actually owns the value.
90
+ * {@link isRequired} and {@link isInvalid} read them from there;
91
+ * {@link isDisabled} reads `aria-disabled` from the group, which is allowed.
92
+ */
93
+ class FileUploadHarness extends ComponentHarness {
94
+ static hostSelector = 'tw-file-upload';
95
+ /** The hidden native control. It carries the value-owning ARIA and state. */
96
+ nativeInput = this.locatorFor('input[type="file"]');
97
+ /** Predicate for `locatorFor` / `locatorForAll`. */
98
+ static with(options = {}) {
99
+ return new HarnessPredicate(FileUploadHarness, options)
100
+ .addOption('label', options.label, async (h, label) => HarnessPredicate.stringMatches(await h.getLabel(), label))
101
+ .addOption('disabled', options.disabled, async (h, disabled) => (await h.isDisabled()) === disabled);
102
+ }
103
+ /** The control's accessible name, from `aria-label` when one is set. */
104
+ async getLabel() {
105
+ return (await this.host()).getAttribute('aria-label');
106
+ }
107
+ /** Whether the group reports `aria-disabled="true"`. */
108
+ async isDisabled() {
109
+ return (await (await this.host()).getAttribute('aria-disabled')) === 'true';
110
+ }
111
+ /** Whether the native file input is marked required. */
112
+ async isRequired() {
113
+ return (await this.nativeInput()).getProperty('required');
114
+ }
115
+ /** Whether the native file input reports `aria-invalid="true"`. */
116
+ async isInvalid() {
117
+ return (await (await this.nativeInput()).getAttribute('aria-invalid')) === 'true';
118
+ }
119
+ /**
120
+ * Every attached file, in list order. Returns `[]` when nothing is attached
121
+ * — the list element is always rendered, just empty.
122
+ */
123
+ async getFiles(filters = {}) {
124
+ return this.locatorForAll(FileUploadFileHarness.with(filters))();
125
+ }
126
+ /** The name of every attached file, in list order. */
127
+ async getFileNames() {
128
+ const files = await this.getFiles();
129
+ return Promise.all(files.map((file) => file.getName()));
130
+ }
131
+ /**
132
+ * Removes the first attached file whose name matches. Throws when nothing
133
+ * matches, rather than failing silently.
134
+ */
135
+ async removeFile(name) {
136
+ const matches = await this.getFiles({ name });
137
+ if (matches.length === 0) {
138
+ throw new Error(`FileUploadHarness.removeFile: no attached file matching ${String(name)}.`);
139
+ }
140
+ await matches[0].remove();
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Generated bundle index. Do not edit.
146
+ */
147
+
148
+ export { FileUploadFileHarness, FileUploadHarness };
149
+ //# sourceMappingURL=cdevhub-ngx-tw-file-upload-testing.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cdevhub-ngx-tw-file-upload-testing.mjs","sources":["../../../projects/ngx-tw/file-upload/testing/file-upload-file-harness.ts","../../../projects/ngx-tw/file-upload/testing/file-upload-harness.ts","../../../projects/ngx-tw/file-upload/testing/cdevhub-ngx-tw-file-upload-testing.ts"],"sourcesContent":["import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing';\nimport type { BaseHarnessFilters } from '@angular/cdk/testing';\n\n/** Filters accepted by `FileUploadFileHarness.with`. */\nexport interface FileUploadFileHarnessFilters extends BaseHarnessFilters {\n /** Match by the attached file's name. */\n name?: string | RegExp;\n}\n\n/**\n * Harness for a single attached file in a `tw-file-upload`'s list.\n *\n * Reach these through {@link FileUploadHarness.getFiles}.\n *\n * Everything here reads the *default* row rendering. A consumer who projects a\n * `*twFileUploadItem` template replaces the row wholesale — including the\n * remove control this harness drives — so {@link getName} and {@link remove}\n * apply only to the built-in row.\n */\nexport class FileUploadFileHarness extends ComponentHarness {\n static hostSelector = 'ul[role=\"list\"] > li';\n\n private readonly removeButton = this.locatorFor('[data-tw-file-upload-remove]');\n\n /** Predicate for `locatorFor` / `locatorForAll`. */\n static with(\n options: FileUploadFileHarnessFilters = {},\n ): HarnessPredicate<FileUploadFileHarness> {\n return new HarnessPredicate(FileUploadFileHarness, options).addOption(\n 'name',\n options.name,\n async (h, name) => HarnessPredicate.stringMatches(await h.getName(), name),\n );\n }\n\n /**\n * The attached file's name, read from the remove control's accessible name\n * (`Remove <name>`). That is the one place the name is exposed through an\n * accessibility contract rather than through presentational markup.\n */\n async getName(): Promise<string> {\n const label = (await (await this.removeButton()).getAttribute('aria-label')) ?? '';\n return label.replace(/^Remove /, '');\n }\n\n /**\n * The row's full rendered text, whitespace-collapsed.\n *\n * The name and the meta line sit in adjacent `<span>`s with only a\n * whitespace-only text node between them, which Angular strips, so they run\n * together here (`notes.txt10 B`). Prefer {@link getName} and\n * {@link getMetaText} for the parts; this is the raw read that still says\n * something about a row rendered from a projected template.\n */\n async getText(): Promise<string> {\n return (await (await this.host()).text()).replace(/\\s+/g, ' ').trim();\n }\n\n /**\n * The row's meta line — the formatted size, plus the status and error text\n * once the item leaves `pending` (`10 B`, `10 B · Uploading`,\n * `10 B · Failed — <error>`).\n *\n * This string is the only place per-item status and error surface in the\n * DOM: there is no `data-status` hook, and the status otherwise shows up\n * only in Tailwind classes, which a harness must not read. Derived by\n * subtracting {@link getName} from {@link getText} for the same reason.\n */\n async getMetaText(): Promise<string> {\n const text = await this.getText();\n const name = await this.getName();\n return text.startsWith(name) ? text.slice(name.length).trim() : text;\n }\n\n /**\n * Clicks the row's remove control.\n *\n * The component retargets focus in an `afterNextRender` — deliberately, so\n * that the list has re-rendered before focus moves — so the focus assertion\n * a caller makes after this resolves is the post-render one.\n */\n async remove(): Promise<void> {\n await (await this.removeButton()).click();\n await this.waitForTasksOutsideAngular();\n }\n}\n","import { ComponentHarness, HarnessPredicate } from '@angular/cdk/testing';\nimport type { BaseHarnessFilters } from '@angular/cdk/testing';\nimport {\n FileUploadFileHarness,\n type FileUploadFileHarnessFilters,\n} from './file-upload-file-harness';\n\n/** Filters accepted by `FileUploadHarness.with`. */\nexport interface FileUploadHarnessFilters extends BaseHarnessFilters {\n /** Match by the control's accessible name (its `aria-label`). */\n label?: string | RegExp;\n /** Match disabled / enabled controls. */\n disabled?: boolean;\n}\n\n/**\n * Harness for `tw-file-upload`.\n *\n * ## What this harness deliberately cannot do\n *\n * It cannot attach a file. Selecting a file means populating\n * `HTMLInputElement.files`, which is read-only to script and which CDK's\n * `TestElement` — the whole reason a harness runs unchanged against a real\n * browser — has no operation for. The only way to do it is\n * `Object.defineProperty` on the raw DOM node, which exists solely in the\n * Testbed environment. Exposing that would make the harness silently\n * unusable in every other environment, so attaching stays the caller's job:\n * seed files through the bound form control (`writeValue` takes a `File[]`),\n * or through the component's own API, and use this harness for everything\n * downstream of the attachment.\n *\n * Nothing here needs a CDK overlay, so the ordinary fixture loader is enough.\n *\n * Note where the ARIA lives: the host is a `role=\"group\"`, which ARIA 1.2 does\n * not allow to carry `aria-required` / `aria-invalid`, so both sit on the\n * hidden `<input type=\"file\">` — the element that actually owns the value.\n * {@link isRequired} and {@link isInvalid} read them from there;\n * {@link isDisabled} reads `aria-disabled` from the group, which is allowed.\n */\nexport class FileUploadHarness extends ComponentHarness {\n static hostSelector = 'tw-file-upload';\n\n /** The hidden native control. It carries the value-owning ARIA and state. */\n private readonly nativeInput = this.locatorFor('input[type=\"file\"]');\n\n /** Predicate for `locatorFor` / `locatorForAll`. */\n static with(\n options: FileUploadHarnessFilters = {},\n ): HarnessPredicate<FileUploadHarness> {\n return new HarnessPredicate(FileUploadHarness, options)\n .addOption('label', options.label, async (h, label) =>\n HarnessPredicate.stringMatches(await h.getLabel(), label),\n )\n .addOption(\n 'disabled',\n options.disabled,\n async (h, disabled) => (await h.isDisabled()) === disabled,\n );\n }\n\n /** The control's accessible name, from `aria-label` when one is set. */\n async getLabel(): Promise<string | null> {\n return (await this.host()).getAttribute('aria-label');\n }\n\n /** Whether the group reports `aria-disabled=\"true\"`. */\n async isDisabled(): Promise<boolean> {\n return (await (await this.host()).getAttribute('aria-disabled')) === 'true';\n }\n\n /** Whether the native file input is marked required. */\n async isRequired(): Promise<boolean> {\n return (await this.nativeInput()).getProperty<boolean>('required');\n }\n\n /** Whether the native file input reports `aria-invalid=\"true\"`. */\n async isInvalid(): Promise<boolean> {\n return (await (await this.nativeInput()).getAttribute('aria-invalid')) === 'true';\n }\n\n /**\n * Every attached file, in list order. Returns `[]` when nothing is attached\n * — the list element is always rendered, just empty.\n */\n async getFiles(\n filters: FileUploadFileHarnessFilters = {},\n ): Promise<FileUploadFileHarness[]> {\n return this.locatorForAll(FileUploadFileHarness.with(filters))();\n }\n\n /** The name of every attached file, in list order. */\n async getFileNames(): Promise<string[]> {\n const files = await this.getFiles();\n return Promise.all(files.map((file) => file.getName()));\n }\n\n /**\n * Removes the first attached file whose name matches. Throws when nothing\n * matches, rather than failing silently.\n */\n async removeFile(name: string | RegExp): Promise<void> {\n const matches = await this.getFiles({ name });\n if (matches.length === 0) {\n throw new Error(\n `FileUploadHarness.removeFile: no attached file matching ${String(name)}.`,\n );\n }\n await matches[0].remove();\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":"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"}
@@ -1,12 +1,12 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, TemplateRef, Directive, input, booleanAttribute, output, ElementRef, DestroyRef, signal, contentChild, viewChild, computed, afterNextRender, isDevMode, forwardRef, ChangeDetectionStrategy, Component } from '@angular/core';
2
+ import { inject, TemplateRef, Directive, input, booleanAttribute, output, ElementRef, Injector, DestroyRef, signal, contentChild, viewChild, computed, afterNextRender, isDevMode, forwardRef, ChangeDetectionStrategy, Component } from '@angular/core';
3
3
  import { NgTemplateOutlet } from '@angular/common';
4
- import { NgControl, NgForm, FormGroupDirective, Validators } from '@angular/forms';
4
+ import { NgControl } from '@angular/forms';
5
5
  import { FocusMonitor, LiveAnnouncer } from '@angular/cdk/a11y';
6
6
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
7
- import { isObservable, merge } from 'rxjs';
7
+ import { isObservable } from 'rxjs';
8
8
  import { tv } from 'tailwind-variants';
9
- import { TW_ERROR_STATE_MATCHER } from '@cdevhub/ngx-tw/core';
9
+ import { wireErrorState } from '@cdevhub/ngx-tw/core';
10
10
  import { FormFieldControl, TW_FORM_FIELD_CONTROL } from '@cdevhub/ngx-tw/form-field';
11
11
  import { ButtonDirective } from '@cdevhub/ngx-tw/button';
12
12
  import { IconComponent } from '@cdevhub/ngx-tw/icon';
@@ -89,11 +89,18 @@ const fileUploadVariants = tv({
89
89
  idle: {},
90
90
  // 1% scale-up — below Tailwind's smallest `scale-95` step but visually
91
91
  // correct for a subtle drag-over lift; the alternative is no lift.
92
+ // No `dark:` variant here, deliberately. The theme layer already
93
+ // inverts the ramp: `_dark.css` maps `--color-primary-50` onto
94
+ // `blue-950`, so `bg-primary-soft` IS the dark wash in dark mode. A
95
+ // `dark:bg-primary-900/20` override on top resolved to `blue-100` —
96
+ // near-white — and turned the drag-over affordance into a bright flash
97
+ // over a `gray-950` dropzone. These were the only two `dark:` utilities
98
+ // in the library, and both were wrong for this reason.
92
99
  draggingValid: {
93
- dropzone: 'border-primary-500 bg-primary-50 dark:bg-primary-900/20 scale-[1.01]',
100
+ dropzone: 'border-primary-500 bg-primary-soft scale-[1.01]',
94
101
  },
95
102
  draggingInvalid: {
96
- dropzone: 'border-error-500 bg-error-50 dark:bg-error-900/20',
103
+ dropzone: 'border-error-500 bg-error-soft',
97
104
  },
98
105
  error: { dropzone: 'border-error-500' },
99
106
  },
@@ -200,20 +207,20 @@ class FileUploadComponent extends FormFieldControl {
200
207
  /** Marks the control as required. Mirrors to `aria-required`. Also inferred from `Validators.required` on a bound `NgControl`. Defaults to `false`. */
201
208
  requiredInput = input(false, { ...(ngDevMode ? { debugName: "requiredInput" } : /* istanbul ignore next */ {}), alias: 'required',
202
209
  transform: booleanAttribute });
203
- /** Headline text rendered inside the drop zone (e.g., `'Drag files here'`). Projected `[twFileUploadHeadline]` content takes precedence over this input. */
210
+ /** Headline text rendered inside the drop zone (e.g., `'Drag files here'`). Projected `[twFileUploadHeadline]` content takes precedence over this input. Defaults to `undefined`. */
204
211
  label = input(undefined, /* @ts-ignore */
205
212
  ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
206
- /** Secondary text rendered under the label (e.g., `'PDF up to 10MB'`). Projected `[twFileUploadDescription]` content takes precedence. */
213
+ /** Secondary text rendered under the label (e.g., `'PDF up to 10MB'`). Projected `[twFileUploadDescription]` content takes precedence. Defaults to `undefined`. */
207
214
  description = input(undefined, /* @ts-ignore */
208
215
  ...(ngDevMode ? [{ debugName: "description" }] : /* istanbul ignore next */ []));
209
216
  /** Label rendered inside the trigger button. Defaults to `'Choose files'`. Use `'Choose file'` when `multiple` is `false` (consumer responsibility). */
210
217
  triggerLabel = input('Choose files', /* @ts-ignore */
211
218
  ...(ngDevMode ? [{ debugName: "triggerLabel" }] : /* istanbul ignore next */ []));
212
- /** Accessible name applied to the host when no visible label is projected. Mirrored to `aria-label`. */
219
+ /** Accessible name applied to the host when no visible label is projected. Mirrored to `aria-label`. Defaults to `undefined`. */
213
220
  ariaLabel = input(undefined, { ...(ngDevMode ? { debugName: "ariaLabel" } : /* istanbul ignore next */ {}), alias: 'aria-label' });
214
- /** ID of an external element that labels the control. Mirrored to `aria-labelledby`. */
221
+ /** ID of an external element that labels the control. Mirrored to `aria-labelledby`. Defaults to `undefined`. */
215
222
  ariaLabelledby = input(undefined, { ...(ngDevMode ? { debugName: "ariaLabelledby" } : /* istanbul ignore next */ {}), alias: 'aria-labelledby' });
216
- /** ID of an external element that describes the control. Form-field merges its hint/error ids alongside. */
223
+ /** ID of an external element that describes the control. Form-field merges its hint/error ids alongside. Defaults to `undefined`. Alias: `aria-describedby`. */
217
224
  ariaDescribedby = input(undefined, { ...(ngDevMode ? { debugName: "ariaDescribedby" } : /* istanbul ignore next */ {}), alias: 'aria-describedby' });
218
225
  /** Optional `name` attribute on the hidden `<input type="file">` for native form submissions. */
219
226
  name = input(undefined, /* @ts-ignore */
@@ -234,13 +241,11 @@ class FileUploadComponent extends FormFieldControl {
234
241
  cleared = output();
235
242
  // ── Injections ──
236
243
  elementRef = inject((ElementRef));
244
+ injector = inject(Injector);
237
245
  focusMonitor = inject(FocusMonitor);
238
246
  liveAnnouncer = inject(LiveAnnouncer);
239
247
  destroyRef = inject(DestroyRef);
240
248
  ngControl = inject(NgControl, { optional: true, self: true });
241
- parentForm = inject(NgForm, { optional: true });
242
- parentFormGroup = inject(FormGroupDirective, { optional: true });
243
- defaultMatcher = inject(TW_ERROR_STATE_MATCHER);
244
249
  // ── Internal mutable state ──
245
250
  itemsSignal = signal([], /* @ts-ignore */
246
251
  ...(ngDevMode ? [{ debugName: "itemsSignal" }] : /* istanbul ignore next */ []));
@@ -252,10 +257,13 @@ class FileUploadComponent extends FormFieldControl {
252
257
  ...(ngDevMode ? [{ debugName: "dragHasInvalidFilesSignal" }] : /* istanbul ignore next */ []));
253
258
  _focused = signal(false, /* @ts-ignore */
254
259
  ...(ngDevMode ? [{ debugName: "_focused" }] : /* istanbul ignore next */ []));
255
- _ngControlRev = signal(0, /* @ts-ignore */
256
- ...(ngDevMode ? [{ debugName: "_ngControlRev" }] : /* istanbul ignore next */ []));
257
- _formSubmitRev = signal(0, /* @ts-ignore */
258
- ...(ngDevMode ? [{ debugName: "_formSubmitRev" }] : /* istanbul ignore next */ []));
260
+ /** @internal Shared `errorState` / `required` / `errors` derivation — see `wireErrorState`. */
261
+ errorWiring = wireErrorState({
262
+ ngControl: () => this.ngControl,
263
+ matcher: () => this.errorStateMatcher(),
264
+ required: () => this.requiredInput(),
265
+ track: [() => this._focused()],
266
+ });
259
267
  describedByIdsSignal = signal([], /* @ts-ignore */
260
268
  ...(ngDevMode ? [{ debugName: "describedByIdsSignal" }] : /* istanbul ignore next */ []));
261
269
  labelledByIdsSignal = signal([], /* @ts-ignore */
@@ -311,36 +319,16 @@ class FileUploadComponent extends FormFieldControl {
311
319
  ...(ngDevMode ? [{ debugName: "empty" }] : /* istanbul ignore next */ []));
312
320
  /** @internal */
313
321
  disabled = computed(() => {
314
- this._ngControlRev();
322
+ this.errorWiring.rev();
315
323
  return this.disabledInput() || this.cvaDisabled() || !!this.ngControl?.disabled;
316
324
  }, /* @ts-ignore */
317
325
  ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
318
326
  /** @internal */
319
- required = computed(() => {
320
- this._ngControlRev();
321
- if (this.requiredInput())
322
- return true;
323
- const ctrl = this.ngControl?.control;
324
- return !!ctrl?.hasValidator(Validators.required);
325
- }, /* @ts-ignore */
326
- ...(ngDevMode ? [{ debugName: "required" }] : /* istanbul ignore next */ []));
327
- /** @internal */
328
- errorState = computed(() => {
329
- this._ngControlRev();
330
- this._formSubmitRev();
331
- this._focused();
332
- const matcher = this.errorStateMatcher() ?? this.defaultMatcher;
333
- const form = this.parentFormGroup ??
334
- this.parentForm;
335
- return matcher.isErrorState(this.ngControl?.control ?? null, form);
336
- }, /* @ts-ignore */
337
- ...(ngDevMode ? [{ debugName: "errorState" }] : /* istanbul ignore next */ []));
327
+ required = this.errorWiring.required;
328
+ /** @internal */
329
+ errorState = this.errorWiring.errorState;
338
330
  /** @internal Validation errors map from the bound `NgControl`. */
339
- errors = computed(() => {
340
- this._ngControlRev();
341
- return this.ngControl?.control?.errors ?? null;
342
- }, /* @ts-ignore */
343
- ...(ngDevMode ? [{ debugName: "errors" }] : /* istanbul ignore next */ []));
331
+ errors = this.errorWiring.errors;
344
332
  /** @internal */
345
333
  controlType = 'file-upload';
346
334
  /** @internal */
@@ -491,8 +479,21 @@ class FileUploadComponent extends FormFieldControl {
491
479
  this.emitChange();
492
480
  this.fileRemoved.emit(removed);
493
481
  this.liveAnnouncer.announce(`${removed.file.name} removed.`, 'polite');
494
- // Move focus to the next remove button, or to the trigger button when none remain.
495
- queueMicrotask(() => {
482
+ // Move focus to the next remove button, or to the trigger button when none
483
+ // remain.
484
+ //
485
+ // This MUST wait for the render, not merely for the microtask queue. A
486
+ // `queueMicrotask` here (what this used to be) runs before Angular's
487
+ // zoneless scheduler has re-rendered the list, so `nextButtons` was still
488
+ // the PRE-removal node list: the "next" button we focused was the one about
489
+ // to be destroyed, and focus fell to `<body>`. Removing the last item took
490
+ // the `length === 0` branch for the same reason — the count was stale, so
491
+ // the trigger never got focus either.
492
+ //
493
+ // It survived unit testing because a spec that calls `detectChanges()`
494
+ // between the click and the assertion has already re-rendered by the time
495
+ // the microtask runs, which is the opposite order from a real browser.
496
+ afterNextRender(() => {
496
497
  const host = this.elementRef.nativeElement;
497
498
  const nextButtons = host.querySelectorAll('[data-tw-file-upload-remove]');
498
499
  if (nextButtons.length === 0) {
@@ -502,7 +503,7 @@ class FileUploadComponent extends FormFieldControl {
502
503
  const fallback = nextButtons[Math.min(idx, nextButtons.length - 1)];
503
504
  fallback?.focus();
504
505
  }
505
- });
506
+ }, { injector: this.injector });
506
507
  }
507
508
  /** Removes all items. Emits `cleared` and calls `onChange([])`. No-op when already empty or disabled. */
508
509
  clear() {
@@ -839,28 +840,14 @@ class FileUploadComponent extends FormFieldControl {
839
840
  this._focused.set(!!origin);
840
841
  if (wasFocused && !origin) {
841
842
  this.onTouched();
842
- this._ngControlRev.update((n) => n + 1);
843
+ this.errorWiring.bump();
843
844
  }
844
845
  });
845
846
  }
846
847
  this.destroyRef.onDestroy(() => {
847
848
  this.focusMonitor.stopMonitoring(this.elementRef);
848
849
  });
849
- const ctrl = this.ngControl?.control;
850
- if (ctrl) {
851
- const streams = [ctrl.statusChanges, ctrl.valueChanges].filter((s) => !!s);
852
- if (streams.length) {
853
- merge(...streams)
854
- .pipe(takeUntilDestroyed(this.destroyRef))
855
- .subscribe(() => this._ngControlRev.update((n) => n + 1));
856
- }
857
- }
858
- const submit = this.parentFormGroup?.ngSubmit ?? this.parentForm?.ngSubmit;
859
- if (submit) {
860
- submit
861
- .pipe(takeUntilDestroyed(this.destroyRef))
862
- .subscribe(() => this._formSubmitRev.update((n) => n + 1));
863
- }
850
+ this.errorWiring.connect();
864
851
  }
865
852
  // ── Helpers ──
866
853
  hasAccessibleNameHint() {
@@ -870,12 +857,12 @@ class FileUploadComponent extends FormFieldControl {
870
857
  return !!host.textContent && host.textContent.trim().length > 0;
871
858
  }
872
859
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: FileUploadComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
873
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: FileUploadComponent, isStandalone: true, selector: "tw-file-upload", inputs: { 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 }, maxFiles: { classPropertyName: "maxFiles", publicName: "maxFiles", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, disabledInput: { classPropertyName: "disabledInput", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, requiredInput: { classPropertyName: "requiredInput", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, description: { classPropertyName: "description", publicName: "description", isSignal: true, isRequired: false, transformFunction: null }, triggerLabel: { classPropertyName: "triggerLabel", publicName: "triggerLabel", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "aria-label", isSignal: true, isRequired: false, transformFunction: null }, ariaLabelledby: { classPropertyName: "ariaLabelledby", publicName: "aria-labelledby", isSignal: true, isRequired: false, transformFunction: null }, ariaDescribedby: { classPropertyName: "ariaDescribedby", publicName: "aria-describedby", isSignal: true, isRequired: false, transformFunction: null }, name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, idInput: { classPropertyName: "idInput", publicName: "id", isSignal: true, isRequired: false, transformFunction: null }, errorStateMatcher: { classPropertyName: "errorStateMatcher", publicName: "errorStateMatcher", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { filesAdded: "filesAdded", fileRemoved: "fileRemoved", fileRejected: "fileRejected", cleared: "cleared" }, host: { attributes: { "role": "group" }, properties: { "id": "id()", "class": "rootClasses()", "attr.aria-label": "ariaLabel() || null", "attr.aria-labelledby": "effectiveAriaLabelledby() || null", "attr.aria-describedby": "effectiveAriaDescribedby() || null", "attr.aria-disabled": "disabled() || null", "attr.aria-required": "required() || null", "attr.aria-invalid": "errorState() || null" } }, providers: [
860
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.7", type: FileUploadComponent, isStandalone: true, selector: "tw-file-upload", inputs: { 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 }, maxFiles: { classPropertyName: "maxFiles", publicName: "maxFiles", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, disabledInput: { classPropertyName: "disabledInput", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, requiredInput: { classPropertyName: "requiredInput", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, description: { classPropertyName: "description", publicName: "description", isSignal: true, isRequired: false, transformFunction: null }, triggerLabel: { classPropertyName: "triggerLabel", publicName: "triggerLabel", isSignal: true, isRequired: false, transformFunction: null }, ariaLabel: { classPropertyName: "ariaLabel", publicName: "aria-label", isSignal: true, isRequired: false, transformFunction: null }, ariaLabelledby: { classPropertyName: "ariaLabelledby", publicName: "aria-labelledby", isSignal: true, isRequired: false, transformFunction: null }, ariaDescribedby: { classPropertyName: "ariaDescribedby", publicName: "aria-describedby", isSignal: true, isRequired: false, transformFunction: null }, name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, idInput: { classPropertyName: "idInput", publicName: "id", isSignal: true, isRequired: false, transformFunction: null }, errorStateMatcher: { classPropertyName: "errorStateMatcher", publicName: "errorStateMatcher", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { filesAdded: "filesAdded", fileRemoved: "fileRemoved", fileRejected: "fileRejected", cleared: "cleared" }, host: { attributes: { "role": "group" }, properties: { "id": "id()", "class": "rootClasses()", "attr.aria-label": "ariaLabel() || null", "attr.aria-labelledby": "effectiveAriaLabelledby() || null", "attr.aria-describedby": "effectiveAriaDescribedby() || null", "attr.aria-disabled": "disabled() || null" } }, providers: [
874
861
  {
875
862
  provide: TW_FORM_FIELD_CONTROL,
876
863
  useExisting: forwardRef(() => FileUploadComponent),
877
864
  },
878
- ], queries: [{ propertyName: "itemSlot", first: true, predicate: FileUploadItemDirective, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "dropzoneRef", first: true, predicate: ["dropzone"], descendants: true, isSignal: true }, { propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true, isSignal: true }, { propertyName: "hiddenInputRef", first: true, predicate: ["hiddenInput"], descendants: true, isSignal: true }], exportAs: ["twFileUpload"], usesInheritance: true, ngImport: i0, template: "<input\n #hiddenInput\n type=\"file\"\n class=\"sr-only\"\n tabindex=\"-1\"\n [id]=\"hiddenInputId\"\n [attr.aria-label]=\"ariaLabel() ?? label() ?? triggerLabel()\"\n [attr.name]=\"name() || null\"\n [attr.accept]=\"accept() || null\"\n [multiple]=\"multiple()\"\n [disabled]=\"disabled()\"\n [required]=\"required()\"\n (change)=\"onNativeChange($event)\"\n/>\n\n<!-- The dropzone is a mouse click/drop convenience target, intentionally NOT a\n focusable control: the inner trigger <button> owns keyboard operability, so\n adding tabindex/keydown here would create a nested-interactive structure.\n No keyboard user is locked out, so the rules' concern does not apply. -->\n<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n<div\n #dropzone\n [class]=\"dropzoneClasses()\"\n (click)=\"open()\"\n (dragenter)=\"onDragEnter($event)\"\n (dragover)=\"onDragOver($event)\"\n (dragleave)=\"onDragLeave($event)\"\n (drop)=\"onDrop($event)\"\n>\n <div [class]=\"dropzoneInnerClasses()\">\n <div [class]=\"illustrationClasses()\">\n <ng-content select=\"[twFileUploadIcon]\">\n <tw-icon name=\"upload-cloud\" [size]=\"iconSize()\" aria-hidden=\"true\" />\n </ng-content>\n </div>\n\n <span [id]=\"headlineId\" [class]=\"headlineClasses()\">\n <ng-content select=\"[twFileUploadHeadline]\">\n @if (label(); as l) {\n {{ l }}\n } @else {\n Drag files here or click to browse\n }\n </ng-content>\n </span>\n\n <span [id]=\"descriptionId\" [class]=\"descriptionClasses()\">\n <ng-content select=\"[twFileUploadDescription]\">\n @if (description(); as d) {\n {{ d }}\n }\n </ng-content>\n </span>\n\n <button\n #trigger\n type=\"button\"\n twButton\n variant=\"outline\"\n color=\"neutral\"\n [size]=\"size()\"\n [disabled]=\"disabled()\"\n [attr.aria-describedby]=\"effectiveAriaDescribedby() || null\"\n (click)=\"onTriggerClick($event)\"\n >\n {{ triggerLabel() }}\n </button>\n </div>\n</div>\n\n<ul [class]=\"listClasses()\" role=\"list\">\n @for (item of items(); track item.id) {\n <li>\n @if (itemSlot(); as slot) {\n <ng-container\n [ngTemplateOutlet]=\"slot.templateRef\"\n [ngTemplateOutletContext]=\"{ $implicit: item, item: item }\"\n />\n } @else {\n <div [class]=\"itemClasses()\">\n <tw-icon\n [name]=\"iconNameFor(item)\"\n size=\"md\"\n [class]=\"itemIconClasses()\"\n aria-hidden=\"true\"\n />\n <div [class]=\"itemBodyClasses()\">\n <span [class]=\"itemNameClasses()\">{{ item.file.name }}</span>\n <span [class]=\"itemMetaClasses()\">{{ metaTextFor(item) }}</span>\n @if (shouldShowProgress(item)) {\n <tw-progress-bar\n [class]=\"itemProgressClasses()\"\n [value]=\"item.progress\"\n size=\"sm\"\n [color]=\"progressColorFor(item)\"\n />\n }\n </div>\n <div [class]=\"itemActionsClasses()\">\n <button\n type=\"button\"\n twButton\n variant=\"ghost\"\n color=\"neutral\"\n size=\"sm\"\n data-tw-file-upload-remove\n [attr.aria-label]=\"'Remove ' + item.file.name\"\n (click)=\"remove(item.id)\"\n >\n <tw-icon twButtonIcon name=\"x\" />\n </button>\n </div>\n </div>\n }\n </li>\n }\n</ul>\n", dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: ButtonDirective, selector: "[twButton]", inputs: ["variant", "color", "size", "disabled", "loading"], exportAs: ["twButton"] }, { kind: "component", type: IconComponent, selector: "tw-icon", inputs: ["name", "img", "color", "size", "ariaLabel", "svg"] }, { kind: "component", type: ProgressBarComponent, selector: "tw-progress-bar", inputs: ["value", "variant", "color", "size", "label", "options"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
865
+ ], queries: [{ propertyName: "itemSlot", first: true, predicate: FileUploadItemDirective, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "dropzoneRef", first: true, predicate: ["dropzone"], descendants: true, isSignal: true }, { propertyName: "triggerRef", first: true, predicate: ["trigger"], descendants: true, isSignal: true }, { propertyName: "hiddenInputRef", first: true, predicate: ["hiddenInput"], descendants: true, isSignal: true }], exportAs: ["twFileUpload"], usesInheritance: true, ngImport: i0, template: "<input\n #hiddenInput\n type=\"file\"\n class=\"sr-only\"\n tabindex=\"-1\"\n [id]=\"hiddenInputId\"\n [attr.aria-label]=\"ariaLabel() ?? label() ?? triggerLabel()\"\n [attr.name]=\"name() || null\"\n [attr.accept]=\"accept() || null\"\n [multiple]=\"multiple()\"\n [disabled]=\"disabled()\"\n [required]=\"required()\"\n [attr.aria-invalid]=\"errorState() || null\"\n (change)=\"onNativeChange($event)\"\n/>\n\n<!-- The dropzone is a mouse click/drop convenience target, intentionally NOT a\n focusable control: the inner trigger <button> owns keyboard operability, so\n adding tabindex/keydown here would create a nested-interactive structure.\n No keyboard user is locked out, so the rules' concern does not apply. -->\n<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n<div\n #dropzone\n [class]=\"dropzoneClasses()\"\n (click)=\"open()\"\n (dragenter)=\"onDragEnter($event)\"\n (dragover)=\"onDragOver($event)\"\n (dragleave)=\"onDragLeave($event)\"\n (drop)=\"onDrop($event)\"\n>\n <div [class]=\"dropzoneInnerClasses()\">\n <div [class]=\"illustrationClasses()\">\n <ng-content select=\"[twFileUploadIcon]\">\n <tw-icon name=\"upload-cloud\" [size]=\"iconSize()\" aria-hidden=\"true\" />\n </ng-content>\n </div>\n\n <span [id]=\"headlineId\" [class]=\"headlineClasses()\">\n <ng-content select=\"[twFileUploadHeadline]\">\n @if (label(); as l) {\n {{ l }}\n } @else {\n Drag files here or click to browse\n }\n </ng-content>\n </span>\n\n <span [id]=\"descriptionId\" [class]=\"descriptionClasses()\">\n <ng-content select=\"[twFileUploadDescription]\">\n @if (description(); as d) {\n {{ d }}\n }\n </ng-content>\n </span>\n\n <button\n #trigger\n type=\"button\"\n twButton\n variant=\"outline\"\n color=\"neutral\"\n [size]=\"size()\"\n [disabled]=\"disabled()\"\n [attr.aria-describedby]=\"effectiveAriaDescribedby() || null\"\n (click)=\"onTriggerClick($event)\"\n >\n {{ triggerLabel() }}\n </button>\n </div>\n</div>\n\n<ul [class]=\"listClasses()\" role=\"list\">\n @for (item of items(); track item.id) {\n <li>\n @if (itemSlot(); as slot) {\n <ng-container\n [ngTemplateOutlet]=\"slot.templateRef\"\n [ngTemplateOutletContext]=\"{ $implicit: item, item: item }\"\n />\n } @else {\n <div [class]=\"itemClasses()\">\n <tw-icon\n [name]=\"iconNameFor(item)\"\n size=\"md\"\n [class]=\"itemIconClasses()\"\n aria-hidden=\"true\"\n />\n <div [class]=\"itemBodyClasses()\">\n <span [class]=\"itemNameClasses()\">{{ item.file.name }}</span>\n <span [class]=\"itemMetaClasses()\">{{ metaTextFor(item) }}</span>\n @if (shouldShowProgress(item)) {\n <tw-progress-bar\n [class]=\"itemProgressClasses()\"\n [value]=\"item.progress\"\n size=\"sm\"\n [color]=\"progressColorFor(item)\"\n />\n }\n </div>\n <div [class]=\"itemActionsClasses()\">\n <button\n type=\"button\"\n twButton\n variant=\"ghost\"\n color=\"neutral\"\n size=\"sm\"\n data-tw-file-upload-remove\n [attr.aria-label]=\"'Remove ' + item.file.name\"\n (click)=\"remove(item.id)\"\n >\n <tw-icon twButtonIcon name=\"x\" />\n </button>\n </div>\n </div>\n }\n </li>\n }\n</ul>\n", dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: ButtonDirective, selector: "[twButton]", inputs: ["variant", "color", "size", "disabled", "loading"], exportAs: ["twButton"] }, { kind: "component", type: IconComponent, selector: "tw-icon", inputs: ["name", "img", "color", "size", "aria-label", "svg"] }, { kind: "component", type: ProgressBarComponent, selector: "tw-progress-bar", inputs: ["value", "variant", "color", "size", "label", "options"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
879
866
  }
880
867
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImport: i0, type: FileUploadComponent, decorators: [{
881
868
  type: Component,
@@ -892,9 +879,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.7", ngImpor
892
879
  '[attr.aria-labelledby]': 'effectiveAriaLabelledby() || null',
893
880
  '[attr.aria-describedby]': 'effectiveAriaDescribedby() || null',
894
881
  '[attr.aria-disabled]': 'disabled() || null',
895
- '[attr.aria-required]': 'required() || null',
896
- '[attr.aria-invalid]': 'errorState() || null',
897
- }, template: "<input\n #hiddenInput\n type=\"file\"\n class=\"sr-only\"\n tabindex=\"-1\"\n [id]=\"hiddenInputId\"\n [attr.aria-label]=\"ariaLabel() ?? label() ?? triggerLabel()\"\n [attr.name]=\"name() || null\"\n [attr.accept]=\"accept() || null\"\n [multiple]=\"multiple()\"\n [disabled]=\"disabled()\"\n [required]=\"required()\"\n (change)=\"onNativeChange($event)\"\n/>\n\n<!-- The dropzone is a mouse click/drop convenience target, intentionally NOT a\n focusable control: the inner trigger <button> owns keyboard operability, so\n adding tabindex/keydown here would create a nested-interactive structure.\n No keyboard user is locked out, so the rules' concern does not apply. -->\n<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n<div\n #dropzone\n [class]=\"dropzoneClasses()\"\n (click)=\"open()\"\n (dragenter)=\"onDragEnter($event)\"\n (dragover)=\"onDragOver($event)\"\n (dragleave)=\"onDragLeave($event)\"\n (drop)=\"onDrop($event)\"\n>\n <div [class]=\"dropzoneInnerClasses()\">\n <div [class]=\"illustrationClasses()\">\n <ng-content select=\"[twFileUploadIcon]\">\n <tw-icon name=\"upload-cloud\" [size]=\"iconSize()\" aria-hidden=\"true\" />\n </ng-content>\n </div>\n\n <span [id]=\"headlineId\" [class]=\"headlineClasses()\">\n <ng-content select=\"[twFileUploadHeadline]\">\n @if (label(); as l) {\n {{ l }}\n } @else {\n Drag files here or click to browse\n }\n </ng-content>\n </span>\n\n <span [id]=\"descriptionId\" [class]=\"descriptionClasses()\">\n <ng-content select=\"[twFileUploadDescription]\">\n @if (description(); as d) {\n {{ d }}\n }\n </ng-content>\n </span>\n\n <button\n #trigger\n type=\"button\"\n twButton\n variant=\"outline\"\n color=\"neutral\"\n [size]=\"size()\"\n [disabled]=\"disabled()\"\n [attr.aria-describedby]=\"effectiveAriaDescribedby() || null\"\n (click)=\"onTriggerClick($event)\"\n >\n {{ triggerLabel() }}\n </button>\n </div>\n</div>\n\n<ul [class]=\"listClasses()\" role=\"list\">\n @for (item of items(); track item.id) {\n <li>\n @if (itemSlot(); as slot) {\n <ng-container\n [ngTemplateOutlet]=\"slot.templateRef\"\n [ngTemplateOutletContext]=\"{ $implicit: item, item: item }\"\n />\n } @else {\n <div [class]=\"itemClasses()\">\n <tw-icon\n [name]=\"iconNameFor(item)\"\n size=\"md\"\n [class]=\"itemIconClasses()\"\n aria-hidden=\"true\"\n />\n <div [class]=\"itemBodyClasses()\">\n <span [class]=\"itemNameClasses()\">{{ item.file.name }}</span>\n <span [class]=\"itemMetaClasses()\">{{ metaTextFor(item) }}</span>\n @if (shouldShowProgress(item)) {\n <tw-progress-bar\n [class]=\"itemProgressClasses()\"\n [value]=\"item.progress\"\n size=\"sm\"\n [color]=\"progressColorFor(item)\"\n />\n }\n </div>\n <div [class]=\"itemActionsClasses()\">\n <button\n type=\"button\"\n twButton\n variant=\"ghost\"\n color=\"neutral\"\n size=\"sm\"\n data-tw-file-upload-remove\n [attr.aria-label]=\"'Remove ' + item.file.name\"\n (click)=\"remove(item.id)\"\n >\n <tw-icon twButtonIcon name=\"x\" />\n </button>\n </div>\n </div>\n }\n </li>\n }\n</ul>\n" }]
882
+ // NOTE: `aria-required` and `aria-invalid` are NOT set here. ARIA 1.2 does
883
+ // not allow either on `role="group"`, and axe flags it as a *critical*
884
+ // `aria-allowed-attr` violation. Both belong on the control that carries
885
+ // the value: the hidden `<input type="file">` takes native `required` and
886
+ // `aria-invalid` in `file-upload.html`.
887
+ }, template: "<input\n #hiddenInput\n type=\"file\"\n class=\"sr-only\"\n tabindex=\"-1\"\n [id]=\"hiddenInputId\"\n [attr.aria-label]=\"ariaLabel() ?? label() ?? triggerLabel()\"\n [attr.name]=\"name() || null\"\n [attr.accept]=\"accept() || null\"\n [multiple]=\"multiple()\"\n [disabled]=\"disabled()\"\n [required]=\"required()\"\n [attr.aria-invalid]=\"errorState() || null\"\n (change)=\"onNativeChange($event)\"\n/>\n\n<!-- The dropzone is a mouse click/drop convenience target, intentionally NOT a\n focusable control: the inner trigger <button> owns keyboard operability, so\n adding tabindex/keydown here would create a nested-interactive structure.\n No keyboard user is locked out, so the rules' concern does not apply. -->\n<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->\n<div\n #dropzone\n [class]=\"dropzoneClasses()\"\n (click)=\"open()\"\n (dragenter)=\"onDragEnter($event)\"\n (dragover)=\"onDragOver($event)\"\n (dragleave)=\"onDragLeave($event)\"\n (drop)=\"onDrop($event)\"\n>\n <div [class]=\"dropzoneInnerClasses()\">\n <div [class]=\"illustrationClasses()\">\n <ng-content select=\"[twFileUploadIcon]\">\n <tw-icon name=\"upload-cloud\" [size]=\"iconSize()\" aria-hidden=\"true\" />\n </ng-content>\n </div>\n\n <span [id]=\"headlineId\" [class]=\"headlineClasses()\">\n <ng-content select=\"[twFileUploadHeadline]\">\n @if (label(); as l) {\n {{ l }}\n } @else {\n Drag files here or click to browse\n }\n </ng-content>\n </span>\n\n <span [id]=\"descriptionId\" [class]=\"descriptionClasses()\">\n <ng-content select=\"[twFileUploadDescription]\">\n @if (description(); as d) {\n {{ d }}\n }\n </ng-content>\n </span>\n\n <button\n #trigger\n type=\"button\"\n twButton\n variant=\"outline\"\n color=\"neutral\"\n [size]=\"size()\"\n [disabled]=\"disabled()\"\n [attr.aria-describedby]=\"effectiveAriaDescribedby() || null\"\n (click)=\"onTriggerClick($event)\"\n >\n {{ triggerLabel() }}\n </button>\n </div>\n</div>\n\n<ul [class]=\"listClasses()\" role=\"list\">\n @for (item of items(); track item.id) {\n <li>\n @if (itemSlot(); as slot) {\n <ng-container\n [ngTemplateOutlet]=\"slot.templateRef\"\n [ngTemplateOutletContext]=\"{ $implicit: item, item: item }\"\n />\n } @else {\n <div [class]=\"itemClasses()\">\n <tw-icon\n [name]=\"iconNameFor(item)\"\n size=\"md\"\n [class]=\"itemIconClasses()\"\n aria-hidden=\"true\"\n />\n <div [class]=\"itemBodyClasses()\">\n <span [class]=\"itemNameClasses()\">{{ item.file.name }}</span>\n <span [class]=\"itemMetaClasses()\">{{ metaTextFor(item) }}</span>\n @if (shouldShowProgress(item)) {\n <tw-progress-bar\n [class]=\"itemProgressClasses()\"\n [value]=\"item.progress\"\n size=\"sm\"\n [color]=\"progressColorFor(item)\"\n />\n }\n </div>\n <div [class]=\"itemActionsClasses()\">\n <button\n type=\"button\"\n twButton\n variant=\"ghost\"\n color=\"neutral\"\n size=\"sm\"\n data-tw-file-upload-remove\n [attr.aria-label]=\"'Remove ' + item.file.name\"\n (click)=\"remove(item.id)\"\n >\n <tw-icon twButtonIcon name=\"x\" />\n </button>\n </div>\n </div>\n }\n </li>\n }\n</ul>\n" }]
898
888
  }], ctorParameters: () => [], propDecorators: { 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 }] }], maxFiles: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxFiles", required: false }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], disabledInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], requiredInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], description: [{ type: i0.Input, args: [{ isSignal: true, alias: "description", required: false }] }], triggerLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "triggerLabel", required: false }] }], ariaLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-label", required: false }] }], ariaLabelledby: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-labelledby", required: false }] }], ariaDescribedby: [{ type: i0.Input, args: [{ isSignal: true, alias: "aria-describedby", required: false }] }], name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }], idInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: false }] }], errorStateMatcher: [{ type: i0.Input, args: [{ isSignal: true, alias: "errorStateMatcher", required: false }] }], filesAdded: [{ type: i0.Output, args: ["filesAdded"] }], fileRemoved: [{ type: i0.Output, args: ["fileRemoved"] }], fileRejected: [{ type: i0.Output, args: ["fileRejected"] }], cleared: [{ type: i0.Output, args: ["cleared"] }], itemSlot: [{ type: i0.ContentChild, args: [i0.forwardRef(() => FileUploadItemDirective), { isSignal: true }] }], dropzoneRef: [{ type: i0.ViewChild, args: ['dropzone', { isSignal: true }] }], triggerRef: [{ type: i0.ViewChild, args: ['trigger', { isSignal: true }] }], hiddenInputRef: [{ type: i0.ViewChild, args: ['hiddenInput', { isSignal: true }] }] } });
899
889
 
900
890
  /**