diff --git a/jest.config.js b/jest.config.js index 7df423e..a89c3bf 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,12 +8,13 @@ module.exports = { 'test/', ], 'testMatch': [ - '**/?(*.)+(spec|test).+(ts|js)' + '**/?(*.)+(spec|test).+(ts|js|tsx)' ], 'transform': { '^.+\\.js$': 'babel-jest', - '^.+\\.ts$': 'ts-jest' + '^.+\\.ts$': 'ts-jest', + '^.*\\.tsx$': 'ts-jest', }, 'setupFilesAfterEnv': [ '/test/jest-setup.js' diff --git a/js/components/FileChooser.tsx b/js/components/FileChooser.tsx new file mode 100644 index 0000000..252f54a --- /dev/null +++ b/js/components/FileChooser.tsx @@ -0,0 +1,210 @@ +import { h, Fragment } from 'preact'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'; + +export interface FilePickerAcceptType { + description?: string | undefined; + accept: Record; +} + +const ACCEPT_EVERYTHING_TYPE: FilePickerAcceptType = { + description: 'Any file', + accept: { '*/*': [] }, +}; + +export interface FileSystemFileHandleLike { + readonly name: string; + readonly kind: string; + readonly isWritable: boolean; + getFile(): Promise; + createWritable: FileSystemFileHandle['createWritable']; +} + +export interface FileChooserProps { + disabled?: boolean; + onChange: (handles: Array) => void; + accept?: FilePickerAcceptType[]; + control?: typeof controlDefault; +} + +const hasPicker: boolean = !!window.showOpenFilePicker; +const controlDefault = hasPicker ? 'picker' : 'input'; + +interface InputFileChooserProps { + disabled?: boolean; + onChange?: (files: FileList) => void; + accept?: FilePickerAcceptType[]; +} + +interface ExtraProps { + accept?: string; +} + +const InputFileChooser = ({ + disabled = false, + onChange = () => { }, + accept = [], +}: InputFileChooserProps) => { + const inputRef = useRef(null); + const filesRef = useRef(); + + const onChangeInternal = useCallback(() => { + if (inputRef.current?.files) { + const newFiles = inputRef.current?.files; + if (filesRef.current !== newFiles) { + filesRef.current = newFiles; + onChange(newFiles); + } + } + }, []); + + const extraProps = useMemo(() => { + // Accept all of the given MIME types and extensions. An argument + // could be made to throw out all of the MIME types and just keep + // the extensions, but this seemed to follow the intent in the + // spec: + // + // https://html.spec.whatwg.org/multipage/input.html#file-upload-state-(type=file) + // + // which allows pretty generous interpretations. + // + const newAccept = []; + for (const type of accept) { + for (const [typeString, suffixes] of Object.entries(type.accept)) { + newAccept.push(typeString); + if (Array.isArray(suffixes)) { + newAccept.push(...suffixes); + } else { + newAccept.push(suffixes); + } + } + } + + const extraProps: { accept?: string } = {}; + if (newAccept.length > 0) { + extraProps['accept'] = newAccept.join(','); + } + + return extraProps; + }, [accept]); + + return ( + + ); +}; + +interface FilePickerChooserProps { + disabled?: boolean; + onChange?: (files: FileSystemFileHandle[]) => void; + accept?: FilePickerAcceptType[]; +} + +const FilePickerChooser = ({ + disabled = false, + onChange = () => { }, + accept = [ACCEPT_EVERYTHING_TYPE] +}: FilePickerChooserProps) => { + const [busy, setBusy] = useState(false); + const [selectedFilename, setSelectedFilename] = useState(); + const fileHandlesRef = useRef(); + + const onClickInternal = useCallback(async () => { + if (busy) { + return; + } + setBusy(true); + try { + const pickedFiles = await window.showOpenFilePicker({ + multiple: false, + excludeAcceptAllOption: true, + types: accept, + }); + if (fileHandlesRef.current !== pickedFiles) { + fileHandlesRef.current = pickedFiles; + onChange(pickedFiles); + } + } catch (e: unknown) { + console.error(e); + } finally { + setBusy(false); + } + }, []); + + useEffect(() => { + setSelectedFilename( + fileHandlesRef.current?.length + ? fileHandlesRef.current[0].name + : 'No file selected'); + }, [fileHandlesRef.current]); + + return ( + <> + +   + {selectedFilename} + + ); +}; + +/** + * File chooser component displayed as a button followed by the name of the + * chosen file (if any). When clicked, the button opens a native file chooser. + * If the browser supports the `window.showOpenFilePicker` function, this + * component uses it to open the file chooser. Otherwise, the component uses + * a regular file input element. + * + * Using `window.showOpenFilePicker` has the advantage of allowing read/write + * access to the file, whereas the regular input element only gives read + * access. + */ +export const FileChooser = ({ + onChange, + control = controlDefault, + ...rest +}: FileChooserProps) => { + + const onChangeForInput = useCallback((files: FileList) => { + const handles: FileSystemFileHandleLike[] = []; + for (let i = 0; i < files.length; i++) { + const file = files.item(i); + if (file === null) { + continue; + } + handles.push({ + kind: 'file', + name: file.name, + getFile: () => Promise.resolve(file), + isWritable: false, + createWritable: (_options) => Promise.reject('File not writable.'), + }); + } + onChange(handles); + }, []); + + const onChangeForPicker = useCallback((fileHandles: FileSystemFileHandle[]) => { + const handles: FileSystemFileHandleLike[] = []; + for (const fileHandle of fileHandles) { + handles.push({ + kind: fileHandle.kind, + name: fileHandle.name, + getFile: () => fileHandle.getFile(), + isWritable: true, + createWritable: (options) => fileHandle.createWritable(options), + }); + } + onChange(handles); + }, []); + + return control === 'picker' + ? ( + + ) + : ( + + ); +}; diff --git a/js/components/FileModal.tsx b/js/components/FileModal.tsx index 64a0ecb..5a82893 100644 --- a/js/components/FileModal.tsx +++ b/js/components/FileModal.tsx @@ -1,11 +1,19 @@ import { h, JSX } from 'preact'; -import { useCallback, useRef, useState } from 'preact/hooks'; -import { DiskDescriptor, DriveNumber, NibbleFormat } from '../formats/types'; +import { useCallback, useState } from 'preact/hooks'; +import { DiskDescriptor, DriveNumber, NibbleFormat, NIBBLE_FORMATS } from '../formats/types'; import { Modal, ModalContent, ModalFooter } from './Modal'; import { loadLocalFile, loadJSON, getHashParts, setHashParts } from './util/files'; import DiskII from '../cards/disk2'; import index from 'json/disks/index.json'; +import { FileChooser, FilePickerAcceptType, FileSystemFileHandleLike } from './FileChooser'; + +const DISK_TYPES: FilePickerAcceptType[] = [ + { + description: 'Disk Images', + accept: { 'application/octet-stream': NIBBLE_FORMATS.map(x => '.' + x) }, + } +]; const categories = index.reduce>( ( @@ -35,27 +43,29 @@ interface FileModalProps { onClose: (closeBox?: boolean) => void; } -export const FileModal = ({ disk2, number, onClose, isOpen } : FileModalProps) => { - const inputRef = useRef(null); +export const FileModal = ({ disk2, number, onClose, isOpen }: FileModalProps) => { const [busy, setBusy] = useState(false); const [empty, setEmpty] = useState(true); const [category, setCategory] = useState(); + const [handles, setHandles] = useState(); const [filename, setFilename] = useState(); const doCancel = useCallback(() => onClose(true), []); - const doOpen = useCallback(() => { + const doOpen = useCallback(async () => { const hashParts = getHashParts(); - if (disk2 && inputRef.current && inputRef.current.files?.length === 1) { + if (disk2 && handles && handles.length === 1) { hashParts[number] = ''; setBusy(true); - loadLocalFile(disk2, number, inputRef.current.files[0]) - .catch(console.error) - .finally(() => { - setBusy(false); - onClose(); - }); + try { + await loadLocalFile(disk2, number, await handles[0].getFile()); + } catch (e) { + console.error(e); + } finally { + setBusy(false); + onClose(); + } } if (disk2 && filename) { @@ -71,13 +81,12 @@ export const FileModal = ({ disk2, number, onClose, isOpen } : FileModalProps) = } setHashParts(hashParts); - }, [ disk2, filename, number, onClose ]); + }, [disk2, filename, number, onClose, handles]); - const onChange = useCallback(() => { - if (inputRef) { - setEmpty(!inputRef.current?.files?.length); - } - }, [ inputRef ]); + const onChange = useCallback((handles: FileSystemFileHandleLike[]) => { + setEmpty(handles.length === 0); + setHandles(handles); + }, []); const doSelectCategory = useCallback( (event: JSX.TargetedMouseEvent) => @@ -112,7 +121,7 @@ export const FileModal = ({ disk2, number, onClose, isOpen } : FileModalProps) = ))} - + diff --git a/package-lock.json b/package-lock.json index 93cc95e..357a41f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,10 +20,12 @@ "@babel/preset-env": "^7.9.0", "@babel/preset-typescript": "^7.16.7", "@testing-library/dom": "^7.30.3", + "@testing-library/preact": "^3.0.1", "@testing-library/user-event": "^13.1.3", "@types/jest": "^27.0.2", "@types/jest-image-snapshot": "^4.3.1", "@types/micromodal": "^0.3.2", + "@types/wicg-file-system-access": "^2020.9.5", "@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/parser": "^5.22.0", "ajv": "^6.12.0", @@ -2901,6 +2903,145 @@ "node": ">=8" } }, + "node_modules/@testing-library/preact": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@testing-library/preact/-/preact-3.1.1.tgz", + "integrity": "sha512-RHjln1psbU4Sh/l8k9/gG3VNEDIEicUhzZ74uEnb4hJ4H9G9p1iOXEEMXB2oD5sZcjciQhpx1QUryM5/sAtTTQ==", + "dev": true, + "dependencies": { + "@testing-library/dom": "^8.11.1" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "preact": ">=10 || ^10.0.0-alpha.0 || ^10.0.0-beta.0" + } + }, + "node_modules/@testing-library/preact/node_modules/@testing-library/dom": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.13.0.tgz", + "integrity": "sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^4.2.0", + "aria-query": "^5.0.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.4.4", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@testing-library/preact/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/preact/node_modules/aria-query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz", + "integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/@testing-library/preact/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/preact/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/preact/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/@testing-library/preact/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/preact/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/preact/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/preact/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@testing-library/user-event": { "version": "13.1.3", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.1.3.tgz", @@ -3336,6 +3477,12 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/wicg-file-system-access": { + "version": "2020.9.5", + "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.5.tgz", + "integrity": "sha512-UYK244awtmcUYQfs7FR8710MJcefL2WvkyHMjA8yJzxd1mo0Gfn88sRZ1Bls7hiUhA2w7ne1gpJ9T5g3G0wOyA==", + "dev": true + }, "node_modules/@types/ws": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", @@ -4531,14 +4678,20 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001283", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001283.tgz", - "integrity": "sha512-9RoKo841j1GQFSJz/nCXOj0sD7tHBtlowjYlrqIUS812x9/emfBLBt6IyMz1zIaYc/eRL8Cs6HPUVi2Hzq4sIg==", + "version": "1.0.30001342", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001342.tgz", + "integrity": "sha512-bn6sOCu7L7jcbBbyNhLg0qzXdJ/PMbybZTH/BA6Roet9wxYRm6Tr9D0s0uhLkOZ6MSG+QU6txUgdpr3MXIVqjA==", "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - } + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + } + ] }, "node_modules/canvas": { "version": "2.8.0", @@ -5188,9 +5341,9 @@ } }, "node_modules/dom-accessibility-api": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz", - "integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz", + "integrity": "sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg==", "dev": true }, "node_modules/domexception": { @@ -15809,6 +15962,107 @@ } } }, + "@testing-library/preact": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@testing-library/preact/-/preact-3.1.1.tgz", + "integrity": "sha512-RHjln1psbU4Sh/l8k9/gG3VNEDIEicUhzZ74uEnb4hJ4H9G9p1iOXEEMXB2oD5sZcjciQhpx1QUryM5/sAtTTQ==", + "dev": true, + "requires": { + "@testing-library/dom": "^8.11.1" + }, + "dependencies": { + "@testing-library/dom": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.13.0.tgz", + "integrity": "sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^4.2.0", + "aria-query": "^5.0.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.4.4", + "pretty-format": "^27.0.2" + } + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "aria-query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz", + "integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==", + "dev": true + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + } + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "@testing-library/user-event": { "version": "13.1.3", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.1.3.tgz", @@ -16205,6 +16459,12 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "@types/wicg-file-system-access": { + "version": "2020.9.5", + "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.5.tgz", + "integrity": "sha512-UYK244awtmcUYQfs7FR8710MJcefL2WvkyHMjA8yJzxd1mo0Gfn88sRZ1Bls7hiUhA2w7ne1gpJ9T5g3G0wOyA==", + "dev": true + }, "@types/ws": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", @@ -17130,9 +17390,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001283", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001283.tgz", - "integrity": "sha512-9RoKo841j1GQFSJz/nCXOj0sD7tHBtlowjYlrqIUS812x9/emfBLBt6IyMz1zIaYc/eRL8Cs6HPUVi2Hzq4sIg==", + "version": "1.0.30001342", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001342.tgz", + "integrity": "sha512-bn6sOCu7L7jcbBbyNhLg0qzXdJ/PMbybZTH/BA6Roet9wxYRm6Tr9D0s0uhLkOZ6MSG+QU6txUgdpr3MXIVqjA==", "dev": true }, "canvas": { @@ -17651,9 +17911,9 @@ } }, "dom-accessibility-api": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz", - "integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz", + "integrity": "sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg==", "dev": true }, "domexception": { diff --git a/package.json b/package.json index 84970fd..9bfae0d 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,12 @@ "@babel/preset-env": "^7.9.0", "@babel/preset-typescript": "^7.16.7", "@testing-library/dom": "^7.30.3", + "@testing-library/preact": "^3.0.1", "@testing-library/user-event": "^13.1.3", "@types/jest": "^27.0.2", "@types/jest-image-snapshot": "^4.3.1", "@types/micromodal": "^0.3.2", + "@types/wicg-file-system-access": "^2020.9.5", "@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/parser": "^5.22.0", "ajv": "^6.12.0", diff --git a/test/components/FileChooser.spec.tsx b/test/components/FileChooser.spec.tsx new file mode 100644 index 0000000..210c061 --- /dev/null +++ b/test/components/FileChooser.spec.tsx @@ -0,0 +1,129 @@ +/** + * @jest-environment ./test/env/jsdom-with-backdoors + */ + +import 'jest'; +import { h } from 'preact'; +import { fireEvent, render, screen, waitFor } from '@testing-library/preact'; + +import 'test/env/jsdom-with-backdoors'; +import { FileChooser, FileChooserProps } from '../../js/components/FileChooser'; + +type ShowOpenFilePicker = typeof window.showOpenFilePicker; + +const FAKE_FILE_HANDLE = { + kind: 'file', + name: 'file name', + createWritable: jest.fn(), + getFile: jest.fn(), + isSameEntry: jest.fn(), + queryPermission: jest.fn(), + requestPermission: jest.fn(), + isFile: true, + isDirectory: false, +} as const; + +// eslint-disable-next-line no-undef +const EMPTY_FILE_LIST = backdoors.newFileList(); + +const FAKE_FILE = new File([], 'fake'); + +describe('FileChooser', () => { + describe('input-based chooser', () => { + it('should be instantiable', () => { + const { container } = render( { }} />); + + expect(container).not.toBeNull(); + }); + + it('should use the file input element', async () => { + render( { }} />); + + const inputElement = await screen.findByRole('button') as HTMLInputElement; + expect(inputElement.type).toBe('file'); + }); + + it('should fire a callback with empty list when no files are selected', async () => { + const onChange = jest.fn(); + render(); + const inputElement = await screen.findByRole('button') as HTMLInputElement; + inputElement.files = EMPTY_FILE_LIST; + fireEvent.change(inputElement); + await waitFor(() => { + expect(onChange).toBeCalledWith([]); + }); + }); + + it('should fire a callback with a file handle when a file is selected', async () => { + const onChange = jest.fn, Parameters>(); + render(); + const inputElement = await screen.findByRole('button') as HTMLInputElement; + // eslint-disable-next-line no-undef + inputElement.files = backdoors.newFileList(FAKE_FILE); + fireEvent.change(inputElement); + await waitFor(async () => { + expect(onChange).toHaveBeenCalled(); + const handleList = onChange.mock.calls[0][0]; + expect(handleList).toHaveLength(1); + const handle = handleList[0]; + expect(handle.kind).toBe('file'); + expect(handle.name).toBe(FAKE_FILE.name); + expect(handle.isWritable).toBe(false); + await expect(handle.getFile()).resolves.toBe(FAKE_FILE); + await expect(handle.createWritable()).rejects.toEqual('File not writable.'); + }); + }); + }); + + describe('picker-base chooser', () => { + const mockFilePicker = jest.fn, Parameters>(); + + beforeEach(() => { + expect(window.showOpenFilePicker).not.toBeDefined(); + window.showOpenFilePicker = mockFilePicker as unknown as ShowOpenFilePicker; + mockFilePicker.mockReset(); + }); + + afterEach(() => { + window.showOpenFilePicker = undefined as unknown as typeof window.showOpenFilePicker; + }); + + it('should be instantiable', () => { + const { container } = render( { }} />); + + expect(container).not.toBeNull(); + }); + + it('should fire a callback with empty list when no files are selected', async () => { + mockFilePicker.mockResolvedValueOnce([]); + const onChange = jest.fn(); + render(); + + fireEvent.click(await screen.findByText('Choose File')); + + await waitFor(() => { + expect(mockFilePicker).toBeCalled(); + expect(onChange).toBeCalledWith([]); + }); + }); + + it('should fire a callback with a file handle when a file is selected', async () => { + mockFilePicker.mockResolvedValueOnce([FAKE_FILE_HANDLE]); + const onChange = jest.fn, Parameters>(); + render(); + + fireEvent.click(await screen.findByText('Choose File')); + + await waitFor(() => { + expect(mockFilePicker).toBeCalled(); + expect(onChange).toHaveBeenCalled(); + const handleList = onChange.mock.calls[0][0]; + expect(handleList).toHaveLength(1); + const handle = handleList[0]; + expect(handle.kind).toBe(FAKE_FILE_HANDLE.kind); + expect(handle.name).toBe(FAKE_FILE_HANDLE.name); + expect(handle.isWritable).toBe(true); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/env/jsdom-with-backdoors.d.ts b/test/env/jsdom-with-backdoors.d.ts new file mode 100644 index 0000000..b6bfab4 --- /dev/null +++ b/test/env/jsdom-with-backdoors.d.ts @@ -0,0 +1,10 @@ +/** + * Provide types for the jsdom-with-backdoors testing environment. + */ +export { }; + +declare global { + const backdoors: { + newFileList(...files: File[]): FileList; + }; +} diff --git a/test/env/jsdom-with-backdoors.js b/test/env/jsdom-with-backdoors.js new file mode 100644 index 0000000..f50d323 --- /dev/null +++ b/test/env/jsdom-with-backdoors.js @@ -0,0 +1,34 @@ +/** + * This is a total and terrible hack that allows us to create otherwise + * uninstantiable jsdom objects. Currently this exposes a way to create + * `FileList` objects. + * + * This was inspired by felipochoa's implementation in GitHub issue: + * https://github.com/jsdom/jsdom/issues/1272. This implementation is + * "better" because it does all of the dirty work during environment + * setup. It still requires typing. + */ + +const JsdomEnvironment = require('jest-environment-jsdom'); + +export default class JsdomEnvironmentWithBackDoors extends JsdomEnvironment { + async setup() { + await super.setup(); + const jsdomUtils = require('jsdom/lib/jsdom/living/generated/utils'); + const jsdomFileList = require('jsdom/lib/jsdom/living/generated/FileList'); + + this.global.backdoors = { + newFileList: (...files) => { + const impl = jsdomFileList.createImpl(this.global); + const fileList = Object.assign([...files], { + item: i => fileList[i], + [jsdomUtils.implSymbol]: impl, + }); + impl[jsdomUtils.wrapperSymbol] = fileList; + const fileListCtor = this.global[jsdomUtils.ctorRegistrySymbol].FileList; + Object.setPrototypeOf(fileList, fileListCtor.prototype); + return fileList; + }, + }; + } +} diff --git a/tsconfig.json b/tsconfig.json index 83e97b2..403784a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,40 +1,40 @@ { "compilerOptions": { - "jsx": "react", - "jsxFactory": "h", - "jsxFragmentFactory": "Fragment", - "module": "esnext", - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "target": "es6", - "lib": ["DOM", "ES6"], - "noImplicitAny": true, - "noImplicitThis": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "exactOptionalPropertyTypes": true, - "moduleResolution": "node", - "resolveJsonModule": true, - "sourceMap": true, - "strictNullChecks": true, - "outDir": "dist", - "baseUrl": ".", - "allowJs": true, - "paths": { - "*": [ - "node_modules/*", - "types/*" - ], - "js/*": [ - "js/*" - ], - "json/*": [ - "json/*" - ], + "jsx": "react", + "jsxFactory": "h", + "jsxFragmentFactory": "Fragment", + "module": "esnext", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "target": "es6", + "lib": ["DOM", "ES6"], + "noImplicitAny": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "sourceMap": true, + "strictNullChecks": true, + "outDir": "dist", + "baseUrl": ".", + "allowJs": true, + "paths": { + "*": [ + "node_modules/*", + "types/*" + ], + "js/*": [ + "js/*" + ], + "json/*": [ + "json/*" + ], "test/*": [ - "test/*" - ] - } + "test/*" + ] + } }, "include": [ "js/**/*",