@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.
- package/package.json +27 -0
- package/src/ds-accordion/ds-accordion-item.js +288 -0
- package/src/ds-accordion/ds-accordion-item.stories.js +82 -0
- package/src/ds-accordion/ds-accordion.a11y.test.js +92 -0
- package/src/ds-accordion/ds-accordion.js +68 -0
- package/src/ds-accordion/ds-accordion.stories.js +118 -0
- package/src/ds-accordion/ds-accordion.test.js +146 -0
- package/src/ds-accordion/index.js +2 -0
- package/src/ds-action-bar/ds-action-bar.js +116 -0
- package/src/ds-action-bar/ds-action-bar.stories.js +86 -0
- package/src/ds-action-bar/ds-action-bar.test.js +64 -0
- package/src/ds-action-bar/index.js +1 -0
- package/src/ds-alert/ds-alert.a11y.test.js +151 -0
- package/src/ds-alert/ds-alert.js +223 -0
- package/src/ds-alert/ds-alert.mdx +142 -0
- package/src/ds-alert/ds-alert.stories.js +166 -0
- package/src/ds-alert/ds-alert.test.js +256 -0
- package/src/ds-alert/index.js +1 -0
- package/src/ds-avatar/ds-avatar.a11y.test.js +45 -0
- package/src/ds-avatar/ds-avatar.js +216 -0
- package/src/ds-avatar/ds-avatar.stories.js +120 -0
- package/src/ds-avatar/ds-avatar.test.js +83 -0
- package/src/ds-avatar/index.js +1 -0
- package/src/ds-avatar-extended/ds-avatar-extended.a11y.test.js +29 -0
- package/src/ds-avatar-extended/ds-avatar-extended.js +108 -0
- package/src/ds-avatar-extended/ds-avatar-extended.stories.js +93 -0
- package/src/ds-avatar-extended/ds-avatar-extended.test.js +66 -0
- package/src/ds-avatar-extended/index.js +1 -0
- package/src/ds-banner/ds-banner.a11y.test.js +51 -0
- package/src/ds-banner/ds-banner.js +233 -0
- package/src/ds-banner/ds-banner.stories.js +185 -0
- package/src/ds-banner/ds-banner.test.js +116 -0
- package/src/ds-banner/index.js +1 -0
- package/src/ds-breadcrumb-item/ds-breadcrumb-item.js +135 -0
- package/src/ds-breadcrumb-item/ds-breadcrumb-item.stories.js +49 -0
- package/src/ds-breadcrumb-item/ds-breadcrumb-item.test.js +55 -0
- package/src/ds-breadcrumbs/ds-breadcrumbs.js +194 -0
- package/src/ds-breadcrumbs/ds-breadcrumbs.stories.js +54 -0
- package/src/ds-breadcrumbs/ds-breadcrumbs.test.js +33 -0
- package/src/ds-button/ds-button.a11y.test.js +49 -0
- package/src/ds-button/ds-button.js +205 -0
- package/src/ds-button/ds-button.mdx +141 -0
- package/src/ds-button/ds-button.stories.js +152 -0
- package/src/ds-button/ds-button.test.js +62 -0
- package/src/ds-button/index.js +1 -0
- package/src/ds-button-group/ds-button-group.js +82 -0
- package/src/ds-button-group/ds-button-group.mdx +39 -0
- package/src/ds-button-group/ds-button-group.stories.js +47 -0
- package/src/ds-button-group/ds-button-group.test.js +47 -0
- package/src/ds-button-group/index.js +1 -0
- package/src/ds-checkbox/ds-checkbox.a11y.test.js +79 -0
- package/src/ds-checkbox/ds-checkbox.js +271 -0
- package/src/ds-checkbox/ds-checkbox.stories.js +77 -0
- package/src/ds-checkbox/ds-checkbox.test.js +191 -0
- package/src/ds-checkbox/index.js +1 -0
- package/src/ds-checkbox-group/ds-checkbox-group.a11y.test.js +146 -0
- package/src/ds-checkbox-group/ds-checkbox-group.js +235 -0
- package/src/ds-checkbox-group/ds-checkbox-group.stories.js +210 -0
- package/src/ds-checkbox-group/ds-checkbox-group.test.js +150 -0
- package/src/ds-checkbox-group/index.js +1 -0
- package/src/ds-dialog/ds-dialog.js +466 -0
- package/src/ds-dialog/ds-dialog.stories.js +274 -0
- package/src/ds-dialog/ds-dialog.test.js +441 -0
- package/src/ds-dialog/index.js +1 -0
- package/src/ds-dropdown/ds-dropdown.a11y.test.js +80 -0
- package/src/ds-dropdown/ds-dropdown.js +891 -0
- package/src/ds-dropdown/ds-dropdown.stories.js +259 -0
- package/src/ds-dropdown/ds-dropdown.test.js +268 -0
- package/src/ds-dropdown/index.js +1 -0
- package/src/ds-dropdown-group/ds-dropdown-group.js +55 -0
- package/src/ds-dropdown-panel/ds-dropdown-panel.js +34 -0
- package/src/ds-file-uploaded/ds-file-uploaded.a11y.test.js +40 -0
- package/src/ds-file-uploaded/ds-file-uploaded.js +135 -0
- package/src/ds-file-uploaded/ds-file-uploaded.mdx +33 -0
- package/src/ds-file-uploaded/ds-file-uploaded.stories.js +81 -0
- package/src/ds-file-uploaded/ds-file-uploaded.test.js +85 -0
- package/src/ds-file-uploader/ds-file-uploader.a11y.test.js +61 -0
- package/src/ds-file-uploader/ds-file-uploader.js +442 -0
- package/src/ds-file-uploader/ds-file-uploader.mdx +44 -0
- package/src/ds-file-uploader/ds-file-uploader.stories.js +76 -0
- package/src/ds-file-uploader/ds-file-uploader.test.js +142 -0
- package/src/ds-header/ds-header.a11y.test.js +38 -0
- package/src/ds-header/ds-header.js +149 -0
- package/src/ds-header/ds-header.stories.js +63 -0
- package/src/ds-header/ds-header.test.js +52 -0
- package/src/ds-header/index.js +1 -0
- package/src/ds-header-nav/ds-header-nav.a11y.test.js +69 -0
- package/src/ds-header-nav/ds-header-nav.js +114 -0
- package/src/ds-header-nav/ds-header-nav.stories.js +17 -0
- package/src/ds-header-nav/ds-header-nav.test.js +93 -0
- package/src/ds-header-nav-item/ds-header-nav-item.a11y.test.js +71 -0
- package/src/ds-header-nav-item/ds-header-nav-item.js +124 -0
- package/src/ds-header-nav-item/ds-header-nav-item.stories.js +43 -0
- package/src/ds-header-nav-item/ds-header-nav-item.test.js +61 -0
- package/src/ds-icon/ds-icon.a11y.test.js +49 -0
- package/src/ds-icon/ds-icon.js +75 -0
- package/src/ds-icon/ds-icon.mdx +36 -0
- package/src/ds-icon/ds-icon.stories.js +88 -0
- package/src/ds-icon/ds-icon.test.js +97 -0
- package/src/ds-icon/index.js +1 -0
- package/src/ds-icon-button/ds-icon-button.a11y.test.js +55 -0
- package/src/ds-icon-button/ds-icon-button.js +224 -0
- package/src/ds-icon-button/ds-icon-button.mdx +131 -0
- package/src/ds-icon-button/ds-icon-button.stories.js +128 -0
- package/src/ds-icon-button/ds-icon-button.test.js +90 -0
- package/src/ds-icon-button/index.js +1 -0
- package/src/ds-input/ds-input.a11y.test.js +145 -0
- package/src/ds-input/ds-input.js +645 -0
- package/src/ds-input/ds-input.mdx +251 -0
- package/src/ds-input/ds-input.stories.js +298 -0
- package/src/ds-input/ds-input.test.js +792 -0
- package/src/ds-input/index.js +1 -0
- package/src/ds-link/ds-link.js +111 -0
- package/src/ds-link/ds-link.stories.js +56 -0
- package/src/ds-link/ds-link.test.js +74 -0
- package/src/ds-list-item/ds-list-item.a11y.test.js +39 -0
- package/src/ds-list-item/ds-list-item.js +292 -0
- package/src/ds-list-item/ds-list-item.stories.js +101 -0
- package/src/ds-list-item/ds-list-item.test.js +63 -0
- package/src/ds-menu/ds-menu.js +30 -0
- package/src/ds-menu/ds-menu.stories.js +120 -0
- package/src/ds-menu/ds-menu.test.js +123 -0
- package/src/ds-menu-group/ds-menu-group.js +101 -0
- package/src/ds-menu-group/ds-menu-group.stories.js +99 -0
- package/src/ds-nav-item/ds-nav-item.a11y.test.js +91 -0
- package/src/ds-nav-item/ds-nav-item.js +307 -0
- package/src/ds-nav-item/ds-nav-item.stories.js +99 -0
- package/src/ds-nav-item/ds-nav-item.test.js +169 -0
- package/src/ds-nav-item/index.js +1 -0
- package/src/ds-nav-vertical/ds-nav-vertical.a11y.test.js +69 -0
- package/src/ds-nav-vertical/ds-nav-vertical.js +173 -0
- package/src/ds-nav-vertical/ds-nav-vertical.stories.js +124 -0
- package/src/ds-nav-vertical/ds-nav-vertical.test.js +176 -0
- package/src/ds-nav-vertical/index.js +1 -0
- package/src/ds-pagination/ds-pagination.a11y.test.js +50 -0
- package/src/ds-pagination/ds-pagination.js +232 -0
- package/src/ds-pagination/ds-pagination.stories.js +63 -0
- package/src/ds-pagination/ds-pagination.test.js +141 -0
- package/src/ds-pagination/index.js +1 -0
- package/src/ds-progress-bar/ds-progress-bar.a11y.test.js +25 -0
- package/src/ds-progress-bar/ds-progress-bar.js +81 -0
- package/src/ds-progress-bar/ds-progress-bar.stories.js +69 -0
- package/src/ds-progress-bar/ds-progress-bar.test.js +60 -0
- package/src/ds-radio/ds-radio.a11y.test.js +69 -0
- package/src/ds-radio/ds-radio.js +240 -0
- package/src/ds-radio/ds-radio.stories.js +102 -0
- package/src/ds-radio/ds-radio.test.js +114 -0
- package/src/ds-radio/index.js +1 -0
- package/src/ds-radio-group/ds-radio-group.a11y.test.js +164 -0
- package/src/ds-radio-group/ds-radio-group.js +257 -0
- package/src/ds-radio-group/ds-radio-group.stories.js +247 -0
- package/src/ds-radio-group/ds-radio-group.test.js +194 -0
- package/src/ds-radio-group/index.js +1 -0
- package/src/ds-rich-list/ds-rich-list.js +246 -0
- package/src/ds-rich-list/ds-rich-list.stories.js +368 -0
- package/src/ds-rich-list/ds-rich-list.test.js +293 -0
- package/src/ds-rich-list-item/ds-rich-list-item.js +579 -0
- package/src/ds-rich-list-item/ds-rich-list-item.stories.js +197 -0
- package/src/ds-rich-list-item/ds-rich-list-item.test.js +434 -0
- package/src/ds-slider/ds-slider.js +399 -0
- package/src/ds-slider/ds-slider.stories.js +107 -0
- package/src/ds-slider/ds-slider.test.js +308 -0
- package/src/ds-spinner/ds-spinner.js +173 -0
- package/src/ds-spinner/ds-spinner.stories.js +52 -0
- package/src/ds-spinner/ds-spinner.test.js +50 -0
- package/src/ds-status-border/ds-status-border.js +88 -0
- package/src/ds-status-border/ds-status-border.stories.js +242 -0
- package/src/ds-status-border/ds-status-border.test.js +168 -0
- package/src/ds-stepper/ds-stepper.a11y.test.js +198 -0
- package/src/ds-stepper/ds-stepper.js +207 -0
- package/src/ds-stepper/ds-stepper.stories.js +530 -0
- package/src/ds-stepper/ds-stepper.test.js +311 -0
- package/src/ds-stepper-item/ds-stepper-item.js +485 -0
- package/src/ds-stepper-item/ds-stepper-item.stories.js +288 -0
- package/src/ds-switch/ds-switch.js +348 -0
- package/src/ds-switch/ds-switch.stories.js +145 -0
- package/src/ds-switch/ds-switch.test.js +226 -0
- package/src/ds-switch/index.js +1 -0
- package/src/ds-tab-item/ds-tab-item.js +341 -0
- package/src/ds-tab-item/ds-tab-item.stories.js +69 -0
- package/src/ds-tabs/ds-tab-panel.js +48 -0
- package/src/ds-tabs/ds-tabs.a11y.test.js +56 -0
- package/src/ds-tabs/ds-tabs.js +180 -0
- package/src/ds-tabs/ds-tabs.stories.js +152 -0
- package/src/ds-tabs/ds-tabs.test.js +306 -0
- package/src/ds-tabs/index.js +3 -0
- package/src/ds-tag-action/ds-tag-action.a11y.test.js +32 -0
- package/src/ds-tag-action/ds-tag-action.js +185 -0
- package/src/ds-tag-action/ds-tag-action.stories.js +55 -0
- package/src/ds-tag-action/ds-tag-action.test.js +44 -0
- package/src/ds-tag-removable/ds-tag-removable.a11y.test.js +24 -0
- package/src/ds-tag-removable/ds-tag-removable.js +146 -0
- package/src/ds-tag-removable/ds-tag-removable.stories.js +52 -0
- package/src/ds-tag-removable/ds-tag-removable.test.js +46 -0
- package/src/ds-tag-status/ds-tag-status.a11y.test.js +93 -0
- package/src/ds-tag-status/ds-tag-status.js +164 -0
- package/src/ds-tag-status/ds-tag-status.stories.js +200 -0
- package/src/ds-tag-status/ds-tag-status.test.js +140 -0
- package/src/ds-tag-status/index.js +1 -0
- package/src/ds-textarea/ds-textarea-clearable.test.js +89 -0
- package/src/ds-textarea/ds-textarea.a11y.test.js +66 -0
- package/src/ds-textarea/ds-textarea.js +505 -0
- package/src/ds-textarea/ds-textarea.stories.js +335 -0
- package/src/ds-textarea/ds-textarea.test.js +218 -0
- package/src/ds-textarea/index.js +1 -0
- package/src/ds-thumbnail/ds-thumbnail.js +207 -0
- package/src/ds-thumbnail/ds-thumbnail.stories.js +217 -0
- package/src/ds-thumbnail/ds-thumbnail.test.js +220 -0
- package/src/ds-toast/ds-toast-provider.js +110 -0
- package/src/ds-toast/ds-toast.a11y.test.js +34 -0
- package/src/ds-toast/ds-toast.js +243 -0
- package/src/ds-toast/ds-toast.stories.js +143 -0
- package/src/ds-toast/ds-toast.test.js +93 -0
- package/src/ds-toast/index.js +2 -0
- package/src/ds-tooltip/ds-tooltip.a11y.test.js +110 -0
- package/src/ds-tooltip/ds-tooltip.js +217 -0
- package/src/ds-tooltip/ds-tooltip.mdx +75 -0
- package/src/ds-tooltip/ds-tooltip.stories.js +72 -0
- package/src/ds-tooltip/ds-tooltip.test.js +191 -0
- package/src/ds-tooltip/index.js +1 -0
- package/src/ds-tooltip/positioner.js +117 -0
- package/src/index.js +50 -0
- package/src/mixins/field-label.mixin.js +113 -0
- package/src/mixins/field-message.mixin.js +66 -0
- package/src/token-provider/index.js +1 -0
- package/src/token-provider/token-provider.a11y.test.js +44 -0
- package/src/token-provider/token-provider.js +85 -0
- package/src/token-provider/token-provider.stories.js +105 -0
- package/src/token-provider/token-provider.test.js +134 -0
- 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
|
+
});
|