diff --git a/package-lock.json b/package-lock.json index 86f1766c..4a5dcb00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,7 +111,6 @@ "resolved": "https://registry.npmjs.org/@arcgis/core/-/core-4.34.8.tgz", "integrity": "sha512-UrEBTjXpSA9fhmmnAENBzz9GG81xALTezQFMXUs2iMB+tiOckmJyBbhATI/W4lIQyUfNEK7Zm/46EP2PhDga/A==", "license": "SEE LICENSE IN copyright.txt", - "peer": true, "dependencies": { "@amcharts/amcharts5": "~5.14.1", "@arcgis/toolkit": "^4.34.0", @@ -200,7 +199,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1092,7 +1090,6 @@ "resolved": "https://registry.npmjs.org/@esri/arcgis-rest-request/-/arcgis-rest-request-4.7.3.tgz", "integrity": "sha512-0logbVDob7/5M7MNYAsB8c4/2S8xsXBrVQBvK1z+8QFCDz/mkJnzSeXQho/OCGhxTTLfEB7eKDXUG7ma7QDuvQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@esri/arcgis-rest-fetch": "^4.0.0", "@esri/arcgis-rest-form-data": "^4.0.0", @@ -1108,7 +1105,6 @@ "resolved": "https://registry.npmjs.org/@esri/calcite-components/-/calcite-components-3.3.3.tgz", "integrity": "sha512-tw+EfJ3pb+Odj71W6E9GUkm8rMbNxfW1KeiI8GgsKDzhr39hMKwY+zYYFFYuO0FONxWGvAB+B8yqB0NvH7WeHw==", "license": "SEE LICENSE.md", - "peer": true, "dependencies": { "@arcgis/lumina": ">=4.34.0-next.158 <4.35.0", "@arcgis/toolkit": ">=4.34.0-next.158 <4.35.0", @@ -1199,7 +1195,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.6.tgz", "integrity": "sha512-4uyt8BOrBsSq6i4yiOV/gG6BnnrvTeyymlNcaN/dKvyU1GoolxAafvIvaNP1RCGPlNab3OuE4MKUQuv2lH+PLQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/component": "0.7.0", "@firebase/logger": "0.5.0", @@ -1266,7 +1261,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.6.tgz", "integrity": "sha512-YYGARbutghQY4zZUWMYia0ib0Y/rb52y72/N0z3vglRHL7ii/AaK9SA7S/dzScVOlCdnbHXz+sc5Dq+r8fwFAg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/app": "0.14.6", "@firebase/component": "0.7.0", @@ -1282,8 +1276,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@firebase/auth": { "version": "1.11.1", @@ -1734,7 +1727,6 @@ "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -1771,8 +1763,7 @@ "version": "0.2.10", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@foliojs-fork/fontkit": { "version": "1.9.2", @@ -2494,7 +2485,6 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -5249,7 +5239,8 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5805,7 +5796,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -6405,7 +6395,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6939,7 +6928,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -8077,7 +8065,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -8539,7 +8526,8 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -8862,7 +8850,6 @@ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8936,7 +8923,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10952,7 +10938,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -10987,7 +10972,6 @@ "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 10.16.0" } @@ -11196,7 +11180,6 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz", "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -11382,6 +11365,7 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11663,7 +11647,6 @@ "devOptional": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.40.0", @@ -12743,7 +12726,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12896,7 +12878,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12913,7 +12894,6 @@ "integrity": "sha512-FxFz0qFhyBsGdIsb697f/EkvHzi5SZOhWAjxcx2dLt+Q532bAlhswcXGYB1yzjZ69kW8UoadFBw7TyNwlq96Iw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "prettier": ">=2.0", "typescript": ">=2.9", @@ -13028,6 +13008,7 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -13042,6 +13023,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -13054,7 +13036,6 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -13135,7 +13116,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13272,7 +13252,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13300,7 +13279,8 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-lorem-ipsum": { "version": "1.4.10", @@ -13937,7 +13917,6 @@ "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -14470,7 +14449,6 @@ "resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.16.tgz", "integrity": "sha512-339U14K6l46EFyRvaPS2ZlL7v7Pb+LlcXT8KAETrGPxq8v1sAjj2HAOB6zrlAK3M+0+ricssfAwsLCwt7Eg8TQ==", "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@testing-library/jest-dom": "^6.6.3", @@ -14940,7 +14918,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -15333,7 +15310,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15591,7 +15567,6 @@ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", diff --git a/packages/utah-design-system/src/components/FileInput.stories.tsx b/packages/utah-design-system/src/components/FileInput.stories.tsx index c8c99ed3..e2492117 100644 --- a/packages/utah-design-system/src/components/FileInput.stories.tsx +++ b/packages/utah-design-system/src/components/FileInput.stories.tsx @@ -93,7 +93,7 @@ export const ReactHookFormValidation: Story = { // eslint-disable-next-line react-hooks/rules-of-hooks const { control, handleSubmit } = useForm({ defaultValues: { - files: null, + files: null as File[] | null, }, }); @@ -108,13 +108,12 @@ export const ReactHookFormValidation: Story = { control={control} name="files" rules={{ required: 'Please select at least one file' }} - render={({ field: { onChange, ...field }, fieldState }) => ( + render={({ field: { onChange, value, ...field }, fieldState }) => ( { - onChange(fileList); - }} + value={value} + onChange={onChange} {...field} {...args} /> @@ -167,3 +166,36 @@ export const HtmlValidation: Story = { isRequired: true, }, }; + +export const Controlled: Story = { + render: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [files, setFiles] = useState(null); + + return ( +
+ +
+ +
+ {files && files.length > 0 && ( +
+ Parent state: {files.map((f) => f.name).join(', ')} +
+ )} +
+ ); + }, + args: { + label: 'Upload document (controlled)', + placeholder: 'Drag a file here or click to upload', + description: + 'This input is controlled. Click Reset to clear files from parent state.', + }, +}; diff --git a/packages/utah-design-system/src/components/FileInput.test.tsx b/packages/utah-design-system/src/components/FileInput.test.tsx new file mode 100644 index 00000000..ec6ebb3a --- /dev/null +++ b/packages/utah-design-system/src/components/FileInput.test.tsx @@ -0,0 +1,286 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FileInput } from './FileInput'; + +// Helper function to create mock File objects +function createMockFile(name: string, _size: number, type: string): File { + const blob = new Blob([''], { type }); + return new File([blob], name, { type, lastModified: Date.now() }); +} + +// Helper function to get the hidden file input element +// Note: File inputs are typically hidden and controlled via file trigger UI +function getFileInput(): HTMLInputElement { + const input = document.querySelector('input[type="file"]'); + if (!input) { + throw new Error('File input not found'); + } + return input as HTMLInputElement; +} + +describe('FileInput', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('uncontrolled mode', () => { + it('should manage files internally when value prop is not provided', async () => { + const onChange = vi.fn(); + render(); + + const file = createMockFile('test.txt', 1024, 'text/plain'); + const input = getFileInput(); + + // Simulate file selection + fireEvent.change(input, { target: { files: [file] } }); + + // In uncontrolled mode, onChange should still be called + expect(onChange).toHaveBeenCalledWith([file]); + + // The file should be displayed + expect(screen.getByText('test.txt')).toBeTruthy(); + }); + + it('should clear files internally when clear button is clicked in uncontrolled mode', async () => { + const onChange = vi.fn(); + render(); + + const file = createMockFile('test.txt', 1024, 'text/plain'); + const input = getFileInput(); + + fireEvent.change(input, { target: { files: [file] } }); + + expect(screen.getByText('test.txt')).toBeTruthy(); + + // Click clear button + const clearButton = screen.getByText('Clear all'); + await userEvent.click(clearButton); + + // onChange should be called with null + expect(onChange).toHaveBeenCalledWith(null); + + // File should no longer be displayed + expect(screen.queryByText('test.txt')).toBeNull(); + }); + }); + + describe('controlled mode', () => { + it('should display files from value prop in controlled mode', () => { + const file1 = createMockFile('document.pdf', 2048, 'application/pdf'); + const file2 = createMockFile('image.png', 4096, 'image/png'); + + render( + , + ); + + expect(screen.getByText('document.pdf')).toBeTruthy(); + expect(screen.getByText('image.png')).toBeTruthy(); + expect(screen.getByText('2 files selected')).toBeTruthy(); + }); + + it('should display no files when value is null in controlled mode', () => { + render(); + + expect(screen.queryByText(/files? selected/)).toBeNull(); + }); + + it('should display no files when value is empty array in controlled mode', () => { + render(); + + expect(screen.queryByText(/files? selected/)).toBeNull(); + }); + + it('should call onChange with new files when files are selected in controlled mode', async () => { + const onChange = vi.fn(); + + render(); + + const file = createMockFile('new-file.txt', 1024, 'text/plain'); + const input = getFileInput(); + + fireEvent.change(input, { target: { files: [file] } }); + + expect(onChange).toHaveBeenCalledWith([file]); + }); + + it('should not update internal state when in controlled mode (parent controls value)', async () => { + const onChange = vi.fn(); + const initialFile = createMockFile('initial.txt', 1024, 'text/plain'); + + // Render with initial value + const { rerender } = render( + , + ); + + expect(screen.getByText('initial.txt')).toBeTruthy(); + + // Simulate file selection + const newFile = createMockFile('new-file.txt', 2048, 'text/plain'); + const input = getFileInput(); + fireEvent.change(input, { target: { files: [newFile] } }); + + // onChange should be called, but in controlled mode the displayed file + // should still be the initial file until parent updates the value prop + expect(onChange).toHaveBeenCalledWith([newFile]); + + // Without parent updating value, the display should still show original file + expect(screen.getByText('initial.txt')).toBeTruthy(); + expect(screen.queryByText('new-file.txt')).toBeNull(); + + // Now parent updates the value + rerender( + , + ); + + // Now the new file should be displayed + expect(screen.getByText('new-file.txt')).toBeTruthy(); + expect(screen.queryByText('initial.txt')).toBeNull(); + }); + + it('should call onChange with null when clear button is clicked in controlled mode', async () => { + const onChange = vi.fn(); + const file = createMockFile('test.pdf', 1024, 'application/pdf'); + + render( + , + ); + + expect(screen.getByText('test.pdf')).toBeTruthy(); + + const clearButton = screen.getByText('Clear all'); + await userEvent.click(clearButton); + + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('should call onChange when individual file is removed in controlled mode', async () => { + const onChange = vi.fn(); + const file1 = createMockFile('file1.txt', 1024, 'text/plain'); + const file2 = createMockFile('file2.txt', 2048, 'text/plain'); + + render( + , + ); + + // Find and click the remove button for the first file + const removeButtons = screen.getAllByRole('button', { name: /Remove/ }); + const firstRemoveButton = removeButtons[0]; + expect(firstRemoveButton).toBeTruthy(); + await userEvent.click(firstRemoveButton!); + + // Should be called with remaining file + expect(onChange).toHaveBeenCalledWith([file2]); + }); + + it('should call onChange with null when last file is removed in controlled mode', async () => { + const onChange = vi.fn(); + const file = createMockFile('only-file.txt', 1024, 'text/plain'); + + render( + , + ); + + const removeButton = screen.getByRole('button', { name: /Remove/ }); + await userEvent.click(removeButton); + + expect(onChange).toHaveBeenCalledWith(null); + }); + }); + + describe('controlled vs uncontrolled detection', () => { + it('should be controlled when value prop is provided (including null)', () => { + const onChange = vi.fn(); + + // With value={null}, component is controlled + render( + , + ); + + const file = createMockFile('test.txt', 1024, 'text/plain'); + const input = getFileInput(); + fireEvent.change(input, { target: { files: [file] } }); + + // onChange should be called but file should not be displayed (controlled mode) + expect(onChange).toHaveBeenCalledWith([file]); + // In controlled mode with value={null}, the file won't show until parent updates + expect(screen.queryByText('test.txt')).toBeNull(); + }); + + it('should be uncontrolled when value prop is undefined', async () => { + const onChange = vi.fn(); + + render(); + + const file = createMockFile('test.txt', 1024, 'text/plain'); + const input = getFileInput(); + fireEvent.change(input, { target: { files: [file] } }); + + // In uncontrolled mode, file should be displayed immediately + expect(onChange).toHaveBeenCalledWith([file]); + expect(screen.getByText('test.txt')).toBeTruthy(); + }); + }); + + describe('onChange callback', () => { + it('should receive File[] when files are selected', () => { + const onChange = vi.fn(); + + render(); + + const file = createMockFile('test.txt', 1024, 'text/plain'); + const input = getFileInput(); + fireEvent.change(input, { target: { files: [file] } }); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith([file]); + expect(Array.isArray(onChange.mock.calls[0]?.[0])).toBe(true); + }); + + it('should receive null when files are cleared', async () => { + const onChange = vi.fn(); + + render(); + + // First add a file + const file = createMockFile('test.txt', 1024, 'text/plain'); + const input = getFileInput(); + fireEvent.change(input, { target: { files: [file] } }); + + // Clear files + const clearButton = screen.getByText('Clear all'); + await userEvent.click(clearButton); + + expect(onChange).toHaveBeenLastCalledWith(null); + }); + + it('should handle multiple files when allowsMultiple is true', () => { + const onChange = vi.fn(); + + render( + , + ); + + const file1 = createMockFile('file1.txt', 1024, 'text/plain'); + const file2 = createMockFile('file2.txt', 2048, 'text/plain'); + const input = getFileInput(); + + fireEvent.change(input, { target: { files: [file1, file2] } }); + + expect(onChange).toHaveBeenCalledWith([file1, file2]); + }); + }); +}); diff --git a/packages/utah-design-system/src/components/FileInput.tsx b/packages/utah-design-system/src/components/FileInput.tsx index 65a93563..066635b5 100644 --- a/packages/utah-design-system/src/components/FileInput.tsx +++ b/packages/utah-design-system/src/components/FileInput.tsx @@ -18,7 +18,8 @@ import { Description, Label } from './Field'; import { Tooltip } from './Tooltip'; import { focusRing } from './utils'; -export interface FileInputProps extends Omit { +export interface FileInputProps + extends Omit { label?: string; description?: string; errorMessage?: string | ((validation: ValidationResult) => string); @@ -37,6 +38,16 @@ export interface FileInputProps extends Omit { * Custom class for the drop zone container */ className?: string; + /** + * Controlled value - the current files selected. + * When provided, the component becomes controlled. + */ + value?: File[] | null; + /** + * Callback when files change. + * We use onChange rather than onSelect to make it clear that we are diverting from AriaFileTrigger which doesn't support being controlled + */ + onChange?: (files: File[] | null) => void; } const labelStyles = tv({ @@ -143,16 +154,28 @@ export function FileInput({ className, allowsMultiple, acceptedFileTypes, - onSelect, + value, + onChange, ...props }: FileInputProps) { - const [selectedFiles, setSelectedFiles] = useState([]); + const isControlled = value !== undefined; + + const [internalFiles, setInternalFiles] = useState([]); + + // Use controlled value if provided, otherwise use internal state + const selectedFiles = isControlled ? (value ?? []) : internalFiles; + + const updateFiles = (files: File[] | null) => { + if (!isControlled) { + setInternalFiles(files ?? []); + } + onChange?.(files); + }; const handleSelect = (fileList: FileList | null) => { if (fileList) { const files = Array.from(fileList); - setSelectedFiles(files); - onSelect?.(fileList); + updateFiles(files); } }; @@ -176,44 +199,18 @@ export function FileInput({ : filesToAdd; if (filteredFiles.length > 0) { - setSelectedFiles(filteredFiles); - - // Create FileList from files - try { - const dataTransfer = new DataTransfer(); - filteredFiles.forEach((file) => dataTransfer.items.add(file)); - onSelect?.(dataTransfer.files); - } catch (error) { - console.warn('DataTransfer API not available:', error); - } + updateFiles(filteredFiles); } } }; const removeFile = (index: number) => { const updated = selectedFiles.filter((_, i) => i !== index); - setSelectedFiles(updated); - - if (updated.length === 0) { - onSelect?.(null); - return; - } - - // Create a new FileList-like object and notify parent - try { - const dataTransfer = new DataTransfer(); - updated.forEach((file) => dataTransfer.items.add(file)); - onSelect?.(dataTransfer.files); - } catch (error) { - // DataTransfer not available in this environment - console.warn('DataTransfer API not available:', error); - } + updateFiles(updated.length === 0 ? null : updated); }; const clearFiles = () => { - setSelectedFiles([]); - // Notify parent that files have been cleared - onSelect?.(null); + updateFiles(null); }; return (