@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.
Files changed (146) 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 -138
  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 -111
  42. package/dist/components/rel-upload/README.md +220 -0
  43. package/dist/index.d.mts +1147 -340
  44. package/dist/index.d.ts +1147 -340
  45. package/dist/index.js +3451 -3163
  46. package/dist/index.js.map +1 -1
  47. package/dist/index.mjs +3416 -3118
  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 +3 -2
  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 -720
  55. package/dist/components/rel-avatar/rel-avatar.ts +0 -142
  56. package/dist/components/rel-badge/rel-badge.ts +0 -175
  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 -565
  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 -723
  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 -483
  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/date-parse.test.ts +0 -116
  74. package/dist/components/rel-date-picker/date-parse.ts +0 -178
  75. package/dist/components/rel-date-picker/index.ts +0 -1
  76. package/dist/components/rel-date-picker/rel-date-picker.ts +0 -1676
  77. package/dist/components/rel-divider/rel-divider.ts +0 -54
  78. package/dist/components/rel-drawer/index.ts +0 -1
  79. package/dist/components/rel-drawer/rel-drawer-footer.ts +0 -27
  80. package/dist/components/rel-drawer/rel-drawer.ts +0 -607
  81. package/dist/components/rel-kbd/rel-kbd.ts +0 -188
  82. package/dist/components/rel-layout/rel-layout.ts +0 -49
  83. package/dist/components/rel-menu/rel-menu.ts +0 -541
  84. package/dist/components/rel-menubar/index.ts +0 -2
  85. package/dist/components/rel-menubar/rel-menubar-menu.css +0 -3
  86. package/dist/components/rel-menubar/rel-menubar-menu.ts +0 -17
  87. package/dist/components/rel-menubar/rel-menubar.ts +0 -777
  88. package/dist/components/rel-nav-menu/README.md +0 -289
  89. package/dist/components/rel-nav-menu/index.ts +0 -3
  90. package/dist/components/rel-nav-menu/rel-nav-menu-item.ts +0 -243
  91. package/dist/components/rel-nav-menu/rel-nav-menu-panel.ts +0 -72
  92. package/dist/components/rel-nav-menu/rel-nav-menu.ts +0 -270
  93. package/dist/components/rel-navbar/README.md +0 -126
  94. package/dist/components/rel-navbar/rel-navbar.ts +0 -105
  95. package/dist/components/rel-otp/index.ts +0 -1
  96. package/dist/components/rel-otp/rel-otp.ts +0 -527
  97. package/dist/components/rel-pagination/index.ts +0 -1
  98. package/dist/components/rel-pagination/rel-pagination.ts +0 -522
  99. package/dist/components/rel-popover/index.ts +0 -2
  100. package/dist/components/rel-popover/rel-popover-content.ts +0 -81
  101. package/dist/components/rel-popover/rel-popover.ts +0 -475
  102. package/dist/components/rel-progress/rel-progress.ts +0 -234
  103. package/dist/components/rel-radio/rel-radio.ts +0 -481
  104. package/dist/components/rel-range-picker/rel-range-picker.ts +0 -1688
  105. package/dist/components/rel-resizable/index.ts +0 -3
  106. package/dist/components/rel-resizable/rel-resizable-group.ts +0 -405
  107. package/dist/components/rel-resizable/rel-resizable-handle.ts +0 -150
  108. package/dist/components/rel-resizable/rel-resizable-panel.ts +0 -58
  109. package/dist/components/rel-segmented-button/rel-segmented-button.ts +0 -217
  110. package/dist/components/rel-select/rel-select.ts +0 -1416
  111. package/dist/components/rel-sheet/rel-sheet.ts +0 -610
  112. package/dist/components/rel-sidebar/README.md +0 -209
  113. package/dist/components/rel-sidebar/rel-sidebar.ts +0 -1191
  114. package/dist/components/rel-skeleton/rel-skeleton.ts +0 -143
  115. package/dist/components/rel-slider/rel-slider.ts +0 -673
  116. package/dist/components/rel-snackbar/rel-snackbar.ts +0 -329
  117. package/dist/components/rel-stepper/rel-stepper.ts +0 -567
  118. package/dist/components/rel-switch/rel-switch.ts +0 -199
  119. package/dist/components/rel-tab/rel-tab.ts +0 -264
  120. package/dist/components/rel-table/README.md +0 -178
  121. package/dist/components/rel-table/rel-table-body.ts +0 -22
  122. package/dist/components/rel-table/rel-table-caption.ts +0 -30
  123. package/dist/components/rel-table/rel-table-cell.ts +0 -43
  124. package/dist/components/rel-table/rel-table-footer.ts +0 -24
  125. package/dist/components/rel-table/rel-table-head.ts +0 -47
  126. package/dist/components/rel-table/rel-table-header.ts +0 -22
  127. package/dist/components/rel-table/rel-table-row.ts +0 -37
  128. package/dist/components/rel-table/rel-table.ts +0 -62
  129. package/dist/components/rel-textfield/rel-textfield.ts +0 -1054
  130. package/dist/components/rel-time-picker/rel-time-picker.ts +0 -1482
  131. package/dist/components/rel-time-picker/time-parse.test.ts +0 -67
  132. package/dist/components/rel-time-picker/time-parse.ts +0 -85
  133. package/dist/components/rel-toast/rel-toast.ts +0 -206
  134. package/dist/components/rel-tooltip/rel-tooltip.ts +0 -193
  135. package/dist/components/rel-topbar/rel-topbar-actions.ts +0 -27
  136. package/dist/components/rel-topbar/rel-topbar-brand.ts +0 -54
  137. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +0 -69
  138. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +0 -31
  139. package/dist/components/rel-topbar/rel-topbar-item.ts +0 -324
  140. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +0 -27
  141. package/dist/components/rel-topbar/rel-topbar-profile.ts +0 -243
  142. package/dist/components/rel-topbar/rel-topbar-utilities.ts +0 -27
  143. package/dist/components/rel-topbar/rel-topbar.ts +0 -446
  144. package/dist/components/rel-upload/UPLOAD-DESIGN-CHANGES.md +0 -134
  145. package/dist/components/rel-upload/rel-upload-actions.ts +0 -163
  146. 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
- }