@arbor-education/design-system.components 4.0.0-react19-alpha.0 → 4.0.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 (115) hide show
  1. package/.github/workflows/pr-review-approve.yaml +2 -1
  2. package/CHANGELOG.md +18 -0
  3. package/dist/components/formField/FormField.d.ts +4 -0
  4. package/dist/components/formField/FormField.d.ts.map +1 -1
  5. package/dist/components/formField/FormField.js +2 -1
  6. package/dist/components/formField/FormField.js.map +1 -1
  7. package/dist/components/formField/FormField.stories.d.ts.map +1 -1
  8. package/dist/components/formField/FormField.stories.js +10 -7
  9. package/dist/components/formField/FormField.stories.js.map +1 -1
  10. package/dist/components/formField/FormField.test.js +14 -0
  11. package/dist/components/formField/FormField.test.js.map +1 -1
  12. package/dist/components/formField/inputs/singleFile/SingleFileInput.d.ts +14 -0
  13. package/dist/components/formField/inputs/singleFile/SingleFileInput.d.ts.map +1 -0
  14. package/dist/components/formField/inputs/singleFile/SingleFileInput.js +35 -0
  15. package/dist/components/formField/inputs/singleFile/SingleFileInput.js.map +1 -0
  16. package/dist/components/formField/inputs/singleFile/SingleFileInput.stories.d.ts +170 -0
  17. package/dist/components/formField/inputs/singleFile/SingleFileInput.stories.d.ts.map +1 -0
  18. package/dist/components/formField/inputs/singleFile/SingleFileInput.stories.js +242 -0
  19. package/dist/components/formField/inputs/singleFile/SingleFileInput.stories.js.map +1 -0
  20. package/dist/components/formField/inputs/singleFile/SingleFileInput.test.d.ts +2 -0
  21. package/dist/components/formField/inputs/singleFile/SingleFileInput.test.d.ts.map +1 -0
  22. package/dist/components/formField/inputs/singleFile/SingleFileInput.test.js +135 -0
  23. package/dist/components/formField/inputs/singleFile/SingleFileInput.test.js.map +1 -0
  24. package/dist/components/widgetCalendar/WidgetCalendar.d.ts +1 -0
  25. package/dist/components/widgetCalendar/WidgetCalendar.d.ts.map +1 -1
  26. package/dist/components/widgetCalendar/WidgetCalendar.js +1 -1
  27. package/dist/components/widgetCalendar/WidgetCalendar.js.map +1 -1
  28. package/dist/components/widgetCalendar/WidgetCalendar.stories.d.ts +2 -0
  29. package/dist/components/widgetCalendar/WidgetCalendar.stories.d.ts.map +1 -1
  30. package/dist/components/widgetCalendar/WidgetCalendar.stories.js +39 -1
  31. package/dist/components/widgetCalendar/WidgetCalendar.stories.js.map +1 -1
  32. package/dist/components/widgetCalendar/WidgetCalendar.test.js +48 -1
  33. package/dist/components/widgetCalendar/WidgetCalendar.test.js.map +1 -1
  34. package/dist/components/widgetCalendar/WidgetCalendarItem.d.ts +1 -0
  35. package/dist/components/widgetCalendar/WidgetCalendarItem.d.ts.map +1 -1
  36. package/dist/components/widgetCalendar/WidgetCalendarItem.js +7 -2
  37. package/dist/components/widgetCalendar/WidgetCalendarItem.js.map +1 -1
  38. package/dist/index.css +18 -0
  39. package/dist/index.css.map +1 -1
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +1 -0
  43. package/dist/index.js.map +1 -1
  44. package/dist/packageManifest.test.d.ts +2 -0
  45. package/dist/packageManifest.test.d.ts.map +1 -0
  46. package/dist/packageManifest.test.js +22 -0
  47. package/dist/packageManifest.test.js.map +1 -0
  48. package/package.json +1 -1
  49. package/src/components/formField/FormField.stories.tsx +10 -7
  50. package/src/components/formField/FormField.test.tsx +35 -0
  51. package/src/components/formField/FormField.tsx +5 -0
  52. package/src/components/formField/inputs/singleFile/SingleFileInput.stories.tsx +339 -0
  53. package/src/components/formField/inputs/singleFile/SingleFileInput.test.tsx +181 -0
  54. package/src/components/formField/inputs/singleFile/SingleFileInput.tsx +101 -0
  55. package/src/components/formField/inputs/singleFile/singleFileInput.scss +17 -0
  56. package/src/components/widgetCalendar/WidgetCalendar.stories.tsx +43 -1
  57. package/src/components/widgetCalendar/WidgetCalendar.test.tsx +73 -1
  58. package/src/components/widgetCalendar/WidgetCalendar.tsx +2 -0
  59. package/src/components/widgetCalendar/WidgetCalendarItem.tsx +14 -1
  60. package/src/index.scss +1 -0
  61. package/src/index.ts +1 -0
  62. package/src/packageManifest.test.ts +43 -0
  63. package/.changeset/empty-clocks-jog.md +0 -5
  64. package/dist/components/combobox/useElementWidth.d.ts +0 -2
  65. package/dist/components/combobox/useElementWidth.d.ts.map +0 -1
  66. package/dist/components/combobox/useElementWidth.js +0 -31
  67. package/dist/components/combobox/useElementWidth.js.map +0 -1
  68. package/dist/components/combobox/useVisibleChips.d.ts +0 -21
  69. package/dist/components/combobox/useVisibleChips.d.ts.map +0 -1
  70. package/dist/components/combobox/useVisibleChips.js +0 -59
  71. package/dist/components/combobox/useVisibleChips.js.map +0 -1
  72. package/dist/components/combobox/useVisibleChips.test.d.ts +0 -2
  73. package/dist/components/combobox/useVisibleChips.test.d.ts.map +0 -1
  74. package/dist/components/combobox/useVisibleChips.test.js +0 -81
  75. package/dist/components/combobox/useVisibleChips.test.js.map +0 -1
  76. package/dist/components/icoText/IcoText.d.ts +0 -37
  77. package/dist/components/icoText/IcoText.d.ts.map +0 -1
  78. package/dist/components/icoText/IcoText.js +0 -29
  79. package/dist/components/icoText/IcoText.js.map +0 -1
  80. package/dist/components/icoText/IcoText.stories.d.ts +0 -60
  81. package/dist/components/icoText/IcoText.stories.d.ts.map +0 -1
  82. package/dist/components/icoText/IcoText.stories.js +0 -319
  83. package/dist/components/icoText/IcoText.stories.js.map +0 -1
  84. package/dist/components/icoText/IcoText.test.d.ts +0 -2
  85. package/dist/components/icoText/IcoText.test.d.ts.map +0 -1
  86. package/dist/components/icoText/IcoText.test.js +0 -27
  87. package/dist/components/icoText/IcoText.test.js.map +0 -1
  88. package/dist/components/tree-row/TreeRow.d.ts +0 -32
  89. package/dist/components/tree-row/TreeRow.d.ts.map +0 -1
  90. package/dist/components/tree-row/TreeRow.js +0 -19
  91. package/dist/components/tree-row/TreeRow.js.map +0 -1
  92. package/dist/components/tree-row/TreeRow.stories.d.ts +0 -13
  93. package/dist/components/tree-row/TreeRow.stories.d.ts.map +0 -1
  94. package/dist/components/tree-row/TreeRow.stories.js +0 -774
  95. package/dist/components/tree-row/TreeRow.stories.js.map +0 -1
  96. package/dist/components/tree-row/TreeRow.test.d.ts +0 -2
  97. package/dist/components/tree-row/TreeRow.test.d.ts.map +0 -1
  98. package/dist/components/tree-row/TreeRow.test.js +0 -262
  99. package/dist/components/tree-row/TreeRow.test.js.map +0 -1
  100. package/dist/components/tree-row/TreeRowSection.d.ts +0 -12
  101. package/dist/components/tree-row/TreeRowSection.d.ts.map +0 -1
  102. package/dist/components/tree-row/TreeRowSection.js +0 -20
  103. package/dist/components/tree-row/TreeRowSection.js.map +0 -1
  104. package/dist/utils/PopupParentContext.d.ts +0 -3
  105. package/dist/utils/PopupParentContext.d.ts.map +0 -1
  106. package/dist/utils/PopupParentContext.js +0 -6
  107. package/dist/utils/PopupParentContext.js.map +0 -1
  108. package/dist/utils/PopupParentContext.stories.d.ts +0 -17
  109. package/dist/utils/PopupParentContext.stories.d.ts.map +0 -1
  110. package/dist/utils/PopupParentContext.stories.js +0 -266
  111. package/dist/utils/PopupParentContext.stories.js.map +0 -1
  112. package/dist/utils/getDefaultPopupParent.d.ts +0 -2
  113. package/dist/utils/getDefaultPopupParent.d.ts.map +0 -1
  114. package/dist/utils/getDefaultPopupParent.js +0 -19
  115. package/dist/utils/getDefaultPopupParent.js.map +0 -1
@@ -0,0 +1,339 @@
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 { useState } from 'react';
12
+ import { expect, fn, userEvent, within } from 'storybook/test';
13
+ import { FormField } from '../../FormField.js';
14
+ import { SingleFileInput } from './SingleFileInput.js';
15
+
16
+ const DESCRIPTION_INTRO = [
17
+ 'The **SingleFileInput** lets a user pick exactly one file for a conventional form POST.',
18
+ 'It shows the chosen file name in a read-only text field, with a button that opens the native file picker.',
19
+ 'The selected file is submitted under the `name` you give it, like any other form control.',
20
+ ].join('\n');
21
+
22
+ const USAGE_GUIDANCE = [
23
+ '### When to use',
24
+ '',
25
+ '- Attaching one document to a form that is submitted with a normal `<form>` POST: a consent letter, a photo, a CSV import',
26
+ '- Anywhere a legacy single-file field needs a Design System equivalent',
27
+ '- Inside `FormField` with `inputType="singleFile"` (the recommended pattern in real forms)',
28
+ '',
29
+ '---',
30
+ '',
31
+ '### When NOT to use',
32
+ '',
33
+ '| Instead of SingleFileInput... | Use... | Why |',
34
+ '|---|---|---|',
35
+ '| Several files, drag and drop, upload progress or per-file delete | [`FileUpload`](?path=/docs/components-fileupload--docs) | Async multi-file dropzone |',
36
+ '| Free-form text | [`TextInput`](?path=/docs/components-formfield-inputs-textinput--docs) | No file is involved |',
37
+ '',
38
+ '---',
39
+ '',
40
+ '### Design guidance',
41
+ '',
42
+ '- **Width is always 100%** of its container: size the wrapper, not the input.',
43
+ '- **Use FormField in real forms** so the label, description, helper link and error text are wired up for you.',
44
+ '- **hasError + disabled is an anti-pattern** because a user cannot correct an error on a field they cannot edit.',
45
+ ].join('\n');
46
+
47
+ const DEVELOPER_NOTES = [
48
+ '### Critical usage patterns',
49
+ '',
50
+ '**1. Reading the selected `File`.** `onChange` receives the native change event from the hidden file input.',
51
+ 'Read `event.currentTarget.files?.[0]` **inside the handler** (React clears `currentTarget` afterwards) to validate',
52
+ 'type or size, then show the result through `FormField`\'s `errorText`.',
53
+ '',
54
+ '**2. Form submission.** `name`, `accept` and `required` are applied to the native `<input type="file">`, which is',
55
+ 'the only control that carries a `name`, so only the file itself is submitted.',
56
+ 'The visible read-only field never submits.',
57
+ '',
58
+ '**3. `ref`.** The forwarded ref points at the native file input, so `ref.current.files` works as usual.',
59
+ 'To reset the selection, submit a form containing the field or dispatch a DOM `reset` event on that form —',
60
+ 'setting `ref.current.value = ""` clears the native input but leaves the displayed file name stale,',
61
+ 'because a programmatic value change does not fire `change`.',
62
+ '',
63
+ '**4. Always provide `id` inside FormField.** The label targets the visible read-only field, which is what',
64
+ 'assistive technology lands on.',
65
+ '',
66
+ '**5. Single file only.** `multiple` is not supported.',
67
+ '',
68
+ '**6. No focus or key handlers.** `onFocus`, `onBlur` and `onKeyDown` are not accepted: the native input never receives focus.',
69
+ '',
70
+ '---',
71
+ '',
72
+ '### Accessibility',
73
+ '',
74
+ '- Keyboard: `Tab` moves from the read-only field to the **Choose file** button; `Enter` or `Space` opens the picker.',
75
+ '- The hidden native input is removed from the tab order and the accessibility tree, so the field is not announced twice.',
76
+ '- The chosen file name is the value of the labelled read-only field, so it is announced on focus.',
77
+ '- Pair `hasError` with `aria-invalid={true}` when used standalone. `FormField` does this automatically.',
78
+ ].join('\n');
79
+
80
+ const RELATED_COMPONENTS = [
81
+ '## Related components',
82
+ '',
83
+ '[FormField](?path=/docs/components-formfield--docs) · [FileUpload](?path=/docs/components-fileupload--docs) · [Button](?path=/docs/components-button--docs)',
84
+ ].join('\n');
85
+
86
+ function SingleFileInputDocsPage() {
87
+ return (
88
+ <>
89
+ <Title />
90
+ <Subtitle />
91
+ <Markdown>{DESCRIPTION_INTRO}</Markdown>
92
+ <DocHeading>Interactive example</DocHeading>
93
+ <DocPrimary />
94
+ <Controls />
95
+ <DocHeading>Usage guidance</DocHeading>
96
+ <Markdown>{USAGE_GUIDANCE}</Markdown>
97
+ <DocHeading>Developer notes</DocHeading>
98
+ <Markdown>{DEVELOPER_NOTES}</Markdown>
99
+ <DocHeading>Examples</DocHeading>
100
+ <Stories title="" />
101
+ <Markdown>{RELATED_COMPONENTS}</Markdown>
102
+ </>
103
+ );
104
+ }
105
+
106
+ const meta = {
107
+ title: 'Components/FormField/Inputs/SingleFileInput',
108
+ component: SingleFileInput,
109
+ tags: ['autodocs'],
110
+ parameters: {
111
+ layout: 'padded',
112
+ docs: {
113
+ page: SingleFileInputDocsPage,
114
+ },
115
+ },
116
+ args: {
117
+ 'aria-label': 'Attachment',
118
+ 'onChange': fn(),
119
+ },
120
+ argTypes: {
121
+ 'hasError': {
122
+ description: 'Applies error-state styling. Does not set `aria-invalid`: pair it when used standalone.',
123
+ control: 'boolean',
124
+ table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' } },
125
+ },
126
+ 'disabled': {
127
+ description: 'Disables the field, the button and the native file input.',
128
+ control: 'boolean',
129
+ table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' } },
130
+ },
131
+ 'required': {
132
+ description: 'Marks the native file input as required for form validation and the visible field as required for assistive technology.',
133
+ control: 'boolean',
134
+ table: { type: { summary: 'boolean' } },
135
+ },
136
+ 'name': {
137
+ description: 'Name the selected file is submitted under.',
138
+ control: 'text',
139
+ table: { type: { summary: 'string' } },
140
+ },
141
+ 'accept': {
142
+ description: 'Comma-separated list of accepted file types, passed to the native file input (for example `.pdf,image/*`).',
143
+ control: 'text',
144
+ table: { type: { summary: 'string' } },
145
+ },
146
+ 'buttonLabel': {
147
+ description: 'Label of the button that opens the native file picker.',
148
+ control: 'text',
149
+ table: { type: { summary: 'string' }, defaultValue: { summary: "'Choose file'" } },
150
+ },
151
+ 'placeholder': {
152
+ description: 'Text shown in the read-only field while no file is chosen.',
153
+ control: 'text',
154
+ table: { type: { summary: 'string' }, defaultValue: { summary: "'No file chosen'" } },
155
+ },
156
+ 'id': {
157
+ description: 'Set on the visible read-only field, so a `<label htmlFor>` targets it. `FormField` passes this automatically.',
158
+ control: 'text',
159
+ table: { type: { summary: 'string' } },
160
+ },
161
+ 'className': {
162
+ description: 'Extra class name(s) added to the wrapper element, alongside `ds-single-file-input`.',
163
+ control: 'text',
164
+ table: { type: { summary: 'string' } },
165
+ },
166
+ 'aria-label': {
167
+ description: 'Accessible name for the visible read-only field when there is no visible label.',
168
+ control: 'text',
169
+ table: { type: { summary: 'string' } },
170
+ },
171
+ 'aria-labelledby': {
172
+ description: 'ID(s) of the element(s) that label the visible read-only field.',
173
+ control: 'text',
174
+ table: { type: { summary: 'string' } },
175
+ },
176
+ 'aria-describedby': {
177
+ description: 'ID(s) of the element(s) that describe the visible read-only field, such as helper or error text. `FormField` sets this automatically.',
178
+ control: 'text',
179
+ table: { type: { summary: 'string' } },
180
+ },
181
+ 'aria-invalid': {
182
+ description: 'Marks the visible read-only field as invalid for assistive technology. `FormField` sets this automatically from `errorText`.',
183
+ control: 'boolean',
184
+ table: { type: { summary: 'boolean' } },
185
+ },
186
+ 'onChange': {
187
+ description: 'Fired with the native change event. Read `event.currentTarget.files?.[0]` to get the selected `File`.',
188
+ control: false,
189
+ table: { type: { summary: '(event: ChangeEvent<HTMLInputElement>) => void' } },
190
+ },
191
+ },
192
+ } satisfies Meta<typeof SingleFileInput>;
193
+
194
+ export default meta;
195
+ type Story = StoryObj<typeof SingleFileInput>;
196
+
197
+ const withDescription = (story: Story, description: string): Story => ({
198
+ ...story,
199
+ parameters: {
200
+ ...story.parameters,
201
+ docs: {
202
+ ...story.parameters?.docs,
203
+ description: { story: description },
204
+ },
205
+ },
206
+ });
207
+
208
+ const sampleFile = () => new File(['%PDF-1.4'], 'consent-letter.pdf', { type: 'application/pdf' });
209
+
210
+ const wrap = (node: React.ReactNode) => (
211
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)' }}>{node}</div>
212
+ );
213
+
214
+ export const Default: Story = withDescription(
215
+ {
216
+ render: args => wrap(<SingleFileInput {...args} />),
217
+ },
218
+ 'No file chosen yet. The read-only field shows its placeholder and the **Choose file** button opens the native picker.',
219
+ );
220
+
221
+ export const Selected: Story = withDescription(
222
+ {
223
+ render: args => wrap(<SingleFileInput {...args} />),
224
+ play: async ({ canvasElement, args }) => {
225
+ const canvas = within(canvasElement);
226
+ const nativeInput = canvasElement.querySelector<HTMLInputElement>('input[type="file"]');
227
+ await userEvent.upload(nativeInput as HTMLInputElement, sampleFile());
228
+
229
+ await expect(canvas.getByRole('textbox', { name: 'Attachment' })).toHaveValue('consent-letter.pdf');
230
+ await expect(args.onChange).toHaveBeenCalled();
231
+ },
232
+ },
233
+ 'After a file is chosen its name replaces the placeholder in the read-only field.',
234
+ );
235
+
236
+ export const Disabled: Story = withDescription(
237
+ {
238
+ args: { disabled: true },
239
+ render: args => wrap(<SingleFileInput {...args} />),
240
+ },
241
+ 'The field, button and native input are all disabled.',
242
+ );
243
+
244
+ export const WithError: Story = withDescription(
245
+ {
246
+ render: () => wrap(
247
+ <FormField
248
+ id="attachment-error"
249
+ label="Attachment"
250
+ inputType="singleFile"
251
+ errorText="Attach a file to continue"
252
+ inputProps={{ name: 'attachment', required: true }}
253
+ />,
254
+ ),
255
+ play: async ({ canvasElement }) => {
256
+ const canvas = within(canvasElement);
257
+ const field = canvas.getByRole('textbox', { name: 'Attachment' });
258
+
259
+ await expect(field).toBeInvalid();
260
+ await expect(field).toHaveAccessibleDescription('Attach a file to continue');
261
+ },
262
+ },
263
+ 'Inside `FormField`, `errorText` turns on the error styling, `aria-invalid` and the described-by link to the message.',
264
+ );
265
+
266
+ const ValidationTemplate = () => {
267
+ const [error, setError] = useState<string>();
268
+ const maxBytes = 1024;
269
+
270
+ return (
271
+ <FormField
272
+ id="attachment-validated"
273
+ label="Attachment"
274
+ inputType="singleFile"
275
+ fieldDescription="PDF, 1KB maximum"
276
+ errorText={error}
277
+ inputProps={{
278
+ name: 'attachment',
279
+ accept: '.pdf',
280
+ onChange: (event) => {
281
+ const file = event.currentTarget.files?.[0];
282
+ setError(file && file.size > maxBytes ? `${file.name} is larger than 1KB` : undefined);
283
+ },
284
+ }}
285
+ />
286
+ );
287
+ };
288
+
289
+ export const WithValidation: Story = withDescription(
290
+ {
291
+ render: () => wrap(<ValidationTemplate />),
292
+ play: async ({ canvasElement }) => {
293
+ const canvas = within(canvasElement);
294
+ const nativeInput = canvasElement.querySelector<HTMLInputElement>('input[type="file"]');
295
+ const oversized = new File([new Uint8Array(2048)], 'big.pdf', { type: 'application/pdf' });
296
+
297
+ await userEvent.upload(nativeInput as HTMLInputElement, oversized);
298
+
299
+ await expect(canvas.getByText('big.pdf is larger than 1KB')).toBeInTheDocument();
300
+ await expect(canvas.getByRole('textbox', { name: 'Attachment' })).toBeInvalid();
301
+ },
302
+ },
303
+ 'The consumer reads the selected `File` in `onChange`, validates it, and reports the result through `errorText`.',
304
+ );
305
+
306
+ export const InAForm: Story = withDescription(
307
+ {
308
+ render: () => wrap(
309
+ <form>
310
+ <FormField
311
+ id="attachment-form"
312
+ label="Consent letter"
313
+ inputType="singleFile"
314
+ fieldDescription="Attach the signed letter"
315
+ helperLinkText="Letter template"
316
+ helperLinkUrl="https://example.com/template"
317
+ inputProps={{ name: 'consentLetter', accept: '.pdf' }}
318
+ />
319
+ </form>,
320
+ ),
321
+ play: async ({ canvasElement }) => {
322
+ const canvas = within(canvasElement);
323
+ const form = canvasElement.querySelector('form') as HTMLFormElement;
324
+ const nativeInput = form.querySelector('input[type="file"]') as HTMLInputElement;
325
+
326
+ // user-event only stubs `files`, so build a real FileList for FormData to read.
327
+ const transfer = new DataTransfer();
328
+ transfer.items.add(sampleFile());
329
+ nativeInput.files = transfer.files;
330
+ nativeInput.dispatchEvent(new Event('change', { bubbles: true }));
331
+
332
+ const submitted = new FormData(form).get('consentLetter');
333
+ await expect(submitted).toBeInstanceOf(File);
334
+ await expect((submitted as File).name).toBe('consent-letter.pdf');
335
+ await expect(canvas.getByRole('textbox', { name: 'Consent letter' })).toHaveValue('consent-letter.pdf');
336
+ },
337
+ },
338
+ 'Within a native `<form>`, the chosen file is submitted under the file input\'s `name`.',
339
+ );
@@ -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
+ }