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