Create a FileChooser component using showOpenFilePicker (#116)

* Create a FileChooser component using showOpenFilePicker

Before, `FileModal` always used a file input control for selecting
local files. This allowed the emulator to read from the file, but
precluded writing back to the file.

With this change, the `FileModal` delegates to the new `FileChooser`
component. The `FileChooser` will use `showOpenFilePicker` if it is
available and a regular file input if it's not.

Using `showOpenFilePicker` has the advantage of allowing the emulator
to write back to the file (if the user grants permission). While the
emulator does not yet take advantage of this write capability, that
will come.

* Addressed comments

*   useState() instead of direct DOM manipulation
*   backed out eslint changes in favor of suppressing the warning
This commit is contained in:
Ian Flanigan 2022-05-28 19:52:48 +02:00 committed by GitHub
parent 273854bcf6
commit 52a1c65fe4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 726 additions and 71 deletions

View File

@ -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': [
'<rootDir>/test/jest-setup.js'

View File

@ -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<string, string | string[]>;
}
const ACCEPT_EVERYTHING_TYPE: FilePickerAcceptType = {
description: 'Any file',
accept: { '*/*': [] },
};
export interface FileSystemFileHandleLike {
readonly name: string;
readonly kind: string;
readonly isWritable: boolean;
getFile(): Promise<File>;
createWritable: FileSystemFileHandle['createWritable'];
}
export interface FileChooserProps {
disabled?: boolean;
onChange: (handles: Array<FileSystemFileHandleLike>) => 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<HTMLInputElement>(null);
const filesRef = useRef<FileList>();
const onChangeInternal = useCallback(() => {
if (inputRef.current?.files) {
const newFiles = inputRef.current?.files;
if (filesRef.current !== newFiles) {
filesRef.current = newFiles;
onChange(newFiles);
}
}
}, []);
const extraProps = useMemo<ExtraProps>(() => {
// 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 (
<input type="file" role='button' aria-label='Open file'
ref={inputRef}
onChange={onChangeInternal}
disabled={disabled}
{...extraProps} />
);
};
interface FilePickerChooserProps {
disabled?: boolean;
onChange?: (files: FileSystemFileHandle[]) => void;
accept?: FilePickerAcceptType[];
}
const FilePickerChooser = ({
disabled = false,
onChange = () => { },
accept = [ACCEPT_EVERYTHING_TYPE]
}: FilePickerChooserProps) => {
const [busy, setBusy] = useState<boolean>(false);
const [selectedFilename, setSelectedFilename] = useState<string>();
const fileHandlesRef = useRef<FileSystemFileHandle[]>();
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 (
<>
<button onClick={onClickInternal} disabled={disabled || busy}>
Choose File
</button>
&nbsp;
<span role="label">{selectedFilename}</span>
</>
);
};
/**
* 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'
? (
<FilePickerChooser onChange={onChangeForPicker} {...rest} />
)
: (
<InputFileChooser onChange={onChangeForInput} {...rest} />
);
};

View File

@ -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<Record<string, DiskDescriptor[]>>(
(
@ -35,27 +43,29 @@ interface FileModalProps {
onClose: (closeBox?: boolean) => void;
}
export const FileModal = ({ disk2, number, onClose, isOpen } : FileModalProps) => {
const inputRef = useRef<HTMLInputElement>(null);
export const FileModal = ({ disk2, number, onClose, isOpen }: FileModalProps) => {
const [busy, setBusy] = useState<boolean>(false);
const [empty, setEmpty] = useState<boolean>(true);
const [category, setCategory] = useState<string>();
const [handles, setHandles] = useState<FileSystemFileHandleLike[]>();
const [filename, setFilename] = useState<string>();
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<HTMLSelectElement>) =>
@ -112,7 +121,7 @@ export const FileModal = ({ disk2, number, onClose, isOpen } : FileModalProps) =
))}
</select>
</div>
<input type="file" ref={inputRef} onChange={onChange} />
<FileChooser onChange={onChange} accept={DISK_TYPES} />
</ModalContent>
<ModalFooter>
<button onClick={doCancel}>Cancel</button>

292
package-lock.json generated
View File

@ -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": {

View File

@ -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",

View File

@ -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(<FileChooser control='input' onChange={() => { }} />);
expect(container).not.toBeNull();
});
it('should use the file input element', async () => {
render(<FileChooser control='input' onChange={() => { }} />);
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(<FileChooser control='input' onChange={onChange} />);
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<ReturnType<FileChooserProps['onChange']>, Parameters<FileChooserProps['onChange']>>();
render(<FileChooser control='input' onChange={onChange} />);
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<ReturnType<ShowOpenFilePicker>, Parameters<ShowOpenFilePicker>>();
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(<FileChooser control='picker' onChange={() => { }} />);
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(<FileChooser control='picker' onChange={onChange} />);
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<ReturnType<FileChooserProps['onChange']>, Parameters<FileChooserProps['onChange']>>();
render(<FileChooser control='picker' onChange={onChange} />);
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);
});
});
});
});

10
test/env/jsdom-with-backdoors.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/**
* Provide types for the jsdom-with-backdoors testing environment.
*/
export { };
declare global {
const backdoors: {
newFileList(...files: File[]): FileList;
};
}

34
test/env/jsdom-with-backdoors.js vendored Normal file
View File

@ -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;
},
};
}
}

View File

@ -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/**/*",