@nuvia-ui/components 4.0.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 (230) hide show
  1. package/package.json +27 -0
  2. package/src/ds-accordion/ds-accordion-item.js +288 -0
  3. package/src/ds-accordion/ds-accordion-item.stories.js +82 -0
  4. package/src/ds-accordion/ds-accordion.a11y.test.js +92 -0
  5. package/src/ds-accordion/ds-accordion.js +68 -0
  6. package/src/ds-accordion/ds-accordion.stories.js +118 -0
  7. package/src/ds-accordion/ds-accordion.test.js +146 -0
  8. package/src/ds-accordion/index.js +2 -0
  9. package/src/ds-action-bar/ds-action-bar.js +116 -0
  10. package/src/ds-action-bar/ds-action-bar.stories.js +86 -0
  11. package/src/ds-action-bar/ds-action-bar.test.js +64 -0
  12. package/src/ds-action-bar/index.js +1 -0
  13. package/src/ds-alert/ds-alert.a11y.test.js +151 -0
  14. package/src/ds-alert/ds-alert.js +223 -0
  15. package/src/ds-alert/ds-alert.mdx +142 -0
  16. package/src/ds-alert/ds-alert.stories.js +166 -0
  17. package/src/ds-alert/ds-alert.test.js +256 -0
  18. package/src/ds-alert/index.js +1 -0
  19. package/src/ds-avatar/ds-avatar.a11y.test.js +45 -0
  20. package/src/ds-avatar/ds-avatar.js +216 -0
  21. package/src/ds-avatar/ds-avatar.stories.js +120 -0
  22. package/src/ds-avatar/ds-avatar.test.js +83 -0
  23. package/src/ds-avatar/index.js +1 -0
  24. package/src/ds-avatar-extended/ds-avatar-extended.a11y.test.js +29 -0
  25. package/src/ds-avatar-extended/ds-avatar-extended.js +108 -0
  26. package/src/ds-avatar-extended/ds-avatar-extended.stories.js +93 -0
  27. package/src/ds-avatar-extended/ds-avatar-extended.test.js +66 -0
  28. package/src/ds-avatar-extended/index.js +1 -0
  29. package/src/ds-banner/ds-banner.a11y.test.js +51 -0
  30. package/src/ds-banner/ds-banner.js +233 -0
  31. package/src/ds-banner/ds-banner.stories.js +185 -0
  32. package/src/ds-banner/ds-banner.test.js +116 -0
  33. package/src/ds-banner/index.js +1 -0
  34. package/src/ds-breadcrumb-item/ds-breadcrumb-item.js +135 -0
  35. package/src/ds-breadcrumb-item/ds-breadcrumb-item.stories.js +49 -0
  36. package/src/ds-breadcrumb-item/ds-breadcrumb-item.test.js +55 -0
  37. package/src/ds-breadcrumbs/ds-breadcrumbs.js +194 -0
  38. package/src/ds-breadcrumbs/ds-breadcrumbs.stories.js +54 -0
  39. package/src/ds-breadcrumbs/ds-breadcrumbs.test.js +33 -0
  40. package/src/ds-button/ds-button.a11y.test.js +49 -0
  41. package/src/ds-button/ds-button.js +205 -0
  42. package/src/ds-button/ds-button.mdx +141 -0
  43. package/src/ds-button/ds-button.stories.js +152 -0
  44. package/src/ds-button/ds-button.test.js +62 -0
  45. package/src/ds-button/index.js +1 -0
  46. package/src/ds-button-group/ds-button-group.js +82 -0
  47. package/src/ds-button-group/ds-button-group.mdx +39 -0
  48. package/src/ds-button-group/ds-button-group.stories.js +47 -0
  49. package/src/ds-button-group/ds-button-group.test.js +47 -0
  50. package/src/ds-button-group/index.js +1 -0
  51. package/src/ds-checkbox/ds-checkbox.a11y.test.js +79 -0
  52. package/src/ds-checkbox/ds-checkbox.js +271 -0
  53. package/src/ds-checkbox/ds-checkbox.stories.js +77 -0
  54. package/src/ds-checkbox/ds-checkbox.test.js +191 -0
  55. package/src/ds-checkbox/index.js +1 -0
  56. package/src/ds-checkbox-group/ds-checkbox-group.a11y.test.js +146 -0
  57. package/src/ds-checkbox-group/ds-checkbox-group.js +235 -0
  58. package/src/ds-checkbox-group/ds-checkbox-group.stories.js +210 -0
  59. package/src/ds-checkbox-group/ds-checkbox-group.test.js +150 -0
  60. package/src/ds-checkbox-group/index.js +1 -0
  61. package/src/ds-dialog/ds-dialog.js +466 -0
  62. package/src/ds-dialog/ds-dialog.stories.js +274 -0
  63. package/src/ds-dialog/ds-dialog.test.js +441 -0
  64. package/src/ds-dialog/index.js +1 -0
  65. package/src/ds-dropdown/ds-dropdown.a11y.test.js +80 -0
  66. package/src/ds-dropdown/ds-dropdown.js +891 -0
  67. package/src/ds-dropdown/ds-dropdown.stories.js +259 -0
  68. package/src/ds-dropdown/ds-dropdown.test.js +268 -0
  69. package/src/ds-dropdown/index.js +1 -0
  70. package/src/ds-dropdown-group/ds-dropdown-group.js +55 -0
  71. package/src/ds-dropdown-panel/ds-dropdown-panel.js +34 -0
  72. package/src/ds-file-uploaded/ds-file-uploaded.a11y.test.js +40 -0
  73. package/src/ds-file-uploaded/ds-file-uploaded.js +135 -0
  74. package/src/ds-file-uploaded/ds-file-uploaded.mdx +33 -0
  75. package/src/ds-file-uploaded/ds-file-uploaded.stories.js +81 -0
  76. package/src/ds-file-uploaded/ds-file-uploaded.test.js +85 -0
  77. package/src/ds-file-uploader/ds-file-uploader.a11y.test.js +61 -0
  78. package/src/ds-file-uploader/ds-file-uploader.js +442 -0
  79. package/src/ds-file-uploader/ds-file-uploader.mdx +44 -0
  80. package/src/ds-file-uploader/ds-file-uploader.stories.js +76 -0
  81. package/src/ds-file-uploader/ds-file-uploader.test.js +142 -0
  82. package/src/ds-header/ds-header.a11y.test.js +38 -0
  83. package/src/ds-header/ds-header.js +149 -0
  84. package/src/ds-header/ds-header.stories.js +63 -0
  85. package/src/ds-header/ds-header.test.js +52 -0
  86. package/src/ds-header/index.js +1 -0
  87. package/src/ds-header-nav/ds-header-nav.a11y.test.js +69 -0
  88. package/src/ds-header-nav/ds-header-nav.js +114 -0
  89. package/src/ds-header-nav/ds-header-nav.stories.js +17 -0
  90. package/src/ds-header-nav/ds-header-nav.test.js +93 -0
  91. package/src/ds-header-nav-item/ds-header-nav-item.a11y.test.js +71 -0
  92. package/src/ds-header-nav-item/ds-header-nav-item.js +124 -0
  93. package/src/ds-header-nav-item/ds-header-nav-item.stories.js +43 -0
  94. package/src/ds-header-nav-item/ds-header-nav-item.test.js +61 -0
  95. package/src/ds-icon/ds-icon.a11y.test.js +49 -0
  96. package/src/ds-icon/ds-icon.js +75 -0
  97. package/src/ds-icon/ds-icon.mdx +36 -0
  98. package/src/ds-icon/ds-icon.stories.js +88 -0
  99. package/src/ds-icon/ds-icon.test.js +97 -0
  100. package/src/ds-icon/index.js +1 -0
  101. package/src/ds-icon-button/ds-icon-button.a11y.test.js +55 -0
  102. package/src/ds-icon-button/ds-icon-button.js +224 -0
  103. package/src/ds-icon-button/ds-icon-button.mdx +131 -0
  104. package/src/ds-icon-button/ds-icon-button.stories.js +128 -0
  105. package/src/ds-icon-button/ds-icon-button.test.js +90 -0
  106. package/src/ds-icon-button/index.js +1 -0
  107. package/src/ds-input/ds-input.a11y.test.js +145 -0
  108. package/src/ds-input/ds-input.js +645 -0
  109. package/src/ds-input/ds-input.mdx +251 -0
  110. package/src/ds-input/ds-input.stories.js +298 -0
  111. package/src/ds-input/ds-input.test.js +792 -0
  112. package/src/ds-input/index.js +1 -0
  113. package/src/ds-link/ds-link.js +111 -0
  114. package/src/ds-link/ds-link.stories.js +56 -0
  115. package/src/ds-link/ds-link.test.js +74 -0
  116. package/src/ds-list-item/ds-list-item.a11y.test.js +39 -0
  117. package/src/ds-list-item/ds-list-item.js +292 -0
  118. package/src/ds-list-item/ds-list-item.stories.js +101 -0
  119. package/src/ds-list-item/ds-list-item.test.js +63 -0
  120. package/src/ds-menu/ds-menu.js +30 -0
  121. package/src/ds-menu/ds-menu.stories.js +120 -0
  122. package/src/ds-menu/ds-menu.test.js +123 -0
  123. package/src/ds-menu-group/ds-menu-group.js +101 -0
  124. package/src/ds-menu-group/ds-menu-group.stories.js +99 -0
  125. package/src/ds-nav-item/ds-nav-item.a11y.test.js +91 -0
  126. package/src/ds-nav-item/ds-nav-item.js +307 -0
  127. package/src/ds-nav-item/ds-nav-item.stories.js +99 -0
  128. package/src/ds-nav-item/ds-nav-item.test.js +169 -0
  129. package/src/ds-nav-item/index.js +1 -0
  130. package/src/ds-nav-vertical/ds-nav-vertical.a11y.test.js +69 -0
  131. package/src/ds-nav-vertical/ds-nav-vertical.js +173 -0
  132. package/src/ds-nav-vertical/ds-nav-vertical.stories.js +124 -0
  133. package/src/ds-nav-vertical/ds-nav-vertical.test.js +176 -0
  134. package/src/ds-nav-vertical/index.js +1 -0
  135. package/src/ds-pagination/ds-pagination.a11y.test.js +50 -0
  136. package/src/ds-pagination/ds-pagination.js +232 -0
  137. package/src/ds-pagination/ds-pagination.stories.js +63 -0
  138. package/src/ds-pagination/ds-pagination.test.js +141 -0
  139. package/src/ds-pagination/index.js +1 -0
  140. package/src/ds-progress-bar/ds-progress-bar.a11y.test.js +25 -0
  141. package/src/ds-progress-bar/ds-progress-bar.js +81 -0
  142. package/src/ds-progress-bar/ds-progress-bar.stories.js +69 -0
  143. package/src/ds-progress-bar/ds-progress-bar.test.js +60 -0
  144. package/src/ds-radio/ds-radio.a11y.test.js +69 -0
  145. package/src/ds-radio/ds-radio.js +240 -0
  146. package/src/ds-radio/ds-radio.stories.js +102 -0
  147. package/src/ds-radio/ds-radio.test.js +114 -0
  148. package/src/ds-radio/index.js +1 -0
  149. package/src/ds-radio-group/ds-radio-group.a11y.test.js +164 -0
  150. package/src/ds-radio-group/ds-radio-group.js +257 -0
  151. package/src/ds-radio-group/ds-radio-group.stories.js +247 -0
  152. package/src/ds-radio-group/ds-radio-group.test.js +194 -0
  153. package/src/ds-radio-group/index.js +1 -0
  154. package/src/ds-rich-list/ds-rich-list.js +246 -0
  155. package/src/ds-rich-list/ds-rich-list.stories.js +368 -0
  156. package/src/ds-rich-list/ds-rich-list.test.js +293 -0
  157. package/src/ds-rich-list-item/ds-rich-list-item.js +579 -0
  158. package/src/ds-rich-list-item/ds-rich-list-item.stories.js +197 -0
  159. package/src/ds-rich-list-item/ds-rich-list-item.test.js +434 -0
  160. package/src/ds-slider/ds-slider.js +399 -0
  161. package/src/ds-slider/ds-slider.stories.js +107 -0
  162. package/src/ds-slider/ds-slider.test.js +308 -0
  163. package/src/ds-spinner/ds-spinner.js +173 -0
  164. package/src/ds-spinner/ds-spinner.stories.js +52 -0
  165. package/src/ds-spinner/ds-spinner.test.js +50 -0
  166. package/src/ds-status-border/ds-status-border.js +88 -0
  167. package/src/ds-status-border/ds-status-border.stories.js +242 -0
  168. package/src/ds-status-border/ds-status-border.test.js +168 -0
  169. package/src/ds-stepper/ds-stepper.a11y.test.js +198 -0
  170. package/src/ds-stepper/ds-stepper.js +207 -0
  171. package/src/ds-stepper/ds-stepper.stories.js +530 -0
  172. package/src/ds-stepper/ds-stepper.test.js +311 -0
  173. package/src/ds-stepper-item/ds-stepper-item.js +485 -0
  174. package/src/ds-stepper-item/ds-stepper-item.stories.js +288 -0
  175. package/src/ds-switch/ds-switch.js +348 -0
  176. package/src/ds-switch/ds-switch.stories.js +145 -0
  177. package/src/ds-switch/ds-switch.test.js +226 -0
  178. package/src/ds-switch/index.js +1 -0
  179. package/src/ds-tab-item/ds-tab-item.js +341 -0
  180. package/src/ds-tab-item/ds-tab-item.stories.js +69 -0
  181. package/src/ds-tabs/ds-tab-panel.js +48 -0
  182. package/src/ds-tabs/ds-tabs.a11y.test.js +56 -0
  183. package/src/ds-tabs/ds-tabs.js +180 -0
  184. package/src/ds-tabs/ds-tabs.stories.js +152 -0
  185. package/src/ds-tabs/ds-tabs.test.js +306 -0
  186. package/src/ds-tabs/index.js +3 -0
  187. package/src/ds-tag-action/ds-tag-action.a11y.test.js +32 -0
  188. package/src/ds-tag-action/ds-tag-action.js +185 -0
  189. package/src/ds-tag-action/ds-tag-action.stories.js +55 -0
  190. package/src/ds-tag-action/ds-tag-action.test.js +44 -0
  191. package/src/ds-tag-removable/ds-tag-removable.a11y.test.js +24 -0
  192. package/src/ds-tag-removable/ds-tag-removable.js +146 -0
  193. package/src/ds-tag-removable/ds-tag-removable.stories.js +52 -0
  194. package/src/ds-tag-removable/ds-tag-removable.test.js +46 -0
  195. package/src/ds-tag-status/ds-tag-status.a11y.test.js +93 -0
  196. package/src/ds-tag-status/ds-tag-status.js +164 -0
  197. package/src/ds-tag-status/ds-tag-status.stories.js +200 -0
  198. package/src/ds-tag-status/ds-tag-status.test.js +140 -0
  199. package/src/ds-tag-status/index.js +1 -0
  200. package/src/ds-textarea/ds-textarea-clearable.test.js +89 -0
  201. package/src/ds-textarea/ds-textarea.a11y.test.js +66 -0
  202. package/src/ds-textarea/ds-textarea.js +505 -0
  203. package/src/ds-textarea/ds-textarea.stories.js +335 -0
  204. package/src/ds-textarea/ds-textarea.test.js +218 -0
  205. package/src/ds-textarea/index.js +1 -0
  206. package/src/ds-thumbnail/ds-thumbnail.js +207 -0
  207. package/src/ds-thumbnail/ds-thumbnail.stories.js +217 -0
  208. package/src/ds-thumbnail/ds-thumbnail.test.js +220 -0
  209. package/src/ds-toast/ds-toast-provider.js +110 -0
  210. package/src/ds-toast/ds-toast.a11y.test.js +34 -0
  211. package/src/ds-toast/ds-toast.js +243 -0
  212. package/src/ds-toast/ds-toast.stories.js +143 -0
  213. package/src/ds-toast/ds-toast.test.js +93 -0
  214. package/src/ds-toast/index.js +2 -0
  215. package/src/ds-tooltip/ds-tooltip.a11y.test.js +110 -0
  216. package/src/ds-tooltip/ds-tooltip.js +217 -0
  217. package/src/ds-tooltip/ds-tooltip.mdx +75 -0
  218. package/src/ds-tooltip/ds-tooltip.stories.js +72 -0
  219. package/src/ds-tooltip/ds-tooltip.test.js +191 -0
  220. package/src/ds-tooltip/index.js +1 -0
  221. package/src/ds-tooltip/positioner.js +117 -0
  222. package/src/index.js +50 -0
  223. package/src/mixins/field-label.mixin.js +113 -0
  224. package/src/mixins/field-message.mixin.js +66 -0
  225. package/src/token-provider/index.js +1 -0
  226. package/src/token-provider/token-provider.a11y.test.js +44 -0
  227. package/src/token-provider/token-provider.js +85 -0
  228. package/src/token-provider/token-provider.stories.js +105 -0
  229. package/src/token-provider/token-provider.test.js +134 -0
  230. package/src/utils/number-input.utils.js +42 -0
@@ -0,0 +1,442 @@
1
+ import { LitElement, html, css, nothing } from 'lit';
2
+ import { FieldLabelMixin, fieldLabelStyles } from '../mixins/field-label.mixin.js';
3
+ import { FieldMessageMixin, fieldMessageStyles } from '../mixins/field-message.mixin.js';
4
+ import '../ds-icon/ds-icon.js';
5
+ import '../ds-rich-list/ds-rich-list.js';
6
+ import '../ds-file-uploaded/ds-file-uploaded.js';
7
+
8
+ /**
9
+ * Parses file size to human readable string
10
+ * @param {number} bytes
11
+ * @returns {string}
12
+ */
13
+ function formatBytes(bytes) {
14
+ if (bytes === 0) return '0 B';
15
+ const k = 1024;
16
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
17
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
18
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
19
+ }
20
+
21
+ /**
22
+ * File Uploader — Drag and drop area for uploading files.
23
+ * Provides label, help/error messages, and renders the uploaded files list.
24
+ *
25
+ * @element ds-file-uploader
26
+ *
27
+ * @prop {string} label - The label text.
28
+ * @prop {string} info - Info tooltip text.
29
+ * @prop {string} helpText - Hint text displayed below the field.
30
+ * @prop {string} errorText - Error text. Changes state to error.
31
+ * @prop {boolean} multiple - Allows multiple files.
32
+ * @prop {boolean} disabled - Disables the component.
33
+ * @prop {string} accept - Accepted file types (e.g. ".jpg, .pdf").
34
+ * @prop {number} maxFileSize - Maximum allowed file size in bytes.
35
+ * @prop {number} maxFiles - Maximum allowed number of files.
36
+ * @prop {Array} files - Array of file objects to render.
37
+ *
38
+ * @fires ds-change - Emitted when files change. Detail contains `files` array.
39
+ * @fires ds-error - Emitted when a file is rejected.
40
+ */
41
+ export class DsFileUploader extends FieldMessageMixin(FieldLabelMixin(LitElement)) {
42
+ static properties = {
43
+ label: { type: String },
44
+ info: { type: String },
45
+ helpText: { type: String, attribute: 'help-text' },
46
+ errorText: { type: String, attribute: 'error-text' },
47
+ multiple: { type: Boolean, reflect: true },
48
+ disabled: { type: Boolean, reflect: true },
49
+ accept: { type: String },
50
+ maxFileSize: { type: Number, attribute: 'max-file-size' },
51
+ maxFiles: { type: Number, attribute: 'max-files' },
52
+ files: { type: Array },
53
+ _isDragging: { type: Boolean, state: true }
54
+ };
55
+
56
+ static styles = [
57
+ fieldLabelStyles,
58
+ fieldMessageStyles,
59
+ css`
60
+ :host {
61
+ display: flex;
62
+ flex-direction: column;
63
+ gap: var(--ds-space-xs);
64
+ width: 100%;
65
+ }
66
+
67
+ .drop-area {
68
+ position: relative;
69
+ display: flex;
70
+ flex-direction: column;
71
+ align-items: center;
72
+ justify-content: center;
73
+ min-height: var(--ds-file-uploader-drop-area-min-height, 72px);
74
+ padding: var(--ds-space-md);
75
+ background-color: transparent;
76
+ border: 1px dashed var(--ds-color-border-strong);
77
+ border-radius: var(--ds-radius-container);
78
+ cursor: pointer;
79
+ transition: background-color 0.2s, border-color 0.2s, border-width 0.2s;
80
+ box-sizing: border-box;
81
+ text-align: center;
82
+ }
83
+
84
+ .drop-area:hover {
85
+ background-color: var(--ds-color-bg-hover);
86
+ }
87
+
88
+ .drop-area:focus-visible {
89
+ outline: none;
90
+ border: 2px solid var(--ds-color-border-focus);
91
+ padding: calc(var(--ds-space-md) - 1px);
92
+ }
93
+
94
+ :host([is-dragging]) .drop-area {
95
+ background-color: var(--ds-color-bg-brand-subtle, var(--ds-color-bg-hover));
96
+ border-color: var(--ds-color-border-brand, var(--ds-color-border-focus));
97
+ }
98
+
99
+ /* Error State */
100
+ :host([error]) .drop-area,
101
+ .drop-area.error {
102
+ border-color: var(--ds-color-border-error);
103
+ }
104
+
105
+ /* Disabled State */
106
+ :host([disabled]) .drop-area {
107
+ pointer-events: none;
108
+ border-color: var(--ds-color-border-disabled);
109
+ background-color: var(--ds-color-bg-disabled);
110
+ opacity: 0.5;
111
+ }
112
+
113
+ /* Disabled Label and Message */
114
+ :host([disabled]) .label-row label {
115
+ color: var(--ds-color-text-disabled);
116
+ cursor: not-allowed;
117
+ }
118
+
119
+ :host([disabled]) .field-message {
120
+ color: var(--ds-color-text-disabled);
121
+ }
122
+
123
+ .drop-area-content {
124
+ display: flex;
125
+ align-items: center;
126
+ gap: var(--ds-space-xs);
127
+ pointer-events: none; /* Let the container handle clicks */
128
+ }
129
+
130
+ .drop-area-content ds-icon {
131
+ color: var(--ds-color-icon-secondary);
132
+ }
133
+
134
+ :host([disabled]) .drop-area-content ds-icon {
135
+ color: var(--ds-color-icon-disabled);
136
+ }
137
+
138
+ .drop-area-text {
139
+ font: var(--ds-typo-content-body-regular);
140
+ color: var(--ds-color-text-secondary);
141
+ }
142
+
143
+ :host([disabled]) .drop-area-text {
144
+ color: var(--ds-color-text-disabled);
145
+ }
146
+
147
+ .files-list {
148
+ margin-top: var(--ds-space-xs);
149
+ display: flex;
150
+ flex-direction: column;
151
+ gap: var(--ds-space-sm);
152
+ }
153
+
154
+ input[type="file"] {
155
+ display: none;
156
+ }
157
+ `
158
+ ];
159
+
160
+ constructor() {
161
+ super();
162
+ this.label = '';
163
+ this.info = '';
164
+ this.helpText = '';
165
+ this.errorText = '';
166
+ this.multiple = false;
167
+ this.disabled = false;
168
+ this.accept = '';
169
+ this.maxFileSize = 0;
170
+ this.maxFiles = 0;
171
+ this.files = [];
172
+ this._isDragging = false;
173
+ this._fileInputId = `ds-file-input-${Math.random().toString(36).substr(2, 9)}`;
174
+ }
175
+
176
+ updated(changedProps) {
177
+ if (changedProps.has('errorText')) {
178
+ this.toggleAttribute('error', !!this.errorText);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Public method to programmatically trigger the file dialog.
184
+ */
185
+ openFileDialog() {
186
+ if (this.disabled) return;
187
+ const input = this.shadowRoot.querySelector('#' + this._fileInputId);
188
+ if (input) input.click();
189
+ }
190
+
191
+ /**
192
+ * Public method to clear all files.
193
+ */
194
+ clear() {
195
+ if (this.disabled) return;
196
+ this.errorText = '';
197
+ // Release memory
198
+ this.files.forEach(f => {
199
+ if (f.previewUrl) URL.revokeObjectURL(f.previewUrl);
200
+ });
201
+ this.files = [];
202
+ const input = this.shadowRoot.querySelector('#' + this._fileInputId);
203
+ if (input) input.value = '';
204
+ this._dispatchChange();
205
+ }
206
+
207
+ _handleClick() {
208
+ this.openFileDialog();
209
+ }
210
+
211
+ _handleKeyDown(e) {
212
+ if (e.key === 'Enter' || e.key === ' ') {
213
+ e.preventDefault();
214
+ this.openFileDialog();
215
+ }
216
+ }
217
+
218
+ _handleDragOver(e) {
219
+ e.preventDefault();
220
+ if (this.disabled) return;
221
+ this._isDragging = true;
222
+ this.toggleAttribute('is-dragging', true);
223
+ }
224
+
225
+ _handleDragLeave(e) {
226
+ e.preventDefault();
227
+ this._isDragging = false;
228
+ this.toggleAttribute('is-dragging', false);
229
+ }
230
+
231
+ _handleDrop(e) {
232
+ e.preventDefault();
233
+ this._isDragging = false;
234
+ this.toggleAttribute('is-dragging', false);
235
+
236
+ if (this.disabled) return;
237
+
238
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
239
+ this._processFiles(Array.from(e.dataTransfer.files));
240
+ }
241
+ }
242
+
243
+ _handleInputChange(e) {
244
+ if (e.target.files && e.target.files.length > 0) {
245
+ this._processFiles(Array.from(e.target.files));
246
+ // Reset input so the same file could be selected again if removed
247
+ e.target.value = '';
248
+ }
249
+ }
250
+
251
+ _processFiles(newRawFiles) {
252
+ this.errorText = '';
253
+ const validFiles = [];
254
+ let rejected = false;
255
+
256
+ // Check Max Files
257
+ if (this.maxFiles > 0 && (this.files.length + newRawFiles.length) > this.maxFiles) {
258
+ this._dispatchError('max-files', `Maximum number of files is ${this.maxFiles}.`);
259
+ rejected = true;
260
+ if (!this.multiple) return; // If single mode exceeds max (shouldn't really happen if correctly configured, but just in case)
261
+ }
262
+
263
+ // Process each
264
+ const toProcess = this.multiple ? newRawFiles : [newRawFiles[0]];
265
+
266
+ for (const file of toProcess) {
267
+ // Check for Duplicates
268
+ const isDuplicate = this.files.some(f => f.filename === file.name && f.file.size === file.size);
269
+ if (isDuplicate) {
270
+ this._dispatchError('duplicate-file', `File "${file.name}" has already been uploaded.`, file);
271
+ rejected = true;
272
+ continue;
273
+ }
274
+
275
+ // Check Size
276
+ const maxBytes = Number(this.maxFileSize);
277
+ if (maxBytes > 0 && file.size > maxBytes) {
278
+ this._dispatchError('max-file-size', `File "${file.name}" exceeds the maximum allowed size of ${formatBytes(maxBytes)}.`, file);
279
+ rejected = true;
280
+ continue;
281
+ }
282
+
283
+ // Check Accept (basic implementation, native input handles most of selection restriction)
284
+ if (this.accept) {
285
+ const acceptList = this.accept.split(',').map(a => a.trim().toLowerCase());
286
+ const ext = file.name.split('.').pop().toLowerCase();
287
+ const baseType = file.type.split('/')[0];
288
+
289
+ const isAccepted = acceptList.some(type => {
290
+ if (type.startsWith('.')) return `.${ext}` === type;
291
+ if (type.endsWith('/*')) return `${baseType}/*` === type;
292
+ return file.type === type;
293
+ });
294
+
295
+ if (!isAccepted) {
296
+ this._dispatchError('invalid-type', `File "${file.name}" has an invalid type.`, file);
297
+ rejected = true;
298
+ continue;
299
+ }
300
+ }
301
+
302
+ // Ensure single/multiple rules
303
+ if (!this.multiple && (validFiles.length > 0 || this.files.length > 0)) {
304
+ // If single and we already have a file, clear existing
305
+ this.files.forEach(f => {
306
+ if (f.previewUrl) URL.revokeObjectURL(f.previewUrl);
307
+ });
308
+ this.files = [];
309
+ }
310
+
311
+ // Icon Mapping
312
+ let icon = 'document';
313
+ let previewUrl = '';
314
+ if (file.type.startsWith('image/')) {
315
+ previewUrl = URL.createObjectURL(file);
316
+ icon = 'image';
317
+ } else if (file.type === 'application/pdf') {
318
+ icon = 'description';
319
+ } else if (file.type.includes('spreadsheet') || file.type.includes('csv') || file.type.includes('excel')) {
320
+ icon = 'table_view';
321
+ } else if (file.type.includes('zip') || file.type.includes('archive') || file.type.includes('tar') || file.type.includes('gzip')) {
322
+ icon = 'folder_zip';
323
+ } else if (file.type.startsWith('video/')) {
324
+ icon = 'movie';
325
+ }
326
+
327
+ validFiles.push({
328
+ id: Math.random().toString(36).substr(2, 9),
329
+ file,
330
+ filename: file.name,
331
+ size: formatBytes(file.size),
332
+ progress: 0,
333
+ loading: false,
334
+ error: false,
335
+ errorMessage: '',
336
+ previewUrl,
337
+ icon
338
+ });
339
+ }
340
+
341
+ if (validFiles.length > 0) {
342
+ if (this.multiple) {
343
+ this.files = [...this.files, ...validFiles];
344
+ } else {
345
+ this.files = [validFiles[0]];
346
+ }
347
+ this._dispatchChange();
348
+ }
349
+ }
350
+
351
+ _dispatchChange() {
352
+ this.dispatchEvent(new CustomEvent('ds-change', {
353
+ bubbles: true,
354
+ composed: true,
355
+ detail: { files: this.files }
356
+ }));
357
+ }
358
+
359
+ _dispatchError(type, message, file = null) {
360
+ this.errorText = message;
361
+ this.dispatchEvent(new CustomEvent('ds-error', {
362
+ bubbles: true,
363
+ composed: true,
364
+ detail: { type, message, file }
365
+ }));
366
+ }
367
+
368
+ _handleRemoveFile(e) {
369
+ const filenameToRemove = e.detail.filename;
370
+ const fileToRemove = this.files.find(f => f.filename === filenameToRemove);
371
+ if (fileToRemove && fileToRemove.previewUrl) {
372
+ URL.revokeObjectURL(fileToRemove.previewUrl);
373
+ }
374
+ this.files = this.files.filter(f => f.filename !== filenameToRemove);
375
+ this._dispatchChange();
376
+ }
377
+
378
+ render() {
379
+ const showDropArea = this.multiple || this.files.length === 0;
380
+
381
+ return html`
382
+ ${this.renderFieldLabel(this.label, this.info, this._fileInputId)}
383
+
384
+ <input
385
+ type="file"
386
+ id="${this._fileInputId}"
387
+ ?multiple=${this.multiple}
388
+ accept="${this.accept || ''}"
389
+ @change=${this._handleInputChange}
390
+ ?disabled=${this.disabled}
391
+ />
392
+
393
+ ${showDropArea ? html`
394
+ <div
395
+ part="drop-area"
396
+ class="drop-area ${this.errorText ? 'error' : ''}"
397
+ role="button"
398
+ tabindex=${this.disabled ? "-1" : "0"}
399
+ @click=${this._handleClick}
400
+ @keydown=${this._handleKeyDown}
401
+ @dragover=${this._handleDragOver}
402
+ @dragleave=${this._handleDragLeave}
403
+ @drop=${this._handleDrop}
404
+ aria-label="Click or drag and drop to upload files"
405
+ aria-disabled=${this.disabled ? 'true' : 'false'}
406
+ >
407
+ <div class="drop-area-content">
408
+ <ds-icon name="upload" size="sm"></ds-icon>
409
+ <span class="drop-area-text">Drop files here or click to upload</span>
410
+ </div>
411
+ </div>
412
+ ` : nothing}
413
+
414
+ ${this.renderFieldMessage(this.helpText, this.errorText)}
415
+
416
+ ${this.files.length > 0 ? html`
417
+ <div class="files-list">
418
+ <ds-rich-list selectable="none">
419
+ ${this.files.map(f => html`
420
+ <ds-file-uploaded
421
+ filename="${f.filename}"
422
+ size="${f.size}"
423
+ progress="${f.progress}"
424
+ preview-url="${f.previewUrl || ''}"
425
+ icon="${f.icon || 'document'}"
426
+ ?loading="${f.loading}"
427
+ ?error="${f.error}"
428
+ error-message="${f.errorMessage}"
429
+ ?disabled=${this.disabled}
430
+ @ds-remove-file=${this._handleRemoveFile}
431
+ ></ds-file-uploaded>
432
+ `)}
433
+ </ds-rich-list>
434
+ </div>
435
+ ` : nothing}
436
+ `;
437
+ }
438
+ }
439
+
440
+ if (!customElements.get('ds-file-uploader')) {
441
+ customElements.define('ds-file-uploader', DsFileUploader);
442
+ }
@@ -0,0 +1,44 @@
1
+ import { Meta, Story, Canvas, ArgsTable, Description } from '@storybook/blocks';
2
+ import * as DsFileUploaderStories from './ds-file-uploader.stories';
3
+
4
+ <Meta of={DsFileUploaderStories} />
5
+
6
+ # DsFileUploader
7
+
8
+ <Description />
9
+
10
+ The `ds-file-uploader` provides a user-friendly drag-and-drop target to select files from the device.
11
+
12
+ ## Anatomy
13
+ It features an optional label, help/error messages, and dynamically renders the `ds-file-uploaded` items summarizing the chosen files.
14
+ It wraps a hidden native `<input type="file">`.
15
+
16
+ ## Examples
17
+
18
+ ### Default (Single File)
19
+ In single file mode, selecting a file replaces the dropzone with the file item itself. Removing the file will re-display the dropzone.
20
+ <Canvas>
21
+ <Story of={DsFileUploaderStories.Default} />
22
+ </Canvas>
23
+
24
+ ### Multiple Files
25
+ In multiple file mode, the dropzone remains visible and stacks the uploaded file items beneath it.
26
+ <Canvas>
27
+ <Story of={DsFileUploaderStories.Multiple} />
28
+ </Canvas>
29
+
30
+ ### With Info Tooltip
31
+ <Canvas>
32
+ <Story of={DsFileUploaderStories.WithInfoTooltip} />
33
+ </Canvas>
34
+
35
+ ### Error State
36
+ <Canvas>
37
+ <Story of={DsFileUploaderStories.ErrorState} />
38
+ </Canvas>
39
+
40
+ ## Validation Configuration
41
+ Use `accept`, `max-file-size`, and `max-files` attributes to constrain inputs natively. Invalid files will emit a `ds-error` event from the component without adding them to the file list.
42
+
43
+ ## API Reference
44
+ <ArgsTable />
@@ -0,0 +1,76 @@
1
+ import './ds-file-uploader.js';
2
+ import { html } from 'lit';
3
+
4
+ export default {
5
+ title: 'Components/File Uploader',
6
+ component: 'ds-file-uploader',
7
+ tags: ['autodocs'],
8
+ argTypes: {
9
+ label: { control: 'text' },
10
+ info: { control: 'text' },
11
+ helpText: { control: 'text', name: 'help-text' },
12
+ errorText: { control: 'text', name: 'error-text' },
13
+ multiple: { control: 'boolean' },
14
+ disabled: { control: 'boolean' },
15
+ accept: { control: 'text' },
16
+ maxFileSize: { control: 'number', name: 'max-file-size' },
17
+ maxFiles: { control: 'number', name: 'max-files' }
18
+ },
19
+ parameters: {
20
+ actions: {
21
+ handles: ['ds-change', 'ds-error']
22
+ }
23
+ }
24
+ };
25
+
26
+ const Template = (args) => html`
27
+ <div style="max-width: 400px;">
28
+ <ds-file-uploader
29
+ label=${args.label || ''}
30
+ info=${args.info || ''}
31
+ help-text=${args.helpText || ''}
32
+ error-text=${args.errorText || ''}
33
+ ?multiple=${args.multiple}
34
+ ?disabled=${args.disabled}
35
+ accept=${args.accept || ''}
36
+ max-file-size=${args.maxFileSize || 0}
37
+ max-files=${args.maxFiles || 0}
38
+ ></ds-file-uploader>
39
+ </div>
40
+ `;
41
+
42
+ export const Default = Template.bind({});
43
+ Default.args = {
44
+ label: 'Upload document',
45
+ helpText: 'Only PDF or DOC files, up to 10MB.',
46
+ multiple: false,
47
+ disabled: false
48
+ };
49
+
50
+ export const Multiple = Template.bind({});
51
+ Multiple.args = {
52
+ ...Default.args,
53
+ label: 'Upload photos',
54
+ helpText: 'Select or drag multiple images. Max 5 files.',
55
+ multiple: true,
56
+ maxFiles: 5,
57
+ accept: 'image/*'
58
+ };
59
+
60
+ export const WithInfoTooltip = Template.bind({});
61
+ WithInfoTooltip.args = {
62
+ ...Default.args,
63
+ info: 'Attachments must not contain personal identifiable information.'
64
+ };
65
+
66
+ export const ErrorState = Template.bind({});
67
+ ErrorState.args = {
68
+ ...Default.args,
69
+ errorText: 'The selected file is too large.'
70
+ };
71
+
72
+ export const Disabled = Template.bind({});
73
+ Disabled.args = {
74
+ ...Default.args,
75
+ disabled: true
76
+ };
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import './ds-file-uploader.js';
3
+
4
+ describe('DsFileUploader', () => {
5
+ let container;
6
+
7
+ beforeEach(() => {
8
+ container = document.createElement('div');
9
+ document.body.appendChild(container);
10
+ });
11
+
12
+ afterEach(() => {
13
+ container.remove();
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ it('renders correctly with default UI', async () => {
18
+ container.innerHTML = '<ds-file-uploader label="Upload doc"></ds-file-uploader>';
19
+ const el = container.querySelector('ds-file-uploader');
20
+ await new Promise(resolve => setTimeout(resolve, 0));
21
+
22
+ expect(el).toBeTruthy();
23
+ expect(el.shadowRoot.querySelector('label').textContent).toContain('Upload doc');
24
+ expect(el.shadowRoot.querySelector('.drop-area')).toBeTruthy();
25
+ expect(el.shadowRoot.querySelector('input[type="file"]')).toBeTruthy();
26
+ });
27
+
28
+ it('hides drop area in single mode when file is uploaded', async () => {
29
+ container.innerHTML = '<ds-file-uploader></ds-file-uploader>';
30
+ const el = container.querySelector('ds-file-uploader');
31
+ await new Promise(resolve => setTimeout(resolve, 0));
32
+
33
+ el._processFiles([new File([''], 'test.png', { type: 'image/png' })]);
34
+ await new Promise(resolve => setTimeout(resolve, 0));
35
+
36
+ expect(el.shadowRoot.querySelector('.drop-area')).toBeNull();
37
+ const uploaded = el.shadowRoot.querySelectorAll('ds-file-uploaded');
38
+ expect(uploaded).toHaveLength(1);
39
+ });
40
+
41
+ it('keeps drop area in multiple mode when files are uploaded', async () => {
42
+ container.innerHTML = '<ds-file-uploader multiple></ds-file-uploader>';
43
+ const el = container.querySelector('ds-file-uploader');
44
+ await new Promise(resolve => setTimeout(resolve, 0));
45
+
46
+ el._processFiles([new File([''], 'test.png', { type: 'image/png' })]);
47
+ await new Promise(resolve => setTimeout(resolve, 0));
48
+
49
+ expect(el.shadowRoot.querySelector('.drop-area')).toBeTruthy();
50
+ const uploaded = el.shadowRoot.querySelectorAll('ds-file-uploaded');
51
+ expect(uploaded).toHaveLength(1);
52
+ });
53
+
54
+ it('emits ds-change event on files array update', async () => {
55
+ container.innerHTML = '<ds-file-uploader></ds-file-uploader>';
56
+ const el = container.querySelector('ds-file-uploader');
57
+ await new Promise(resolve => setTimeout(resolve, 0));
58
+
59
+ const changeSpy = vi.fn();
60
+ el.addEventListener('ds-change', changeSpy);
61
+
62
+ el._processFiles([new File(['content'], 'test.pdf', { type: 'application/pdf' })]);
63
+
64
+ expect(changeSpy).toHaveBeenCalledOnce();
65
+ const detail = changeSpy.mock.calls[0][0].detail;
66
+ expect(detail.files).toHaveLength(1);
67
+ expect(detail.files[0].filename).toBe('test.pdf');
68
+ });
69
+
70
+ it('validates maxFileSize and emits error', async () => {
71
+ container.innerHTML = '<ds-file-uploader max-file-size="100"></ds-file-uploader>';
72
+ const el = container.querySelector('ds-file-uploader');
73
+ await new Promise(resolve => setTimeout(resolve, 0));
74
+
75
+ const errorSpy = vi.fn();
76
+ el.addEventListener('ds-error', errorSpy);
77
+
78
+ // Create a file > 100 bytes
79
+ const bigFile = new File(['a'.repeat(200)], 'big.txt', { type: 'text/plain' });
80
+ el._processFiles([bigFile]);
81
+
82
+ expect(errorSpy).toHaveBeenCalledOnce();
83
+ const detail = errorSpy.mock.calls[0][0].detail;
84
+ expect(detail.type).toBe('max-file-size');
85
+ expect(el.files).toHaveLength(0); // Should not be added
86
+ });
87
+
88
+ it('removes file from list on ds-remove-file event', async () => {
89
+ container.innerHTML = '<ds-file-uploader></ds-file-uploader>';
90
+ const el = container.querySelector('ds-file-uploader');
91
+ await new Promise(resolve => setTimeout(resolve, 0));
92
+
93
+ el._processFiles([new File([''], 'todelete.png', { type: 'image/png' })]);
94
+ await new Promise(resolve => setTimeout(resolve, 0));
95
+ expect(el.files).toHaveLength(1);
96
+
97
+ const uploadedEl = el.shadowRoot.querySelector('ds-file-uploaded');
98
+ uploadedEl.dispatchEvent(new CustomEvent('ds-remove-file', {
99
+ bubbles: true,
100
+ composed: true,
101
+ detail: { filename: 'todelete.png' }
102
+ }));
103
+ await new Promise(resolve => setTimeout(resolve, 0));
104
+
105
+ expect(el.files).toHaveLength(0);
106
+ });
107
+
108
+ it('clears all files programmatically', async () => {
109
+ container.innerHTML = '<ds-file-uploader></ds-file-uploader>';
110
+ const el = container.querySelector('ds-file-uploader');
111
+ await new Promise(resolve => setTimeout(resolve, 0));
112
+
113
+ el._processFiles([new File([''], 'file1.pdf')]);
114
+ expect(el.files).toHaveLength(1);
115
+
116
+ el.clear();
117
+ expect(el.files).toHaveLength(0);
118
+ });
119
+
120
+ it('rejects duplicate files and emits error', async () => {
121
+ container.innerHTML = '<ds-file-uploader multiple></ds-file-uploader>';
122
+ const el = container.querySelector('ds-file-uploader');
123
+ await new Promise(resolve => setTimeout(resolve, 0));
124
+
125
+ const errorSpy = vi.fn();
126
+ el.addEventListener('ds-error', errorSpy);
127
+
128
+ const file = new File(['content'], 'duplicate.txt', { type: 'text/plain' });
129
+
130
+ // First upload should succeed
131
+ el._processFiles([file]);
132
+ expect(el.files).toHaveLength(1);
133
+
134
+ // Second upload of the same file should fail
135
+ el._processFiles([file]);
136
+
137
+ expect(errorSpy).toHaveBeenCalledOnce();
138
+ const detail = errorSpy.mock.calls[0][0].detail;
139
+ expect(detail.type).toBe('duplicate-file');
140
+ expect(el.files).toHaveLength(1); // Should still only have 1 file
141
+ });
142
+ });