@reliability-design/web 1.4.6 → 1.4.7
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/CHANGELOG.md +211 -49
- package/dist/components/rel-accordion/README.md +132 -132
- package/dist/components/rel-alert/README.md +145 -141
- package/dist/components/rel-autocomplete/README.md +156 -61
- package/dist/components/rel-avatar/README.md +116 -130
- package/dist/components/rel-badge/README.md +98 -93
- package/dist/components/rel-breadcrumb/README.md +96 -102
- package/dist/components/rel-button/README.md +160 -130
- package/dist/components/rel-calendar/README.md +109 -109
- package/dist/components/rel-card/README.md +75 -94
- package/dist/components/rel-cardmedia/README.md +100 -127
- package/dist/components/rel-carousel/README.md +184 -166
- package/dist/components/rel-checkbox/README.md +93 -75
- package/dist/components/rel-chip/README.md +132 -121
- package/dist/components/rel-container/README.md +107 -99
- package/dist/components/rel-date-picker/README.md +200 -289
- package/dist/components/rel-divider/README.md +78 -100
- package/dist/components/rel-drawer/README.md +187 -200
- package/dist/components/rel-kbd/README.md +141 -151
- package/dist/components/rel-menu/README.md +178 -151
- package/dist/components/rel-menubar/README.md +218 -329
- package/dist/components/rel-otp/README.md +170 -440
- package/dist/components/rel-pagination/README.md +100 -424
- package/dist/components/rel-popover/README.md +203 -568
- package/dist/components/rel-progress/README.md +88 -110
- package/dist/components/rel-radio/README.md +131 -138
- package/dist/components/rel-range-picker/README.md +162 -0
- package/dist/components/rel-resizable/README.md +199 -140
- package/dist/components/rel-segmented-button/README.md +155 -141
- package/dist/components/rel-select/README.md +154 -93
- package/dist/components/rel-sheet/README.md +168 -224
- package/dist/components/rel-skeleton/README.md +103 -0
- package/dist/components/rel-slider/README.md +130 -135
- package/dist/components/rel-snackbar/README.md +162 -134
- package/dist/components/rel-stepper/README.md +158 -160
- package/dist/components/rel-switch/README.md +108 -102
- package/dist/components/rel-tab/README.md +171 -163
- package/dist/components/rel-textfield/README.md +159 -69
- package/dist/components/rel-time-picker/README.md +122 -157
- package/dist/components/rel-toast/README.md +157 -148
- package/dist/components/rel-tooltip/README.md +108 -111
- package/dist/components/rel-upload/README.md +220 -0
- package/dist/index.d.mts +1147 -340
- package/dist/index.d.ts +1147 -340
- package/dist/index.js +3451 -3163
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3416 -3118
- package/dist/index.mjs.map +1 -1
- package/dist/styles/vars.css +877 -866
- package/dist/{vars-XNMTSRVN.css → vars-JHTUGNRG.css} +877 -866
- package/package.json +3 -2
- package/dist/components/rel-accordion/rel-accordion.ts +0 -336
- package/dist/components/rel-alert/rel-alert.ts +0 -305
- package/dist/components/rel-autocomplete/rel-autocomplete.ts +0 -720
- package/dist/components/rel-avatar/rel-avatar.ts +0 -142
- package/dist/components/rel-badge/rel-badge.ts +0 -175
- package/dist/components/rel-breadcrumb/rel-breadcrumb.ts +0 -106
- package/dist/components/rel-button/rel-button.ts +0 -560
- package/dist/components/rel-calendar/index.ts +0 -1
- package/dist/components/rel-calendar/rel-calendar.ts +0 -565
- package/dist/components/rel-card/rel-card.ts +0 -88
- package/dist/components/rel-cardmedia/rel-cardmedia.ts +0 -88
- package/dist/components/rel-carousel/index.ts +0 -1
- package/dist/components/rel-carousel/rel-carousel.ts +0 -723
- package/dist/components/rel-chart/README.md +0 -286
- package/dist/components/rel-chart/index.ts +0 -1
- package/dist/components/rel-chart/rel-chart.ts +0 -754
- package/dist/components/rel-checkbox/rel-checkbox.ts +0 -382
- package/dist/components/rel-chip/rel-chip.ts +0 -483
- package/dist/components/rel-container/rel-container.ts +0 -95
- package/dist/components/rel-date-picker/date-format.test.ts +0 -43
- package/dist/components/rel-date-picker/date-format.ts +0 -47
- package/dist/components/rel-date-picker/date-parse.test.ts +0 -116
- package/dist/components/rel-date-picker/date-parse.ts +0 -178
- package/dist/components/rel-date-picker/index.ts +0 -1
- package/dist/components/rel-date-picker/rel-date-picker.ts +0 -1676
- package/dist/components/rel-divider/rel-divider.ts +0 -54
- package/dist/components/rel-drawer/index.ts +0 -1
- package/dist/components/rel-drawer/rel-drawer-footer.ts +0 -27
- package/dist/components/rel-drawer/rel-drawer.ts +0 -607
- package/dist/components/rel-kbd/rel-kbd.ts +0 -188
- package/dist/components/rel-layout/rel-layout.ts +0 -49
- package/dist/components/rel-menu/rel-menu.ts +0 -541
- package/dist/components/rel-menubar/index.ts +0 -2
- package/dist/components/rel-menubar/rel-menubar-menu.css +0 -3
- package/dist/components/rel-menubar/rel-menubar-menu.ts +0 -17
- package/dist/components/rel-menubar/rel-menubar.ts +0 -777
- package/dist/components/rel-nav-menu/README.md +0 -289
- package/dist/components/rel-nav-menu/index.ts +0 -3
- package/dist/components/rel-nav-menu/rel-nav-menu-item.ts +0 -243
- package/dist/components/rel-nav-menu/rel-nav-menu-panel.ts +0 -72
- package/dist/components/rel-nav-menu/rel-nav-menu.ts +0 -270
- package/dist/components/rel-navbar/README.md +0 -126
- package/dist/components/rel-navbar/rel-navbar.ts +0 -105
- package/dist/components/rel-otp/index.ts +0 -1
- package/dist/components/rel-otp/rel-otp.ts +0 -527
- package/dist/components/rel-pagination/index.ts +0 -1
- package/dist/components/rel-pagination/rel-pagination.ts +0 -522
- package/dist/components/rel-popover/index.ts +0 -2
- package/dist/components/rel-popover/rel-popover-content.ts +0 -81
- package/dist/components/rel-popover/rel-popover.ts +0 -475
- package/dist/components/rel-progress/rel-progress.ts +0 -234
- package/dist/components/rel-radio/rel-radio.ts +0 -481
- package/dist/components/rel-range-picker/rel-range-picker.ts +0 -1688
- package/dist/components/rel-resizable/index.ts +0 -3
- package/dist/components/rel-resizable/rel-resizable-group.ts +0 -405
- package/dist/components/rel-resizable/rel-resizable-handle.ts +0 -150
- package/dist/components/rel-resizable/rel-resizable-panel.ts +0 -58
- package/dist/components/rel-segmented-button/rel-segmented-button.ts +0 -217
- package/dist/components/rel-select/rel-select.ts +0 -1416
- package/dist/components/rel-sheet/rel-sheet.ts +0 -610
- package/dist/components/rel-sidebar/README.md +0 -209
- package/dist/components/rel-sidebar/rel-sidebar.ts +0 -1191
- package/dist/components/rel-skeleton/rel-skeleton.ts +0 -143
- package/dist/components/rel-slider/rel-slider.ts +0 -673
- package/dist/components/rel-snackbar/rel-snackbar.ts +0 -329
- package/dist/components/rel-stepper/rel-stepper.ts +0 -567
- package/dist/components/rel-switch/rel-switch.ts +0 -199
- package/dist/components/rel-tab/rel-tab.ts +0 -264
- package/dist/components/rel-table/README.md +0 -178
- package/dist/components/rel-table/rel-table-body.ts +0 -22
- package/dist/components/rel-table/rel-table-caption.ts +0 -30
- package/dist/components/rel-table/rel-table-cell.ts +0 -43
- package/dist/components/rel-table/rel-table-footer.ts +0 -24
- package/dist/components/rel-table/rel-table-head.ts +0 -47
- package/dist/components/rel-table/rel-table-header.ts +0 -22
- package/dist/components/rel-table/rel-table-row.ts +0 -37
- package/dist/components/rel-table/rel-table.ts +0 -62
- package/dist/components/rel-textfield/rel-textfield.ts +0 -1054
- package/dist/components/rel-time-picker/rel-time-picker.ts +0 -1482
- package/dist/components/rel-time-picker/time-parse.test.ts +0 -67
- package/dist/components/rel-time-picker/time-parse.ts +0 -85
- package/dist/components/rel-toast/rel-toast.ts +0 -206
- package/dist/components/rel-tooltip/rel-tooltip.ts +0 -193
- package/dist/components/rel-topbar/rel-topbar-actions.ts +0 -27
- package/dist/components/rel-topbar/rel-topbar-brand.ts +0 -54
- package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +0 -69
- package/dist/components/rel-topbar/rel-topbar-dropdown.ts +0 -31
- package/dist/components/rel-topbar/rel-topbar-item.ts +0 -324
- package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +0 -27
- package/dist/components/rel-topbar/rel-topbar-profile.ts +0 -243
- package/dist/components/rel-topbar/rel-topbar-utilities.ts +0 -27
- package/dist/components/rel-topbar/rel-topbar.ts +0 -446
- package/dist/components/rel-upload/UPLOAD-DESIGN-CHANGES.md +0 -134
- package/dist/components/rel-upload/rel-upload-actions.ts +0 -163
- package/dist/components/rel-upload/rel-upload.ts +0 -968
|
@@ -1,968 +0,0 @@
|
|
|
1
|
-
import { LitElement, html, css, nothing } from "lit";
|
|
2
|
-
import { customElement, property, state, query } from "lit/decorators.js";
|
|
3
|
-
import "@reliability-design/icons/rel-icon";
|
|
4
|
-
import "../rel-alert/rel-alert";
|
|
5
|
-
import "../rel-progress/rel-progress";
|
|
6
|
-
import { announce } from "../../utils/announce";
|
|
7
|
-
|
|
8
|
-
/** Per-file lifecycle state. Mirrors the Figma "Upload File Row" variants 1:1. */
|
|
9
|
-
export type UploadStatus = "ready" | "uploading" | "success" | "error";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Aggregate state, derived from `files` — never set directly. Drives the list
|
|
13
|
-
* heading, the alert, and the footer button in `<rel-upload-actions>`, and
|
|
14
|
-
* corresponds to the Figma "Upload Modal" variants.
|
|
15
|
-
*/
|
|
16
|
-
export type UploadPhase =
|
|
17
|
-
| "empty" // Zero state
|
|
18
|
-
| "selected" // Single upload / Multi upload
|
|
19
|
-
| "uploading" // Loading
|
|
20
|
-
| "delayed" // Delay
|
|
21
|
-
| "failed"; // Failure
|
|
22
|
-
|
|
23
|
-
export interface UploadFile {
|
|
24
|
-
/** Stable identity for keyed rendering and remove/retry targeting. */
|
|
25
|
-
id: string;
|
|
26
|
-
name: string;
|
|
27
|
-
/** Size in bytes. */
|
|
28
|
-
size: number;
|
|
29
|
-
status: UploadStatus;
|
|
30
|
-
/** 0–100, present while uploading and after success. */
|
|
31
|
-
progress?: number;
|
|
32
|
-
/** Bytes transferred so far. */
|
|
33
|
-
loaded?: number;
|
|
34
|
-
/** Human-readable failure reason, e.g. "file exceeds the 10 MB limit". */
|
|
35
|
-
error?: string;
|
|
36
|
-
/** The underlying File. Absent when the app supplies `files` itself. */
|
|
37
|
-
file?: File;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface UploadRejection {
|
|
41
|
-
name: string;
|
|
42
|
-
reason: "format" | "size";
|
|
43
|
-
message: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface UploaderContext {
|
|
47
|
-
/** The entry being uploaded. */
|
|
48
|
-
file: UploadFile;
|
|
49
|
-
/** Aborted when `cancel()` is called. */
|
|
50
|
-
signal: AbortSignal;
|
|
51
|
-
/** Report bytes transferred. `total` defaults to the file size. */
|
|
52
|
-
onProgress: (loaded: number, total?: number) => void;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Supply this to let `<rel-upload>` drive the whole lifecycle itself. */
|
|
56
|
-
export type Uploader = (file: File, ctx: UploaderContext) => Promise<void>;
|
|
57
|
-
|
|
58
|
-
/** Snapshot handed to `<rel-upload-actions>` on every `rel-state` event. */
|
|
59
|
-
export interface UploadState {
|
|
60
|
-
phase: UploadPhase;
|
|
61
|
-
total: number;
|
|
62
|
-
failed: number;
|
|
63
|
-
canUpload: boolean;
|
|
64
|
-
primaryLabel: string;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
let uid = 0;
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* `<rel-upload>` — dropzone plus per-file upload list.
|
|
71
|
-
*
|
|
72
|
-
* Two modes, chosen by whether `uploader` is set:
|
|
73
|
-
*
|
|
74
|
-
* - **Controlled** (default): the component picks and validates files, then
|
|
75
|
-
* emits intents (`rel-upload`, `rel-retry`, `rel-cancel`, `rel-remove`). The
|
|
76
|
-
* app performs the transfer and writes progress back onto `files`.
|
|
77
|
-
* - **Self-driving**: with an `uploader` function the component runs the whole
|
|
78
|
-
* batch itself — concurrency, progress, the delay warning, abort and retry.
|
|
79
|
-
*
|
|
80
|
-
* The Cancel / Upload buttons are NOT rendered here. They live in
|
|
81
|
-
* `<rel-upload-actions>` so they can sit in `<rel-modal>`'s footer slot and
|
|
82
|
-
* stay pinned while this body scrolls.
|
|
83
|
-
*
|
|
84
|
-
* Emits (bubbling + composed):
|
|
85
|
-
* - `rel-files-added` — `{ added: UploadFile[], rejected: UploadRejection[] }`
|
|
86
|
-
* - `rel-invalid` — `{ rejected: UploadRejection[] }`
|
|
87
|
-
* - `rel-change` — selection changed; `{ files: UploadFile[] }`
|
|
88
|
-
* - `rel-remove` — row removed; `{ id, file }`
|
|
89
|
-
* - `rel-upload` — Upload pressed; `{ files: UploadFile[] }`
|
|
90
|
-
* - `rel-retry` — Retry pressed; `{ files: UploadFile[] }`
|
|
91
|
-
* - `rel-cancel` — Cancel pressed
|
|
92
|
-
* - `rel-progress` — `{ id, progress, loaded, total }`
|
|
93
|
-
* - `rel-file-complete`— `{ id, file }`
|
|
94
|
-
* - `rel-file-error` — `{ id, file, error }`
|
|
95
|
-
* - `rel-complete` — batch settled; `{ succeeded, failed }`
|
|
96
|
-
* - `rel-state` — derived snapshot changed; `detail: UploadState`
|
|
97
|
-
*
|
|
98
|
-
* @element rel-upload
|
|
99
|
-
*/
|
|
100
|
-
@customElement("rel-upload")
|
|
101
|
-
export class RelUpload extends LitElement {
|
|
102
|
-
/** Comma-separated accept list, e.g. ".pdf,.png,.jpg" or "image/*". */
|
|
103
|
-
@property({ type: String }) accept = "";
|
|
104
|
-
|
|
105
|
-
/** Max size per file as a human string ("10MB", "500kb"). Empty → no limit. */
|
|
106
|
-
@property({ type: String, attribute: "max-size" }) maxSize = "";
|
|
107
|
-
|
|
108
|
-
/** Allow more than one file. */
|
|
109
|
-
@property({ type: Boolean }) multiple = false;
|
|
110
|
-
|
|
111
|
-
/** Disables the whole control. */
|
|
112
|
-
@property({ type: Boolean, reflect: true }) disabled = false;
|
|
113
|
-
|
|
114
|
-
/** Overrides the derived "PDF, PNG or JPG - up to 10 MB per file" hint. */
|
|
115
|
-
@property({ type: String }) hint = "";
|
|
116
|
-
|
|
117
|
-
/** Dropzone headline. */
|
|
118
|
-
@property({ type: String, attribute: "dropzone-title" })
|
|
119
|
-
dropzoneTitle = "Drag and drop your files here";
|
|
120
|
-
|
|
121
|
-
/** Browse button label. */
|
|
122
|
-
@property({ type: String, attribute: "browse-label" }) browseLabel = "Browse files";
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* The file list. Set it to drive the component in controlled mode; the
|
|
126
|
-
* component also maintains it itself as files are added and removed.
|
|
127
|
-
*/
|
|
128
|
-
@property({ attribute: false }) files: UploadFile[] = [];
|
|
129
|
-
|
|
130
|
-
/** Supply to run uploads internally. See {@link Uploader}. */
|
|
131
|
-
@property({ attribute: false }) uploader?: Uploader;
|
|
132
|
-
|
|
133
|
-
/** Max files transferred at once in self-driving mode. */
|
|
134
|
-
@property({ type: Number }) concurrency = 3;
|
|
135
|
-
|
|
136
|
-
/** ms of continuous uploading before the "taking longer than usual" alert. */
|
|
137
|
-
@property({ type: Number, attribute: "delay-threshold" }) delayThreshold = 15000;
|
|
138
|
-
|
|
139
|
-
@state() private dragOver = false;
|
|
140
|
-
@state() private delayed = false;
|
|
141
|
-
|
|
142
|
-
@query(".file-input") private inputEl!: HTMLInputElement;
|
|
143
|
-
|
|
144
|
-
private aborter?: AbortController;
|
|
145
|
-
private delayTimer?: ReturnType<typeof setTimeout>;
|
|
146
|
-
private lastSignature = "";
|
|
147
|
-
private lastHeading = "";
|
|
148
|
-
private lastAlertText = "";
|
|
149
|
-
|
|
150
|
-
static styles = css`
|
|
151
|
-
:host {
|
|
152
|
-
display: block;
|
|
153
|
-
box-sizing: border-box;
|
|
154
|
-
color: var(--rel-text-default);
|
|
155
|
-
font-family: var(--rel-fonts-font-family);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
:host([disabled]) {
|
|
159
|
-
opacity: 0.6;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
:host([disabled]) .body {
|
|
163
|
-
pointer-events: none;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
.body {
|
|
167
|
-
display: flex;
|
|
168
|
-
flex-direction: column;
|
|
169
|
-
gap: var(--rel-spacing-lg, 20px);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/* ── Dropzone ─────────────────────────────────────────────────────────── */
|
|
173
|
-
|
|
174
|
-
.dropzone {
|
|
175
|
-
box-sizing: border-box;
|
|
176
|
-
display: flex;
|
|
177
|
-
flex-direction: column;
|
|
178
|
-
align-items: center;
|
|
179
|
-
justify-content: center;
|
|
180
|
-
gap: var(--rel-spacing-md, 16px);
|
|
181
|
-
padding: var(--rel-spacing-2xl, 32px) var(--rel-spacing-xl, 24px);
|
|
182
|
-
background: var(--rel-bg-surface);
|
|
183
|
-
border: 1px dashed var(--rel-border-accent);
|
|
184
|
-
border-radius: var(--rel-border-radius-md, 16px);
|
|
185
|
-
text-align: center;
|
|
186
|
-
cursor: pointer;
|
|
187
|
-
transition: border-color 0.15s ease, background 0.15s ease;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/* Design gap: no drag-over frame exists in Figma. */
|
|
191
|
-
.dropzone.dragover {
|
|
192
|
-
border-style: solid;
|
|
193
|
-
border-color: var(--rel-border-brand);
|
|
194
|
-
background: color-mix(in srgb, var(--rel-bg-brand) 8%, var(--rel-bg-surface));
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
.icon-badge {
|
|
198
|
-
display: inline-flex;
|
|
199
|
-
align-items: center;
|
|
200
|
-
justify-content: center;
|
|
201
|
-
padding: var(--rel-spacing-sm, 12px);
|
|
202
|
-
background: var(--rel-bg-surface);
|
|
203
|
-
border: 1px solid var(--rel-border-subtle);
|
|
204
|
-
border-radius: var(--rel-border-radius-full-rounded, 1000px);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
.dz-copy {
|
|
208
|
-
display: flex;
|
|
209
|
-
flex-direction: column;
|
|
210
|
-
align-items: center;
|
|
211
|
-
gap: var(--rel-spacing-2xs, 4px);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
.dz-title {
|
|
215
|
-
margin: 0;
|
|
216
|
-
color: var(--rel-text-default);
|
|
217
|
-
font-family: var(--rel-body-lg-medium-font-family);
|
|
218
|
-
font-weight: var(--rel-body-lg-medium-font-weight);
|
|
219
|
-
font-size: var(--rel-body-lg-medium-font-size);
|
|
220
|
-
line-height: var(--rel-body-lg-medium-line-height);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
.dz-hint {
|
|
224
|
-
margin: 0;
|
|
225
|
-
color: var(--rel-text-caption);
|
|
226
|
-
font-family: var(--rel-body-sm-regular-font-family);
|
|
227
|
-
font-weight: var(--rel-body-sm-regular-font-weight);
|
|
228
|
-
font-size: var(--rel-body-sm-regular-font-size);
|
|
229
|
-
line-height: var(--rel-body-sm-regular-line-height);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/* The dropzone is a mouse affordance only — the Browse button is the sole
|
|
233
|
-
focusable control, so there are no nested interactive elements. */
|
|
234
|
-
.browse {
|
|
235
|
-
display: inline-flex;
|
|
236
|
-
align-items: center;
|
|
237
|
-
gap: var(--rel-spacing-xs, 8px);
|
|
238
|
-
padding: var(--rel-spacing-xs, 8px) var(--rel-spacing-md, 16px);
|
|
239
|
-
background: transparent;
|
|
240
|
-
color: var(--rel-text-default);
|
|
241
|
-
border: 1px solid var(--rel-action-neutral-border-outline, var(--rel-border-accent));
|
|
242
|
-
border-radius: var(--rel-border-radius-full-rounded, 1000px);
|
|
243
|
-
cursor: pointer;
|
|
244
|
-
font-family: var(--rel-body-md-semibold-font-family);
|
|
245
|
-
font-weight: var(--rel-body-md-semibold-font-weight);
|
|
246
|
-
font-size: var(--rel-body-md-semibold-font-size);
|
|
247
|
-
line-height: var(--rel-body-md-semibold-line-height);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
.browse:hover:not(:disabled) {
|
|
251
|
-
background: var(--rel-action-neutral-outline-hover, var(--rel-bg-surface-elevated));
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
.browse:focus-visible {
|
|
255
|
-
outline: 2px solid var(--rel-border-focus);
|
|
256
|
-
outline-offset: 2px;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
.browse:disabled {
|
|
260
|
-
cursor: not-allowed;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/* ── File list ────────────────────────────────────────────────────────── */
|
|
264
|
-
|
|
265
|
-
.file-list {
|
|
266
|
-
display: flex;
|
|
267
|
-
flex-direction: column;
|
|
268
|
-
gap: var(--rel-spacing-sm, 12px);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
.list-heading {
|
|
272
|
-
margin: 0;
|
|
273
|
-
color: var(--rel-text-default);
|
|
274
|
-
font-family: var(--rel-body-md-semibold-font-family);
|
|
275
|
-
font-weight: var(--rel-body-md-semibold-font-weight);
|
|
276
|
-
font-size: var(--rel-body-md-semibold-font-size);
|
|
277
|
-
line-height: var(--rel-body-md-semibold-line-height);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
.rows {
|
|
281
|
-
display: flex;
|
|
282
|
-
flex-direction: column;
|
|
283
|
-
gap: var(--rel-spacing-xs, 8px);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
.row {
|
|
287
|
-
display: flex;
|
|
288
|
-
align-items: center;
|
|
289
|
-
gap: var(--rel-spacing-sm, 12px);
|
|
290
|
-
box-sizing: border-box;
|
|
291
|
-
padding: var(--rel-spacing-sm, 12px) var(--rel-spacing-md, 16px);
|
|
292
|
-
background: var(--rel-bg-surface);
|
|
293
|
-
border: 1px solid var(--rel-border-default);
|
|
294
|
-
border-radius: var(--rel-border-radius-sm, 8px);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
.row.error {
|
|
298
|
-
background: var(--rel-bg-danger-light);
|
|
299
|
-
border-color: var(--rel-border-danger);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
.row > rel-icon {
|
|
303
|
-
flex-shrink: 0;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
.row-content {
|
|
307
|
-
display: flex;
|
|
308
|
-
flex-direction: column;
|
|
309
|
-
gap: var(--rel-spacing-3xs, 2px);
|
|
310
|
-
flex: 1;
|
|
311
|
-
min-width: 0;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
.row.uploading .row-content {
|
|
315
|
-
gap: var(--rel-spacing-2xs, 4px);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
.row-name {
|
|
319
|
-
overflow: hidden;
|
|
320
|
-
text-overflow: ellipsis;
|
|
321
|
-
white-space: nowrap;
|
|
322
|
-
color: var(--rel-text-default);
|
|
323
|
-
font-family: var(--rel-body-md-regular-font-family);
|
|
324
|
-
font-weight: var(--rel-body-md-regular-font-weight);
|
|
325
|
-
font-size: var(--rel-body-md-regular-font-size);
|
|
326
|
-
line-height: var(--rel-body-md-regular-line-height);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
.row-meta {
|
|
330
|
-
color: var(--rel-text-caption);
|
|
331
|
-
font-family: var(--rel-body-sm-regular-font-family);
|
|
332
|
-
font-weight: var(--rel-body-sm-regular-font-weight);
|
|
333
|
-
font-size: var(--rel-body-sm-regular-font-size);
|
|
334
|
-
line-height: var(--rel-body-sm-regular-line-height);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
.row.success .row-meta {
|
|
338
|
-
color: var(--rel-text-success);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
.row.error .row-meta {
|
|
342
|
-
color: var(--rel-text-danger);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/* Figma's 4px rounded track; rel-progress ships a 6px square one. */
|
|
346
|
-
rel-progress::part(track) {
|
|
347
|
-
height: 4px;
|
|
348
|
-
border-radius: var(--rel-border-radius-full-rounded, 1000px);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
rel-progress::part(indicator) {
|
|
352
|
-
background: var(--rel-bg-success);
|
|
353
|
-
border-radius: var(--rel-border-radius-full-rounded, 1000px);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
.row-trailing {
|
|
357
|
-
display: flex;
|
|
358
|
-
align-items: center;
|
|
359
|
-
gap: var(--rel-spacing-sm, 12px);
|
|
360
|
-
flex-shrink: 0;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
.status-success {
|
|
364
|
-
color: var(--rel-text-success);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
.status-error {
|
|
368
|
-
color: var(--rel-text-danger);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
.remove {
|
|
372
|
-
display: inline-flex;
|
|
373
|
-
align-items: center;
|
|
374
|
-
justify-content: center;
|
|
375
|
-
padding: 0;
|
|
376
|
-
background: none;
|
|
377
|
-
border: none;
|
|
378
|
-
cursor: pointer;
|
|
379
|
-
color: var(--rel-text-default);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
.remove:focus-visible {
|
|
383
|
-
outline: 2px solid var(--rel-border-focus);
|
|
384
|
-
outline-offset: 2px;
|
|
385
|
-
border-radius: var(--rel-border-radius-xs, 4px);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
.file-input {
|
|
389
|
-
display: none;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
@media (prefers-reduced-motion: reduce) {
|
|
393
|
-
.dropzone {
|
|
394
|
-
transition: none;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
`;
|
|
398
|
-
|
|
399
|
-
// ── derived ───────────────────────────────────────────────────────────────
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Aggregate state. Read-only; corresponds to the Figma modal variants.
|
|
403
|
-
*
|
|
404
|
-
* `failed` means there is nothing left to send AND something errored — while
|
|
405
|
-
* any row is still `ready` the primary action is Upload, not Retry.
|
|
406
|
-
*/
|
|
407
|
-
get phase(): UploadPhase {
|
|
408
|
-
if (this.files.length === 0) return "empty";
|
|
409
|
-
if (this.files.some((f) => f.status === "uploading")) {
|
|
410
|
-
return this.delayed ? "delayed" : "uploading";
|
|
411
|
-
}
|
|
412
|
-
if (this.pending.length > 0) return "selected";
|
|
413
|
-
if (this.files.some((f) => f.status === "error")) return "failed";
|
|
414
|
-
return "selected";
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/** Files still eligible to be sent. */
|
|
418
|
-
private get pending(): UploadFile[] {
|
|
419
|
-
return this.files.filter((f) => f.status === "ready");
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
private get failedFiles(): UploadFile[] {
|
|
423
|
-
return this.files.filter((f) => f.status === "error");
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Failed rows worth sending again. A file rejected by `accept`/`max-size`
|
|
428
|
-
* will fail validation every time, so retrying it is pointless — re-check
|
|
429
|
-
* rather than re-queueing it blindly.
|
|
430
|
-
*/
|
|
431
|
-
private get retryable(): UploadFile[] {
|
|
432
|
-
const maxBytes = this.parseMaxBytes();
|
|
433
|
-
return this.failedFiles.filter(
|
|
434
|
-
(f) => !!f.file && this.matchesAccept(f.file) && f.file.size <= maxBytes
|
|
435
|
-
);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
get canUpload(): boolean {
|
|
439
|
-
if (this.disabled) return false;
|
|
440
|
-
const phase = this.phase;
|
|
441
|
-
if (phase === "uploading" || phase === "delayed") return false;
|
|
442
|
-
if (phase === "failed") return this.retryable.length > 0;
|
|
443
|
-
return this.pending.length > 0;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/** Footer button label — "Upload", "Upload 3 files" or "Retry upload". */
|
|
447
|
-
get primaryLabel(): string {
|
|
448
|
-
if (this.phase === "failed") return "Retry upload";
|
|
449
|
-
const n = this.files.length;
|
|
450
|
-
return n > 1 ? `Upload ${n} files` : "Upload";
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/** The snapshot `<rel-upload-actions>` renders from. */
|
|
454
|
-
get state(): UploadState {
|
|
455
|
-
return {
|
|
456
|
-
phase: this.phase,
|
|
457
|
-
total: this.files.length,
|
|
458
|
-
failed: this.failedFiles.length,
|
|
459
|
-
canUpload: this.canUpload,
|
|
460
|
-
primaryLabel: this.primaryLabel,
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
private get listHeading(): string {
|
|
465
|
-
const n = this.files.length;
|
|
466
|
-
switch (this.phase) {
|
|
467
|
-
case "uploading":
|
|
468
|
-
case "delayed":
|
|
469
|
-
return `Uploading ${n} ${n === 1 ? "file" : "files"}`;
|
|
470
|
-
case "failed":
|
|
471
|
-
return `${n} ${n === 1 ? "file" : "files"} - ${this.failedFiles.length} failed`;
|
|
472
|
-
default:
|
|
473
|
-
return n === 1 ? "Selected file (1)" : `Selected files (${n})`;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
private get hintText(): string {
|
|
478
|
-
if (this.hint) return this.hint;
|
|
479
|
-
const formats = this.acceptDisplay;
|
|
480
|
-
const size = this.maxSize ? ` - up to ${this.maxSize} per file` : "";
|
|
481
|
-
return `${formats}${size}`;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
private get acceptDisplay(): string {
|
|
485
|
-
const tokens = this.accept
|
|
486
|
-
.split(",")
|
|
487
|
-
.map((s) => s.trim())
|
|
488
|
-
.filter(Boolean)
|
|
489
|
-
.map((tok) => {
|
|
490
|
-
if (tok.startsWith(".")) return tok.slice(1).toUpperCase();
|
|
491
|
-
if (tok.endsWith("/*")) return tok.slice(0, -2).toUpperCase();
|
|
492
|
-
return (tok.split("/").pop() ?? tok).toUpperCase();
|
|
493
|
-
});
|
|
494
|
-
if (tokens.length === 0) return "Any file type";
|
|
495
|
-
if (tokens.length === 1) return tokens[0];
|
|
496
|
-
return `${tokens.slice(0, -1).join(", ")} or ${tokens[tokens.length - 1]}`;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
private get alertContent(): { type: "warning" | "error"; message1: string; message2: string } | null {
|
|
500
|
-
if (this.phase === "delayed") {
|
|
501
|
-
return {
|
|
502
|
-
type: "warning",
|
|
503
|
-
message1: "This is taking longer than usual",
|
|
504
|
-
message2:
|
|
505
|
-
"Large files can take a few minutes on a slow connection. You can leave this open or come back later.",
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
const failed = this.failedFiles.length;
|
|
509
|
-
if (failed > 0 && this.phase !== "uploading") {
|
|
510
|
-
const limit = this.maxSize ? `under ${this.maxSize} and ` : "";
|
|
511
|
-
return {
|
|
512
|
-
type: "error",
|
|
513
|
-
message1:
|
|
514
|
-
failed === 1 && this.files.length === 1
|
|
515
|
-
? "We couldn't upload your file"
|
|
516
|
-
: `We couldn't upload ${failed} of your files`,
|
|
517
|
-
message2: `Check that each file is ${limit}in a supported format, then try again.`,
|
|
518
|
-
};
|
|
519
|
-
}
|
|
520
|
-
return null;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// ── validation ────────────────────────────────────────────────────────────
|
|
524
|
-
|
|
525
|
-
private parseMaxBytes(): number {
|
|
526
|
-
const s = this.maxSize.trim().toLowerCase();
|
|
527
|
-
if (!s) return Infinity;
|
|
528
|
-
const m = s.match(/^([\d.]+)\s*(b|kb|mb|gb)?$/);
|
|
529
|
-
if (!m) return Infinity;
|
|
530
|
-
const n = parseFloat(m[1]);
|
|
531
|
-
const unit = m[2] || "b";
|
|
532
|
-
const mult = unit === "gb" ? 1e9 : unit === "mb" ? 1e6 : unit === "kb" ? 1e3 : 1;
|
|
533
|
-
return n * mult;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
private matchesAccept(file: File): boolean {
|
|
537
|
-
const raw = this.accept.trim();
|
|
538
|
-
if (!raw) return true;
|
|
539
|
-
const tokens = raw
|
|
540
|
-
.split(",")
|
|
541
|
-
.map((s) => s.trim().toLowerCase())
|
|
542
|
-
.filter(Boolean);
|
|
543
|
-
const name = file.name.toLowerCase();
|
|
544
|
-
const type = (file.type || "").toLowerCase();
|
|
545
|
-
return tokens.some((tok) => {
|
|
546
|
-
if (tok.startsWith(".")) return name.endsWith(tok);
|
|
547
|
-
if (tok.endsWith("/*")) return type.startsWith(tok.slice(0, -1));
|
|
548
|
-
return type === tok;
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
static fmtSize(bytes: number): string {
|
|
553
|
-
if (bytes < 1000) return `${bytes} B`;
|
|
554
|
-
if (bytes < 1e6) return `${(bytes / 1e3).toFixed(0)} KB`;
|
|
555
|
-
return `${(bytes / 1e6).toFixed(1)} MB`;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
private static ext(name: string): string {
|
|
559
|
-
const i = name.lastIndexOf(".");
|
|
560
|
-
return i > -1 ? name.slice(i + 1).toUpperCase() : "FILE";
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* Validate and append. Invalid files are kept as `error` rows rather than
|
|
565
|
-
* dropped — that is how the Figma Failure state surfaces them.
|
|
566
|
-
*/
|
|
567
|
-
private ingest(list: FileList | null) {
|
|
568
|
-
if (!list || list.length === 0) return;
|
|
569
|
-
const maxBytes = this.parseMaxBytes();
|
|
570
|
-
const incoming = this.multiple ? Array.from(list) : Array.from(list).slice(0, 1);
|
|
571
|
-
|
|
572
|
-
const added: UploadFile[] = [];
|
|
573
|
-
const rejected: UploadRejection[] = [];
|
|
574
|
-
|
|
575
|
-
for (const f of incoming) {
|
|
576
|
-
const entry: UploadFile = {
|
|
577
|
-
id: `rel-upload-${++uid}`,
|
|
578
|
-
name: f.name,
|
|
579
|
-
size: f.size,
|
|
580
|
-
status: "ready",
|
|
581
|
-
file: f,
|
|
582
|
-
};
|
|
583
|
-
if (!this.matchesAccept(f)) {
|
|
584
|
-
entry.status = "error";
|
|
585
|
-
entry.error = "unsupported file type";
|
|
586
|
-
rejected.push({ name: f.name, reason: "format", message: `${f.name}: unsupported file type` });
|
|
587
|
-
} else if (f.size > maxBytes) {
|
|
588
|
-
entry.status = "error";
|
|
589
|
-
entry.error = `file exceeds the ${this.maxSize} limit`;
|
|
590
|
-
rejected.push({
|
|
591
|
-
name: f.name,
|
|
592
|
-
reason: "size",
|
|
593
|
-
message: `${f.name}: exceeds ${this.maxSize}`,
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
added.push(entry);
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// Replace same-named entries instead of duplicating them.
|
|
600
|
-
const names = new Set(added.map((f) => f.name));
|
|
601
|
-
const kept = this.multiple ? this.files.filter((f) => !names.has(f.name)) : [];
|
|
602
|
-
this.files = [...kept, ...added];
|
|
603
|
-
|
|
604
|
-
if (rejected.length) this.emit("rel-invalid", { rejected });
|
|
605
|
-
this.emit("rel-files-added", { added, rejected });
|
|
606
|
-
this.emit("rel-change", { files: this.files });
|
|
607
|
-
const accepted = added.length - rejected.length;
|
|
608
|
-
if (accepted > 0) {
|
|
609
|
-
announce(`${accepted} file${accepted === 1 ? "" : "s"} added`);
|
|
610
|
-
}
|
|
611
|
-
if (rejected.length > 0) {
|
|
612
|
-
announce(`${rejected.length} file${rejected.length === 1 ? "" : "s"} rejected`, "assertive");
|
|
613
|
-
}
|
|
614
|
-
this.notify();
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// ── upload orchestration (self-driving mode) ──────────────────────────────
|
|
618
|
-
|
|
619
|
-
private patch(id: string, partial: Partial<UploadFile>) {
|
|
620
|
-
this.files = this.files.map((f) => (f.id === id ? { ...f, ...partial } : f));
|
|
621
|
-
this.notify();
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
private startDelayTimer() {
|
|
625
|
-
this.stopDelayTimer();
|
|
626
|
-
if (this.delayThreshold <= 0) return;
|
|
627
|
-
this.delayTimer = setTimeout(() => {
|
|
628
|
-
if (this.files.some((f) => f.status === "uploading")) {
|
|
629
|
-
this.delayed = true;
|
|
630
|
-
this.notify();
|
|
631
|
-
}
|
|
632
|
-
}, this.delayThreshold);
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
private stopDelayTimer() {
|
|
636
|
-
if (this.delayTimer) clearTimeout(this.delayTimer);
|
|
637
|
-
this.delayTimer = undefined;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
private async runBatch(targets: UploadFile[]) {
|
|
641
|
-
const uploader = this.uploader;
|
|
642
|
-
if (!uploader || targets.length === 0) return;
|
|
643
|
-
|
|
644
|
-
this.aborter = new AbortController();
|
|
645
|
-
const signal = this.aborter.signal;
|
|
646
|
-
this.startDelayTimer();
|
|
647
|
-
|
|
648
|
-
const queue = [...targets];
|
|
649
|
-
const workers = Array.from({ length: Math.max(1, this.concurrency) }, async () => {
|
|
650
|
-
while (queue.length) {
|
|
651
|
-
if (signal.aborted) return;
|
|
652
|
-
const item = queue.shift();
|
|
653
|
-
if (!item || !item.file) continue;
|
|
654
|
-
|
|
655
|
-
this.patch(item.id, { status: "uploading", progress: 0, loaded: 0, error: undefined });
|
|
656
|
-
try {
|
|
657
|
-
await uploader(item.file, {
|
|
658
|
-
file: item,
|
|
659
|
-
signal,
|
|
660
|
-
onProgress: (loaded, total) => {
|
|
661
|
-
const t = total ?? item.size;
|
|
662
|
-
const progress = t > 0 ? Math.min(100, Math.round((loaded / t) * 100)) : 0;
|
|
663
|
-
this.patch(item.id, { loaded, progress });
|
|
664
|
-
this.emit("rel-progress", { id: item.id, progress, loaded, total: t });
|
|
665
|
-
},
|
|
666
|
-
});
|
|
667
|
-
if (signal.aborted) {
|
|
668
|
-
this.patch(item.id, { status: "ready", progress: undefined, loaded: undefined });
|
|
669
|
-
return;
|
|
670
|
-
}
|
|
671
|
-
this.patch(item.id, { status: "success", progress: 100, loaded: item.size });
|
|
672
|
-
this.emit("rel-file-complete", { id: item.id, file: item.file });
|
|
673
|
-
} catch (err) {
|
|
674
|
-
if (signal.aborted) {
|
|
675
|
-
this.patch(item.id, { status: "ready", progress: undefined, loaded: undefined });
|
|
676
|
-
return;
|
|
677
|
-
}
|
|
678
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
679
|
-
this.patch(item.id, { status: "error", error: message, progress: undefined });
|
|
680
|
-
this.emit("rel-file-error", { id: item.id, file: item.file, error: message });
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
});
|
|
684
|
-
|
|
685
|
-
await Promise.all(workers);
|
|
686
|
-
|
|
687
|
-
this.stopDelayTimer();
|
|
688
|
-
this.delayed = false;
|
|
689
|
-
this.aborter = undefined;
|
|
690
|
-
|
|
691
|
-
if (!signal.aborted) {
|
|
692
|
-
this.emit("rel-complete", {
|
|
693
|
-
succeeded: this.files.filter((f) => f.status === "success"),
|
|
694
|
-
failed: this.failedFiles,
|
|
695
|
-
});
|
|
696
|
-
}
|
|
697
|
-
this.notify();
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// ── public API ────────────────────────────────────────────────────────────
|
|
701
|
-
|
|
702
|
-
/** Start (or resume) the batch. Emits `rel-upload`; runs it when `uploader` is set. */
|
|
703
|
-
upload() {
|
|
704
|
-
if (!this.canUpload) return;
|
|
705
|
-
const targets = this.pending;
|
|
706
|
-
this.emit("rel-upload", { files: targets });
|
|
707
|
-
if (this.uploader) void this.runBatch(targets);
|
|
708
|
-
else this.notify();
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
/** Re-queue every retryable failed row and send again. */
|
|
712
|
-
retry() {
|
|
713
|
-
const targets = this.retryable;
|
|
714
|
-
if (targets.length === 0) return;
|
|
715
|
-
const ids = new Set(targets.map((f) => f.id));
|
|
716
|
-
this.files = this.files.map((f) =>
|
|
717
|
-
ids.has(f.id) ? { ...f, status: "ready" as const, error: undefined, progress: undefined } : f
|
|
718
|
-
);
|
|
719
|
-
const requeued = this.files.filter((f) => ids.has(f.id));
|
|
720
|
-
this.emit("rel-retry", { files: requeued });
|
|
721
|
-
if (this.uploader) void this.runBatch(requeued);
|
|
722
|
-
else this.notify();
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/** Abort anything in flight and emit `rel-cancel`. Does not clear the list. */
|
|
726
|
-
cancel() {
|
|
727
|
-
this.aborter?.abort();
|
|
728
|
-
this.stopDelayTimer();
|
|
729
|
-
this.delayed = false;
|
|
730
|
-
this.emit("rel-cancel");
|
|
731
|
-
this.notify();
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
/** Remove one row by id. */
|
|
735
|
-
removeFile(id: string) {
|
|
736
|
-
const target = this.files.find((f) => f.id === id);
|
|
737
|
-
this.files = this.files.filter((f) => f.id !== id);
|
|
738
|
-
this.emit("rel-remove", { id, file: target?.file });
|
|
739
|
-
this.emit("rel-change", { files: this.files });
|
|
740
|
-
if (target) announce(`${target.name} removed`);
|
|
741
|
-
this.notify();
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
/** Clear everything and abort in-flight transfers. */
|
|
745
|
-
reset() {
|
|
746
|
-
this.aborter?.abort();
|
|
747
|
-
this.stopDelayTimer();
|
|
748
|
-
this.files = [];
|
|
749
|
-
this.delayed = false;
|
|
750
|
-
if (this.inputEl) this.inputEl.value = "";
|
|
751
|
-
this.emit("rel-change", { files: this.files });
|
|
752
|
-
announce("File list cleared");
|
|
753
|
-
this.notify();
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
// ── events ────────────────────────────────────────────────────────────────
|
|
757
|
-
|
|
758
|
-
private emit(type: string, detail?: unknown) {
|
|
759
|
-
this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
/**
|
|
763
|
-
* Emit `rel-state` only when the derived snapshot actually changes, so
|
|
764
|
-
* progress ticks don't spam `<rel-upload-actions>`.
|
|
765
|
-
*/
|
|
766
|
-
private notify() {
|
|
767
|
-
const s = this.state;
|
|
768
|
-
const signature = `${s.phase}|${s.total}|${s.failed}|${s.canUpload}|${s.primaryLabel}`;
|
|
769
|
-
if (signature === this.lastSignature) return;
|
|
770
|
-
this.lastSignature = signature;
|
|
771
|
-
this.emit("rel-state", s);
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
private openPicker = () => {
|
|
775
|
-
if (this.disabled) return;
|
|
776
|
-
this.inputEl?.click();
|
|
777
|
-
};
|
|
778
|
-
|
|
779
|
-
private onInputChange = (e: Event) => {
|
|
780
|
-
const input = e.target as HTMLInputElement;
|
|
781
|
-
this.ingest(input.files);
|
|
782
|
-
// Reset so re-picking the same file still fires change.
|
|
783
|
-
input.value = "";
|
|
784
|
-
};
|
|
785
|
-
|
|
786
|
-
private onDragOver = (e: DragEvent) => {
|
|
787
|
-
if (this.disabled) return;
|
|
788
|
-
e.preventDefault();
|
|
789
|
-
this.dragOver = true;
|
|
790
|
-
};
|
|
791
|
-
|
|
792
|
-
private onDragLeave = (e: DragEvent) => {
|
|
793
|
-
e.preventDefault();
|
|
794
|
-
this.dragOver = false;
|
|
795
|
-
};
|
|
796
|
-
|
|
797
|
-
private onDrop = (e: DragEvent) => {
|
|
798
|
-
e.preventDefault();
|
|
799
|
-
this.dragOver = false;
|
|
800
|
-
if (this.disabled) return;
|
|
801
|
-
this.ingest(e.dataTransfer?.files ?? null);
|
|
802
|
-
};
|
|
803
|
-
|
|
804
|
-
connectedCallback() {
|
|
805
|
-
super.connectedCallback();
|
|
806
|
-
// Let a late-connecting <rel-upload-actions> pick up the initial state.
|
|
807
|
-
queueMicrotask(() => this.emit("rel-state", this.state));
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
disconnectedCallback() {
|
|
811
|
-
super.disconnectedCallback();
|
|
812
|
-
this.stopDelayTimer();
|
|
813
|
-
this.aborter?.abort();
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
updated() {
|
|
817
|
-
this.notify();
|
|
818
|
-
const heading = this.files.length ? this.listHeading : "";
|
|
819
|
-
if (heading !== this.lastHeading) {
|
|
820
|
-
this.lastHeading = heading;
|
|
821
|
-
if (heading) {
|
|
822
|
-
announce(heading, this.phase === "failed" ? "assertive" : "polite");
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
const alert = this.alertContent;
|
|
826
|
-
const alertText = alert ? `${alert.message1}. ${alert.message2}` : "";
|
|
827
|
-
if (alertText !== this.lastAlertText) {
|
|
828
|
-
this.lastAlertText = alertText;
|
|
829
|
-
if (alertText) announce(alertText, "assertive");
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
// ── render ────────────────────────────────────────────────────────────────
|
|
834
|
-
|
|
835
|
-
private rowMeta(f: UploadFile): string {
|
|
836
|
-
switch (f.status) {
|
|
837
|
-
case "uploading": {
|
|
838
|
-
const pct = f.progress ?? 0;
|
|
839
|
-
const loaded = RelUpload.fmtSize(f.loaded ?? 0);
|
|
840
|
-
return `${pct}% - ${loaded} of ${RelUpload.fmtSize(f.size)}`;
|
|
841
|
-
}
|
|
842
|
-
case "success":
|
|
843
|
-
return `${RelUpload.ext(f.name)} - ${RelUpload.fmtSize(f.size)} - Uploaded`;
|
|
844
|
-
case "error":
|
|
845
|
-
return `Upload failed - ${f.error ?? "please try again"}`;
|
|
846
|
-
default: {
|
|
847
|
-
const base = `${RelUpload.ext(f.name)} - ${RelUpload.fmtSize(f.size)}`;
|
|
848
|
-
const busy = this.phase === "uploading" || this.phase === "delayed";
|
|
849
|
-
return busy ? `${base} - Waiting` : base;
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
private renderRow(f: UploadFile) {
|
|
855
|
-
return html`
|
|
856
|
-
<div class="row ${f.status}">
|
|
857
|
-
<rel-icon name="document" size="md"></rel-icon>
|
|
858
|
-
|
|
859
|
-
<div class="row-content">
|
|
860
|
-
<span class="row-name" title=${f.name}>${f.name}</span>
|
|
861
|
-
${f.status === "uploading"
|
|
862
|
-
? html`<rel-progress
|
|
863
|
-
mode="linear-determinate"
|
|
864
|
-
.value=${f.progress ?? 0}
|
|
865
|
-
></rel-progress>`
|
|
866
|
-
: nothing}
|
|
867
|
-
<span class="row-meta">${this.rowMeta(f)}</span>
|
|
868
|
-
</div>
|
|
869
|
-
|
|
870
|
-
<div class="row-trailing">
|
|
871
|
-
${f.status === "success"
|
|
872
|
-
? html`<rel-icon
|
|
873
|
-
class="status-success"
|
|
874
|
-
name="check-circle"
|
|
875
|
-
size="md"
|
|
876
|
-
role="img"
|
|
877
|
-
aria-label="Uploaded"
|
|
878
|
-
></rel-icon>`
|
|
879
|
-
: nothing}
|
|
880
|
-
${f.status === "error"
|
|
881
|
-
? html`<rel-icon
|
|
882
|
-
class="status-error"
|
|
883
|
-
name="error"
|
|
884
|
-
size="md"
|
|
885
|
-
role="img"
|
|
886
|
-
aria-label="Failed"
|
|
887
|
-
></rel-icon>`
|
|
888
|
-
: nothing}
|
|
889
|
-
<button
|
|
890
|
-
class="remove"
|
|
891
|
-
aria-label="Remove ${f.name}"
|
|
892
|
-
?disabled=${this.disabled}
|
|
893
|
-
@click=${() => this.removeFile(f.id)}
|
|
894
|
-
>
|
|
895
|
-
<rel-icon name="delete" size="md"></rel-icon>
|
|
896
|
-
</button>
|
|
897
|
-
</div>
|
|
898
|
-
</div>
|
|
899
|
-
`;
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
render() {
|
|
903
|
-
const alert = this.alertContent;
|
|
904
|
-
|
|
905
|
-
return html`
|
|
906
|
-
<div class="body">
|
|
907
|
-
${alert
|
|
908
|
-
? html`<rel-alert
|
|
909
|
-
type=${alert.type}
|
|
910
|
-
appearance="subtle"
|
|
911
|
-
message1=${alert.message1}
|
|
912
|
-
message2=${alert.message2}
|
|
913
|
-
></rel-alert>`
|
|
914
|
-
: nothing}
|
|
915
|
-
|
|
916
|
-
<div
|
|
917
|
-
class="dropzone ${this.dragOver ? "dragover" : ""}"
|
|
918
|
-
@click=${this.openPicker}
|
|
919
|
-
@dragover=${this.onDragOver}
|
|
920
|
-
@dragleave=${this.onDragLeave}
|
|
921
|
-
@drop=${this.onDrop}
|
|
922
|
-
>
|
|
923
|
-
<span class="icon-badge">
|
|
924
|
-
<rel-icon name="upload" size="md"></rel-icon>
|
|
925
|
-
</span>
|
|
926
|
-
|
|
927
|
-
<div class="dz-copy">
|
|
928
|
-
<p class="dz-title">${this.dropzoneTitle}</p>
|
|
929
|
-
<p class="dz-hint">${this.hintText}</p>
|
|
930
|
-
</div>
|
|
931
|
-
|
|
932
|
-
<button
|
|
933
|
-
class="browse"
|
|
934
|
-
type="button"
|
|
935
|
-
?disabled=${this.disabled}
|
|
936
|
-
@click=${(e: Event) => {
|
|
937
|
-
e.stopPropagation();
|
|
938
|
-
this.openPicker();
|
|
939
|
-
}}
|
|
940
|
-
>
|
|
941
|
-
${this.browseLabel}
|
|
942
|
-
</button>
|
|
943
|
-
</div>
|
|
944
|
-
|
|
945
|
-
${this.files.length
|
|
946
|
-
? html`<div class="file-list">
|
|
947
|
-
<p class="list-heading">${this.listHeading}</p>
|
|
948
|
-
<div class="rows" role="list">${this.files.map((f) => this.renderRow(f))}</div>
|
|
949
|
-
</div>`
|
|
950
|
-
: nothing}
|
|
951
|
-
|
|
952
|
-
<input
|
|
953
|
-
class="file-input"
|
|
954
|
-
type="file"
|
|
955
|
-
?multiple=${this.multiple}
|
|
956
|
-
accept=${this.accept || nothing}
|
|
957
|
-
@change=${this.onInputChange}
|
|
958
|
-
/>
|
|
959
|
-
</div>
|
|
960
|
-
`;
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
declare global {
|
|
965
|
-
interface HTMLElementTagNameMap {
|
|
966
|
-
"rel-upload": RelUpload;
|
|
967
|
-
}
|
|
968
|
-
}
|