@arbor-education/design-system.components 3.5.0 → 3.6.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/.github/workflows/pr-review-approve.yaml +2 -1
- package/CHANGELOG.md +6 -0
- package/dist/components/formField/FormField.d.ts +4 -0
- package/dist/components/formField/FormField.d.ts.map +1 -1
- package/dist/components/formField/FormField.js +2 -1
- package/dist/components/formField/FormField.js.map +1 -1
- package/dist/components/formField/FormField.stories.d.ts.map +1 -1
- package/dist/components/formField/FormField.stories.js +10 -7
- package/dist/components/formField/FormField.stories.js.map +1 -1
- package/dist/components/formField/FormField.test.js +14 -0
- package/dist/components/formField/FormField.test.js.map +1 -1
- package/dist/components/formField/inputs/singleFile/SingleFileInput.d.ts +14 -0
- package/dist/components/formField/inputs/singleFile/SingleFileInput.d.ts.map +1 -0
- package/dist/components/formField/inputs/singleFile/SingleFileInput.js +35 -0
- package/dist/components/formField/inputs/singleFile/SingleFileInput.js.map +1 -0
- package/dist/components/formField/inputs/singleFile/SingleFileInput.stories.d.ts +170 -0
- package/dist/components/formField/inputs/singleFile/SingleFileInput.stories.d.ts.map +1 -0
- package/dist/components/formField/inputs/singleFile/SingleFileInput.stories.js +242 -0
- package/dist/components/formField/inputs/singleFile/SingleFileInput.stories.js.map +1 -0
- package/dist/components/formField/inputs/singleFile/SingleFileInput.test.d.ts +2 -0
- package/dist/components/formField/inputs/singleFile/SingleFileInput.test.d.ts.map +1 -0
- package/dist/components/formField/inputs/singleFile/SingleFileInput.test.js +135 -0
- package/dist/components/formField/inputs/singleFile/SingleFileInput.test.js.map +1 -0
- package/dist/index.css +18 -0
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/formField/FormField.stories.tsx +10 -7
- package/src/components/formField/FormField.test.tsx +35 -0
- package/src/components/formField/FormField.tsx +5 -0
- package/src/components/formField/inputs/singleFile/SingleFileInput.stories.tsx +339 -0
- package/src/components/formField/inputs/singleFile/SingleFileInput.test.tsx +181 -0
- package/src/components/formField/inputs/singleFile/SingleFileInput.tsx +101 -0
- package/src/components/formField/inputs/singleFile/singleFileInput.scss +17 -0
- package/src/index.scss +1 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest';
|
|
2
|
+
import { act, render, screen } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { createRef, type ChangeEvent } from 'react';
|
|
5
|
+
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
6
|
+
import { SingleFileInput } from './SingleFileInput.js';
|
|
7
|
+
|
|
8
|
+
const pdf = new File(['pdf'], 'report.pdf', { type: 'application/pdf' });
|
|
9
|
+
|
|
10
|
+
describe('SingleFileInput', () => {
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('renders an empty read-only field with the default placeholder and a choose button', () => {
|
|
16
|
+
render(<SingleFileInput aria-label="Attachment" />);
|
|
17
|
+
|
|
18
|
+
const field = screen.getByLabelText('Attachment');
|
|
19
|
+
expect(field).toHaveAttribute('readonly');
|
|
20
|
+
expect(field).toHaveValue('');
|
|
21
|
+
expect(field).toHaveAttribute('placeholder', 'No file chosen');
|
|
22
|
+
expect(screen.getByRole('button', { name: 'Choose file' })).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('uses a custom placeholder', () => {
|
|
26
|
+
render(<SingleFileInput aria-label="Attachment" placeholder="Pick a PDF" />);
|
|
27
|
+
expect(screen.getByLabelText('Attachment')).toHaveAttribute('placeholder', 'Pick a PDF');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('clicking the button opens the native file picker', async () => {
|
|
31
|
+
const user = userEvent.setup();
|
|
32
|
+
const click = vi.spyOn(HTMLInputElement.prototype, 'click');
|
|
33
|
+
const ref = createRef<HTMLInputElement>();
|
|
34
|
+
render(<SingleFileInput ref={ref} aria-label="Attachment" />);
|
|
35
|
+
|
|
36
|
+
await user.click(screen.getByRole('button', { name: 'Choose file' }));
|
|
37
|
+
|
|
38
|
+
expect(click).toHaveBeenCalledWith();
|
|
39
|
+
expect(click.mock.contexts).toContain(ref.current);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('shows the chosen file name in the labelled read-only field', async () => {
|
|
43
|
+
const user = userEvent.setup();
|
|
44
|
+
const ref = createRef<HTMLInputElement>();
|
|
45
|
+
render(<SingleFileInput ref={ref} aria-label="Attachment" />);
|
|
46
|
+
|
|
47
|
+
await user.upload(ref.current as HTMLInputElement, pdf);
|
|
48
|
+
|
|
49
|
+
expect(screen.getByRole('textbox', { name: 'Attachment' })).toHaveValue('report.pdf');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('clears the file name when the selection is cleared', async () => {
|
|
53
|
+
const user = userEvent.setup();
|
|
54
|
+
const ref = createRef<HTMLInputElement>();
|
|
55
|
+
render(<SingleFileInput ref={ref} aria-label="Attachment" />);
|
|
56
|
+
|
|
57
|
+
await user.upload(ref.current as HTMLInputElement, pdf);
|
|
58
|
+
await user.upload(ref.current as HTMLInputElement, []);
|
|
59
|
+
|
|
60
|
+
expect(screen.getByLabelText('Attachment')).toHaveValue('');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('clears the file name when the surrounding form is reset', async () => {
|
|
64
|
+
const user = userEvent.setup();
|
|
65
|
+
const ref = createRef<HTMLInputElement>();
|
|
66
|
+
render(
|
|
67
|
+
<form aria-label="Upload">
|
|
68
|
+
<SingleFileInput ref={ref} aria-label="Attachment" />
|
|
69
|
+
</form>,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await user.upload(ref.current as HTMLInputElement, pdf);
|
|
73
|
+
expect(screen.getByLabelText('Attachment')).toHaveValue('report.pdf');
|
|
74
|
+
|
|
75
|
+
act(() => {
|
|
76
|
+
screen.getByRole('form', { name: 'Upload' }).dispatchEvent(new Event('reset'));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(screen.getByLabelText('Attachment')).toHaveValue('');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('calls onChange with the native event so the consumer can read the File', async () => {
|
|
83
|
+
const user = userEvent.setup();
|
|
84
|
+
let selectedFile: File | undefined;
|
|
85
|
+
const onChange = vi.fn((event: ChangeEvent<HTMLInputElement>) => {
|
|
86
|
+
selectedFile = event.currentTarget.files?.[0];
|
|
87
|
+
});
|
|
88
|
+
const ref = createRef<HTMLInputElement>();
|
|
89
|
+
render(<SingleFileInput ref={ref} aria-label="Attachment" onChange={onChange} />);
|
|
90
|
+
|
|
91
|
+
await user.upload(ref.current as HTMLInputElement, pdf);
|
|
92
|
+
|
|
93
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
94
|
+
expect(selectedFile).toBe(pdf);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('passes name, accept and required to the native file input', () => {
|
|
98
|
+
const ref = createRef<HTMLInputElement>();
|
|
99
|
+
render(<SingleFileInput ref={ref} aria-label="Attachment" name="attachment" accept=".pdf" required />);
|
|
100
|
+
|
|
101
|
+
expect(ref.current).toHaveAttribute('type', 'file');
|
|
102
|
+
expect(ref.current).toHaveAttribute('name', 'attachment');
|
|
103
|
+
expect(ref.current).toHaveAttribute('accept', '.pdf');
|
|
104
|
+
expect(ref.current).toBeRequired();
|
|
105
|
+
expect(ref.current).not.toHaveAttribute('multiple');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('exposes required to assistive technology on the visible field', () => {
|
|
109
|
+
render(<SingleFileInput aria-label="Attachment" required />);
|
|
110
|
+
expect(screen.getByLabelText('Attachment')).toBeRequired();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('does not submit the visible read-only field', () => {
|
|
114
|
+
const { container } = render(
|
|
115
|
+
<form>
|
|
116
|
+
<SingleFileInput aria-label="Attachment" name="attachment" />
|
|
117
|
+
</form>,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const namedControls = container.querySelectorAll('[name]');
|
|
121
|
+
expect(namedControls).toHaveLength(1);
|
|
122
|
+
expect(namedControls[0]).toHaveAttribute('type', 'file');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('disables the field, button and native input', () => {
|
|
126
|
+
const ref = createRef<HTMLInputElement>();
|
|
127
|
+
render(<SingleFileInput ref={ref} aria-label="Attachment" disabled />);
|
|
128
|
+
|
|
129
|
+
expect(screen.getByLabelText('Attachment')).toBeDisabled();
|
|
130
|
+
expect(screen.getByRole('button', { name: 'Choose file' })).toBeDisabled();
|
|
131
|
+
expect(ref.current).toBeDisabled();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('applies error styling to the field and the button', () => {
|
|
135
|
+
render(<SingleFileInput aria-label="Attachment" hasError />);
|
|
136
|
+
|
|
137
|
+
expect(screen.getByLabelText('Attachment')).toHaveClass('ds-input--error');
|
|
138
|
+
expect(screen.getByRole('button', { name: 'Choose file' })).toHaveClass('ds-button--error');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('forwards id and aria attributes to the visible field', () => {
|
|
142
|
+
render(<SingleFileInput id="attachment" aria-label="Attachment" aria-describedby="hint" aria-invalid />);
|
|
143
|
+
|
|
144
|
+
const field = screen.getByLabelText('Attachment');
|
|
145
|
+
expect(field).toHaveAttribute('id', 'attachment');
|
|
146
|
+
expect(field).toHaveAttribute('aria-describedby', 'hint');
|
|
147
|
+
expect(field).toBeInvalid();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('tabs from the read-only field to the button and skips the hidden native input', async () => {
|
|
151
|
+
const user = userEvent.setup();
|
|
152
|
+
render(<SingleFileInput aria-label="Attachment" />);
|
|
153
|
+
|
|
154
|
+
await user.tab();
|
|
155
|
+
expect(screen.getByLabelText('Attachment')).toHaveFocus();
|
|
156
|
+
|
|
157
|
+
await user.tab();
|
|
158
|
+
expect(screen.getByRole('button', { name: 'Choose file' })).toHaveFocus();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('opens the file picker from the keyboard with Enter and Space', async () => {
|
|
162
|
+
const user = userEvent.setup();
|
|
163
|
+
const click = vi.spyOn(HTMLInputElement.prototype, 'click');
|
|
164
|
+
render(<SingleFileInput aria-label="Attachment" />);
|
|
165
|
+
|
|
166
|
+
await user.tab();
|
|
167
|
+
await user.tab();
|
|
168
|
+
await user.keyboard('{Enter}');
|
|
169
|
+
expect(click).toHaveBeenCalledTimes(1);
|
|
170
|
+
|
|
171
|
+
await user.keyboard(' ');
|
|
172
|
+
expect(click).toHaveBeenCalledTimes(2);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('uses a custom button label', () => {
|
|
176
|
+
render(<SingleFileInput aria-label="Attachment" buttonLabel="Browse" />);
|
|
177
|
+
|
|
178
|
+
expect(screen.getByRole('button', { name: 'Browse' })).toBeInTheDocument();
|
|
179
|
+
expect(screen.queryByRole('button', { name: 'Choose file' })).not.toBeInTheDocument();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import classNames from 'classnames';
|
|
2
|
+
import { Button } from 'Components/button/Button';
|
|
3
|
+
import { forwardRef, useCallback, useRef, useState, type ChangeEvent, type InputHTMLAttributes } from 'react';
|
|
4
|
+
import { useComponentDidMount } from 'Utils/hooks/useComponentDidMount';
|
|
5
|
+
|
|
6
|
+
export type SingleFileInputProps = {
|
|
7
|
+
hasError?: boolean;
|
|
8
|
+
buttonLabel?: string;
|
|
9
|
+
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'type' | 'value' | 'defaultValue' | 'multiple' | 'onFocus' | 'onBlur' | 'onKeyDown'>;
|
|
10
|
+
|
|
11
|
+
/** The forwarded ref targets the hidden native file input; `id` and ARIA props go on the visible read-only field. */
|
|
12
|
+
export const SingleFileInput = forwardRef<HTMLInputElement, SingleFileInputProps>((props, ref) => {
|
|
13
|
+
const {
|
|
14
|
+
hasError = false,
|
|
15
|
+
buttonLabel = 'Choose file',
|
|
16
|
+
placeholder = 'No file chosen',
|
|
17
|
+
className,
|
|
18
|
+
disabled = false,
|
|
19
|
+
required,
|
|
20
|
+
id,
|
|
21
|
+
onChange,
|
|
22
|
+
'aria-describedby': ariaDescribedBy,
|
|
23
|
+
'aria-invalid': ariaInvalid,
|
|
24
|
+
'aria-label': ariaLabel,
|
|
25
|
+
'aria-labelledby': ariaLabelledBy,
|
|
26
|
+
...rest
|
|
27
|
+
} = props;
|
|
28
|
+
|
|
29
|
+
const [fileName, setFileName] = useState('');
|
|
30
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
31
|
+
|
|
32
|
+
const setRefs = useCallback((node: HTMLInputElement | null) => {
|
|
33
|
+
fileInputRef.current = node;
|
|
34
|
+
if (typeof ref === 'function') {
|
|
35
|
+
ref(node);
|
|
36
|
+
}
|
|
37
|
+
else if (ref) {
|
|
38
|
+
ref.current = node;
|
|
39
|
+
}
|
|
40
|
+
}, [ref]);
|
|
41
|
+
|
|
42
|
+
useComponentDidMount(() => {
|
|
43
|
+
const form = fileInputRef.current?.form;
|
|
44
|
+
const clearFileName = () => setFileName('');
|
|
45
|
+
form?.addEventListener('reset', clearFileName);
|
|
46
|
+
return () => form?.removeEventListener('reset', clearFileName);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
50
|
+
setFileName(event.currentTarget.files?.[0]?.name ?? '');
|
|
51
|
+
onChange?.(event);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className={classNames('ds-single-file-input', className)}>
|
|
56
|
+
<input
|
|
57
|
+
{...rest}
|
|
58
|
+
ref={setRefs}
|
|
59
|
+
type="file"
|
|
60
|
+
className="sr-only"
|
|
61
|
+
tabIndex={-1}
|
|
62
|
+
aria-hidden="true"
|
|
63
|
+
required={required}
|
|
64
|
+
disabled={disabled}
|
|
65
|
+
onChange={handleChange}
|
|
66
|
+
/>
|
|
67
|
+
<input
|
|
68
|
+
id={id}
|
|
69
|
+
type="text"
|
|
70
|
+
readOnly
|
|
71
|
+
value={fileName}
|
|
72
|
+
placeholder={placeholder}
|
|
73
|
+
disabled={disabled}
|
|
74
|
+
aria-required={required}
|
|
75
|
+
aria-describedby={ariaDescribedBy}
|
|
76
|
+
aria-invalid={ariaInvalid}
|
|
77
|
+
aria-label={ariaLabel}
|
|
78
|
+
aria-labelledby={ariaLabelledBy}
|
|
79
|
+
className={classNames('ds-input', 'ds-input--M', 'ds-single-file-input__name', {
|
|
80
|
+
'ds-input--error': hasError,
|
|
81
|
+
})}
|
|
82
|
+
/>
|
|
83
|
+
<Button
|
|
84
|
+
type="button"
|
|
85
|
+
variant="secondary"
|
|
86
|
+
error={hasError}
|
|
87
|
+
disabled={disabled}
|
|
88
|
+
className="ds-single-file-input__trigger"
|
|
89
|
+
onClick={() => fileInputRef.current?.click()}
|
|
90
|
+
>
|
|
91
|
+
{buttonLabel}
|
|
92
|
+
</Button>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
SingleFileInput.displayName = 'SingleFileInput';
|
|
98
|
+
|
|
99
|
+
export namespace SingleFileInput {
|
|
100
|
+
export type Props = SingleFileInputProps;
|
|
101
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
.ds-single-file-input {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
gap: var(--spacing-small);
|
|
6
|
+
width: 100%;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.ds-single-file-input__name {
|
|
10
|
+
flex: 1 1 auto;
|
|
11
|
+
min-width: 0;
|
|
12
|
+
text-overflow: ellipsis;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.ds-single-file-input__trigger {
|
|
16
|
+
flex: none;
|
|
17
|
+
}
|
package/src/index.scss
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
@use "components/formField/label/label.scss";
|
|
18
18
|
@use "components/formField/inputs/number/numberInput.scss";
|
|
19
19
|
@use "components/formField/inputs/time/timeInput.scss";
|
|
20
|
+
@use "components/formField/inputs/singleFile/singleFileInput.scss";
|
|
20
21
|
@use "components/formField/inputs/radio/radioButtonInput.scss";
|
|
21
22
|
@use "components/formField/inputs/checkbox/checkboxInput.scss";
|
|
22
23
|
@use "components/formField/inputs/selectDropdown/selectDropdown";
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ export { NumberInput } from 'Components/formField/inputs/number/NumberInput';
|
|
|
35
35
|
export { RadioButtonGroup } from 'Components/formField/inputs/radio/RadioButtonGroup';
|
|
36
36
|
export { RadioButtonInput } from 'Components/formField/inputs/radio/RadioButtonInput';
|
|
37
37
|
export { SelectDropdown } from 'Components/formField/inputs/selectDropdown/SelectDropdown';
|
|
38
|
+
export { SingleFileInput } from 'Components/formField/inputs/singleFile/SingleFileInput';
|
|
38
39
|
export { Skeleton } from 'Components/skeleton/Skeleton';
|
|
39
40
|
export { TextInput } from 'Components/formField/inputs/text/TextInput';
|
|
40
41
|
export { TextArea } from 'Components/formField/inputs/textArea/TextArea';
|