@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.
- package/CHANGELOG.md +16 -0
- package/dist/components/fileUpload/FileList.d.ts +7 -0
- package/dist/components/fileUpload/FileList.d.ts.map +1 -0
- package/dist/components/fileUpload/FileList.js +29 -0
- package/dist/components/fileUpload/FileList.js.map +1 -0
- package/dist/components/fileUpload/FileList.stories.d.ts +47 -0
- package/dist/components/fileUpload/FileList.stories.d.ts.map +1 -0
- package/dist/components/fileUpload/FileList.stories.js +136 -0
- package/dist/components/fileUpload/FileList.stories.js.map +1 -0
- package/dist/components/fileUpload/FileList.test.d.ts +2 -0
- package/dist/components/fileUpload/FileList.test.d.ts.map +1 -0
- package/dist/components/fileUpload/FileList.test.js +147 -0
- package/dist/components/fileUpload/FileList.test.js.map +1 -0
- package/dist/components/fileUpload/FileUpload.d.ts +14 -3
- package/dist/components/fileUpload/FileUpload.d.ts.map +1 -1
- package/dist/components/fileUpload/FileUpload.js +8 -25
- package/dist/components/fileUpload/FileUpload.js.map +1 -1
- package/dist/components/fileUpload/FileUpload.stories.d.ts +16 -1
- package/dist/components/fileUpload/FileUpload.stories.d.ts.map +1 -1
- package/dist/components/fileUpload/FileUpload.stories.js +51 -0
- package/dist/components/fileUpload/FileUpload.stories.js.map +1 -1
- package/dist/components/fileUpload/FileUpload.test.js +42 -0
- package/dist/components/fileUpload/FileUpload.test.js.map +1 -1
- package/dist/components/heading/Heading.d.ts +2 -387
- package/dist/components/heading/Heading.d.ts.map +1 -1
- package/dist/components/heading/Heading.js +12 -4
- package/dist/components/heading/Heading.js.map +1 -1
- package/dist/components/heading/Heading.stories.d.ts +12 -387
- package/dist/components/heading/Heading.stories.d.ts.map +1 -1
- package/dist/components/heading/Heading.stories.js +19 -0
- package/dist/components/heading/Heading.stories.js.map +1 -1
- package/dist/components/heading/Heading.test.js +24 -0
- package/dist/components/heading/Heading.test.js.map +1 -1
- package/dist/components/table/Table.d.ts +2 -0
- package/dist/components/table/Table.d.ts.map +1 -1
- package/dist/components/table/cellRenderers/CheckboxCellRenderer.stories.d.ts +1 -0
- package/dist/components/table/cellRenderers/CheckboxCellRenderer.stories.d.ts.map +1 -1
- package/dist/components/table/cellRenderers/CheckboxCellRenderer.stories.js +4 -0
- package/dist/components/table/cellRenderers/CheckboxCellRenderer.stories.js.map +1 -1
- package/dist/components/table/cellRenderers/CheckboxCellRenderer.test.js +7 -0
- package/dist/components/table/cellRenderers/CheckboxCellRenderer.test.js.map +1 -1
- package/dist/components/table/cellRenderers/InlineTextCellRenderer.d.ts +1 -0
- package/dist/components/table/cellRenderers/InlineTextCellRenderer.d.ts.map +1 -1
- package/dist/components/table/cellRenderers/InlineTextCellRenderer.js +2 -2
- package/dist/components/table/cellRenderers/InlineTextCellRenderer.js.map +1 -1
- package/dist/components/table/cellRenderers/InlineTextCellRenderer.stories.d.ts +14 -0
- package/dist/components/table/cellRenderers/InlineTextCellRenderer.stories.d.ts.map +1 -1
- package/dist/components/table/cellRenderers/InlineTextCellRenderer.stories.js +12 -0
- package/dist/components/table/cellRenderers/InlineTextCellRenderer.stories.js.map +1 -1
- package/dist/components/table/cellRenderers/InlineTextCellRenderer.test.d.ts +2 -0
- package/dist/components/table/cellRenderers/InlineTextCellRenderer.test.d.ts.map +1 -0
- package/dist/components/table/cellRenderers/InlineTextCellRenderer.test.js +28 -0
- package/dist/components/table/cellRenderers/InlineTextCellRenderer.test.js.map +1 -0
- package/dist/components/table/cellRenderers/NumberInputCellRenderer.d.ts +1 -0
- package/dist/components/table/cellRenderers/NumberInputCellRenderer.d.ts.map +1 -1
- package/dist/components/table/cellRenderers/NumberInputCellRenderer.js +2 -2
- package/dist/components/table/cellRenderers/NumberInputCellRenderer.js.map +1 -1
- package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.d.ts +14 -0
- package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.d.ts.map +1 -1
- package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.js +12 -0
- package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.js.map +1 -1
- package/dist/components/table/cellRenderers/NumberInputCellRenderer.test.js +10 -0
- package/dist/components/table/cellRenderers/NumberInputCellRenderer.test.js.map +1 -1
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.js +1 -1
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.js.map +1 -1
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.d.ts +1 -0
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.d.ts.map +1 -1
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.js +4 -0
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.js.map +1 -1
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.test.js +9 -0
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.test.js.map +1 -1
- package/dist/index.css +23 -0
- package/dist/index.css.map +1 -1
- package/package.json +1 -1
- package/src/components/fileUpload/FileList.stories.tsx +145 -0
- package/src/components/fileUpload/FileList.test.tsx +190 -0
- package/src/components/fileUpload/FileList.tsx +125 -0
- package/src/components/fileUpload/FileUpload.stories.tsx +79 -0
- package/src/components/fileUpload/FileUpload.test.tsx +82 -0
- package/src/components/fileUpload/FileUpload.tsx +29 -108
- package/src/components/fileUpload/fileUpload.scss +8 -0
- package/src/components/heading/Heading.stories.tsx +23 -0
- package/src/components/heading/Heading.test.tsx +38 -0
- package/src/components/heading/Heading.tsx +27 -3
- package/src/components/heading/heading.scss +18 -0
- package/src/components/table/cellRenderers/CheckboxCellRenderer.stories.tsx +16 -0
- package/src/components/table/cellRenderers/CheckboxCellRenderer.test.tsx +8 -0
- package/src/components/table/cellRenderers/InlineTextCellRenderer.stories.tsx +22 -0
- package/src/components/table/cellRenderers/InlineTextCellRenderer.test.tsx +31 -0
- package/src/components/table/cellRenderers/InlineTextCellRenderer.tsx +4 -1
- package/src/components/table/cellRenderers/NumberInputCellRenderer.stories.tsx +24 -0
- package/src/components/table/cellRenderers/NumberInputCellRenderer.test.tsx +13 -1
- package/src/components/table/cellRenderers/NumberInputCellRenderer.tsx +4 -1
- package/src/components/table/cellRenderers/SelectDropdownCellRenderer.stories.tsx +19 -0
- package/src/components/table/cellRenderers/SelectDropdownCellRenderer.test.tsx +11 -0
- 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
|
|
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
|
|
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
|
-
{
|
|
136
|
+
{fileToEdit?.href && fileToEdit.onEdit && (
|
|
140
137
|
<FileUploadLinkModal
|
|
141
138
|
title="Update URL attachment"
|
|
142
139
|
submitLabel="Update"
|
|
143
|
-
initialLink={{ url:
|
|
140
|
+
initialLink={{ url: fileToEdit.href, name: fileToEdit.fileName }}
|
|
144
141
|
onSubmit={(link) => {
|
|
145
|
-
|
|
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
|
-
<
|
|
153
|
-
{uploadedFiles
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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;
|