@arbor-education/design-system.components 4.7.1 → 4.9.0

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 (96) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/components/fileUpload/FileList.d.ts +7 -0
  3. package/dist/components/fileUpload/FileList.d.ts.map +1 -0
  4. package/dist/components/fileUpload/FileList.js +29 -0
  5. package/dist/components/fileUpload/FileList.js.map +1 -0
  6. package/dist/components/fileUpload/FileList.stories.d.ts +47 -0
  7. package/dist/components/fileUpload/FileList.stories.d.ts.map +1 -0
  8. package/dist/components/fileUpload/FileList.stories.js +136 -0
  9. package/dist/components/fileUpload/FileList.stories.js.map +1 -0
  10. package/dist/components/fileUpload/FileList.test.d.ts +2 -0
  11. package/dist/components/fileUpload/FileList.test.d.ts.map +1 -0
  12. package/dist/components/fileUpload/FileList.test.js +147 -0
  13. package/dist/components/fileUpload/FileList.test.js.map +1 -0
  14. package/dist/components/fileUpload/FileUpload.d.ts +14 -3
  15. package/dist/components/fileUpload/FileUpload.d.ts.map +1 -1
  16. package/dist/components/fileUpload/FileUpload.js +8 -25
  17. package/dist/components/fileUpload/FileUpload.js.map +1 -1
  18. package/dist/components/fileUpload/FileUpload.stories.d.ts +16 -1
  19. package/dist/components/fileUpload/FileUpload.stories.d.ts.map +1 -1
  20. package/dist/components/fileUpload/FileUpload.stories.js +51 -0
  21. package/dist/components/fileUpload/FileUpload.stories.js.map +1 -1
  22. package/dist/components/fileUpload/FileUpload.test.js +42 -0
  23. package/dist/components/fileUpload/FileUpload.test.js.map +1 -1
  24. package/dist/components/heading/Heading.d.ts +2 -387
  25. package/dist/components/heading/Heading.d.ts.map +1 -1
  26. package/dist/components/heading/Heading.js +12 -4
  27. package/dist/components/heading/Heading.js.map +1 -1
  28. package/dist/components/heading/Heading.stories.d.ts +12 -387
  29. package/dist/components/heading/Heading.stories.d.ts.map +1 -1
  30. package/dist/components/heading/Heading.stories.js +19 -0
  31. package/dist/components/heading/Heading.stories.js.map +1 -1
  32. package/dist/components/heading/Heading.test.js +24 -0
  33. package/dist/components/heading/Heading.test.js.map +1 -1
  34. package/dist/components/table/Table.d.ts +2 -0
  35. package/dist/components/table/Table.d.ts.map +1 -1
  36. package/dist/components/table/cellRenderers/CheckboxCellRenderer.stories.d.ts +1 -0
  37. package/dist/components/table/cellRenderers/CheckboxCellRenderer.stories.d.ts.map +1 -1
  38. package/dist/components/table/cellRenderers/CheckboxCellRenderer.stories.js +4 -0
  39. package/dist/components/table/cellRenderers/CheckboxCellRenderer.stories.js.map +1 -1
  40. package/dist/components/table/cellRenderers/CheckboxCellRenderer.test.js +7 -0
  41. package/dist/components/table/cellRenderers/CheckboxCellRenderer.test.js.map +1 -1
  42. package/dist/components/table/cellRenderers/InlineTextCellRenderer.d.ts +1 -0
  43. package/dist/components/table/cellRenderers/InlineTextCellRenderer.d.ts.map +1 -1
  44. package/dist/components/table/cellRenderers/InlineTextCellRenderer.js +2 -2
  45. package/dist/components/table/cellRenderers/InlineTextCellRenderer.js.map +1 -1
  46. package/dist/components/table/cellRenderers/InlineTextCellRenderer.stories.d.ts +14 -0
  47. package/dist/components/table/cellRenderers/InlineTextCellRenderer.stories.d.ts.map +1 -1
  48. package/dist/components/table/cellRenderers/InlineTextCellRenderer.stories.js +12 -0
  49. package/dist/components/table/cellRenderers/InlineTextCellRenderer.stories.js.map +1 -1
  50. package/dist/components/table/cellRenderers/InlineTextCellRenderer.test.d.ts +2 -0
  51. package/dist/components/table/cellRenderers/InlineTextCellRenderer.test.d.ts.map +1 -0
  52. package/dist/components/table/cellRenderers/InlineTextCellRenderer.test.js +28 -0
  53. package/dist/components/table/cellRenderers/InlineTextCellRenderer.test.js.map +1 -0
  54. package/dist/components/table/cellRenderers/NumberInputCellRenderer.d.ts +1 -0
  55. package/dist/components/table/cellRenderers/NumberInputCellRenderer.d.ts.map +1 -1
  56. package/dist/components/table/cellRenderers/NumberInputCellRenderer.js +2 -2
  57. package/dist/components/table/cellRenderers/NumberInputCellRenderer.js.map +1 -1
  58. package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.d.ts +14 -0
  59. package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.d.ts.map +1 -1
  60. package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.js +12 -0
  61. package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.js.map +1 -1
  62. package/dist/components/table/cellRenderers/NumberInputCellRenderer.test.js +10 -0
  63. package/dist/components/table/cellRenderers/NumberInputCellRenderer.test.js.map +1 -1
  64. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.js +1 -1
  65. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.js.map +1 -1
  66. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.d.ts +1 -0
  67. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.d.ts.map +1 -1
  68. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.js +4 -0
  69. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.js.map +1 -1
  70. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.test.js +9 -0
  71. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.test.js.map +1 -1
  72. package/dist/index.css +23 -0
  73. package/dist/index.css.map +1 -1
  74. package/package.json +1 -1
  75. package/src/components/fileUpload/FileList.stories.tsx +145 -0
  76. package/src/components/fileUpload/FileList.test.tsx +190 -0
  77. package/src/components/fileUpload/FileList.tsx +125 -0
  78. package/src/components/fileUpload/FileUpload.stories.tsx +79 -0
  79. package/src/components/fileUpload/FileUpload.test.tsx +82 -0
  80. package/src/components/fileUpload/FileUpload.tsx +29 -108
  81. package/src/components/fileUpload/fileUpload.scss +8 -0
  82. package/src/components/heading/Heading.stories.tsx +23 -0
  83. package/src/components/heading/Heading.test.tsx +38 -0
  84. package/src/components/heading/Heading.tsx +27 -3
  85. package/src/components/heading/heading.scss +18 -0
  86. package/src/components/table/cellRenderers/CheckboxCellRenderer.stories.tsx +16 -0
  87. package/src/components/table/cellRenderers/CheckboxCellRenderer.test.tsx +8 -0
  88. package/src/components/table/cellRenderers/InlineTextCellRenderer.stories.tsx +22 -0
  89. package/src/components/table/cellRenderers/InlineTextCellRenderer.test.tsx +31 -0
  90. package/src/components/table/cellRenderers/InlineTextCellRenderer.tsx +4 -1
  91. package/src/components/table/cellRenderers/NumberInputCellRenderer.stories.tsx +24 -0
  92. package/src/components/table/cellRenderers/NumberInputCellRenderer.test.tsx +13 -1
  93. package/src/components/table/cellRenderers/NumberInputCellRenderer.tsx +4 -1
  94. package/src/components/table/cellRenderers/SelectDropdownCellRenderer.stories.tsx +19 -0
  95. package/src/components/table/cellRenderers/SelectDropdownCellRenderer.test.tsx +11 -0
  96. package/src/components/table/cellRenderers/SelectDropdownCellRenderer.tsx +1 -1
@@ -0,0 +1,125 @@
1
+ import classNames from 'classnames';
2
+ import { Button } from 'Components/button/Button';
3
+ import { Card } from 'Components/card/Card';
4
+ import { Icon } from 'Components/icon/Icon';
5
+ import { Progress } from 'Components/progress/Progress';
6
+ import { Separator } from 'Components/separator/Separator';
7
+ import type { UploadedFile } from 'Components/fileUpload/FileUpload';
8
+ import type { ReactNode } from 'react';
9
+
10
+ export type FileListProps = {
11
+ uploadedFiles: UploadedFile[];
12
+ onEditFile?: (index: number) => void;
13
+ };
14
+
15
+ const renderFileLink = ({ href = '', linkProps, renderLink }: UploadedFile, children: ReactNode) => {
16
+ const className = 'ds-file-upload__file-link';
17
+ if (renderLink) {
18
+ return renderLink({ href, className, children, linkProps });
19
+ }
20
+ return (
21
+ <a href={href} target="_blank" rel="noopener noreferrer" className={className} {...linkProps}>
22
+ {children}
23
+ </a>
24
+ );
25
+ };
26
+
27
+ export const FileList = (props: FileListProps) => {
28
+ const {
29
+ uploadedFiles,
30
+ onEditFile,
31
+ } = props;
32
+
33
+ return (
34
+ <Card className="ds-file-upload__file-list">
35
+ {uploadedFiles.map((file, index) => {
36
+ const {
37
+ fileName,
38
+ fileSize,
39
+ fileType,
40
+ href,
41
+ onEdit,
42
+ onDelete,
43
+ onDownload,
44
+ hasUploadFailed,
45
+ progressPercentage,
46
+ errorMessage,
47
+ } = file;
48
+ const icon = file.icon ?? (href ? 'link' : 'file');
49
+ const nameNode = <span className="ds-file-upload__file-name">{fileName}</span>;
50
+
51
+ return (
52
+ <div key={fileName} className="ds-file-upload__file-outer">
53
+ <span className="ds-file-upload__file">
54
+ <span className="ds-file-upload__icon-wrapper">
55
+ <Icon size={24} name={icon} color="var(--color-brand-600)" />
56
+ </span>
57
+ <span className="ds-file-upload__file-details">
58
+ {href ? renderFileLink(file, nameNode) : nameNode}
59
+ {
60
+ hasUploadFailed || errorMessage
61
+ ? (
62
+ <span className="ds-file-upload__upload-failed">
63
+ <Icon name="circle-alert" />
64
+ {' '}
65
+ {errorMessage ?? 'Upload failed'}
66
+ </span>
67
+ )
68
+ : <span className="ds-file-upload__file-meta">{href ?? `${fileType}, ${fileSize}`}</span>
69
+ }
70
+ </span>
71
+ {href && (
72
+ <Button
73
+ variant="secondary"
74
+ isRound
75
+ onClick={() => navigator.clipboard.writeText(href)}
76
+ >
77
+ <Icon name="copy" screenReaderText="Copy link" />
78
+ </Button>
79
+ )}
80
+ {href && onEditFile && onEdit && (
81
+ <Button
82
+ variant="secondary"
83
+ isRound
84
+ onClick={() => onEditFile(index)}
85
+ >
86
+ <Icon name="pencil" screenReaderText="Edit link" />
87
+ </Button>
88
+ )}
89
+ {onDownload && (
90
+ <Button
91
+ variant="secondary"
92
+ isRound
93
+ onClick={onDownload}
94
+ >
95
+ <Icon name="download" screenReaderText="Download file" />
96
+ </Button>
97
+ )}
98
+ {onDelete && (
99
+ <Button
100
+ variant="secondary"
101
+ isRound
102
+ onClick={onDelete}
103
+ >
104
+ <Icon name="trash" screenReaderText={href ? 'Delete link' : 'Delete file'} />
105
+ </Button>
106
+ )}
107
+ </span>
108
+ {typeof progressPercentage === 'number' && progressPercentage < 100
109
+ && (
110
+ <Progress
111
+ value={progressPercentage}
112
+ className="ds-file-upload__progress-bar"
113
+ aria-label="Upload progress bar"
114
+ indicatorClassName={classNames('ds-file-upload__progress-bar-indicator', {
115
+ 'ds-file-upload__progress-bar-indicator--failed': hasUploadFailed,
116
+ })}
117
+ />
118
+ )}
119
+ {index < uploadedFiles.length - 1 && <Separator />}
120
+ </div>
121
+ );
122
+ })}
123
+ </Card>
124
+ );
125
+ };
@@ -189,6 +189,15 @@ const meta = {
189
189
  },
190
190
  },
191
191
  },
192
+ errorMessage: {
193
+ description:
194
+ 'Error message rendered beneath the dropzone with an alert icon. Use it for errors that apply to the upload as a whole. '
195
+ + 'For an error on a single file, set `errorMessage` on that file object instead.',
196
+ control: 'text',
197
+ table: {
198
+ type: { summary: 'string' },
199
+ },
200
+ },
192
201
  className: {
193
202
  description: 'Additional CSS class name(s) applied to the root element.',
194
203
  control: 'text',
@@ -742,6 +751,76 @@ export default FailedDuringUploadExample;
742
751
 
743
752
  // ─────────────────────────────────────────────────────────────────────────────
744
753
 
754
+ export const WithErrorMessage: Story = withDescription(
755
+ {
756
+ args: {
757
+ uploadedFiles: completedFiles,
758
+ errorMessage: 'File limit exceeded',
759
+ },
760
+ render: args => (
761
+ <div style={{ maxWidth: '560px', padding: 'var(--spacing-large)' }}>
762
+ <FileUpload {...args} />
763
+ </div>
764
+ ),
765
+ },
766
+ 'An `errorMessage` renders beneath the dropzone with an alert icon. Use it for errors '
767
+ + 'that apply to the upload as a whole. Edit the message in the Controls panel to try your own copy.',
768
+ );
769
+
770
+ // ─────────────────────────────────────────────────────────────────────────────
771
+
772
+ export const ErrorMessageWithoutFiles: Story = withDescription(
773
+ {
774
+ args: {
775
+ uploadedFiles: [],
776
+ errorMessage: 'Your upload could not be started. Refresh the page and try again.',
777
+ },
778
+ render: args => (
779
+ <div style={{ maxWidth: '560px', padding: 'var(--spacing-large)' }}>
780
+ <FileUpload {...args} />
781
+ </div>
782
+ ),
783
+ },
784
+ 'The upload-level `errorMessage` also renders when no files are attached, '
785
+ + 'directly beneath the empty dropzone.',
786
+ );
787
+
788
+ // ─────────────────────────────────────────────────────────────────────────────
789
+
790
+ export const FileWithErrorMessage: Story = withDescription(
791
+ {
792
+ args: {
793
+ uploadedFiles: [
794
+ {
795
+ fileName: 'oversized-photo.png',
796
+ fileType: 'PNG',
797
+ fileSize: '18 MB',
798
+ onDelete: noop,
799
+ hasUploadFailed: true,
800
+ errorMessage: 'This file is larger than the 10 MB limit.',
801
+ },
802
+ {
803
+ fileName: 'spring-term-report-2024.pdf',
804
+ fileType: 'PDF',
805
+ fileSize: '1.2 MB',
806
+ onDownload: noop,
807
+ onDelete: noop,
808
+ },
809
+ ],
810
+ },
811
+ render: args => (
812
+ <div style={{ maxWidth: '560px', padding: 'var(--spacing-large)' }}>
813
+ <FileUpload {...args} />
814
+ </div>
815
+ ),
816
+ },
817
+ 'A file-level `errorMessage` explains why one specific file failed. It is set on the '
818
+ + 'file object and is separate from the upload-level `errorMessage`. The second file is '
819
+ + 'unaffected.',
820
+ );
821
+
822
+ // ─────────────────────────────────────────────────────────────────────────────
823
+
745
824
  export const CustomDropzoneContent: Story = withDescription(
746
825
  {
747
826
  render: () => (
@@ -122,6 +122,88 @@ describe('FileUpload', () => {
122
122
  });
123
123
  });
124
124
 
125
+ describe('error messages', () => {
126
+ test('renders the upload-level errorMessage with the error class', () => {
127
+ const { container } = render(
128
+ <FileUpload
129
+ uploadedFiles={[]}
130
+ uploadFiles={vi.fn()}
131
+ errorMessage="Some files could not be attached."
132
+ />,
133
+ );
134
+ expect(screen.getByText('Some files could not be attached.')).toBeInTheDocument();
135
+ expect(container.querySelector('.ds-file-upload__error')).toBeInTheDocument();
136
+ });
137
+
138
+ test('does not render the upload-level error when errorMessage is not set', () => {
139
+ const { container } = render(<FileUpload uploadedFiles={[]} uploadFiles={vi.fn()} />);
140
+ expect(container.querySelector('.ds-file-upload__error')).not.toBeInTheDocument();
141
+ });
142
+
143
+ test('renders the upload-level errorMessage alongside the file list', () => {
144
+ render(
145
+ <FileUpload
146
+ uploadedFiles={[baseFile]}
147
+ uploadFiles={vi.fn()}
148
+ errorMessage="Some files could not be attached."
149
+ />,
150
+ );
151
+ expect(screen.getByText('Some files could not be attached.')).toBeInTheDocument();
152
+ expect(screen.getByText('report.pdf')).toBeInTheDocument();
153
+ });
154
+
155
+ test('replaces file meta with the file-level errorMessage', () => {
156
+ render(
157
+ <FileUpload
158
+ uploadedFiles={[{ ...baseFile, errorMessage: 'This file is larger than the 10 MB limit.' }]}
159
+ uploadFiles={vi.fn()}
160
+ />,
161
+ );
162
+ expect(screen.getByText('This file is larger than the 10 MB limit.')).toBeInTheDocument();
163
+ expect(screen.queryByText('PDF, 1.2 MB')).not.toBeInTheDocument();
164
+ });
165
+
166
+ test('shows the file-level errorMessage instead of "Upload failed" when both are set', () => {
167
+ render(
168
+ <FileUpload
169
+ uploadedFiles={[{ ...baseFile, hasUploadFailed: true, errorMessage: 'File is corrupt.' }]}
170
+ uploadFiles={vi.fn()}
171
+ />,
172
+ );
173
+ expect(screen.getByText('File is corrupt.')).toBeInTheDocument();
174
+ expect(screen.queryByText('Upload failed')).not.toBeInTheDocument();
175
+ });
176
+
177
+ test('shows the upload-level and file-level errors together', () => {
178
+ render(
179
+ <FileUpload
180
+ uploadedFiles={[{ ...baseFile, errorMessage: 'File is corrupt.' }]}
181
+ uploadFiles={vi.fn()}
182
+ errorMessage="Some files could not be attached."
183
+ />,
184
+ );
185
+ expect(screen.getByText('Some files could not be attached.')).toBeInTheDocument();
186
+ expect(screen.getByText('File is corrupt.')).toBeInTheDocument();
187
+ });
188
+
189
+ test('keeps the link anchor and replaces the href meta with the file-level errorMessage', () => {
190
+ render(
191
+ <FileUpload
192
+ uploadedFiles={[{
193
+ fileName: 'Pastoral policy',
194
+ href: 'https://example.com/policy',
195
+ errorMessage: 'This link could not be reached.',
196
+ onDelete: vi.fn(),
197
+ }]}
198
+ uploadFiles={vi.fn()}
199
+ />,
200
+ );
201
+ expect(screen.getByRole('link', { name: 'Pastoral policy' })).toHaveAttribute('href', 'https://example.com/policy');
202
+ expect(screen.getByText('This link could not be reached.')).toBeInTheDocument();
203
+ expect(screen.queryByText('https://example.com/policy')).not.toBeInTheDocument();
204
+ });
205
+ });
206
+
125
207
  describe('progress bar', () => {
126
208
  test('renders progress bar when progressPercentage is less than 100', () => {
127
209
  const { container } = render(
@@ -3,18 +3,17 @@ import { Button } from 'Components/button/Button';
3
3
  import { Card } from 'Components/card/Card';
4
4
  import type { IconName } from 'Components/icon/allowedIcons';
5
5
  import { Icon } from 'Components/icon/Icon';
6
- import { Separator } from 'Components/separator/Separator';
7
6
  import { useState, type AnchorHTMLAttributes, type ReactElement, type ReactNode } from 'react';
8
7
  import Dropzone from 'react-dropzone';
9
- import { Progress } from 'Components/progress/Progress';
10
8
  import type { WidgetRenderLinkArgs } from 'Components/widget/types';
11
9
  import { FileUploadLinkModal, type FileUploadLink } from './FileUploadLinkModal.js';
10
+ import { FileList, type FileListProps as ListProps } from './FileList.js';
12
11
 
13
12
  export type { FileUploadLink };
14
13
 
15
14
  type LinkAnchorProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href' | 'className' | 'children'>;
16
15
 
17
- type UploadedFile = {
16
+ export type UploadedFile = {
18
17
  fileName: string;
19
18
  icon?: IconName;
20
19
  fileType?: string;
@@ -24,9 +23,10 @@ type UploadedFile = {
24
23
  renderLink?: (args: WidgetRenderLinkArgs) => ReactElement;
25
24
  onEdit?: (link: FileUploadLink) => void;
26
25
  onDownload?: () => void;
27
- onDelete: () => void;
26
+ onDelete?: () => void;
28
27
  progressPercentage?: number;
29
28
  hasUploadFailed?: boolean;
29
+ errorMessage?: string;
30
30
  };
31
31
 
32
32
  export type FileUploadProps = {
@@ -35,28 +35,18 @@ export type FileUploadProps = {
35
35
  uploadFiles: (files: File[]) => void;
36
36
  dropzoneContent?: ReactNode;
37
37
  onAddLink?: (link: FileUploadLink) => void;
38
+ errorMessage?: string;
38
39
  };
39
40
 
40
41
  type LinkPopupState = { mode: 'add' } | { mode: 'edit'; index: number } | null;
41
42
 
42
- const renderFileLink = ({ href = '', linkProps, renderLink }: UploadedFile, children: ReactNode) => {
43
- const className = 'ds-file-upload__file-link';
44
- if (renderLink) {
45
- return renderLink({ href, className, children, linkProps });
46
- }
47
- return (
48
- <a href={href} target="_blank" rel="noopener noreferrer" className={className} {...linkProps}>
49
- {children}
50
- </a>
51
- );
52
- };
53
-
54
43
  export const FileUpload = (props: FileUploadProps) => {
55
44
  const {
56
45
  className,
57
46
  uploadedFiles,
58
47
  uploadFiles,
59
48
  onAddLink,
49
+ errorMessage,
60
50
  } = props;
61
51
 
62
52
  const [linkPopup, setLinkPopup] = useState<LinkPopupState>(null);
@@ -88,7 +78,7 @@ export const FileUpload = (props: FileUploadProps) => {
88
78
  </>
89
79
  );
90
80
 
91
- const editedFile = linkPopup?.mode === 'edit' ? uploadedFiles[linkPopup.index] : undefined;
81
+ const fileToEdit = linkPopup?.mode === 'edit' ? uploadedFiles[linkPopup.index] : undefined;
92
82
 
93
83
  return (
94
84
  <div
@@ -125,6 +115,13 @@ export const FileUpload = (props: FileUploadProps) => {
125
115
  </Card>
126
116
  )}
127
117
  </Dropzone>
118
+ {errorMessage && (
119
+ <span className="ds-file-upload__error">
120
+ <Icon name="alert-triangle" />
121
+ {' '}
122
+ {errorMessage}
123
+ </span>
124
+ )}
128
125
  {linkPopup?.mode === 'add' && onAddLink && (
129
126
  <FileUploadLinkModal
130
127
  title="Add URL attachment"
@@ -136,108 +133,32 @@ export const FileUpload = (props: FileUploadProps) => {
136
133
  onCancel={() => setLinkPopup(null)}
137
134
  />
138
135
  )}
139
- {editedFile?.href && editedFile.onEdit && (
136
+ {fileToEdit?.href && fileToEdit.onEdit && (
140
137
  <FileUploadLinkModal
141
138
  title="Update URL attachment"
142
139
  submitLabel="Update"
143
- initialLink={{ url: editedFile.href, name: editedFile.fileName }}
140
+ initialLink={{ url: fileToEdit.href, name: fileToEdit.fileName }}
144
141
  onSubmit={(link) => {
145
- editedFile.onEdit?.(link);
142
+ fileToEdit.onEdit?.(link);
146
143
  setLinkPopup(null);
147
144
  }}
148
145
  onCancel={() => setLinkPopup(null)}
149
146
  />
150
147
  )}
151
148
  {uploadedFiles.length > 0 && (
152
- <Card className="ds-file-upload__file-list">
153
- {uploadedFiles.map((file, index) => {
154
- const {
155
- fileName,
156
- fileSize,
157
- fileType,
158
- href,
159
- onEdit,
160
- onDelete,
161
- onDownload,
162
- hasUploadFailed,
163
- progressPercentage,
164
- } = file;
165
- const icon = file.icon ?? (href ? 'link' : 'file');
166
- const nameNode = <span className="ds-file-upload__file-name">{fileName}</span>;
167
- return (
168
- <div key={fileName} className="ds-file-upload__file-outer">
169
- <span className="ds-file-upload__file">
170
- <span className="ds-file-upload__icon-wrapper">
171
- <Icon size={24} name={icon} color="var(--color-brand-600)" />
172
- </span>
173
- <span className="ds-file-upload__file-details">
174
- {href ? renderFileLink(file, nameNode) : nameNode}
175
- {
176
- hasUploadFailed
177
- ? (
178
- <span className="ds-file-upload__upload-failed">
179
- <Icon name="circle-alert" />
180
- {' '}
181
- Upload failed
182
- </span>
183
- )
184
- : <span className="ds-file-upload__file-meta">{href ?? `${fileType}, ${fileSize}`}</span>
185
- }
186
- </span>
187
- {href && (
188
- <Button
189
- variant="secondary"
190
- isRound
191
- onClick={() => navigator.clipboard.writeText(href)}
192
- >
193
- <Icon name="copy" screenReaderText="Copy link" />
194
- </Button>
195
- )}
196
- {href && onEdit && (
197
- <Button
198
- variant="secondary"
199
- isRound
200
- onClick={() => setLinkPopup({ mode: 'edit', index })}
201
- >
202
- <Icon name="pencil" screenReaderText="Edit link" />
203
- </Button>
204
- )}
205
- {onDownload && (
206
- <Button
207
- variant="secondary"
208
- isRound
209
- onClick={onDownload}
210
- >
211
- <Icon name="download" screenReaderText="Download file" />
212
- </Button>
213
- )}
214
- {onDelete && (
215
- <Button
216
- variant="secondary"
217
- isRound
218
- onClick={onDelete}
219
- >
220
- <Icon name="trash" screenReaderText={href ? 'Delete link' : 'Delete file'} />
221
- </Button>
222
- )}
223
- </span>
224
- {typeof progressPercentage === 'number' && progressPercentage < 100
225
- && (
226
- <Progress
227
- value={progressPercentage}
228
- className="ds-file-upload__progress-bar"
229
- aria-label="Upload progress bar"
230
- indicatorClassName={classNames('ds-file-upload__progress-bar-indicator', {
231
- 'ds-file-upload__progress-bar-indicator--failed': hasUploadFailed,
232
- })}
233
- />
234
- )}
235
- {index < uploadedFiles.length - 1 && <Separator />}
236
- </div>
237
- );
238
- })}
239
- </Card>
149
+ <FileList
150
+ uploadedFiles={uploadedFiles}
151
+ onEditFile={index => setLinkPopup({ mode: 'edit', index })}
152
+ />
240
153
  )}
241
154
  </div>
242
155
  );
243
156
  };
157
+
158
+ FileUpload.FileList = FileList;
159
+
160
+ export namespace FileUpload {
161
+ export type Props = FileUploadProps;
162
+ export type FileListProps = ListProps;
163
+ export type File = UploadedFile;
164
+ }
@@ -3,6 +3,14 @@
3
3
  flex-direction: column;
4
4
  gap: var(--spacing-small);
5
5
 
6
+ &__error {
7
+ color: var(--form-field-text-error-color-error-text);
8
+
9
+ .ds-icon {
10
+ color: var(--form-field-text-error-color-error-icon);
11
+ }
12
+ }
13
+
6
14
  &__file {
7
15
  display: flex;
8
16
  flex-direction: row;
@@ -256,6 +256,17 @@ const meta = {
256
256
  type: { summary: 'string' },
257
257
  },
258
258
  },
259
+ errorMessage: {
260
+ control: 'text',
261
+ description: [
262
+ 'Error message shown beneath the heading with the error icon, matching the FormField error style.',
263
+ 'Setting it also sets `aria-describedby` on the heading so screen readers announce the message with it.',
264
+ 'Write it as what went wrong plus how to fix it.',
265
+ ].join(' '),
266
+ table: {
267
+ type: { summary: 'string' },
268
+ },
269
+ },
259
270
  },
260
271
  } satisfies Meta<typeof Heading>;
261
272
 
@@ -447,6 +458,18 @@ export const Default: Story = withDescription(
447
458
  'The interactive canvas story — every prop is wired to the **Controls** panel. Use the controls to change `level` (1–4) and see the heading typography scale live. The `children` control is disabled because complex JSX cannot be round-tripped through Storybook\'s serialiser — use the named stories below to explore composition patterns.',
448
459
  );
449
460
 
461
+ export const WithError: Story = withDescription(
462
+ {
463
+ args: {
464
+ level: 1,
465
+ children: 'Student records',
466
+ errorMessage: 'Select a school before saving these records',
467
+ },
468
+ render: args => <Heading {...args} />,
469
+ },
470
+ 'Error state — setting `errorMessage` shows the message beneath the heading with the error icon, and links it to the heading through `aria-describedby`. Use it for a page-level problem that the user must fix, such as a missing required selection.',
471
+ );
472
+
450
473
  export const AllLevels: Story = withDescription(
451
474
  {
452
475
  render: AllLevelsTemplate,
@@ -26,4 +26,42 @@ describe('Heading', () => {
26
26
 
27
27
  expect(screen.getByText('foobar').tagName).toBe('H2');
28
28
  });
29
+
30
+ test('renders the error message when errorMessage is set', () => {
31
+ render(<Heading errorMessage="Enter a student name">Student</Heading>);
32
+
33
+ expect(screen.getByText('Enter a student name')).toBeInTheDocument();
34
+ });
35
+
36
+ test('links the heading to the error message with aria-describedby', () => {
37
+ render(<Heading errorMessage="Enter a student name">Student</Heading>);
38
+
39
+ const heading = screen.getByRole('heading', { name: 'Student' });
40
+ const message = screen.getByText('Enter a student name');
41
+ expect(heading).toHaveAttribute('aria-describedby', message.id);
42
+ });
43
+
44
+ test('preserves an existing aria-describedby alongside the error message id', () => {
45
+ render(
46
+ <Heading errorMessage="Enter a student name" aria-describedby="hint">
47
+ Student
48
+ </Heading>,
49
+ );
50
+
51
+ const message = screen.getByText('Enter a student name');
52
+ expect(screen.getByRole('heading')).toHaveAttribute('aria-describedby', `hint ${message.id}`);
53
+ });
54
+
55
+ test('does not render an error or aria-describedby when errorMessage is not set', () => {
56
+ render(<Heading>Student</Heading>);
57
+
58
+ expect(screen.getByRole('heading')).not.toHaveAttribute('aria-describedby');
59
+ expect(document.querySelector('.ds-heading__error-message')).not.toBeInTheDocument();
60
+ });
61
+
62
+ test('does not forward errorMessage to the DOM element', () => {
63
+ render(<Heading errorMessage="Enter a student name">Student</Heading>);
64
+
65
+ expect(screen.getByRole('heading')).not.toHaveAttribute('errormessage');
66
+ });
29
67
  });
@@ -1,5 +1,6 @@
1
1
  import classNames from 'classnames';
2
- import { createElement, type HTMLAttributes, type HTMLProps } from 'react';
2
+ import { createElement, useId, type HTMLAttributes, type HTMLProps } from 'react';
3
+ import { Icon } from 'Components/icon/Icon';
3
4
 
4
5
  type HeadingSubContainerProps = HTMLAttributes<HTMLSpanElement>;
5
6
 
@@ -21,10 +22,12 @@ export type HeadingLevel = 1 | 2 | 3 | 4;
21
22
 
22
23
  type HeadingProps = {
23
24
  level?: HeadingLevel;
25
+ errorMessage?: string;
24
26
  } & HTMLProps<HTMLHeadingElement>;
25
27
 
26
28
  export const Heading = (props: HeadingProps) => {
27
- const { level = 1, children } = props;
29
+ const { level = 1, children, errorMessage, ...rest } = props;
30
+ const generatedId = useId();
28
31
  const Component = {
29
32
  1: 'h1',
30
33
  2: 'h2',
@@ -32,7 +35,28 @@ export const Heading = (props: HeadingProps) => {
32
35
  4: 'h4',
33
36
  }[level];
34
37
 
35
- return createElement(Component, { ...props, className: classNames('ds-heading', props.className) }, children);
38
+ const errorId = `${generatedId}-error`;
39
+ const describedBy = classNames(rest['aria-describedby'], errorMessage && errorId) || undefined;
40
+
41
+ const heading = createElement(
42
+ Component,
43
+ { ...rest, 'aria-describedby': describedBy, 'className': classNames('ds-heading', rest.className) },
44
+ children,
45
+ );
46
+
47
+ if (!errorMessage) {
48
+ return heading;
49
+ }
50
+
51
+ return (
52
+ <div className="ds-heading__wrapper">
53
+ {heading}
54
+ <span className="ds-heading__error-message" id={errorId}>
55
+ <Icon size={12} name="triangle-alert" />
56
+ {errorMessage}
57
+ </span>
58
+ </div>
59
+ );
36
60
  };
37
61
 
38
62
  Heading.InnerContainer = HeadingInnerContainer;