@reliability-design/web 1.4.6 → 1.4.8

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 (148) hide show
  1. package/CHANGELOG.md +246 -49
  2. package/dist/components/rel-accordion/README.md +133 -132
  3. package/dist/components/rel-alert/README.md +145 -141
  4. package/dist/components/rel-autocomplete/README.md +172 -61
  5. package/dist/components/rel-avatar/README.md +118 -130
  6. package/dist/components/rel-badge/README.md +97 -93
  7. package/dist/components/rel-breadcrumb/README.md +96 -102
  8. package/dist/components/rel-button/README.md +190 -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 +186 -166
  13. package/dist/components/rel-checkbox/README.md +96 -75
  14. package/dist/components/rel-chip/README.md +138 -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 +88 -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-layout/README.md +34 -0
  21. package/dist/components/rel-menu/README.md +180 -151
  22. package/dist/components/rel-menubar/README.md +218 -329
  23. package/dist/components/rel-otp/README.md +171 -440
  24. package/dist/components/rel-pagination/README.md +100 -424
  25. package/dist/components/rel-popover/README.md +203 -568
  26. package/dist/components/rel-progress/README.md +88 -110
  27. package/dist/components/rel-radio/README.md +131 -138
  28. package/dist/components/rel-range-picker/README.md +165 -0
  29. package/dist/components/rel-resizable/README.md +199 -140
  30. package/dist/components/rel-segmented-button/README.md +156 -141
  31. package/dist/components/rel-select/README.md +156 -93
  32. package/dist/components/rel-sheet/README.md +168 -224
  33. package/dist/components/rel-skeleton/README.md +104 -0
  34. package/dist/components/rel-slider/README.md +131 -135
  35. package/dist/components/rel-snackbar/README.md +168 -134
  36. package/dist/components/rel-stepper/README.md +162 -160
  37. package/dist/components/rel-switch/README.md +117 -102
  38. package/dist/components/rel-tab/README.md +173 -163
  39. package/dist/components/rel-textfield/README.md +176 -69
  40. package/dist/components/rel-time-picker/README.md +122 -157
  41. package/dist/components/rel-toast/README.md +165 -148
  42. package/dist/components/rel-tooltip/README.md +119 -111
  43. package/dist/components/rel-upload/README.md +220 -0
  44. package/dist/index.d.mts +1726 -447
  45. package/dist/index.d.ts +1726 -447
  46. package/dist/index.js +13715 -10870
  47. package/dist/index.js.map +1 -1
  48. package/dist/index.mjs +14018 -11163
  49. package/dist/index.mjs.map +1 -1
  50. package/dist/styles/focus-ring.ts +84 -0
  51. package/dist/styles/vars.css +892 -866
  52. package/dist/{vars-XNMTSRVN.css → vars-DXX6QOAG.css} +892 -866
  53. package/package.json +3 -2
  54. package/dist/components/rel-accordion/rel-accordion.ts +0 -336
  55. package/dist/components/rel-alert/rel-alert.ts +0 -305
  56. package/dist/components/rel-autocomplete/rel-autocomplete.ts +0 -720
  57. package/dist/components/rel-avatar/rel-avatar.ts +0 -142
  58. package/dist/components/rel-badge/rel-badge.ts +0 -175
  59. package/dist/components/rel-breadcrumb/rel-breadcrumb.ts +0 -106
  60. package/dist/components/rel-button/rel-button.ts +0 -560
  61. package/dist/components/rel-calendar/index.ts +0 -1
  62. package/dist/components/rel-calendar/rel-calendar.ts +0 -565
  63. package/dist/components/rel-card/rel-card.ts +0 -88
  64. package/dist/components/rel-cardmedia/rel-cardmedia.ts +0 -88
  65. package/dist/components/rel-carousel/index.ts +0 -1
  66. package/dist/components/rel-carousel/rel-carousel.ts +0 -723
  67. package/dist/components/rel-chart/README.md +0 -286
  68. package/dist/components/rel-chart/index.ts +0 -1
  69. package/dist/components/rel-chart/rel-chart.ts +0 -754
  70. package/dist/components/rel-checkbox/rel-checkbox.ts +0 -382
  71. package/dist/components/rel-chip/rel-chip.ts +0 -483
  72. package/dist/components/rel-container/rel-container.ts +0 -95
  73. package/dist/components/rel-date-picker/date-format.test.ts +0 -43
  74. package/dist/components/rel-date-picker/date-format.ts +0 -47
  75. package/dist/components/rel-date-picker/date-parse.test.ts +0 -116
  76. package/dist/components/rel-date-picker/date-parse.ts +0 -178
  77. package/dist/components/rel-date-picker/index.ts +0 -1
  78. package/dist/components/rel-date-picker/rel-date-picker.ts +0 -1676
  79. package/dist/components/rel-divider/rel-divider.ts +0 -54
  80. package/dist/components/rel-drawer/index.ts +0 -1
  81. package/dist/components/rel-drawer/rel-drawer-footer.ts +0 -27
  82. package/dist/components/rel-drawer/rel-drawer.ts +0 -607
  83. package/dist/components/rel-kbd/rel-kbd.ts +0 -188
  84. package/dist/components/rel-layout/rel-layout.ts +0 -49
  85. package/dist/components/rel-menu/rel-menu.ts +0 -541
  86. package/dist/components/rel-menubar/index.ts +0 -2
  87. package/dist/components/rel-menubar/rel-menubar-menu.css +0 -3
  88. package/dist/components/rel-menubar/rel-menubar-menu.ts +0 -17
  89. package/dist/components/rel-menubar/rel-menubar.ts +0 -777
  90. package/dist/components/rel-nav-menu/README.md +0 -289
  91. package/dist/components/rel-nav-menu/index.ts +0 -3
  92. package/dist/components/rel-nav-menu/rel-nav-menu-item.ts +0 -243
  93. package/dist/components/rel-nav-menu/rel-nav-menu-panel.ts +0 -72
  94. package/dist/components/rel-nav-menu/rel-nav-menu.ts +0 -270
  95. package/dist/components/rel-navbar/README.md +0 -126
  96. package/dist/components/rel-navbar/rel-navbar.ts +0 -105
  97. package/dist/components/rel-otp/index.ts +0 -1
  98. package/dist/components/rel-otp/rel-otp.ts +0 -527
  99. package/dist/components/rel-pagination/index.ts +0 -1
  100. package/dist/components/rel-pagination/rel-pagination.ts +0 -522
  101. package/dist/components/rel-popover/index.ts +0 -2
  102. package/dist/components/rel-popover/rel-popover-content.ts +0 -81
  103. package/dist/components/rel-popover/rel-popover.ts +0 -475
  104. package/dist/components/rel-progress/rel-progress.ts +0 -234
  105. package/dist/components/rel-radio/rel-radio.ts +0 -481
  106. package/dist/components/rel-range-picker/rel-range-picker.ts +0 -1688
  107. package/dist/components/rel-resizable/index.ts +0 -3
  108. package/dist/components/rel-resizable/rel-resizable-group.ts +0 -405
  109. package/dist/components/rel-resizable/rel-resizable-handle.ts +0 -150
  110. package/dist/components/rel-resizable/rel-resizable-panel.ts +0 -58
  111. package/dist/components/rel-segmented-button/rel-segmented-button.ts +0 -217
  112. package/dist/components/rel-select/rel-select.ts +0 -1416
  113. package/dist/components/rel-sheet/rel-sheet.ts +0 -610
  114. package/dist/components/rel-sidebar/README.md +0 -209
  115. package/dist/components/rel-sidebar/rel-sidebar.ts +0 -1191
  116. package/dist/components/rel-skeleton/rel-skeleton.ts +0 -143
  117. package/dist/components/rel-slider/rel-slider.ts +0 -673
  118. package/dist/components/rel-snackbar/rel-snackbar.ts +0 -329
  119. package/dist/components/rel-stepper/rel-stepper.ts +0 -567
  120. package/dist/components/rel-switch/rel-switch.ts +0 -199
  121. package/dist/components/rel-tab/rel-tab.ts +0 -264
  122. package/dist/components/rel-table/README.md +0 -178
  123. package/dist/components/rel-table/rel-table-body.ts +0 -22
  124. package/dist/components/rel-table/rel-table-caption.ts +0 -30
  125. package/dist/components/rel-table/rel-table-cell.ts +0 -43
  126. package/dist/components/rel-table/rel-table-footer.ts +0 -24
  127. package/dist/components/rel-table/rel-table-head.ts +0 -47
  128. package/dist/components/rel-table/rel-table-header.ts +0 -22
  129. package/dist/components/rel-table/rel-table-row.ts +0 -37
  130. package/dist/components/rel-table/rel-table.ts +0 -62
  131. package/dist/components/rel-textfield/rel-textfield.ts +0 -1054
  132. package/dist/components/rel-time-picker/rel-time-picker.ts +0 -1482
  133. package/dist/components/rel-time-picker/time-parse.test.ts +0 -67
  134. package/dist/components/rel-time-picker/time-parse.ts +0 -85
  135. package/dist/components/rel-toast/rel-toast.ts +0 -206
  136. package/dist/components/rel-tooltip/rel-tooltip.ts +0 -193
  137. package/dist/components/rel-topbar/rel-topbar-actions.ts +0 -27
  138. package/dist/components/rel-topbar/rel-topbar-brand.ts +0 -54
  139. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +0 -69
  140. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +0 -31
  141. package/dist/components/rel-topbar/rel-topbar-item.ts +0 -324
  142. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +0 -27
  143. package/dist/components/rel-topbar/rel-topbar-profile.ts +0 -243
  144. package/dist/components/rel-topbar/rel-topbar-utilities.ts +0 -27
  145. package/dist/components/rel-topbar/rel-topbar.ts +0 -446
  146. package/dist/components/rel-upload/UPLOAD-DESIGN-CHANGES.md +0 -134
  147. package/dist/components/rel-upload/rel-upload-actions.ts +0 -163
  148. 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
- }