@arbor-education/design-system.components 0.25.3 → 0.25.5

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 (110) hide show
  1. package/.gather/skills/write-stories/README.md +1 -1
  2. package/.gather/skills/write-stories/SKILL.md +1 -1
  3. package/.gather/skills/write-stories/meta.md +1 -1
  4. package/CHANGELOG.md +16 -0
  5. package/component-library.md +26 -1
  6. package/dist/components/breadcrumbs/BreadcrumbCopyButton.d.ts +3 -0
  7. package/dist/components/breadcrumbs/BreadcrumbCopyButton.d.ts.map +1 -0
  8. package/dist/components/breadcrumbs/BreadcrumbCopyButton.js +13 -0
  9. package/dist/components/breadcrumbs/BreadcrumbCopyButton.js.map +1 -0
  10. package/dist/components/breadcrumbs/BreadcrumbDropdown.d.ts +3 -0
  11. package/dist/components/breadcrumbs/BreadcrumbDropdown.d.ts.map +1 -0
  12. package/dist/components/breadcrumbs/BreadcrumbDropdown.js +8 -0
  13. package/dist/components/breadcrumbs/BreadcrumbDropdown.js.map +1 -0
  14. package/dist/components/breadcrumbs/BreadcrumbEllipsis.d.ts +3 -0
  15. package/dist/components/breadcrumbs/BreadcrumbEllipsis.d.ts.map +1 -0
  16. package/dist/components/breadcrumbs/BreadcrumbEllipsis.js +7 -0
  17. package/dist/components/breadcrumbs/BreadcrumbEllipsis.js.map +1 -0
  18. package/dist/components/breadcrumbs/BreadcrumbItemSeparator.d.ts +2 -0
  19. package/dist/components/breadcrumbs/BreadcrumbItemSeparator.d.ts.map +1 -0
  20. package/dist/components/breadcrumbs/BreadcrumbItemSeparator.js +5 -0
  21. package/dist/components/breadcrumbs/BreadcrumbItemSeparator.js.map +1 -0
  22. package/dist/components/breadcrumbs/BreadcrumbLink.d.ts +6 -0
  23. package/dist/components/breadcrumbs/BreadcrumbLink.d.ts.map +1 -0
  24. package/dist/components/breadcrumbs/BreadcrumbLink.js +9 -0
  25. package/dist/components/breadcrumbs/BreadcrumbLink.js.map +1 -0
  26. package/dist/components/breadcrumbs/Breadcrumbs.d.ts +21 -0
  27. package/dist/components/breadcrumbs/Breadcrumbs.d.ts.map +1 -0
  28. package/dist/components/breadcrumbs/Breadcrumbs.js +51 -0
  29. package/dist/components/breadcrumbs/Breadcrumbs.js.map +1 -0
  30. package/dist/components/breadcrumbs/Breadcrumbs.stories.d.ts +81 -0
  31. package/dist/components/breadcrumbs/Breadcrumbs.stories.d.ts.map +1 -0
  32. package/dist/components/breadcrumbs/Breadcrumbs.stories.js +681 -0
  33. package/dist/components/breadcrumbs/Breadcrumbs.stories.js.map +1 -0
  34. package/dist/components/breadcrumbs/Breadcrumbs.test.d.ts +2 -0
  35. package/dist/components/breadcrumbs/Breadcrumbs.test.d.ts.map +1 -0
  36. package/dist/components/breadcrumbs/Breadcrumbs.test.js +160 -0
  37. package/dist/components/breadcrumbs/Breadcrumbs.test.js.map +1 -0
  38. package/dist/components/breadcrumbs/types.d.ts +27 -0
  39. package/dist/components/breadcrumbs/types.d.ts.map +1 -0
  40. package/dist/components/breadcrumbs/types.js +2 -0
  41. package/dist/components/breadcrumbs/types.js.map +1 -0
  42. package/dist/components/fileUpload/FileUpload.d.ts +21 -0
  43. package/dist/components/fileUpload/FileUpload.d.ts.map +1 -0
  44. package/dist/components/fileUpload/FileUpload.js +23 -0
  45. package/dist/components/fileUpload/FileUpload.js.map +1 -0
  46. package/dist/components/fileUpload/FileUpload.stories.d.ts +71 -0
  47. package/dist/components/fileUpload/FileUpload.stories.d.ts.map +1 -0
  48. package/dist/components/fileUpload/FileUpload.stories.js +736 -0
  49. package/dist/components/fileUpload/FileUpload.stories.js.map +1 -0
  50. package/dist/components/fileUpload/FileUpload.test.d.ts +2 -0
  51. package/dist/components/fileUpload/FileUpload.test.d.ts.map +1 -0
  52. package/dist/components/fileUpload/FileUpload.test.js +99 -0
  53. package/dist/components/fileUpload/FileUpload.test.js.map +1 -0
  54. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js +1 -1
  55. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.js.map +1 -1
  56. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.stories.d.ts.map +1 -1
  57. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.stories.js +14 -15
  58. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.stories.js.map +1 -1
  59. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.test.js +11 -0
  60. package/dist/components/formField/inputs/selectDropdown/SelectDropdown.test.js.map +1 -1
  61. package/dist/components/icon/allowedIcons.d.ts +1 -0
  62. package/dist/components/icon/allowedIcons.d.ts.map +1 -1
  63. package/dist/components/icon/allowedIcons.js +2 -1
  64. package/dist/components/icon/allowedIcons.js.map +1 -1
  65. package/dist/components/skeleton/Skeleton.d.ts +10 -0
  66. package/dist/components/skeleton/Skeleton.d.ts.map +1 -0
  67. package/dist/components/skeleton/Skeleton.js +9 -0
  68. package/dist/components/skeleton/Skeleton.js.map +1 -0
  69. package/dist/components/skeleton/Skeleton.stories.d.ts +99 -0
  70. package/dist/components/skeleton/Skeleton.stories.d.ts.map +1 -0
  71. package/dist/components/skeleton/Skeleton.stories.js +539 -0
  72. package/dist/components/skeleton/Skeleton.stories.js.map +1 -0
  73. package/dist/components/skeleton/Skeleton.test.d.ts +2 -0
  74. package/dist/components/skeleton/Skeleton.test.d.ts.map +1 -0
  75. package/dist/components/skeleton/Skeleton.test.js +49 -0
  76. package/dist/components/skeleton/Skeleton.test.js.map +1 -0
  77. package/dist/index.css +225 -0
  78. package/dist/index.css.map +1 -1
  79. package/dist/index.d.ts +3 -0
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +3 -0
  82. package/dist/index.js.map +1 -1
  83. package/package.json +2 -1
  84. package/src/components/breadcrumbs/BreadcrumbCopyButton.tsx +27 -0
  85. package/src/components/breadcrumbs/BreadcrumbDropdown.tsx +27 -0
  86. package/src/components/breadcrumbs/BreadcrumbEllipsis.tsx +16 -0
  87. package/src/components/breadcrumbs/BreadcrumbItemSeparator.tsx +3 -0
  88. package/src/components/breadcrumbs/BreadcrumbLink.tsx +29 -0
  89. package/src/components/breadcrumbs/Breadcrumbs.stories.tsx +870 -0
  90. package/src/components/breadcrumbs/Breadcrumbs.test.tsx +185 -0
  91. package/src/components/breadcrumbs/Breadcrumbs.tsx +92 -0
  92. package/src/components/breadcrumbs/breadcrumbs.scss +158 -0
  93. package/src/components/breadcrumbs/types.ts +31 -0
  94. package/src/components/button/button.scss +2 -0
  95. package/src/components/dropdown/dropdown.scss +1 -0
  96. package/src/components/fileUpload/FileUpload.stories.tsx +920 -0
  97. package/src/components/fileUpload/FileUpload.test.tsx +172 -0
  98. package/src/components/fileUpload/FileUpload.tsx +144 -0
  99. package/src/components/fileUpload/fileUpload.scss +74 -0
  100. package/src/components/formField/inputs/selectDropdown/SelectDropdown.stories.tsx +15 -16
  101. package/src/components/formField/inputs/selectDropdown/SelectDropdown.test.tsx +19 -0
  102. package/src/components/formField/inputs/selectDropdown/SelectDropdown.tsx +1 -1
  103. package/src/components/icon/allowedIcons.tsx +2 -0
  104. package/src/components/progress/progress.scss +1 -0
  105. package/src/components/skeleton/Skeleton.stories.tsx +748 -0
  106. package/src/components/skeleton/Skeleton.test.tsx +55 -0
  107. package/src/components/skeleton/Skeleton.tsx +36 -0
  108. package/src/components/skeleton/skeleton.scss +23 -0
  109. package/src/index.scss +3 -0
  110. package/src/index.ts +3 -0
@@ -0,0 +1,920 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import {
3
+ Controls,
4
+ Heading as DocHeading,
5
+ Markdown,
6
+ Primary as DocPrimary,
7
+ Stories,
8
+ Subtitle,
9
+ Title,
10
+ } from '@storybook/addon-docs/blocks';
11
+ import { fn } from 'storybook/test';
12
+ import { useEffect, useRef, useState } from 'react';
13
+ import { Button } from 'Components/button/Button';
14
+ import { FileUpload } from './FileUpload.js';
15
+ import type { FileUploadProps } from './FileUpload.js';
16
+
17
+ // ─── Content strings ──────────────────────────────────────────────────────────
18
+
19
+ const DESCRIPTION_INTRO = `
20
+ The \`FileUpload\` component provides a drag-and-drop file upload area combined
21
+ with a managed list of uploaded (or uploading) files. It is built on top of
22
+ [react-dropzone](https://react-dropzone.js.org/) and handles the visual
23
+ presentation of the upload state machine — idle dropzone, in-progress upload
24
+ with a progress bar, upload failure, and completed file with download/delete
25
+ actions.
26
+
27
+ Use it wherever school staff need to attach documents to records: term reports,
28
+ policy PDFs, student photographs, spreadsheet imports, and so on.
29
+ `.trim();
30
+
31
+ const USAGE_GUIDANCE = `
32
+ ### When to use
33
+
34
+ - Uploading one or more files as part of a form flow (e.g. attaching a
35
+ behaviour letter to a student record).
36
+ - Displaying the progress of an ongoing upload alongside files that have
37
+ already completed.
38
+ - Giving staff a clear way to remove or re-download files they have attached.
39
+
40
+ ---
41
+
42
+ ### When NOT to use
43
+
44
+ | Situation | Prefer instead |
45
+ |-----------|---------------|
46
+ | You need a plain \`<input type="file">\` without drag-and-drop | A \`FormField\` wrapping a native file input |
47
+ | The user can only ever upload a single file and progress feedback is not needed | A simple \`<input type="file">\` or a \`Button\` that opens a file picker |
48
+ | The upload is hidden from the user (background sync) | No UI component needed — handle it silently in a service layer |
49
+ `.trim();
50
+
51
+ const DEVELOPER_NOTES = `
52
+ ### Critical usage patterns
53
+
54
+ The component is **not stateful by itself** — it is a controlled component. You
55
+ are responsible for:
56
+
57
+ 1. Calling your upload API inside \`uploadFiles\` and pushing the new
58
+ file (with \`progressPercentage: 0\`) into your own state.
59
+ 2. Updating \`progressPercentage\` as the upload progresses (e.g. via an
60
+ \`XMLHttpRequest\` progress event or polling).
61
+ 3. Setting \`progressPercentage: 100\` (or removing the property) when the
62
+ upload completes — the progress bar is hidden automatically at 100.
63
+ 4. Setting \`hasUploadFailed: true\` if the upload errors — this replaces the
64
+ file-type/size metadata with a red "Upload failed" message and turns the
65
+ progress bar red.
66
+
67
+ The progress bar is rendered **only** when \`progressPercentage\` is a number
68
+ and is strictly less than 100. At exactly 100 the bar disappears.
69
+
70
+ ---
71
+
72
+ ### Accessibility
73
+
74
+ The dropzone area is rendered by \`react-dropzone\` and is fully keyboard
75
+ accessible: it receives focus, responds to \`Enter\` and \`Space\` to open the
76
+ file picker, and announces itself to screen readers. The file list uses
77
+ semantic \`<span>\` elements for layout; make sure the \`fileName\` values
78
+ you pass are human-readable rather than GUIDs.
79
+
80
+ ---
81
+
82
+ ### TypeScript types
83
+
84
+ The \`UploadedFile\` type is **not exported** from the component module. Until
85
+ it is exported you can inline the shape or declare your own local type:
86
+
87
+ \`\`\`tsx
88
+ type UploadedFile = {
89
+ fileName: string;
90
+ icon?: string; // any IconName from the design system Icon component
91
+ fileType: string; // e.g. 'PDF', 'XLSX', 'DOCX'
92
+ fileSize: string; // human-readable, e.g. '1.2 MB'
93
+ onDownload?: () => void;
94
+ onDelete: () => void;
95
+ progressPercentage?: number; // 0–100; hidden at exactly 100
96
+ hasUploadFailed?: boolean;
97
+ };
98
+ \`\`\`
99
+ `.trim();
100
+
101
+ const RELATED_COMPONENTS = `
102
+ ## Related components
103
+
104
+ - [Progress](?path=/docs/components-progress--docs) — the progress bar used
105
+ inside each file row to indicate upload completion.
106
+ - [Button](?path=/docs/components-button--docs) — the "click to upload",
107
+ download, and delete buttons inside the component.
108
+ - [Card](?path=/docs/components-card--docs) — the visual wrapper for both the
109
+ dropzone area and the file list.
110
+ - [FormField](?path=/docs/components-formfield--docs) — use when you need a
111
+ labelled file input without drag-and-drop behaviour.
112
+ - [Icon](?path=/docs/components-icon--docs) — all file-type and action icons
113
+ rendered inside the component.
114
+ `.trim();
115
+
116
+ const PROPS_INTRO = 'The preview below is wired to the **Controls** panel — tweak any prop to see the story update in real time.';
117
+
118
+ // ─── Docs page ────────────────────────────────────────────────────────────────
119
+
120
+ function FileUploadDocsPage() {
121
+ return (
122
+ <>
123
+ <Title />
124
+ <Subtitle />
125
+ <Markdown>{DESCRIPTION_INTRO}</Markdown>
126
+ <DocHeading>Interactive example</DocHeading>
127
+ <Markdown>{PROPS_INTRO}</Markdown>
128
+ <DocPrimary />
129
+ <Controls />
130
+ <DocHeading>Usage guidance</DocHeading>
131
+ <Markdown>{USAGE_GUIDANCE}</Markdown>
132
+ <DocHeading>Developer notes</DocHeading>
133
+ <Markdown>{DEVELOPER_NOTES}</Markdown>
134
+ <DocHeading>Examples</DocHeading>
135
+ <Stories title="" />
136
+ <Markdown>{RELATED_COMPONENTS}</Markdown>
137
+ </>
138
+ );
139
+ }
140
+
141
+ // ─── Meta ─────────────────────────────────────────────────────────────────────
142
+
143
+ const meta = {
144
+ title: 'Components/FileUpload',
145
+ component: FileUpload,
146
+ tags: ['autodocs'],
147
+ parameters: {
148
+ layout: 'padded',
149
+ docs: {
150
+ page: FileUploadDocsPage,
151
+ },
152
+ },
153
+ argTypes: {
154
+ uploadedFiles: {
155
+ description:
156
+ 'Array of file objects to display in the file list. Each object describes '
157
+ + 'a file that has been selected, is uploading, has completed, or has failed. '
158
+ + 'See the **TypeScript types** section for the full shape.',
159
+ control: false,
160
+ table: {
161
+ type: { summary: 'UploadedFile[]' },
162
+ },
163
+ },
164
+ uploadFiles: {
165
+ description:
166
+ 'Callback fired when the user drops files onto the dropzone or selects them '
167
+ + 'via the file picker. Receives the native `File[]` array. You are responsible '
168
+ + 'for starting the upload and adding the files to `uploadedFiles` state.',
169
+ control: false,
170
+ table: {
171
+ type: { summary: '(files: File[]) => void' },
172
+ },
173
+ },
174
+ dropzoneContent: {
175
+ description:
176
+ 'Custom content rendered inside the dropzone area. Defaults to a '
177
+ + '"Drag and drop, or click to upload" paragraph. Pass a `ReactNode` to '
178
+ + 'replace this with your own copy or layout.',
179
+ control: false,
180
+ table: {
181
+ type: { summary: 'ReactNode' },
182
+ defaultValue: {
183
+ summary: '<p>Drag and drop, or <Button variant="text-link">click to upload</Button></p>',
184
+ },
185
+ },
186
+ },
187
+ className: {
188
+ description: 'Additional CSS class name(s) applied to the root element.',
189
+ control: 'text',
190
+ table: {
191
+ type: { summary: 'string' },
192
+ },
193
+ },
194
+ },
195
+ args: {
196
+ uploadedFiles: [],
197
+ uploadFiles: fn(),
198
+ },
199
+ } satisfies Meta<typeof FileUpload>;
200
+
201
+ export default meta;
202
+ type Story = StoryObj<typeof meta>;
203
+
204
+ // ─── Helper ───────────────────────────────────────────────────────────────────
205
+
206
+ const withDescription = (story: Story, description: string): Story => ({
207
+ ...story,
208
+ parameters: {
209
+ ...story.parameters,
210
+ docs: {
211
+ ...story.parameters?.docs,
212
+ description: {
213
+ story: description,
214
+ },
215
+ },
216
+ },
217
+ });
218
+
219
+ // ─── Shared helpers ───────────────────────────────────────────────────────────
220
+
221
+ const noop = () => {};
222
+
223
+ const completedFiles: FileUploadProps['uploadedFiles'] = [
224
+ {
225
+ fileName: 'spring-term-report-2024.pdf',
226
+ icon: 'file',
227
+ fileType: 'PDF',
228
+ fileSize: '1.2 MB',
229
+ onDownload: noop,
230
+ onDelete: noop,
231
+ },
232
+ {
233
+ fileName: 'attendance-summary-jan.xlsx',
234
+ icon: 'file',
235
+ fileType: 'XLSX',
236
+ fileSize: '348 KB',
237
+ onDownload: noop,
238
+ onDelete: noop,
239
+ },
240
+ {
241
+ fileName: 'behaviour-policy-2024.docx',
242
+ icon: 'file',
243
+ fileType: 'DOCX',
244
+ fileSize: '89 KB',
245
+ onDownload: noop,
246
+ onDelete: noop,
247
+ },
248
+ ];
249
+
250
+ // ─── Stories ──────────────────────────────────────────────────────────────────
251
+
252
+ export const Default: Story = withDescription(
253
+ {
254
+ args: {
255
+ uploadedFiles: [],
256
+ uploadFiles: fn(),
257
+ },
258
+ render: args => (
259
+ <div style={{ maxWidth: '560px', padding: 'var(--spacing-large)' }}>
260
+ <FileUpload {...args} />
261
+ </div>
262
+ ),
263
+ },
264
+ 'The default state shows the dropzone area with no files attached. '
265
+ + 'Drop files onto the area or click it to open the native file picker.',
266
+ );
267
+
268
+ // ─────────────────────────────────────────────────────────────────────────────
269
+
270
+ export const WithUploadedFiles: Story = withDescription(
271
+ {
272
+ render: () => (
273
+ <div style={{ maxWidth: '560px', padding: 'var(--spacing-large)' }}>
274
+ <FileUpload uploadedFiles={completedFiles} uploadFiles={noop} />
275
+ </div>
276
+ ),
277
+ parameters: {
278
+ controls: { disable: true },
279
+ docs: {
280
+ source: {
281
+ language: 'tsx',
282
+ code: `
283
+ import { FileUpload } from '@arbor-education/design-system.components';
284
+
285
+ function UploadWithFilesExample() {
286
+ return (
287
+ <FileUpload
288
+ uploadedFiles={[
289
+ {
290
+ fileName: 'spring-term-report-2024.pdf',
291
+ fileType: 'PDF',
292
+ fileSize: '1.2 MB',
293
+ onDownload: () => { /* download logic */ },
294
+ onDelete: () => { /* remove from state */ },
295
+ },
296
+ {
297
+ fileName: 'attendance-summary-jan.xlsx',
298
+ fileType: 'XLSX',
299
+ fileSize: '348 KB',
300
+ onDownload: () => { /* download logic */ },
301
+ onDelete: () => { /* remove from state */ },
302
+ },
303
+ {
304
+ fileName: 'behaviour-policy-2024.docx',
305
+ fileType: 'DOCX',
306
+ fileSize: '89 KB',
307
+ onDownload: () => { /* download logic */ },
308
+ onDelete: () => { /* remove from state */ },
309
+ },
310
+ ]}
311
+ uploadFiles={(files) => { /* start upload */ }}
312
+ />
313
+ );
314
+ }
315
+ export default UploadWithFilesExample;
316
+ `.trim(),
317
+ },
318
+ },
319
+ },
320
+ },
321
+ 'Multiple completed files with both download and delete actions. '
322
+ + 'Each file shows its type and size. The download button only appears when '
323
+ + '`onDownload` is provided.',
324
+ );
325
+
326
+ // ─────────────────────────────────────────────────────────────────────────────
327
+
328
+ export const SingleFile: Story = withDescription(
329
+ {
330
+ render: () => (
331
+ <div style={{ maxWidth: '560px', padding: 'var(--spacing-large)' }}>
332
+ <FileUpload
333
+ uploadedFiles={[{
334
+ fileName: 'spring-term-report-2024.pdf',
335
+ fileType: 'PDF',
336
+ fileSize: '1.2 MB',
337
+ onDownload: noop,
338
+ onDelete: noop,
339
+ }]}
340
+ uploadFiles={noop}
341
+ />
342
+ </div>
343
+ ),
344
+ parameters: {
345
+ controls: { disable: true },
346
+ docs: {
347
+ source: {
348
+ language: 'tsx',
349
+ code: `
350
+ import { FileUpload } from '@arbor-education/design-system.components';
351
+
352
+ function SingleFileExample() {
353
+ return (
354
+ <FileUpload
355
+ uploadedFiles={[
356
+ {
357
+ fileName: 'spring-term-report-2024.pdf',
358
+ fileType: 'PDF',
359
+ fileSize: '1.2 MB',
360
+ onDownload: () => { /* download logic */ },
361
+ onDelete: () => { /* remove from state */ },
362
+ },
363
+ ]}
364
+ uploadFiles={(files) => { /* start upload */ }}
365
+ />
366
+ );
367
+ }
368
+ export default SingleFileExample;
369
+ `.trim(),
370
+ },
371
+ },
372
+ },
373
+ },
374
+ 'A single completed file. When there is only one file in the list the '
375
+ + '`Separator` between rows is not rendered.',
376
+ );
377
+
378
+ // ─────────────────────────────────────────────────────────────────────────────
379
+
380
+ export const WithoutDownloadButton: Story = withDescription(
381
+ {
382
+ render: () => (
383
+ <div style={{ maxWidth: '560px', padding: 'var(--spacing-large)' }}>
384
+ <FileUpload
385
+ uploadedFiles={[
386
+ {
387
+ fileName: 'spring-term-report-2024.pdf',
388
+ fileType: 'PDF',
389
+ fileSize: '1.2 MB',
390
+ onDelete: noop,
391
+ },
392
+ {
393
+ fileName: 'attendance-summary-jan.xlsx',
394
+ fileType: 'XLSX',
395
+ fileSize: '348 KB',
396
+ onDelete: noop,
397
+ },
398
+ ]}
399
+ uploadFiles={noop}
400
+ />
401
+ </div>
402
+ ),
403
+ parameters: {
404
+ controls: { disable: true },
405
+ docs: {
406
+ source: {
407
+ language: 'tsx',
408
+ code: `
409
+ import { FileUpload } from '@arbor-education/design-system.components';
410
+
411
+ function NoDownloadExample() {
412
+ return (
413
+ <FileUpload
414
+ uploadedFiles={[
415
+ {
416
+ fileName: 'spring-term-report-2024.pdf',
417
+ fileType: 'PDF',
418
+ fileSize: '1.2 MB',
419
+ // onDownload omitted — download button will not render
420
+ onDelete: () => { /* remove from state */ },
421
+ },
422
+ {
423
+ fileName: 'attendance-summary-jan.xlsx',
424
+ fileType: 'XLSX',
425
+ fileSize: '348 KB',
426
+ onDelete: () => { /* remove from state */ },
427
+ },
428
+ ]}
429
+ uploadFiles={(files) => { /* start upload */ }}
430
+ />
431
+ );
432
+ }
433
+ export default NoDownloadExample;
434
+ `.trim(),
435
+ },
436
+ },
437
+ },
438
+ },
439
+ 'When `onDownload` is omitted from a file object the download button is not '
440
+ + 'rendered for that row. Only the delete (trash) button appears. Use this when '
441
+ + 'the uploaded file should not be retrievable after submission.',
442
+ );
443
+
444
+ // ─────────────────────────────────────────────────────────────────────────────
445
+
446
+ export const UploadInProgress: Story = withDescription(
447
+ {
448
+ render: () => (
449
+ <div style={{ maxWidth: '560px', padding: 'var(--spacing-large)' }}>
450
+ <FileUpload
451
+ uploadedFiles={[
452
+ {
453
+ fileName: 'spring-term-report-2024.pdf',
454
+ fileType: 'PDF',
455
+ fileSize: '1.2 MB',
456
+ onDelete: noop,
457
+ progressPercentage: 25,
458
+ },
459
+ {
460
+ fileName: 'attendance-summary-jan.xlsx',
461
+ fileType: 'XLSX',
462
+ fileSize: '348 KB',
463
+ onDelete: noop,
464
+ progressPercentage: 72,
465
+ },
466
+ {
467
+ fileName: 'behaviour-policy-2024.docx',
468
+ fileType: 'DOCX',
469
+ fileSize: '89 KB',
470
+ onDownload: noop,
471
+ onDelete: noop,
472
+ },
473
+ ]}
474
+ uploadFiles={noop}
475
+ />
476
+ </div>
477
+ ),
478
+ parameters: {
479
+ controls: { disable: true },
480
+ docs: {
481
+ source: {
482
+ language: 'tsx',
483
+ code: `
484
+ import { FileUpload } from '@arbor-education/design-system.components';
485
+
486
+ // progressPercentage < 100 shows the progress bar.
487
+ // progressPercentage === 100 (or undefined) hides it.
488
+ function UploadInProgressExample() {
489
+ return (
490
+ <FileUpload
491
+ uploadedFiles={[
492
+ {
493
+ fileName: 'spring-term-report-2024.pdf',
494
+ fileType: 'PDF',
495
+ fileSize: '1.2 MB',
496
+ onDelete: () => { /* cancel / remove */ },
497
+ progressPercentage: 25,
498
+ },
499
+ {
500
+ fileName: 'attendance-summary-jan.xlsx',
501
+ fileType: 'XLSX',
502
+ fileSize: '348 KB',
503
+ onDelete: () => { /* cancel / remove */ },
504
+ progressPercentage: 72,
505
+ },
506
+ {
507
+ fileName: 'behaviour-policy-2024.docx',
508
+ fileType: 'DOCX',
509
+ fileSize: '89 KB',
510
+ onDownload: () => { /* download logic */ },
511
+ onDelete: () => { /* remove */ },
512
+ // No progressPercentage — this file finished earlier
513
+ },
514
+ ]}
515
+ uploadFiles={(files) => { /* start upload */ }}
516
+ />
517
+ );
518
+ }
519
+ export default UploadInProgressExample;
520
+ `.trim(),
521
+ },
522
+ },
523
+ },
524
+ },
525
+ 'Multiple files at different stages of upload. A progress bar appears beneath '
526
+ + 'each row where `progressPercentage` is a number between 0 and 99 (inclusive). '
527
+ + 'The third file has no `progressPercentage` — it completed earlier and shows '
528
+ + 'only the download and delete actions.',
529
+ );
530
+
531
+ // ─────────────────────────────────────────────────────────────────────────────
532
+
533
+ export const FailedUpload: Story = withDescription(
534
+ {
535
+ render: () => (
536
+ <div style={{ maxWidth: '560px', padding: 'var(--spacing-large)' }}>
537
+ <FileUpload
538
+ uploadedFiles={[
539
+ {
540
+ fileName: 'corrupted-report.pdf',
541
+ fileType: 'PDF',
542
+ fileSize: '540 KB',
543
+ onDelete: noop,
544
+ hasUploadFailed: true,
545
+ },
546
+ {
547
+ fileName: 'spring-term-report-2024.pdf',
548
+ fileType: 'PDF',
549
+ fileSize: '1.2 MB',
550
+ onDownload: noop,
551
+ onDelete: noop,
552
+ },
553
+ ]}
554
+ uploadFiles={noop}
555
+ />
556
+ </div>
557
+ ),
558
+ parameters: {
559
+ controls: { disable: true },
560
+ docs: {
561
+ source: {
562
+ language: 'tsx',
563
+ code: `
564
+ import { FileUpload } from '@arbor-education/design-system.components';
565
+
566
+ function FailedUploadExample() {
567
+ return (
568
+ <FileUpload
569
+ uploadedFiles={[
570
+ {
571
+ fileName: 'corrupted-report.pdf',
572
+ fileType: 'PDF',
573
+ fileSize: '540 KB',
574
+ onDelete: () => { /* remove from state */ },
575
+ hasUploadFailed: true,
576
+ },
577
+ {
578
+ fileName: 'spring-term-report-2024.pdf',
579
+ fileType: 'PDF',
580
+ fileSize: '1.2 MB',
581
+ onDownload: () => { /* download logic */ },
582
+ onDelete: () => { /* remove from state */ },
583
+ },
584
+ ]}
585
+ uploadFiles={(files) => { /* retry or start new upload */ }}
586
+ />
587
+ );
588
+ }
589
+ export default FailedUploadExample;
590
+ `.trim(),
591
+ },
592
+ },
593
+ },
594
+ },
595
+ "When `hasUploadFailed` is `true` the file's type/size metadata is replaced "
596
+ + 'with a red "Upload failed" message. A completed file alongside the failed one '
597
+ + 'shows the contrast between the two states.',
598
+ );
599
+
600
+ // ─────────────────────────────────────────────────────────────────────────────
601
+
602
+ export const FailedDuringUpload: Story = withDescription(
603
+ {
604
+ render: () => (
605
+ <div style={{ maxWidth: '560px', padding: 'var(--spacing-large)' }}>
606
+ <FileUpload
607
+ uploadedFiles={[
608
+ {
609
+ fileName: 'oversized-photo.png',
610
+ fileType: 'PNG',
611
+ fileSize: '18 MB',
612
+ onDelete: noop,
613
+ hasUploadFailed: true,
614
+ progressPercentage: 63,
615
+ },
616
+ {
617
+ fileName: 'corrupted-report.pdf',
618
+ fileType: 'PDF',
619
+ fileSize: '540 KB',
620
+ onDelete: noop,
621
+ hasUploadFailed: true,
622
+ },
623
+ ]}
624
+ uploadFiles={noop}
625
+ />
626
+ </div>
627
+ ),
628
+ parameters: {
629
+ controls: { disable: true },
630
+ docs: {
631
+ source: {
632
+ language: 'tsx',
633
+ code: `
634
+ import { FileUpload } from '@arbor-education/design-system.components';
635
+
636
+ // Both hasUploadFailed: true AND progressPercentage < 100 renders
637
+ // a red progress bar in addition to the "Upload failed" message.
638
+ function FailedDuringUploadExample() {
639
+ return (
640
+ <FileUpload
641
+ uploadedFiles={[
642
+ {
643
+ fileName: 'oversized-photo.png',
644
+ fileType: 'PNG',
645
+ fileSize: '18 MB',
646
+ onDelete: () => { /* remove from state */ },
647
+ hasUploadFailed: true,
648
+ progressPercentage: 63,
649
+ },
650
+ {
651
+ fileName: 'corrupted-report.pdf',
652
+ fileType: 'PDF',
653
+ fileSize: '540 KB',
654
+ onDelete: () => { /* remove from state */ },
655
+ hasUploadFailed: true,
656
+ // No progressPercentage — failed before any progress was recorded
657
+ },
658
+ ]}
659
+ uploadFiles={(files) => { /* start upload */ }}
660
+ />
661
+ );
662
+ }
663
+ export default FailedDuringUploadExample;
664
+ `.trim(),
665
+ },
666
+ },
667
+ },
668
+ },
669
+ 'When both `hasUploadFailed: true` **and** `progressPercentage < 100` are set '
670
+ + 'together the progress bar is rendered in its error (red) state below the '
671
+ + '"Upload failed" message. The first file shows this combined state; the second '
672
+ + 'failed before any progress was recorded so no bar appears.',
673
+ );
674
+
675
+ // ─────────────────────────────────────────────────────────────────────────────
676
+
677
+ export const CustomDropzoneContent: Story = withDescription(
678
+ {
679
+ render: () => (
680
+ <div style={{ maxWidth: '560px', padding: 'var(--spacing-large)' }}>
681
+ <FileUpload
682
+ uploadedFiles={[]}
683
+ uploadFiles={noop}
684
+ dropzoneContent={(
685
+ <>
686
+ <p className="ds-text">
687
+ Attach a PDF or spreadsheet (max&nbsp;10&nbsp;MB per file)
688
+ </p>
689
+ <Button variant="secondary">Browse files</Button>
690
+ </>
691
+ )}
692
+ />
693
+ </div>
694
+ ),
695
+ parameters: {
696
+ controls: { disable: true },
697
+ docs: {
698
+ source: {
699
+ language: 'tsx',
700
+ code: `
701
+ import { FileUpload, Button } from '@arbor-education/design-system.components';
702
+
703
+ function CustomDropzoneExample() {
704
+ return (
705
+ <FileUpload
706
+ uploadedFiles={[]}
707
+ uploadFiles={(files) => { /* start upload */ }}
708
+ dropzoneContent={
709
+ <>
710
+ <p>Attach a PDF or spreadsheet (max 10 MB per file)</p>
711
+ <Button variant="secondary">Browse files</Button>
712
+ </>
713
+ }
714
+ />
715
+ );
716
+ }
717
+ export default CustomDropzoneExample;
718
+ `.trim(),
719
+ },
720
+ },
721
+ },
722
+ },
723
+ 'Pass a `ReactNode` to `dropzoneContent` to replace the default '
724
+ + '"Drag and drop, or click to upload" copy. This is useful when you need '
725
+ + 'context-specific instructions or a branded call-to-action button.',
726
+ );
727
+
728
+ // ─────────────────────────────────────────────────────────────────────────────
729
+ // SimulatedUploadLifecycle — named template component required for useState
730
+ // ─────────────────────────────────────────────────────────────────────────────
731
+
732
+ type SimulatedFile = {
733
+ fileName: string;
734
+ fileType: string;
735
+ fileSize: string;
736
+ progressPercentage?: number;
737
+ hasUploadFailed?: boolean;
738
+ };
739
+
740
+ const DEMO_FILENAME = 'spring-term-report-2024.pdf';
741
+
742
+ const SimulatedUploadLifecycleTemplate = () => {
743
+ const [files, setFiles] = useState<SimulatedFile[]>([]);
744
+ const [isRunning, setIsRunning] = useState(false);
745
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
746
+
747
+ const clearTimer = () => {
748
+ if (intervalRef.current !== null) {
749
+ clearInterval(intervalRef.current);
750
+ intervalRef.current = null;
751
+ }
752
+ };
753
+
754
+ const startUpload = () => {
755
+ clearTimer();
756
+ setIsRunning(true);
757
+ setFiles([{ fileName: DEMO_FILENAME, fileType: 'PDF', fileSize: '1.2 MB', progressPercentage: 0 }]);
758
+ };
759
+
760
+ const restart = () => {
761
+ clearTimer();
762
+ setIsRunning(false);
763
+ setFiles([]);
764
+ };
765
+
766
+ useEffect(() => {
767
+ if (!isRunning) {
768
+ return;
769
+ }
770
+
771
+ intervalRef.current = setInterval(() => {
772
+ setFiles((prev) => {
773
+ const current = prev[0];
774
+ if (!current || typeof current.progressPercentage !== 'number') {
775
+ return prev;
776
+ }
777
+
778
+ const next = current.progressPercentage + 8;
779
+ if (next >= 100) {
780
+ clearTimer();
781
+ setIsRunning(false);
782
+ return [{ ...current, progressPercentage: 100 }];
783
+ }
784
+ return [{ ...current, progressPercentage: next }];
785
+ });
786
+ }, 250);
787
+
788
+ return () => {
789
+ clearTimer();
790
+ };
791
+ }, [isRunning]);
792
+
793
+ return (
794
+ <div style={{ maxWidth: '560px', padding: 'var(--spacing-large)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
795
+ <div style={{ display: 'flex', gap: 'var(--spacing-medium)' }}>
796
+ <Button variant="primary" onClick={startUpload} disabled={isRunning}>
797
+ Simulate upload
798
+ </Button>
799
+ <Button variant="secondary" onClick={restart} disabled={files.length === 0 && !isRunning}>
800
+ Restart
801
+ </Button>
802
+ </div>
803
+ <FileUpload
804
+ uploadedFiles={files.map(f => ({
805
+ ...f,
806
+ onDelete: restart,
807
+ ...(typeof f.progressPercentage === 'number' && f.progressPercentage >= 100
808
+ ? { onDownload: noop }
809
+ : {}),
810
+ }))}
811
+ uploadFiles={startUpload}
812
+ />
813
+ </div>
814
+ );
815
+ };
816
+
817
+ export const SimulatedUploadLifecycle: Story = withDescription(
818
+ {
819
+ render: SimulatedUploadLifecycleTemplate,
820
+ parameters: {
821
+ controls: { disable: true },
822
+ docs: {
823
+ source: {
824
+ language: 'tsx',
825
+ code: `
826
+ import { useState, useEffect, useRef } from 'react';
827
+ import { FileUpload, Button } from '@arbor-education/design-system.components';
828
+
829
+ type ManagedFile = {
830
+ fileName: string;
831
+ fileType: string;
832
+ fileSize: string;
833
+ progressPercentage?: number;
834
+ };
835
+
836
+ function UploadLifecycleDemo() {
837
+ const [files, setFiles] = useState<ManagedFile[]>([]);
838
+ const [isRunning, setIsRunning] = useState(false);
839
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
840
+
841
+ const clearTimer = () => {
842
+ if (intervalRef.current !== null) {
843
+ clearInterval(intervalRef.current);
844
+ intervalRef.current = null;
845
+ }
846
+ };
847
+
848
+ const startUpload = () => {
849
+ clearTimer();
850
+ setIsRunning(true);
851
+ setFiles([{
852
+ fileName: 'spring-term-report-2024.pdf',
853
+ fileType: 'PDF',
854
+ fileSize: '1.2 MB',
855
+ progressPercentage: 0,
856
+ }]);
857
+ };
858
+
859
+ useEffect(() => {
860
+ if (!isRunning) return;
861
+
862
+ intervalRef.current = setInterval(() => {
863
+ setFiles((prev) => {
864
+ const current = prev[0];
865
+ if (!current || typeof current.progressPercentage !== 'number') return prev;
866
+
867
+ const next = current.progressPercentage + 8;
868
+ if (next >= 100) {
869
+ clearTimer();
870
+ setIsRunning(false);
871
+ return [{ ...current, progressPercentage: 100 }];
872
+ }
873
+ return [{ ...current, progressPercentage: next }];
874
+ });
875
+ }, 250);
876
+
877
+ return () => {
878
+ clearTimer();
879
+ };
880
+ }, [isRunning]);
881
+
882
+ return (
883
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
884
+ <div style={{ display: 'flex', gap: 'var(--spacing-medium)' }}>
885
+ <Button variant="primary" onClick={startUpload} disabled={isRunning}>
886
+ Simulate upload
887
+ </Button>
888
+ <Button
889
+ variant="secondary"
890
+ onClick={() => { clearTimer(); setIsRunning(false); setFiles([]); }}
891
+ disabled={files.length === 0 && !isRunning}
892
+ >
893
+ Restart
894
+ </Button>
895
+ </div>
896
+ <FileUpload
897
+ uploadedFiles={files.map((f) => ({
898
+ ...f,
899
+ onDelete: () => { clearTimer(); setIsRunning(false); setFiles([]); },
900
+ ...(typeof f.progressPercentage === 'number' && f.progressPercentage >= 100
901
+ ? { onDownload: () => { /* download */ } }
902
+ : {}),
903
+ }))}
904
+ uploadFiles={startUpload}
905
+ />
906
+ </div>
907
+ );
908
+ }
909
+ export default UploadLifecycleDemo;
910
+ `.trim(),
911
+ },
912
+ },
913
+ },
914
+ },
915
+ 'An interactive demo of the full upload lifecycle. Click **"Simulate upload"** '
916
+ + '(or drop a real file) to watch a file enter the list at 0%, tick up through '
917
+ + 'the progress bar, and complete at 100% — at which point the bar disappears '
918
+ + 'and the download button becomes available. Click **"Restart"** to reset and '
919
+ + 'run the demo again.',
920
+ );